From ba6f5386c8f63b62f398925fa440d089e780d128 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:56:23 +0000 Subject: [PATCH 01/13] core: allow moderators to change member <-> observer roles in groups (#7279) --- src/Simplex/Chat/Library/Commands.hs | 3 +++ src/Simplex/Chat/Library/Internal.hs | 8 ++++++++ src/Simplex/Chat/Library/Subscriber.hs | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 75f875de20..2f38e716ec 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2884,6 +2884,9 @@ processChatCommand cxt nm = \case when (length memberIds > 1 && (anyAdmin || newRole >= GRAdmin)) $ throwCmdError "can't change role of multiple members when admins selected, or new role is admin" when anyPending $ throwCmdError "can't change role of members pending approval" + -- TODO allow moderators (recipients already accept it; needs UI too): the observer..member limit is an + -- TODO `all` over targets - maxRole can't express it, a max hides targets below GRObserver (relay, + -- TODO unknown) that receivers reject. Fold roleRequiredToChange per target, or add allModeratable. assertUserGroupRole gInfo $ maximum ([GRAdmin, maxRole, newRole] :: [GroupMemberRole]) -- in relay groups the roster has a single signer, so only the owner may change member/moderator/admin roles when (useRelays' gInfo && (isRosterRole newRole || anyPrivilegedTarget) && memberRole' (membership gInfo) /= GROwner) $ diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 28ce796469..8f0d411c5a 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1291,6 +1291,14 @@ isRosterRole r = r == GRMember || r == GRModerator || r == GRAdmin isPrivilegedRole :: GroupMemberRole -> Bool isPrivilegedRole r = r >= GRMember +-- Minimum role allowed to change a member's role from `from` to `to` (moderators only within observer..member). +roleRequiredToChange :: GroupMemberRole -> GroupMemberRole -> GroupMemberRole +roleRequiredToChange from to + | moderatable from && moderatable to = GRModerator + | otherwise = maximum ([GRAdmin, from, to] :: [GroupMemberRole]) + where + moderatable r = GRObserver <= r && r <= GRMember + -- Drop non-privileged-role entries and de-duplicate by memberId, keeping the first. -- Runs on the parsed roster blob. validateGroupRoster :: [RosterMember] -> [RosterMember] diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 056c9fac8d..4e03d03958 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3363,7 +3363,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = -- applyMember writes the change (role, or role + pinned key for a freshly TOFU-created member); -- the delivery scope (relay forwarding) is computed on the pre-change role changeMemberRole gInfo' member@GroupMember {memberRole = fromRole} created applyMember gEvent createItem - | senderRole < maximum ([GRAdmin, fromRole, memRole] :: [GroupMemberRole]) = + | senderRole < roleRequiredToChange fromRole memRole = messageError "x.grp.mem.role with insufficient member permissions" $> Nothing | useRelays' gInfo && (isRosterRole memRole || isRosterRole fromRole) && senderRole /= GROwner = messageError "x.grp.mem.role: only the owner can change member, moderator and admin roles in relay groups" $> Nothing From 966c9f794753b7aad3bc196f5b3d519428a8aa8d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:20:37 +0000 Subject: [PATCH 02/13] core: fail compilation on name shadowing (#7283) --- simplex-chat.cabal | 14 +++++++------- src/Simplex/Chat/Library/Commands.hs | 14 +++++++------- src/Simplex/Chat/Library/Internal.hs | 8 ++++---- src/Simplex/Chat/Library/Subscriber.hs | 4 ++-- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 4e4528d6e7..9cd2e1dd7f 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -325,7 +325,7 @@ library src default-extensions: StrictData - 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 + 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 -Werror=name-shadowing build-depends: aeson ==2.2.* , ansi-terminal >=0.10 && <0.12 @@ -402,7 +402,7 @@ executable simplex-bot apps/simplex-bot default-extensions: StrictData - 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 + 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 -Werror=name-shadowing -threaded build-depends: base >=4.7 && <5 , directory ==1.3.* @@ -421,7 +421,7 @@ executable simplex-bot-advanced apps/simplex-bot-advanced default-extensions: StrictData - 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 + 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 -Werror=name-shadowing -threaded build-depends: async ==2.2.* , base >=4.7 && <5 @@ -452,7 +452,7 @@ executable simplex-broadcast-bot Broadcast.Bot Broadcast.Options 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 + 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 -Werror=name-shadowing -threaded build-depends: async ==2.2.* , base >=4.7 && <5 @@ -482,7 +482,7 @@ executable simplex-chat apps/simplex-chat default-extensions: StrictData - 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 + 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 -Werror=name-shadowing -threaded build-depends: aeson ==2.2.* , base >=4.7 && <5 @@ -525,7 +525,7 @@ executable simplex-directory-service Directory.Store.Migrate Directory.Util 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 -rtsopts + 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 -Werror=name-shadowing -threaded -rtsopts build-depends: aeson ==2.2.* , async ==2.2.* @@ -648,7 +648,7 @@ test-suite simplex-chat-test default-extensions: StrictData -- add -fhpc to ghc-options below to run tests with coverage - 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 + 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 -Werror=name-shadowing -threaded build-depends: QuickCheck ==2.14.* , aeson ==2.2.* diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 2f38e716ec..c8786a92de 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4308,7 +4308,7 @@ processChatCommand cxt nm = \case CLShort l -> do let l' = serverShortLink l knownLinkPlans l' >>= \case - Just (l, p) -> pure (l, Nothing, Nothing, p) + Just (createdLink, p) -> pure (createdLink, Nothing, Nothing, p) Nothing -> do (FixedLinkData {linkConnReq = cReq, rootKey}, cData) <- getShortLinkConnReq nm user l' contactSLinkData_ <- mapM linkDataBadge =<< liftIO (decodeLinkUserData cData) @@ -4492,15 +4492,15 @@ processChatCommand cxt nm = \case case plan of CPContactAddress (CAPContactViaAddress Contact {contactId}) -> processChatCommand cxt nm $ APIConnectContactViaAddress userId incognito contactId - CPContactAddress (CAPOk (Just sld) _) | isJust vName -> connectContactViaName sld vName + CPContactAddress (CAPOk (Just sld) _) | isJust vName -> connectContactViaName sld CPGroupLink (GLPOk (Just GroupShortLinkInfo {direct = False}) (Just gld) _) - | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld vName + | ACCL SCMContact ccl <- ccLink -> joinChannelViaRelays ccl gld _ -> processChatCommand cxt nm $ APIConnect userId incognito $ Just ccLink | otherwise = pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan where vName = nameDomain <$> planSimplexName - joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> Maybe SimplexDomain -> CM ChatResponse - joinChannelViaRelays ccl gld vName = do + joinChannelViaRelays :: CreatedLinkContact -> GroupShortLinkData -> CM ChatResponse + joinChannelViaRelays ccl gld = do GroupInfo {groupId} <- prepareChannelGroup processChatCommand cxt nm APIConnectPreparedGroup {groupId, incognito, ownerContact = Nothing, msgContent_ = Nothing} `catchAllErrors` \e -> do @@ -4515,8 +4515,8 @@ processChatCommand cxt nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId deleteGroupConnections user gInfo False withFastStore' $ \db -> deleteGroup db user gInfo - connectContactViaName :: ContactShortLinkData -> Maybe SimplexDomain -> CM ChatResponse - connectContactViaName sld vName = + connectContactViaName :: ContactShortLinkData -> CM ChatResponse + connectContactViaName sld = processChatCommand cxt nm (APIPrepareContact userId ccLink vName sld) >>= \case CRNewPreparedChat _ (AChat SCTDirect (Chat (DirectChat Contact {contactId}) _ _)) -> processChatCommand cxt nm (APIConnectPreparedContact contactId incognito Nothing) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 8f0d411c5a..f4d684a3c3 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1363,12 +1363,12 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c fInvDescr_ <- join <$> forM file getRcvFileInvDescr -- channel items carry no from-member; a signed one falls back to the stored author (verified attribution) member_ <- maybe (resolveAuthor signedByGMId_) (pure . Just) (chatItemRcvFromMember ci) - processContentItem member_ ci mc fInvDescr_ signedMsg_ + processContentItem member_ ci mc fInvDescr_ | otherwise -> pure [] (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file, meta = CIMeta {showGroupAsSender}}) -> do fInvDescr_ <- join <$> forM file getSndFileInvDescr let member_ = if showGroupAsSender && isNothing signedMsg_ then Nothing else Just membership - processContentItem member_ ci mc fInvDescr_ signedMsg_ + processContentItem member_ ci mc fInvDescr_ _ -> pure [] where resolveAuthor :: Maybe GroupMemberId -> CM (Maybe GroupMember) @@ -1406,8 +1406,8 @@ sendHistory user gInfo@GroupInfo {membership} m@GroupMember {activeConn = Just c fInv = xftpFileInvitation fileName fileSize fInvDescr in Just (fInv, fileDescrText) | otherwise = Nothing - processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> Maybe SignedMsg -> CM [(GrpMsgForward, VerifiedMsg 'Json)] - processContentItem member_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ signedMsg_ = + processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [(GrpMsgForward, VerifiedMsg 'Json)] + processContentItem member_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 4e03d03958..28515aa068 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -650,10 +650,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do groupInfo <- withStore $ \db -> getGroupInfo db cxt user groupId subMode <- chatReadVar subscriptionMode - groupConnIds@(cmdId, connId) <- prepareAgentCreation user CFCreateConnGrpInv True SCMInvitation + groupConnIds@(cmdId, grpConnId) <- prepareAgentCreation user CFCreateConnGrpInv True SCMInvitation gVar <- asks random withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode - withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId True SCMInvitation CR.IKPQOff subMode + withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) grpConnId True SCMInvitation CR.IKPQOff subMode -- TODO REMOVE LEGACY ^^^ SENT msgId proxy -> do void $ continueSending connEntity conn From 2ea5940e81d94470ebfae61ad149c8c718f31042 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:35:13 +0400 Subject: [PATCH 03/13] core, ui: per-server roles for self-hosted servers (#7254) * core, ui: plan per-server roles for self-hosted servers * core: add per-server roles field to UserServer * core: add nullable role columns to protocol_servers * core: persist per-server roles * core: validate server coverage using per-server roles * test: cover per-server roles resolution and coverage * multiplatform: per-server role toggles for self-hosted servers * ios: per-server role toggles for self-hosted servers * core: per-server role overrides with per-role defaults * test: cover three-state per-server role resolution * fix: derive Eq for ServerRolesOverride * multiplatform: three-state role dropdowns on saved servers * ios: three-state role pickers on saved servers * test: per-server roles independent across two servers * test: enable names role in name-resolution tests * style: trim comments in per-server roles code * chore: rename server_roles migration to 20260716 (last) * core: per-server roles override operator roles, inherit when unset * multiplatform: per-server role default inherits from operator * ios: per-server role default inherits from operator * refactor(servers): tidy per-server roles per review - dedup no-operator default into ServerRoles.noOperatorDefault (Kotlin/Swift) - iOS: move roles-section control flow to the call site via a named gate, and parse the server address once instead of up to three times - Kotlin: collapse redundant derivedState; revert defaultOn->default rename for cross-platform parity - drop unused Hashable conformance on Swift ServerRoles - add agentServerCfgs test for names inheritance from an operator - remove no-op enableNamesRole calls from dormant DirectoryTests - fix ChatClient import ordering * chore(migration): date server_roles migration 20260720 * only show roles when server is enabled, move section above QR code --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/AppAPITypes.swift | 17 +- .../ProtocolServerView.swift | 33 ++ .../chat/simplex/common/model/SimpleXAPI.kt | 19 +- .../networkAndServers/ProtocolServerView.kt | 53 ++- ...self-hosted-server-roles-implementation.md | 342 ++++++++++++++++++ ...-07-14-self-hosted-server-roles-product.md | 138 +++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/Operators.hs | 42 ++- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260720_server_roles.hs | 23 ++ .../Store/Postgres/Migrations/chat_schema.sql | 5 +- src/Simplex/Chat/Store/Profiles.hs | 44 ++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260720_server_roles.hs | 22 ++ .../Store/SQLite/Migrations/chat_schema.sql | 3 + src/Simplex/Chat/View.hs | 18 +- tests/ChatClient.hs | 27 +- tests/ChatTests/Names.hs | 11 + tests/OperatorTests.hs | 81 ++++- 19 files changed, 834 insertions(+), 54 deletions(-) create mode 100644 plans/2026-07-14-self-hosted-server-roles-implementation.md create mode 100644 plans/2026-07-14-self-hosted-server-roles-product.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260720_server_roles.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260720_server_roles.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 5707cf5d15..40b88ec338 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1813,6 +1813,15 @@ struct ServerRoles: Equatable, Codable { var storage: Bool var proxy: Bool var names: Bool + + // roles applied when a server matches no operator, mirrors core resolveServerRoles (Operators.hs) + static let noOperatorDefault = ServerRoles(storage: true, proxy: true, names: false) +} + +struct ServerRolesOverride: Equatable, Codable, Hashable { + var storage: Bool? + var proxy: Bool? + var names: Bool? } struct UserOperatorServers: Identifiable, Equatable, Codable { @@ -1839,8 +1848,8 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { serverDomains: [], conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false), enabled: false, - smpRoles: ServerRoles(storage: true, proxy: true, names: true), - xftpRoles: ServerRoles(storage: true, proxy: true, names: false) + smpRoles: ServerRoles.noOperatorDefault, + xftpRoles: ServerRoles.noOperatorDefault ) } set { `operator` = newValue } @@ -1962,11 +1971,12 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable { var tested: Bool? var enabled: Bool var deleted: Bool + var roles: ServerRolesOverride = ServerRolesOverride() 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 + l.enabled == r.enabled && l.deleted == r.deleted && l.roles == r.roles } var id: String { "\(server) \(createdAt)" } @@ -2026,6 +2036,7 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable { case tested case enabled case deleted + case roles } } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 5299b7d415..b953ade0a6 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -81,6 +81,9 @@ struct ProtocolServerView: View { .textSelection(.enabled) } useServerSection(true) + if let inherited = serverRolesInherited { + serverRolesSection(inherited: inherited) + } } } } @@ -110,6 +113,9 @@ struct ProtocolServerView: View { } } useServerSection(valid) + if let inherited = serverRolesInherited { + serverRolesSection(inherited: inherited) + } if valid { Section(header: Text("Add to another device").foregroundColor(theme.colors.secondary)) { MutableQRCode(uri: $serverToEdit.server, small: true) @@ -120,6 +126,33 @@ struct ProtocolServerView: View { } } + // inherited SMP roles for the per-server roles section, nil when the section should not be shown + private var serverRolesInherited: ServerRoles? { + guard let (serverProtocol, serverOperator) = serverProtocolAndOperator(serverToEdit, userServers), + serverProtocol == .smp && serverToEdit.enabled, !serverToEdit.preset || serverToEdit.roles != ServerRolesOverride() + else { return nil } + return serverOperator?.smpRoles ?? ServerRoles.noOperatorDefault + } + + private func serverRolesSection(inherited: ServerRoles) -> some View { + Section { + rolePicker("To receive", $serverToEdit.roles.storage, defaultOn: inherited.storage) + rolePicker("For private routing", $serverToEdit.roles.proxy, defaultOn: inherited.proxy) + rolePicker("To resolve names", $serverToEdit.roles.names, defaultOn: inherited.names) + } header: { + Text("Use for messages").foregroundColor(theme.colors.secondary) + } + } + + private func rolePicker(_ title: LocalizedStringKey, _ selection: Binding, defaultOn: Bool) -> some View { + Picker(title, selection: selection) { + Text(String.localizedStringWithFormat(NSLocalizedString("default (%@)", comment: "pref value"), NSLocalizedString(defaultOn ? "yes" : "no", comment: "pref value"))).tag(Bool?.none) + Text("yes").tag(Bool?.some(true)) + Text("no").tag(Bool?.some(false)) + } + .frame(height: 36) + } + private func useServerSection(_ valid: Bool) -> some View { Section(header: Text("Use server").foregroundColor(theme.colors.secondary)) { HStack { 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 4ebfec1faa..f9438fca32 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 @@ -4647,6 +4647,18 @@ data class ServerRoles( val storage: Boolean, val proxy: Boolean, val names: Boolean +) { + companion object { + // roles applied when a server matches no operator, mirrors core resolveServerRoles (Operators.hs) + val noOperatorDefault = ServerRoles(storage = true, proxy = true, names = false) + } +} + +@Serializable +data class ServerRolesOverride( + val storage: Boolean? = null, + val proxy: Boolean? = null, + val names: Boolean? = null ) @Serializable @@ -4668,8 +4680,8 @@ data class UserOperatorServers( serverDomains = emptyList(), conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false), enabled = false, - smpRoles = ServerRoles(storage = true, proxy = true, names = true), - xftpRoles = ServerRoles(storage = true, proxy = true, names = false) + smpRoles = ServerRoles.noOperatorDefault, + xftpRoles = ServerRoles.noOperatorDefault ) companion object { @@ -4801,7 +4813,8 @@ data class UserServer( val preset: Boolean, val tested: Boolean? = null, val enabled: Boolean, - val deleted: Boolean + val deleted: Boolean, + val roles: ServerRolesOverride = ServerRolesOverride(), ) { @Transient private val createdAt: Date = Date() 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 b3326bd2e9..3c8cc9e0ce 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.text.font.FontFamily 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.model.ServerAddress.Companion.parseServerAddress import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* @@ -88,6 +89,7 @@ fun ProtocolServerView( ProtocolServerLayout( draftServer, serverProtocol, + userServers, testing.value, testServer = { testing.value = true @@ -120,6 +122,7 @@ fun ProtocolServerView( private fun ProtocolServerLayout( server: MutableState, serverProtocol: ServerProtocol, + userServers: MutableState>, testing: Boolean, testServer: () -> Unit, onDelete: () -> Unit, @@ -130,7 +133,7 @@ private fun ProtocolServerLayout( if (server.value.preset) { PresetServer(server, testing, testServer) } else { - CustomServer(server, testing, testServer, onDelete) + CustomServer(server, testing, testServer, onDelete, serverProtocol, userServers) } SectionBottomSpacer() } @@ -164,15 +167,11 @@ fun CustomServer( testing: Boolean, testServer: () -> Unit, onDelete: (() -> Unit)?, + serverProtocol: ServerProtocol? = null, + userServers: MutableState>? = null ) { val serverAddress = remember { mutableStateOf(server.value.server) } - val valid = remember { - derivedStateOf { - with(parseServerAddress(serverAddress.value)) { - this?.valid == true - } - } - } + val valid = remember { derivedStateOf { parseServerAddress(serverAddress.value)?.valid == true } } SectionView( stringResource(MR.strings.smp_servers_your_server_address), icon = painterResource(MR.images.ic_error), @@ -196,6 +195,12 @@ fun CustomServer( UseServerSection(server, valid.value, testing, testServer, onDelete) + val op = remember(server.value.server) { serverProtocolAndOperator(server.value, userServers?.value ?: listOf())?.second } + if (serverProtocol == ServerProtocol.SMP && server.value.enabled && (!server.value.preset || server.value.roles != ServerRolesOverride())) { + SectionDividerSpaced() + ServerRolesSection(server, op?.smpRoles ?: ServerRoles.noOperatorDefault) + } + if (valid.value) { SectionDividerSpaced() SectionView(stringResource(MR.strings.smp_servers_add_to_another_device)) { @@ -204,6 +209,38 @@ fun CustomServer( } } +@Composable +private fun ServerRolesSection(server: MutableState, inherited: ServerRoles) { + SectionView(stringResource(MR.strings.operator_use_for_messages)) { + RoleDropDown(stringResource(MR.strings.operator_use_for_messages_receiving), server.value.roles.storage, defaultOn = inherited.storage) { + server.value = server.value.copy(roles = server.value.roles.copy(storage = it)) + } + RoleDropDown(stringResource(MR.strings.operator_use_for_messages_private_routing), server.value.roles.proxy, defaultOn = inherited.proxy) { + server.value = server.value.copy(roles = server.value.roles.copy(proxy = it)) + } + RoleDropDown(stringResource(MR.strings.operator_use_for_names), server.value.roles.names, defaultOn = inherited.names) { + server.value = server.value.copy(roles = server.value.roles.copy(names = it)) + } + } +} + +@Composable +private fun RoleDropDown(title: String, value: Boolean?, defaultOn: Boolean, onSelected: (Boolean?) -> Unit) { + val values = remember(defaultOn, appPrefs.appLanguage.state.value) { + listOf( + null to String.format(generalGetString(MR.strings.chat_preferences_default), generalGetString(if (defaultOn) MR.strings.chat_preferences_yes else MR.strings.chat_preferences_no)), + true to generalGetString(MR.strings.chat_preferences_yes), + false to generalGetString(MR.strings.chat_preferences_no) + ) + } + ExposedDropDownSettingRow( + title, + values, + rememberUpdatedState(value), + onSelected = onSelected + ) +} + @Composable private fun UseServerSection( server: MutableState, diff --git a/plans/2026-07-14-self-hosted-server-roles-implementation.md b/plans/2026-07-14-self-hosted-server-roles-implementation.md new file mode 100644 index 0000000000..06c5064a99 --- /dev/null +++ b/plans/2026-07-14-self-hosted-server-roles-implementation.md @@ -0,0 +1,342 @@ +# Per-server roles for self-hosted servers — Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give each individual self-hosted SMP server its own receive / private-routing / name-resolution toggles, stored per server and plumbed to the agent. + +**Architecture:** Add `roles :: Maybe ServerRoles` to simplex-chat's `UserServer'`; store it in three nullable `protocol_servers` columns; resolve `Maybe → ServerRoles` in `agentServerCfgs` (default `storage+proxy` on, `names` off). No simplexmq change. Add the three toggles to custom SMP server screens on Kotlin and Swift. + +**Tech Stack:** Haskell (simplex-chat), SQLite + PostgreSQL migrations, Kotlin (multiplatform), Swift (iOS). Tests via `cabal test` (HSpec). + +**Design doc:** `plans/2026-07-14-self-hosted-server-roles-product.md` + +**Companion checkout:** simplex-chat builds against simplexmq pinned in `cabal.project:24`; the working `../simplexmq` already contains `ServerRoles{storage,proxy,names}`. To build/test locally against it, uncomment `packages: . ../simplexmq` (`cabal.project:2`). No simplexmq edits are made by this plan. + +--- + +## Chunk 1: Haskell backend (types, resolution, DB, store, validation, tests) + +### Task 1: Add per-server `roles` to `UserServer'` and resolve it in `agentServerCfgs` + +**Files:** +- Modify: `src/Simplex/Chat/Operators.hs:242-250` (type), `:331-333` (constructor), `:438-448` (resolution), add constant near `operatorRoles` (`:174`) + +- [ ] **Step 1: Add the field to `UserServer'`** (after `enabled`, before `deleted`) + +```haskell +data UserServer' s (p :: ProtocolType) = UserServer + { serverId :: DBEntityId' s, + server :: ProtoServerWithAuth p, + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + roles :: Maybe ServerRoles, + deleted :: Bool + } +``` + +- [ ] **Step 2: Add the default constant** (next to `operatorRoles`, `:174`) + +```haskell +-- Default roles for a self-hosted server without stored roles: receive + private +-- routing on, name resolution off. Also the default for newly added servers. +defaultUserServerRoles :: ServerRoles +defaultUserServerRoles = ServerRoles {storage = True, proxy = True, names = False} +``` + +- [ ] **Step 3: Set `roles = Nothing` in the sole constructor** `newUserServer_` (`:333`) + +```haskell +newUserServer_ preset enabled server = + UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, roles = Nothing, deleted = False} +``` + +- [ ] **Step 4: Resolve per-server roles in `agentServerCfgs`** (`:442-448`). Bind the field via record pattern (no `OverloadedRecordDot`; bare `roles` selector is ambiguous with `ServerCfg.roles`). + +```haskell + agentServer srv@UserServer {server, enabled, roles = srvRoles} = + case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of + Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled}) + | opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op} + | otherwise -> Nothing + Nothing -> + Just ServerCfg {server, enabled, operator = Nothing, roles = fromMaybe defaultUserServerRoles srvRoles} +``` + +- [ ] **Step 5: Drop the now-unused `allRoles` import.** `allRoles` (`Operators.hs:52`) had its only use at `:448`, which Step 4 replaces. Remove it from the import list to avoid an unused-import warning: + +```haskell +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..)) +``` + +- [ ] **Step 6: Build.** `fromMaybe` is already imported in `Operators.hs`. `ServerRoles(..)` is imported (`:52`). + +Run: `cabal build lib:simplex-chat --ghc-options -O0` +Expected: compile errors only at other `UserServer{...}` construction sites — none exist beyond `newUserServer_` and `getProtocolServers` (fixed in Task 3). Record *patterns* elsewhere (`Operators.hs:410,416,532,537,538,568`, `Profiles.hs:981`) are unaffected. + +- [ ] **Step 7: Commit** `feat(servers): add per-server roles field to UserServer` + +### Task 2: Database migrations (SQLite + Postgres) + schema + +**Files:** +- Create: `src/Simplex/Chat/Store/SQLite/Migrations/M20260714_server_roles.hs` +- Create: `src/Simplex/Chat/Store/Postgres/Migrations/M20260714_server_roles.hs` +- Modify: `src/Simplex/Chat/Store/SQLite/Migrations.hs` (import + list), `src/Simplex/Chat/Store/Postgres/Migrations.hs` (import + list) +- Modify: `src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql`, `src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql` (regenerated, see Step 5) + +- [ ] **Step 1: SQLite migration** (template: `M20260707_file_digest.hs`, `M20260603_simplex_name.hs`) + +```haskell +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260714_server_roles where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260714_server_roles :: Query +m20260714_server_roles = + [sql| +ALTER TABLE protocol_servers ADD COLUMN role_storage INTEGER; +ALTER TABLE protocol_servers ADD COLUMN role_proxy INTEGER; +ALTER TABLE protocol_servers ADD COLUMN role_names INTEGER; +|] + +down_m20260714_server_roles :: Query +down_m20260714_server_roles = + [sql| +ALTER TABLE protocol_servers DROP COLUMN role_storage; +ALTER TABLE protocol_servers DROP COLUMN role_proxy; +ALTER TABLE protocol_servers DROP COLUMN role_names; +|] +``` + +- [ ] **Step 2: Postgres migration** (template: Postgres `M20260707_file_digest.hs`; `[r|...|]` and `Text`) + +```haskell +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260714_server_roles where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260714_server_roles :: Text +m20260714_server_roles = + [r| +ALTER TABLE protocol_servers ADD COLUMN role_storage SMALLINT; +ALTER TABLE protocol_servers ADD COLUMN role_proxy SMALLINT; +ALTER TABLE protocol_servers ADD COLUMN role_names SMALLINT; +|] + +down_m20260714_server_roles :: Text +down_m20260714_server_roles = + [r| +ALTER TABLE protocol_servers DROP COLUMN role_storage; +ALTER TABLE protocol_servers DROP COLUMN role_proxy; +ALTER TABLE protocol_servers DROP COLUMN role_names; +|] +``` + +- [ ] **Step 3: Register SQLite migration** (`SQLite/Migrations.hs`): add `import ...M20260714_server_roles` after the `M20260707_file_digest` import (`:166`), and append to the list after the `20260707_file_digest` entry (`:330`) — add a comma to that line: + +```haskell + ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest), + ("20260714_server_roles", m20260714_server_roles, Just down_m20260714_server_roles) +``` + +- [ ] **Step 4: Register Postgres migration** (`Postgres/Migrations.hs`): mirror Step 3 at the import (`:43`) and list (`:84`). + +- [ ] **Step 5: Regenerate reference schema.** The repo keeps `chat_schema.sql` in sync with migrations (see the "ci: update query plans" commits). Regenerate both SQLite and Postgres `chat_schema.sql` using the repo's schema-dump script rather than hand-editing. If no script is found, hand-add the three columns to the `protocol_servers` block in both `chat_schema.sql` files (SQLite `INTEGER`, Postgres `smallint`). + +Run: `cabal build lib:simplex-chat --ghc-options -O0` +Expected: PASS. + +- [ ] **Step 6: Commit** `feat(servers): add nullable role columns to protocol_servers` + +### Task 3: Store read/write of `roles` (`Profiles.hs`) + +**Files:** +- Modify: `src/Simplex/Chat/Store/Profiles.hs:639-654` (select/read), `:656-667` (insert), `:669-679` (update) + +- [ ] **Step 1: Read roles in `getProtocolServers`.** Extend the SELECT and `toUserServer`. Store roles as three nullable `BoolInt` columns; reconstruct `Maybe ServerRoles` (all present → `Just`, else `Nothing`). + +**MUST split with `:.`.** The existing select is 8 columns → an 8-tuple. Adding 3 gives 11, but the SQLite/Postgres `FromRow` instances cap flat tuples at **10** — an 11-element flat tuple has no instance and will not compile. Parse the 3 role columns as a trailing group via `:.`. + +```haskell + [sql| + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, + role_storage, role_proxy, role_names + FROM protocol_servers + WHERE user_id = ? AND protocol = ? + |] +``` + +```haskell + toUserServer :: ((DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) :. (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt)) -> UserServer p + toUserServer ((serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) :. (rStorage, rProxy, rNames)) = + let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) + roles = ServerRoles <$> (unBI <$> rStorage) <*> (unBI <$> rProxy) <*> (unBI <$> rNames) + in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, roles, deleted = False} +``` + +`ServerRoles <$> mA <*> mB <*> mC :: Maybe ServerRoles` (Applicative on `Maybe`) yields `Just` only when all three are present, else `Nothing` — matching the all-or-none storage. Constructor arg order (`storage, proxy, names`) matches the column order. Ensure `:.` (from the DB simple package) and `ServerRoles(..)` are imported in `Profiles.hs`. + +- [ ] **Step 2: Write roles in `insertProtocolServer`.** Add the columns and values (extract `roles` via record pattern): + +```haskell +insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled, roles} = do + DB.execute + db + [sql| + INSERT INTO protocol_servers + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, + role_storage, role_proxy, role_names, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (userId, ts, ts)) +``` + +- [ ] **Step 3: Write roles in `updateProtocolServer`** similarly: + +```haskell +updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled, roles} = + DB.execute + db + [sql| + UPDATE protocol_servers + SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, + preset = ?, tested = ?, enabled = ?, + role_storage = ?, role_proxy = ?, role_names = ?, updated_at = ? + WHERE smp_server_id = ? + |] + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (ts, serverId)) +``` + +- [ ] **Step 4: Add the `roleColumns` helper** (private, in `Profiles.hs`). Map each selector over the `Maybe` — no two-branch `case`, so the all-or-none property is structural (a `Nothing` maps to three `Nothing`s automatically): + +```haskell +roleColumns :: Maybe ServerRoles -> (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt) +roleColumns mr = (BI . storage <$> mr, BI . proxy <$> mr, BI . names <$> mr) +``` + +(`storage`/`proxy`/`names` are unambiguous selectors — unique to `ServerRoles`. If a future duplicate makes them ambiguous under `DuplicateRecordFields`, fall back to a punned `\case`.) + +- [ ] **Step 5: Build.** `cabal build lib:simplex-chat --ghc-options -O0` → PASS. +- [ ] **Step 6: Commit** `feat(servers): persist per-server roles` + +### Task 4: Update validation for the custom (self-hosted) bucket + +**Files:** +- Modify: `src/Simplex/Chat/Operators.hs:524-532` (`noServersErrs`, `hasRole`), `:564-569` (`noNamesServersWarns`, `namesEnabled`) + +This task **fuses two parallel role-coverage helpers into one.** `hasRole` (`:531`, storage/proxy) and `namesEnabled` (`:569`, names) are the same group-level check specialised to different role selectors, both returning `True` for the custom bucket. They cannot express per-server roles, so both are replaced by a single role-parameterised `hasRoleCoverage`. + +**Preserve** (do not touch): `noServers` + `srvEnabled` (still used for the `USENoServers` empty-check at `:526`) and `opEnabled` (also used by `noChatRelaysWarns`, `:560`). **Remove:** `hasRole` and `namesEnabled`. + +- [ ] **Step 1: Make coverage checks per-server-role-aware for the no-operator bucket.** Today `hasRole`/`namesEnabled` return `True` for the custom bucket (`operator' = Nothing`) and are group-level `u -> Bool` filters combined with `noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss`. Group-level filtering **cannot** express per-server roles for the custom bucket — the evaluation must move to the per-server level. + +**Effective-role helper** — takes the protocol singleton (`operatorRoles` needs it; `noServersErrs` runs for BOTH SMP and XFTP at `:522-523`, so hardcoding `SPSMP` would read `smpRoles` for XFTP operator servers — a bug): + +```haskell +-- effective role for coverage: operator servers use operator roles (per protocol), +-- self-hosted servers use per-server roles (default when unset). +serverHasRole :: UserProtocol p => SProtocolType p -> (ServerRoles -> Bool) -> Maybe ServerOperator -> Maybe ServerRoles -> Bool +serverHasRole p roleSel op srvRoles = case op of + Just o@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p o) + Nothing -> roleSel (fromMaybe defaultUserServerRoles srvRoles) +``` + +**Restructure the checks to per-server evaluation.** For a role selector, "coverage exists" = any enabled, non-deleted server in any bucket whose effective role is on. Per bucket `u` the operator is `operator' u` and each server's roles come from `AUS _ UserServer{enabled, deleted, roles}` (the `roles` field is reachable — same pattern already used at `:532,:568`): + +```haskell + hasRoleCoverage :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> [u] -> Bool + hasRoleCoverage p roleSel = + any (\u -> any (srvOk (operator' u)) (map aUserServer' (servers' p u))) + where + srvOk op (AUS _ UserServer {enabled, deleted, roles}) = + enabled && not deleted && serverHasRole p roleSel op roles +``` + +Then rewrite `noServersErrs` (`:527`) storage/proxy branches as `[USEStorageMissing p' user | not (hasRoleCoverage p storage uss)] <> [USEProxyMissing p' user | not (hasRoleCoverage p proxy uss)]` (keep the `noServers opEnabled` empty-check at `:526` as-is), and `noNamesServersWarns` (`:565`) as `[USWNoNamesServers user | not (hasRoleCoverage SPSMP names uss)]`. Keep the exact constructors (`USEStorageMissing`, `USEProxyMissing`, `USWNoNamesServers`). XFTP `names` is never checked (names coverage is SMP-only). + +**Deliberate duplication (do not try to unify with `agentServerCfgs`).** Both this helper and `agentServerCfgs` (Task 1) resolve "operator-vs-self-hosted → effective roles", but their semantics for a *disabled operator* differ: `agentServerCfgs` drops the server entirely (`opEnabled … | otherwise -> Nothing`), whereas coverage treats it as contributing no roles while `USENoServers` handles existence. They also run over different shapes (`[ServerCfg]` per user vs. `[UserOperatorServers]` across users). Unifying them would grow the blast radius for no clarity gain; the shared logic is a 2-line `case` — leave it in both. (Flag for future: if a third consumer appears, extract `effectiveServerRoles :: SProtocolType p -> Maybe ServerOperator -> Maybe ServerRoles -> Maybe ServerRoles`.) + +- [ ] **Step 2: Build + reason through each call site.** Ensure `noServers`/`noNamesServers` now consult per-server roles for the custom bucket. `cabal build lib:simplex-chat --ghc-options -O0` → PASS. +- [ ] **Step 3: Commit** `fix(servers): validate coverage using per-server roles` + +### Task 5: Haskell tests + +**Files:** +- Modify/Create: server-config test module (locate the existing `agentServerCfgs` / operators test; e.g. under `tests/` — search `agentServerCfgs`, `validateUserServers`, `Operators`). If none, add `tests/ChatTests/ServerRolesTest.hs` and register it in the test runner. + +- [ ] **Step 1: Write failing tests.** + - `agentServerCfgs` for a self-hosted SMP server with `roles = Just (ServerRoles True False True)` → `ServerCfg.roles == ServerRoles True False True`, `operator == Nothing`. + - self-hosted with `roles = Nothing` → `ServerCfg.roles == defaultUserServerRoles` (`names == False`). + - operator-matched server ignores per-server `roles` → uses `operatorRoles`. + - store round-trip: insert a self-hosted server with `Just` roles, read back equal; insert with `Nothing`, read back `Nothing`. + - `validateUserServers`: custom-bucket-only user with all servers `names = False` → `USWNoNamesServers` present; enabling `names` on one → absent. +- [ ] **Step 2: Run, expect FAIL.** `cabal test --test-option=--match="/ServerRoles/"` +- [ ] **Step 3: (Implementation already done in Tasks 1–4.)** Run, expect PASS. +- [ ] **Step 4: Commit** `test(servers): cover per-server roles resolution and storage` + +--- + +## Chunk 2: UI (Kotlin + Swift) + +### Task 6: Kotlin (Desktop/Android) model + toggles + +**Files:** +- Modify: `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt:4787` (`UserServer`), `:4808,4818-4845` (sample/empty), and `.../networkAndServers/ScanProtocolServer.kt:22` +- Modify: `.../networkAndServers/ProtocolServerView.kt` (`UseServerSection` / `CustomServer`, ~`:161-234`) and `.../networkAndServers/NewServerView.kt` +- Reference: `.../networkAndServers/OperatorView.kt:260-320` (toggle pattern), `ServerRoles` (`SimpleXAPI.kt:4636`) + +- [ ] **Step 1: Add `roles` to `UserServer`** with default to avoid breaking constructors: + +```kotlin +val roles: ServerRoles? = null, +``` +Place it before `deleted`; update the primary constructor and the named-arg call sites (`empty` `:4808`, samples `:4818-4845`, `ScanProtocolServer.kt:22`). Since it has a default, unchanged call sites still compile. + +- [ ] **Step 2: Add the toggles to custom SMP servers.** In `ProtocolServerView.kt`, inside `CustomServer` / `UseServerSection`, when `server.protocol == smp && !server.preset`, add a `SectionView(MR.strings.operator_use_for_messages)` with three `PreferenceToggle` rows bound to the server's roles (receive → `storage`, private routing → `proxy`, names → `names`), mirroring `OperatorView.kt:261-320`. Editing writes back a new `ServerRoles` onto the edited `UserServer` state. Default the displayed roles to `ServerRoles(storage = true, proxy = true, names = false)` when `roles == null`. + - Strings: `operator_use_for_messages`, `operator_use_for_messages_receiving`, `operator_use_for_messages_private_routing`, `operator_use_for_names` (already exist, `strings.xml:2195-2198`). + - Do NOT show for XFTP or preset/operator servers. + +- [ ] **Step 3: New-server default.** In `NewServerView.kt`, construct the new SMP `UserServer` with `roles = ServerRoles(storage = true, proxy = true, names = false)` so the toggles show the default and persist on save. + +- [ ] **Step 4: Build.** Compile the multiplatform common module (repo's gradle/build command). +- [ ] **Step 5: Commit** `feat(servers): per-server role toggles on Android/desktop` + +### Task 7: Swift (iOS) model + toggles + +**Files:** +- Modify: `apps/ios/Shared/Model/AppAPITypes.swift:1953` (`UserServer` struct + `CodingKeys` `:2017`) +- Modify: `apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift` (`customServer()` / `useServerSection` `:88,123`) and `NewServerView.swift` +- Reference: `.../NetworkAndServers/OperatorView.swift:106-114` (toggle pattern), `ServerRoles` (`AppAPITypes.swift:1807`) + +- [ ] **Step 1: Add `roles` to `UserServer`.** + +```swift +var roles: ServerRoles? +``` +Add `case roles` to `CodingKeys` (`:2017`). Update initializers / sample data accordingly. + +- [ ] **Step 2: Add the toggles to custom SMP servers.** In `ProtocolServerView.swift`, inside `customServer()` / `useServerSection`, when the server is SMP and `!preset`, add a `Section("Use for messages")` with three `Toggle`s bound to the server's roles (`"To receive"` → `storage`, `"For private routing"` → `proxy`, `"To resolve names"` → `names`), mirroring `OperatorView.swift:106-114`. Bind to the edited server's `roles`, defaulting to `ServerRoles(storage: true, proxy: true, names: false)` when `nil`. + - iOS string keys are the literal English text (already used by `OperatorView.swift`); `"To resolve names"` may still need adding to `Localizable.strings` translations (English == key). + - Do NOT show for XFTP or preset/operator servers. + +- [ ] **Step 3: New-server default.** In `NewServerView.swift`, default the new SMP server's `roles` to `ServerRoles(storage: true, proxy: true, names: false)`. + +- [ ] **Step 4: Build** the iOS target (or type-check the changed files). +- [ ] **Step 5: Commit** `feat(servers): per-server role toggles on iOS` + +--- + +## Verification (whole feature) + +- [ ] `cabal test --test-option=--match="/ServerRoles/"` passes. +- [ ] Full backend build: `cabal build --ghc-options -O0`. +- [ ] Migration up/down: apply and roll back `20260714_server_roles` on a copy DB (SQLite + Postgres); confirm existing rows read as `roles = Nothing` → resolved to `defaultUserServerRoles`. +- [ ] Manual UI: add two self-hosted SMP servers, set different role toggles on each, save, reopen — toggles are independent and persisted; name resolution defaults off; toggles absent on XFTP and operator servers. +- [ ] Round-trip via API: `APIGetUserServers` returns `roles` for edited servers and omits it (Nothing) for untouched ones. diff --git a/plans/2026-07-14-self-hosted-server-roles-product.md b/plans/2026-07-14-self-hosted-server-roles-product.md new file mode 100644 index 0000000000..d91fdc2ff2 --- /dev/null +++ b/plans/2026-07-14-self-hosted-server-roles-product.md @@ -0,0 +1,138 @@ +# Per-server roles for self-hosted servers — Product / Design + +**Date:** 2026-07-14 +**Status:** Design (approved for planning) +**Scope:** simplex-chat backend + DB + Desktop/Android (Kotlin) + iOS (Swift). **No simplexmq changes.** + +## Problem + +Self-hosted SMP servers cannot be configured individually for what they are used +for. The three capabilities — **receiving** messages (`storage` role), **private +routing** (`proxy` role), and **name resolution** (`names` role) — are controlled +only per *operator* (SimpleX / Flux), via the operator toggles in `OperatorView`. +Servers the user adds themselves fall into the "custom" / no-operator bucket and +are hard-coded to `allRoles` (all three on) in `agentServerCfgs` +(`src/Simplex/Chat/Operators.hs:448`). There is no UI, storage, or type to give a +single self-hosted server a subset of roles. + +This is a problem because a self-hosted server is usually not a name resolver +(name resolution needs an Ethereum/SNRC endpoint via `[NAMES] enable: on` on the +smp-server). Today such a server is still offered to the agent as names-capable, +so a name lookup can be routed to it and fail. Users also may want a self-hosted +server used only for receiving, or only for private routing. + +## Goal + +Give **each individual added self-hosted SMP server** its own independent toggles: +**To receive** / **For private routing** / **To resolve names**, stored per server +and plumbed to the agent as that server's `ServerRoles`. + +## Current state (verified) + +- **Agent (simplexmq) — already complete.** `ServerCfg { server, operator :: Maybe + OperatorId, enabled, roles :: ServerRoles }` and `ServerRoles { storage, proxy, + names }` exist (`Simplex/Messaging/Agent/Env/SQLite.hs:98,106`). The agent + partitions servers into `storageSrvs / proxySrvs / nameSrvs` in `mkUserServers` + and consumes them (`getNextServer`, `getSMPProxyClient`, `getNextNameServer` / + `resolveName` / RSLV). The agent always receives **concrete** `ServerRoles` and + never applies defaults. +- **simplex-chat backend.** `UserServer'` (`Operators.hs:242`) has **no roles + field**. Roles are resolved only in `agentServerCfgs` (`Operators.hs:438-448`): + operator servers → `operatorRoles p op` (read from `server_operators` columns); + self-hosted servers → hard-coded `allRoles`. +- **DB.** `protocol_servers` (`chat_schema.sql:548`) stores all user servers with + only `enabled / preset / tested` — **no operator link and no role columns**. + Roles live only per-operator in `server_operators` + (`smp_role_storage/proxy/names`, `xftp_role_storage/proxy`). +- **UI.** Operator screens (`OperatorView.kt:260-320`, `OperatorView.swift:106-114`) + have the three toggles. The individual server screens + (`ProtocolServerView`, `NewServerView` on both platforms) show only + address / test / enabled / delete. Client `UserServer` + (`SimpleXAPI.kt:4787`, `AppAPITypes.swift:1953`) has no roles field. + +## Decisions + +1. **Where the logic lives — no simplexmq change.** The accurate, consistent + approach follows the existing layering: the agent always gets a concrete + `ServerRoles`; the only place roles are resolved/defaulted is simplex-chat's + `agentServerCfgs`. We keep the `Maybe ServerRoles → ServerRoles` resolution + there, alongside the existing `allRoles` / `operatorRoles` handling. No core + version bump, no wire/protocol change. +2. **SMP self-hosted only.** SMP has all three roles; XFTP has no `names` role. + The toggles are shown only for custom (non-preset) SMP servers. XFTP + self-hosted servers are functionally unchanged. +3. **Legacy default `NULL → names OFF`.** For rows without stored roles (existing + servers after upgrade, or servers not yet edited) the resolved value is + `defaultUserServerRoles = ServerRoles { storage = True, proxy = True, names = + False }`. This is also the default for newly added servers, so migrated and new + servers behave identically: receive + private routing on, name resolution off. + +## Design + +### Types (`Operators.hs`) + +- Add `roles :: Maybe ServerRoles` to `UserServer'` (per-server; `Nothing` = use + default). `DuplicateRecordFields` is already enabled (both `ServerCfg` and + `UserServer'` will have a `roles` field). +- Add `defaultUserServerRoles :: ServerRoles = ServerRoles True True False`. +- `agentServerCfgs`, self-hosted branch (`:448`): + `roles = fromMaybe defaultUserServerRoles (roles srv)`. Operator branch + unchanged — the per-server field is ignored for operator-matched servers, so + operator roles still win. + +Per-server, not global: `roles` is one value per `UserServer`; each server +resolves to its own `ServerCfg`. + +### Storage (`protocol_servers`) + +Three **nullable** columns (mirrors `server_operators`): `role_storage`, +`role_proxy`, `role_names` (INTEGER, no default → existing rows NULL). Read into +`Maybe ServerRoles` (all three present → `Just`, else `Nothing`); write all three +from `roles`. + +### chat↔UI API + +`UserServer'` JSON is derived with `defaultJSON` (`omitNothingFields = True`), so +the new `Maybe` `roles` field is omitted when `Nothing` — exactly like the +existing `tested :: Maybe Bool`. Older clients/servers remain compatible. + +### Validation (`Operators.hs:519-577`) + +`noNamesServersWarns` (`:564-569`) and `noServersErrs`'s `hasRole` (`:531`) +currently derive coverage from the *operator* and treat the no-operator (custom) +bucket as `True` for every role. With per-server roles and names-off default this +would be wrong (a self-hosted-only user would never see "no name servers"). These +must evaluate the custom bucket from each server's resolved per-server `roles`. + +### UI (Kotlin + Swift) + +- Add `roles: ServerRoles?` to the client `UserServer`. +- On custom (non-preset) **SMP** server screens only — `ProtocolServerView` and + `NewServerView`, both platforms — add a "Use for messages" section with the + three toggles, mirroring `OperatorView`. Reuse existing string keys: + `operator_use_for_messages`, `operator_use_for_messages_receiving`, + `operator_use_for_messages_private_routing`, `operator_use_for_names`. +- New server default: `storage = on, proxy = on, names = off`. + +## Behavior / backward compatibility + +- Existing self-hosted servers keep receive + private routing; name resolution is + off after upgrade (intentional — avoids routing lookups to non-resolver + servers). A user who wants a self-hosted resolver enables the toggle. +- No protocol / wire change; no simplexmq change; operator behavior unchanged. + +## Out of scope + +- Per-server roles for operator (SimpleX/Flux) servers — they keep operator-level + roles. +- XFTP per-server roles. +- Any change to the name-resolution protocol or the agent. + +## Testing strategy + +- Haskell: unit tests for `agentServerCfgs` (self-hosted `Just`/`Nothing` → + correct `ServerCfg.roles`; operator servers ignore per-server roles), store + round-trip of `roles` through `protocol_servers`, and `validateUserServers` + names/storage/proxy coverage for the custom bucket. +- UI: manual verification that toggles render only for custom SMP servers, persist + per server, default names-off, and survive save/reload. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 9cd2e1dd7f..f410089bff 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -151,6 +151,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history + Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles else exposed-modules: Simplex.Chat.Archive @@ -319,6 +320,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history + Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index ad03529613..a216bbb1fc 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -49,7 +49,7 @@ import Simplex.Chat.Operators.Conditions import Simplex.Chat.Protocol (RelayCapabilities (..), RelayProfile (..)) import Simplex.Chat.Types (ShortLinkContact, User) import Simplex.Chat.Types.Shared (RelayStatus) -import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..)) import Simplex.Messaging.Agent.Protocol (sameShortLinkContact) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) import Simplex.Messaging.Agent.Store.Entity @@ -176,6 +176,22 @@ operatorRoles p op = case p of SPSMP -> smpRoles op SPXFTP -> xftpRoles op +data ServerRolesOverride = ServerRolesOverride + { storage :: Maybe Bool, + proxy :: Maybe Bool, + names :: Maybe Bool + } + deriving (Eq, Show) + +emptyServerRolesOverride :: ServerRolesOverride +emptyServerRolesOverride = ServerRolesOverride {storage = Nothing, proxy = Nothing, names = Nothing} + +-- each role: override if set, else the operator's role (if any), else default (receive on, proxy on, names off) +resolveServerRoles :: UserProtocol p => SProtocolType p -> Maybe ServerOperator -> ServerRolesOverride -> ServerRoles +resolveServerRoles p op ServerRolesOverride {storage, proxy, names} = + ServerRoles {storage = fromMaybe s storage, proxy = fromMaybe pr proxy, names = fromMaybe n names} + where ServerRoles {storage = s, proxy = pr, names = n} = maybe (ServerRoles True True False) (operatorRoles p) op + conditionsAccepted :: ServerOperator -> Bool conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAcceptance of CAAccepted {} -> True @@ -245,6 +261,7 @@ data UserServer' s (p :: ProtocolType) = UserServer preset :: Bool, tested :: Maybe Bool, enabled :: Bool, + roles :: ServerRolesOverride, deleted :: Bool } deriving (Show) @@ -330,7 +347,7 @@ newUserServer = newUserServer_ False True newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p newUserServer_ preset enabled server = - UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} + UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, roles = emptyServerRolesOverride, deleted = False} presetChatRelay :: Bool -> RelayProfile -> [Text] -> ShortLinkContact -> NewUserChatRelay presetChatRelay = newChatRelay_ True @@ -439,13 +456,13 @@ agentServerCfgs :: UserProtocol p => SProtocolType p -> [(Text, ServerOperator)] agentServerCfgs p opDomains = mapMaybe agentServer where agentServer :: UserServer' s p -> Maybe (ServerCfg p) - agentServer srv@UserServer {server, enabled} = + agentServer srv@UserServer {server, enabled, roles = srvRoles} = case find (\(d, _) -> any (matchingHost d) (srvHost srv)) opDomains of Just (_, op@ServerOperator {operatorId = DBEntityId opId, enabled = opEnabled}) - | opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = operatorRoles p op} + | opEnabled -> Just ServerCfg {server, enabled, operator = Just opId, roles = resolveServerRoles p (Just op) srvRoles} | otherwise -> Nothing Nothing -> - Just ServerCfg {server, enabled, operator = Nothing, roles = allRoles} + Just ServerCfg {server, enabled, operator = Nothing, roles = resolveServerRoles p Nothing srvRoles} matchingHost :: Text -> TransportHost -> Bool matchingHost d = \case @@ -524,11 +541,10 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] noServersErrs p user uss | noServers opEnabled = [USENoServers p' user] - | otherwise = [USEStorageMissing p' user | noServers (hasRole storage)] <> [USEProxyMissing p' user | noServers (hasRole proxy)] + | otherwise = [USEStorageMissing p' user | not (any (hasRole p (\ServerRoles {storage} -> storage)) uss)] <> [USEProxyMissing p' user | not (any (hasRole p (\ServerRoles {proxy} -> proxy)) uss)] where p' = AProtocolType p noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss - hasRole roleSel = maybe True (\op@ServerOperator {enabled} -> enabled && roleSel (operatorRoles p op)) . operator' srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted serverErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [u] -> [UserServersError] serverErrs p uss = mapMaybe duplicateErr_ srvs @@ -542,6 +558,10 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] userServers p = map aUserServer' . concatMap (servers' p) + -- a group covers a role if its operator is enabled and some enabled server resolves it on + hasRole :: (UserServersClass u, UserProtocol p) => SProtocolType p -> (ServerRoles -> Bool) -> u -> Bool + hasRole p roleSel u = + opEnabled u && any (\(AUS _ UserServer {enabled, deleted, roles}) -> enabled && not deleted && roleSel (resolveServerRoles p (operator' u) roles)) (map aUserServer' (servers' p u)) chatRelayErrs :: UserServersClass u => [u] -> [UserServersError] chatRelayErrs uss = concatMap duplicateErrs_ cRelays where @@ -562,11 +582,7 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other noChatRelays cond = not $ any relayEnabled $ userChatRelays $ filter cond uss relayEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted noNamesServersWarns :: UserServersClass u => Maybe User -> [u] -> [UserServersWarning] - noNamesServersWarns user uss = [USWNoNamesServers user | noNamesServers] - where - noNamesServers = not $ any srvEnabled $ userServers SPSMP $ filter namesEnabled uss - srvEnabled (AUS _ UserServer {deleted, enabled}) = enabled && not deleted - namesEnabled = maybe True (\op@ServerOperator {enabled} -> enabled && names (operatorRoles SPSMP op)) . operator' + noNamesServersWarns user uss = [USWNoNamesServers user | not (any (hasRole SPSMP (\ServerRoles {names} -> names)) uss)] userChatRelays :: UserServersClass u => [u] -> [AUserChatRelay] userChatRelays = map aUserChatRelay' . concatMap chatRelays' opEnabled :: UserServersClass u => u -> Bool @@ -591,6 +607,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "UCA") ''UsageConditionsAction) $(JQ.deriveJSON defaultJSON ''ServerOperatorConditions) +$(JQ.deriveJSON defaultJSON ''ServerRolesOverride) + instance ProtocolTypeI p => ToJSON (UserServer' s p) where toEncoding = $(JQ.mkToEncoding defaultJSON ''UserServer') toJSON = $(JQ.mkToJSON defaultJSON ''UserServer') diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 58e2d9ea45..3131cbd245 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -44,6 +44,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest import Simplex.Chat.Store.Postgres.Migrations.M20260714_member_security_code import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history +import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -87,7 +88,8 @@ schemaMigrations = ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest), ("20260714_member_security_code", m20260714_member_security_code, Just down_m20260714_member_security_code), ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), - ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history) + ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), + ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260720_server_roles.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260720_server_roles.hs new file mode 100644 index 0000000000..dcfb532e03 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260720_server_roles.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260720_server_roles :: Text +m20260720_server_roles = + [r| +ALTER TABLE protocol_servers ADD COLUMN role_storage SMALLINT; +ALTER TABLE protocol_servers ADD COLUMN role_proxy SMALLINT; +ALTER TABLE protocol_servers ADD COLUMN role_names SMALLINT; +|] + +down_m20260720_server_roles :: Text +down_m20260720_server_roles = + [r| +ALTER TABLE protocol_servers DROP COLUMN role_storage; +ALTER TABLE protocol_servers DROP COLUMN role_proxy; +ALTER TABLE protocol_servers DROP COLUMN role_names; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 9489d1bcd7..e901c8858e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1187,7 +1187,10 @@ CREATE TABLE test_chat_schema.protocol_servers ( user_id bigint NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, - protocol text DEFAULT 'smp'::text NOT NULL + protocol text DEFAULT 'smp'::text NOT NULL, + role_storage smallint, + role_proxy smallint, + role_names smallint ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 19120bca27..ce6a8c4c9f 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -642,41 +642,45 @@ getProtocolServers db p User {userId} = <$> DB.query db [sql| - SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, + role_storage, role_proxy, role_names FROM protocol_servers WHERE user_id = ? AND protocol = ? |] (userId, decodeLatin1 $ strEncode p) where - toUserServer :: (DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) -> UserServer p - toUserServer (serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) = + toUserServer :: ((DBEntityId, NonEmpty TransportHost, String, C.KeyHash, Maybe Text, BoolInt, Maybe BoolInt, BoolInt) :. (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt)) -> UserServer p + toUserServer ((serverId, host, port, keyHash, auth_, BI preset, tested, BI enabled) :. (rStorage, rProxy, rNames)) = let server = ProtoServerWithAuth (ProtocolServer p host port keyHash) (BasicAuth . encodeUtf8 <$> auth_) - in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, deleted = False} + roles = ServerRolesOverride (unBI <$> rStorage) (unBI <$> rProxy) (unBI <$> rNames) + in UserServer {serverId, server, preset, tested = unBI <$> tested, enabled, roles, deleted = False} insertProtocolServer :: forall p. ProtocolTypeI p => DB.Connection -> SProtocolType p -> User -> UTCTime -> NewUserServer p -> IO (UserServer p) -insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled} = do +insertProtocolServer db p User {userId} ts srv@UserServer {server, preset, tested, enabled, roles} = do DB.execute db [sql| INSERT INTO protocol_servers - (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, + role_storage, role_proxy, role_names, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, userId, ts, ts)) + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (userId, ts, ts)) sId <- insertedRowId db pure (srv :: NewUserServer p) {serverId = DBEntityId sId} updateProtocolServer :: ProtocolTypeI p => DB.Connection -> SProtocolType p -> UTCTime -> UserServer p -> IO () -updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled} = +updateProtocolServer db p ts UserServer {serverId, server, preset, tested, enabled, roles} = DB.execute db [sql| UPDATE protocol_servers SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, - preset = ?, tested = ?, enabled = ?, updated_at = ? + preset = ?, tested = ?, enabled = ?, + role_storage = ?, role_proxy = ?, role_names = ?, updated_at = ? WHERE smp_server_id = ? |] - (serverColumns p server :. (BI preset, BI <$> tested, BI enabled, ts, serverId)) + (serverColumns p server :. (BI preset, BI <$> tested, BI enabled) :. roleColumns roles :. (ts, serverId)) serverColumns :: ProtocolTypeI p => SProtocolType p -> ProtoServerWithAuth p -> (Text, NonEmpty TransportHost, String, C.KeyHash, Maybe Text) serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) = @@ -684,6 +688,9 @@ serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) auth = safeDecodeUtf8 . unBasicAuth <$> auth_ in (protocol, host, port, keyHash, auth) +roleColumns :: ServerRolesOverride -> (Maybe BoolInt, Maybe BoolInt, Maybe BoolInt) +roleColumns ServerRolesOverride {storage, proxy, names} = (BI <$> storage, BI <$> proxy, BI <$> names) + getChatRelays :: DB.Connection -> User -> IO [UserChatRelay] getChatRelays db User {userId} = map toChatRelay @@ -772,7 +779,10 @@ updateServerOperator db currentTs ServerOperator {operatorId, enabled, smpRoles, SET enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ?, updated_at = ? WHERE server_operator_id = ? |] - (BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), currentTs, operatorId) + (BI enabled, BI smpStorage, BI smpProxy, BI smpNames, BI xftpStorage, BI xftpProxy, currentTs, operatorId) + where + ServerRoles {storage = smpStorage, proxy = smpProxy, names = smpNames} = smpRoles + ServerRoles {storage = xftpStorage, proxy = xftpProxy} = xftpRoles getUpdateServerOperators :: DB.Connection -> NonEmpty PresetOperator -> Bool -> IO [(Maybe PresetOperator, Maybe ServerOperator)] getUpdateServerOperators db presetOps newUser = do @@ -810,7 +820,10 @@ getUpdateServerOperators db presetOps newUser = do SET trade_name = ?, legal_name = ?, server_domains = ?, enabled = ?, smp_role_storage = ?, smp_role_proxy = ?, smp_role_names = ?, xftp_role_storage = ?, xftp_role_proxy = ? WHERE server_operator_id = ? |] - (tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles), operatorId) + (tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI smpStorage, BI smpProxy, BI smpNames, BI xftpStorage, BI xftpProxy, operatorId) + where + ServerRoles {storage = smpStorage, proxy = smpProxy, names = smpNames} = smpRoles + ServerRoles {storage = xftpStorage, proxy = xftpProxy} = xftpRoles insertOperator :: NewServerOperator -> IO ServerOperator insertOperator op@ServerOperator {operatorTag, tradeName, legalName, serverDomains, enabled, smpRoles, xftpRoles} = do DB.execute @@ -820,9 +833,12 @@ getUpdateServerOperators db presetOps newUser = do (server_operator_tag, trade_name, legal_name, server_domains, enabled, smp_role_storage, smp_role_proxy, smp_role_names, xftp_role_storage, xftp_role_proxy) VALUES (?,?,?,?,?,?,?,?,?,?) |] - (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI (storage smpRoles), BI (proxy smpRoles), BI (names smpRoles), BI (storage xftpRoles), BI (proxy xftpRoles)) + (operatorTag, tradeName, legalName, T.intercalate "," serverDomains, BI enabled, BI smpStorage, BI smpProxy, BI smpNames, BI xftpStorage, BI xftpProxy) opId <- insertedRowId db pure op {operatorId = DBEntityId opId} + where + ServerRoles {storage = smpStorage, proxy = smpProxy, names = smpNames} = smpRoles + ServerRoles {storage = xftpStorage, proxy = xftpProxy} = xftpRoles autoAcceptConditions op UsageConditions {conditionsCommit} now = acceptConditions_ db op conditionsCommit now True $> op {conditionsAcceptance = CAAccepted (Just now) True} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index f9a27f93af..7fc18617e7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -167,6 +167,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest import Simplex.Chat.Store.SQLite.Migrations.M20260714_member_security_code import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history +import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -333,7 +334,8 @@ schemaMigrations = ("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest), ("20260714_member_security_code", m20260714_member_security_code, Just down_m20260714_member_security_code), ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), - ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history) + ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), + ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260720_server_roles.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260720_server_roles.hs new file mode 100644 index 0000000000..ca6982593a --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260720_server_roles.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260720_server_roles :: Query +m20260720_server_roles = + [sql| +ALTER TABLE protocol_servers ADD COLUMN role_storage INTEGER; +ALTER TABLE protocol_servers ADD COLUMN role_proxy INTEGER; +ALTER TABLE protocol_servers ADD COLUMN role_names INTEGER; +|] + +down_m20260720_server_roles :: Query +down_m20260720_server_roles = + [sql| +ALTER TABLE protocol_servers DROP COLUMN role_storage; +ALTER TABLE protocol_servers DROP COLUMN role_proxy; +ALTER TABLE protocol_servers DROP COLUMN role_names; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 1c5a5bb445..e42b537225 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -565,6 +565,9 @@ CREATE TABLE IF NOT EXISTS "protocol_servers"( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')), protocol TEXT NOT NULL DEFAULT 'smp', + role_storage INTEGER, + role_proxy INTEGER, + role_names INTEGER, UNIQUE(user_id, host, port) ) STRICT; CREATE TABLE xftp_file_descriptions( diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c136a08c7d..3cf7554cf5 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1659,12 +1659,12 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRela testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested viewRoles op@ServerOperator {enabled} | not enabled = "disabled" - | storage rs && proxy rs = "enabled" - | storage rs = "enabled storage" - | proxy rs = "enabled proxy" + | rStorage && rProxy = "enabled" + | rStorage = "enabled storage" + | rProxy = "enabled proxy" | otherwise = "disabled (servers known)" where - rs = operatorRoles p op + ServerRoles {storage = rStorage, proxy = rProxy} = operatorRoles p op viewChatRelays :: [UserChatRelay] -> [StyledString] viewChatRelays [] = [] viewChatRelays cRelays @@ -1752,12 +1752,12 @@ viewOpEnabled ServerOperator {enabled, smpRoles, xftpRoles} | both smpRoles && both xftpRoles = "enabled" | otherwise = "SMP " <> viewRoles smpRoles <> ", XFTP " <> viewRoles xftpRoles where - no rs = not $ storage rs || proxy rs - both rs = storage rs && proxy rs - viewRoles rs + no ServerRoles {storage, proxy} = not $ storage || proxy + both ServerRoles {storage, proxy} = storage && proxy + viewRoles rs@ServerRoles {storage, proxy} | both rs = "enabled" - | storage rs = "enabled storage" - | proxy rs = "enabled proxy" + | storage = "enabled storage" + | proxy = "enabled proxy" | otherwise = "disabled (servers known)" viewConditionsAction :: UsageConditionsAction -> [StyledString] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 029c637980..77d91bf522 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -1,4 +1,5 @@ {-# LANGUAGE CPP #-} +{-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} @@ -23,15 +24,17 @@ import Control.Monad.Except import Control.Monad.Reader import Data.Functor (($>)) import Data.List (dropWhileEnd, find) +import qualified Data.List.NonEmpty as L import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import Data.Time.Clock (getCurrentTime) import Network.Socket import Simplex.Chat -import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), WebPreviewConfig (..), defaultSimpleNetCfg) +import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), ChatController (..), ChatDatabase (..), ChatLogLevel (..), ChatResponse (..), WebPreviewConfig (..), defaultSimpleNetCfg) import Simplex.Chat.Core import Simplex.Chat.Library.Commands +import Simplex.Chat.Operators import Simplex.Chat.Options import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (currentChatVersion, pqEncryptionCompressionVersion) @@ -50,6 +53,7 @@ import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.Protocol (currentSMPAgentVersion, duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval +import Simplex.Messaging.Agent.Store.Entity (SDBStored (..)) import Simplex.Messaging.Agent.Store.Interface (closeDBStore) import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..), MigrationError) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -57,6 +61,7 @@ import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) import qualified Simplex.Messaging.Crypto.Ratchet as CR +import Simplex.Messaging.Protocol (ProtocolType (..)) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) import NameResolver (NameRegistry, resolverNamesConfig, withNameResolver) @@ -394,6 +399,26 @@ withTestChatCfgOpts ps cfg opts dbPrefix = bracket (startTestChat ps cfg opts db withTestOutput :: HasCallStack => (HasCallStack => TestParams -> IO ()) -> TestParams -> IO () withTestOutput test ps = test ps {printOutput = True} +-- Opt the client's SMP servers into name resolution (self-hosted servers default names off). +enableNamesRole :: HasCallStack => TestCC -> IO () +enableNamesRole TestCC {chatController = cc} = do + r <- execChatCommand' (APIGetUserServers 1) 0 `runReaderT` cc + case r of + Right (CRUserServers _ uoss) -> do + r' <- execChatCommand' (APISetUserServers 1 (L.fromList (map toUpdated uoss))) 0 `runReaderT` cc + either (fail . show) (const $ pure ()) r' + Right other -> fail $ "enableNamesRole: unexpected response " <> show other + Left e -> fail $ "enableNamesRole: APIGetUserServers failed " <> show e + where + toUpdated UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = + UpdatedUserOperatorServers + { operator, + smpServers = map (AUS SDBStored . enableNames) smpServers, + xftpServers = map (AUS SDBStored) xftpServers, + chatRelays = map (AUCR SDBStored) chatRelays + } + enableNames srv@UserServer {roles} = (srv :: UserServer 'PSMP) {roles = (roles :: ServerRolesOverride) {names = Just True}} + readTerminalOutput :: VirtualTerminal -> TQueue String -> IO () readTerminalOutput t termQ = do let w = virtualWindow t diff --git a/tests/ChatTests/Names.hs b/tests/ChatTests/Names.hs index ce0343800c..46b62801fe 100644 --- a/tests/ChatTests/Names.hs +++ b/tests/ChatTests/Names.hs @@ -33,6 +33,7 @@ testConnectByName ps = withSmpServerAndNames $ \reg -> where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) test reg alice bob = do + mapM_ enableNamesRole [alice, bob] alice ##> "/ad" (shortLink, _) <- getContactLinks alice True registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) @@ -67,6 +68,7 @@ testConnectByNameNotClaimed ps = withSmpServerAndNames $ \reg -> where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) test reg alice bob = do + mapM_ enableNamesRole [alice, bob] alice ##> "/ad" (shortLink, _) <- getContactLinks alice True registerName reg aliceName (contactNameRecord "alice" (T.pack shortLink)) @@ -79,6 +81,7 @@ testConnectByNameKnownContactNotClaimed ps = withSmpServerAndNames $ \reg -> where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) test reg alice bob = do + mapM_ enableNamesRole [alice, bob] alice ##> "/ad" (shortLink, _) <- getContactLinks alice True bob ##> ("/c " <> shortLink) @@ -100,6 +103,7 @@ testConnectByNameNotFound ps = withSmpServerAndNames $ \_reg -> testChat2 aliceProfile bobProfile test ps where test _alice bob = do + enableNamesRole bob bob ##> "/c @nobody.simplex" bob .<## "smpErr = NAME {nameErr = NOT_FOUND}}" @@ -109,6 +113,7 @@ testSetNameNotOwnAddress ps = withSmpServerAndNames $ \reg -> where aliceName = SimplexNameInfo NTContact (SimplexDomain TLDSimplex "alice" []) test reg alice bob = do + mapM_ enableNamesRole [alice, bob] bob ##> "/ad" (bobShortLink, _) <- getContactLinks bob True registerName reg aliceName (contactNameRecord "alice" (T.pack bobShortLink)) @@ -123,6 +128,7 @@ testChannelDomainLinkJoinUnverified ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] (shortLink, fullLink) <- prepareChannel1Relay "team" alice cath registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) alice ##> "/public group access #team domain=team.simplex" @@ -142,6 +148,7 @@ testChannelDomainVerify ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] (shortLink, fullLink) <- prepareChannel1Relay "team" alice cath registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) alice ##> "/public group access #team domain=team.simplex" @@ -171,6 +178,7 @@ testConnectByChannelName ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] (shortLink, _) <- prepareChannel1Relay "team" alice cath registerName reg teamName (channelNameRecord "team" (T.pack shortLink)) alice ##> "/public group access #team domain=team.simplex" @@ -204,6 +212,7 @@ testConnectByNameChannelAndContact ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] (channelLink, _) <- prepareChannel1Relay "team" alice cath alice ##> "/ad" (contactLink, _) <- getContactLinks alice True @@ -242,6 +251,7 @@ testConnectByNameContactAndChannel ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] (channelLink, _) <- prepareChannel1Relay "acme" alice cath alice ##> "/ad" (contactLink, _) <- getContactLinks alice True @@ -260,6 +270,7 @@ testConnectByNameBusinessAndChannel ps = withSmpServerAndNames $ \reg -> withNewTestChat ps "alice" aliceProfile $ \alice -> withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> withNewTestChat ps "bob" bobProfile $ \bob -> do + mapM_ enableNamesRole [alice, cath, bob] (channelLink, _) <- prepareChannel1Relay "biz" alice cath alice ##> "/ad" (contactLink, fullLink) <- getContactLinks alice True diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index d0b48a9019..c7ce5e8206 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -23,7 +23,7 @@ import Simplex.Chat.Operators.Presets import Simplex.Chat.Protocol (RelayProfile (..), mkRelayProfile) import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) -import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol @@ -33,6 +33,7 @@ operatorTests :: Spec operatorTests = describe "managing server operators" $ do validateServersTest updatedServersTest + perServerRolesTest validateServersTest :: Spec validateServersTest = describe "validate user servers" $ do @@ -122,6 +123,84 @@ updatedServersTest = describe "validate user servers" $ do PresetServers {operators} = presetServers defaultChatConfig customRelayAddr = either error id $ strDecode "https://relay.example.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y" +perServerRolesTest :: Spec +perServerRolesTest = describe "per-server roles" $ do + describe "agentServerCfgs resolution" $ do + it "self-hosted server keeps its per-server roles" $ + case agentServerCfgs SPSMP opDomains [selfHostedSMP (ServerRolesOverride (Just True) (Just False) (Just True))] of + [ServerCfg {operator, roles}] -> do + operator `shouldBe` Nothing + rolesTuple roles `shouldBe` (True, False, True) + cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs + it "self-hosted server without roles falls back to default roles" $ + case agentServerCfgs SPSMP opDomains [selfHostedSMP emptyServerRolesOverride] of + [ServerCfg {operator, roles}] -> do + operator `shouldBe` Nothing + rolesTuple roles `shouldBe` (True, True, False) + cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs + it "self-hosted partial override keeps defaults for unset roles" $ + case agentServerCfgs SPSMP opDomains [selfHostedSMP (ServerRolesOverride Nothing Nothing (Just True))] of + [ServerCfg {operator, roles}] -> do + operator `shouldBe` Nothing + rolesTuple roles `shouldBe` (True, True, True) + cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs + it "self-hosted explicit No overrides a default-yes role" $ + case agentServerCfgs SPSMP opDomains [selfHostedSMP (ServerRolesOverride (Just False) Nothing Nothing)] of + [ServerCfg {operator, roles}] -> do + operator `shouldBe` Nothing + rolesTuple roles `shouldBe` (False, True, False) + cfgs -> expectationFailure $ "expected one self-hosted ServerCfg, got: " <> show cfgs + it "operator-matched server without override inherits operator roles" $ + -- operator roles are all on; no override -> all inherited, including names + case agentServerCfgs SPSMP opDomains [opMatchedSMP emptyServerRolesOverride] of + [ServerCfg {operator, roles}] -> do + operator `shouldBe` Just 1 + rolesTuple roles `shouldBe` (True, True, True) + cfgs -> expectationFailure $ "expected one operator-matched ServerCfg, got: " <> show cfgs + it "operator-matched server applies its override over operator roles" $ + -- operator roles are all on; override turns storage/names off, proxy inherits (on) + case agentServerCfgs SPSMP opDomains [opMatchedSMP (ServerRolesOverride (Just False) Nothing (Just False))] of + [ServerCfg {operator, roles}] -> do + operator `shouldBe` Just 1 + rolesTuple roles `shouldBe` (False, True, False) + cfgs -> expectationFailure $ "expected one operator-matched ServerCfg, got: " <> show cfgs + it "two self-hosted servers resolve their three roles independently" $ + -- agentServerCfgs preserves input order + let a = (newUserServer "smp://abcd@self.example.com" :: NewUserServer 'PSMP) {roles = ServerRolesOverride (Just False) (Just True) (Just True)} + b = (newUserServer "smp://abcd@self2.example.com" :: NewUserServer 'PSMP) {roles = ServerRolesOverride (Just True) (Just False) Nothing} + in case agentServerCfgs SPSMP opDomains [a, b] of + [ServerCfg {operator = opA, roles = rolesA}, ServerCfg {operator = opB, roles = rolesB}] -> do + (opA, opB) `shouldBe` (Nothing, Nothing) + rolesTuple rolesA `shouldBe` (False, True, True) + rolesTuple rolesB `shouldBe` (True, False, False) + cfgs -> expectationFailure $ "expected two self-hosted ServerCfgs, got: " <> show cfgs + describe "validateUserServers per-server names coverage" $ do + it "self-hosted-only user without names servers warns USWNoNamesServers" $ do + let (_errs, warns) = validateUserServers [selfHostedUser emptyServerRolesOverride] [] + warns `shouldSatisfy` elem (USWNoNamesServers Nothing) + it "self-hosted user with a names server does not warn USWNoNamesServers" $ do + let (_errs, warns) = validateUserServers [selfHostedUser (ServerRolesOverride Nothing Nothing (Just True))] [] + warns `shouldSatisfy` notElem (USWNoNamesServers Nothing) + where + testOp = operatorSimpleXChat {operatorId = DBEntityId 1} + opDomains = operatorDomains [testOp] + -- host matches no operator domain -> self-hosted + selfHostedSMP :: ServerRolesOverride -> NewUserServer 'PSMP + selfHostedSMP r = (newUserServer "smp://abcd@self.example.com" :: NewUserServer 'PSMP) {roles = r} + -- host matches operator domain simplex.im + opMatchedSMP :: ServerRolesOverride -> NewUserServer 'PSMP + opMatchedSMP r = (newUserServer "smp://abcd@smp8.simplex.im" :: NewUserServer 'PSMP) {roles = r} + selfHostedUser :: ServerRolesOverride -> UpdatedUserOperatorServers + selfHostedUser r = + UpdatedUserOperatorServers + { operator = Nothing, + smpServers = [AUS SDBNew $ selfHostedSMP r], + xftpServers = [], + chatRelays = [] + } + rolesTuple :: ServerRoles -> (Bool, Bool, Bool) + rolesTuple ServerRoles {storage, proxy, names} = (storage, proxy, names) + deriving instance Eq User deriving instance Eq UserServersError From 52f1f7403de0939d69260e065d7078295a59338a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Mon, 20 Jul 2026 17:50:22 +0100 Subject: [PATCH 04/13] core: update query plans --- .../Chat/Store/SQLite/Migrations/chat_query_plans.txt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 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 18af438621..36f32944e2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4064,7 +4064,8 @@ SCAN s SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?) Query: - SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled + SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled, + role_storage, role_proxy, role_names FROM protocol_servers WHERE user_id = ? AND protocol = ? @@ -4901,8 +4902,9 @@ Plan: Query: INSERT INTO protocol_servers - (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, user_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?) + (protocol, host, port, key_hash, basic_auth, preset, tested, enabled, + role_storage, role_proxy, role_names, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -5433,7 +5435,8 @@ SEARCH msg_deliveries USING INDEX idx_msg_deliveries_agent_msg_id (connection_id Query: UPDATE protocol_servers SET protocol = ?, host = ?, port = ?, key_hash = ?, basic_auth = ?, - preset = ?, tested = ?, enabled = ?, updated_at = ? + preset = ?, tested = ?, enabled = ?, + role_storage = ?, role_proxy = ?, role_names = ?, updated_at = ? WHERE smp_server_id = ? Plan: From 451bb5148f3f80b203262b9ac5b4cb70b3af8075 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:25:22 +0400 Subject: [PATCH 05/13] core: fix missing ": " in CLI display name prompt (#7280) --- src/Simplex/Chat/Core.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 5bcd381fed..c8cf201421 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -156,7 +156,7 @@ createActiveUser cc CoreChatOpts {chatRelay, headless} createBot_ userDisplayNam \It is only stored on your device and you can change it later." where loop = do - displayName <- T.pack <$> withPrompt "display name" getLine + displayName <- T.pack <$> withPrompt "display name: " getLine createUser loop False $ mkProfile displayName mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing} createUser onError clientService p = From afdae36ea39281955db54fffe13dc3a62b6446a6 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:25:49 +0400 Subject: [PATCH 06/13] tests: enable names role for directory channel name tests (#6643) --- tests/Bots/DirectoryTests.hs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 0d49e075b9..15f713cd1c 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -1760,6 +1760,7 @@ withDirectoryServiceCfgOwnersGroup ps cfg createOwnersGroup webFolder test = do withNewTestChatCfg ps cfg serviceDbPrefix directoryProfile $ \ds -> withNewTestChatCfg ps cfg "super_user" aliceProfile $ \superUser -> do connectUsers ds superUser + enableNamesRole ds when createOwnersGroup $ do superUser ##> "/g owners" superUser <## "group #owners is created" @@ -2131,6 +2132,7 @@ testDirectoryChannelName ps = withSmpServerAndNames $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do + enableNamesRole bob bob `connectVia` dsLink (shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay registerName reg newsName (channelNameRecord "news" (T.pack shortLink)) @@ -2171,6 +2173,7 @@ testDirectoryChannelNameNotVerified ps = withSmpServerAndNames $ \reg -> withDirectoryServiceCfg ps testCfg $ \superUser dsLink -> withNewTestChatCfg ps testCfg "bob" bobProfile $ \bob -> withRelay ps $ \relay -> do + enableNamesRole bob bob `connectVia` dsLink (shortLink, _fullLink) <- prepareChannel1Relay "news" bob relay registerName reg newsName (channelNameRecord "news" (T.pack shortLink)) From 4cf5a8d9ce76b8e34e9d0f6baf80fa61ce110b38 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:22:48 +0000 Subject: [PATCH 07/13] docs: Improve FAQ.md (#6360) * docs: Add FAQ * corrections Co-authored-by: Evgeny --------- Co-authored-by: Evgeny --- docs/FAQ.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/FAQ.md b/docs/FAQ.md index 8c14168811..21cb08417f 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -21,6 +21,7 @@ revision: 13.08.2025 - [Why cannot I delete messages I sent from my contact's device?](#why-cannot-i-delete-messages-i-sent-from-my-contacts-device) - [What do group roles mean?](#what-do-group-roles-mean) - [I don't want to share a web link or a QR code. How can I connect?](#i-dont-want-to-share-a-web-link-or-a-qr-code-how-can-i-connect) +- [How to change servers to my own](#how-to-change-servers-to-my-own) [Troubleshooting](#troubleshooting) - [I do not receive messages or message notifications](#i-do-not-receive-messages-or-message-notifications) @@ -175,6 +176,16 @@ For example, this link: `https://smp18.simplex.im/i#E74vSxMwDnEx6DAvRCZmzBeZwwAs becomes: `simplex:/i#E74vSxMwDnEx6DAvRCZmzBeZwwAseJUD/yVTHjaaH_EzL19DG7fvd46Mjry3IBqYT0UMo5G7l4jQ?h=smp18.simplex.im` +### How to change servers to my own? + +Add your server in Settings -> Network & servers -> Your servers. If you have multiple profiles, you need to add your server in every profile. +After that, disable all preset servers. If you have multiple profiles, you must first add your server to all profiles for this option to be available. + +To resolve SimpleX public names, if your server does not support it, you may leave preset servers enabled only for resolving the public names. + +Please note: configured servers only determine which servers will be used for the new connections (members/contacts). Previously created connections don't switch automatically. You can manually switch all existing contacts and small/important groups (each member needs to be switched), and then leave and re-join all large groups. +You can switch contacts/members manually to another server by going to their profile and clicking on "Change receiving address", note that the current server must be online for the address change to work. + ## Troubleshooting ### I do not receive messages or message notifications From 9586c974395437e3d570a3b03e77ab2965b418fb Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:28:54 +0000 Subject: [PATCH 08/13] ui: show XFTP servers used for a file in message info (#7088) Surface the XFTP servers that hosted a file's chunks in the message info screen (Android, desktop, iOS), so a user can see which servers they are downloading from (or, for sent files, uploading to) whenever they want to know. APIGetChatItemInfo now returns fileXftpServers, derived from the stored file description (private snd descr for sent items, rcv descr for received items); extraction is best-effort and never fails the call. --- .../Shared/Views/Chat/ChatItemInfoView.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 3 +- .../common/views/chat/ChatItemInfoView.kt | 14 ++ .../commonMain/resources/MR/base/strings.xml | 2 + ...26-06-17-message-info-file-xftp-servers.md | 140 ++++++++++++++++++ src/Simplex/Chat/Library/Commands.hs | 3 +- src/Simplex/Chat/Library/Internal.hs | 29 +++- src/Simplex/Chat/Messages.hs | 5 +- src/Simplex/Chat/Store/Files.hs | 10 ++ 10 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 plans/2026-06-17-message-info-file-xftp-servers.md diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 99de13451f..2213b34586 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -216,6 +216,9 @@ struct ChatItemInfoView: View { } } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } } } @@ -545,6 +548,9 @@ struct ChatItemInfoView: View { shareText += [String.localizedStringWithFormat(NSLocalizedString("File status: %@", comment: "copied message info"), file.fileStatus.id)] } } + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + shareText += [String.localizedStringWithFormat(NSLocalizedString("File servers: %@", comment: "copied message info"), servers.map(serverHostname).joined(separator: ", "))] + } if let qi = ci.quotedItem { shareText += ["", NSLocalizedString("## In reply to", comment: "copied message info")] let t = qi.text diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 91b2a85fa8..d02bff75f8 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -6033,6 +6033,7 @@ public struct ChatItemInfo: Decodable, Hashable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? } public struct ChatItemVersion: Decodable, Hashable { 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 a755b8bc6e..284738fe14 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 @@ -5383,7 +5383,8 @@ data class ChatTag( class ChatItemInfo( val itemVersions: List, val memberDeliveryStatuses: List?, - val forwardedFromChatItem: AChatItem? + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() ) @Serializable 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 0679edd4f9..64c6160665 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 @@ -30,6 +30,7 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.group.MemberProfileImage import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* +import chat.simplex.common.views.usersettings.networkAndServers.serverHostname import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.serialization.encodeToString @@ -290,6 +291,16 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools } } } + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView(stringResource(MR.strings.info_row_file_servers)) { + ciInfo.fileXftpServers.forEach { server -> + SectionItemView { + Text(serverHostname(server), maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.fillMaxWidth()) + } + } + } + } } @Composable @@ -581,6 +592,9 @@ fun itemInfoShareText(chatModel: ChatModel, ci: ChatItem, chatItemInfo: ChatItem shareText.add(String.format(generalGetString(MR.strings.share_text_file_status), jsonShort.encodeToString(ci.file.fileStatus))) } } + if (ci.file != null && chatItemInfo.fileXftpServers.isNotEmpty()) { + shareText.add(String.format(generalGetString(MR.strings.share_text_file_servers), chatItemInfo.fileXftpServers.joinToString(", ") { serverHostname(it) })) + } val qi = ci.quotedItem if (qi != null) { shareText.add("") 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 6989521c6b..617c268b8a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2025,6 +2025,7 @@ Record updated at Message status File status + File servers Sent at Created at Received at @@ -2035,6 +2036,7 @@ Record updated at: %s Message status: %s File status: %s + File servers: %s Sent at: %s Created at: %s Received at: %s diff --git a/plans/2026-06-17-message-info-file-xftp-servers.md b/plans/2026-06-17-message-info-file-xftp-servers.md new file mode 100644 index 0000000000..f7803984cf --- /dev/null +++ b/plans/2026-06-17-message-info-file-xftp-servers.md @@ -0,0 +1,140 @@ +# Plan: Show XFTP servers used for a file in Message Info + +## Goal +In the message-info screen, when the message contains a file, show the list of XFTP servers that hosted the file's chunks ("servers used to upload the file"). + +## Why this is needed +For transparency: a recipient downloads a file's chunks from XFTP relays without any visible indication of which servers those are. Surfacing them in message info lets a user see exactly what servers they are downloading from (and, for sent files, uploading to) whenever they want to know — useful for trust, debugging, and deciding whether to download. + +## Decisions (confirmed) +- **Direction:** Both sent and received files. +- **Visibility:** Always shown when the item has a file (not gated behind Developer tools). +- **Platform:** Android, desktop, and iOS. + +## Why this needs a core change (not Kotlin-only) +The server list is **already known to the app** — the file description (which lists each chunk's XFTP server replicas) is what the agent uses to upload/download. But that data lives in the **core (Haskell) layer**: it's stored in the `files` table (`private_snd_file_descr` for sent, the rcv file descr row for received) and parsed inside the core. It is **never surfaced across the core→client API boundary**: the client-facing `CIFile` and `ChatItemInfo` types carry no server info. So this is a **plumbing task** (expose existing data through the API response), not an algorithmic one. The core already has the extraction helper. + +## Existing building blocks (no new logic needed) +- `Internal.hs:764-766` — `fileServers` extracts the unique `[XFTPServer]` from a parsed description's chunk replicas. Currently a local `where` helper; will be lifted to a reusable top-level function. +- `Internal.hs:747` — `parseFileDescription :: Text -> CM (ValidFileDescription 'FRecipient)`. +- `Store/Files.hs:42` — `getRcvFileDescrByRcvFileId` returns `RcvFileDescr {fileDescrText, fileDescrComplete}` (received files). +- `Store/Files.hs:197` — `setSndFTPrivateSndDescr` stores `private_snd_file_descr` (sent files). A matching getter must be added (none exists today). +- `XFTPServer` already JSON-encodes to a string on the wire (see `XFTPServerSummary.xftpServer :: XFTPServer` → Kotlin `xftpServer: String`), so the Kotlin field can be `List`. + +--- + +## Implementation + +### 1. Core — extraction helper (`src/Simplex/Chat/Library/Internal.hs`) +- Lift `fileServers` out of `receiveViaCompleteFD`'s `where` block to a top-level function, generalized to work on any party's description chunks: + ```haskell + fileDescrServers :: FD.FileDescription p -> [XFTPServer] + fileDescrServers FD.FileDescription {chunks} = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + ``` + (Keep `receiveViaCompleteFD` working by calling the lifted helper.) +- Add a convenience that resolves a file's servers by direction, returning `[]` when no XFTP description exists (inline/legacy files): + ```haskell + getChatItemFileServers :: User -> ChatItem c d -> CM [XFTPServer] + ``` + - For **received** (`SMDRcv`) with an XFTP file: `getRcvFileDescrByRcvFileId` → `parseFileDescription` → `fileDescrServers`. + - For **sent** (`SMDSnd`) with an XFTP file: read `private_snd_file_descr` (new getter) → parse the sender description → `fileDescrServers`. + - Guard on `fileProtocol == FPXFTP`; wrap parse in tolerant error handling so a missing/partial descr yields `[]` (no crash, no section). + +### 2. Core — store getter (`src/Simplex/Chat/Store/Files.hs`) +- Add `getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text)` reading `private_snd_file_descr` from `files` (mirror of `setSndFTPrivateSndDescr`; column already selected at `Files.hs:799`). Export it. + +### 3. Core — extend `ChatItemInfo` (`src/Simplex/Chat/Messages.hs`) +- Add field: + ```haskell + data ChatItemInfo = ChatItemInfo + { itemVersions :: [ChatItemVersion], + memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] -- NEW (empty when no file / not XFTP) + } + ``` +- `$(JQ.deriveJSON defaultJSON ''ChatItemInfo)` at `Messages.hs:1531` regenerates the instance automatically. Import `XFTPServer` if not already in scope. +- Note: using `[XFTPServer]` (not `Maybe`) keeps the JSON additive and the Kotlin side simple; empty list = nothing to show. + +### 4. Core — populate in handler (`src/Simplex/Chat/Library/Commands.hs:619`) +- In `APIGetChatItemInfo`, after computing `forwardedFromChatItem`: + ```haskell + fileXftpServers <- getChatItemFileServers user ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} + ``` + +### 5. Kotlin model (`apps/multiplatform/.../model/ChatModel.kt:5207`) +```kotlin +class ChatItemInfo( + val itemVersions: List, + val memberDeliveryStatuses: List?, + val forwardedFromChatItem: AChatItem?, + val fileXftpServers: List = emptyList() // NEW; server hosts as strings +) +``` +(`XFTPServer` serializes to a string, matching the existing `XFTPServerSummary.xftpServer: String`.) + +### 6. Desktop/shared UI (`apps/multiplatform/.../views/chat/ChatItemInfoView.kt`) +- In `Details()` (`:250-283`), after the existing file-status block, add (always-visible, when present): + ```kotlin + if (ci.file != null && ciInfo.fileXftpServers.isNotEmpty()) { + // section header + one InfoRow/host per server + } + ``` + - Render as its own `SectionView` with a title (e.g. "File servers") and one row per server host, styled like the existing info rows. For long host strings, single-line with ellipsis (consistent with the long-name handling already on this branch). +- Add the servers to the shareable text in `itemInfoShareText` (`:534-580`), near the existing file-status share line (`:564-565`), so "Share" / copy includes them. + +### 7. Translations (additive only — never rename/remove keys) +- Add one new key to `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml`, e.g.: + ```xml + File servers + ``` + Base locale only; Weblate fans out to other locales. Do not touch existing keys. + +### 8. iOS model (`apps/ios/SimpleXChat/ChatTypes.swift:5773`) +Same core change (shared `libsimplex`), so iOS only needs the Swift model + view. Add the optional field (optional so an absent key never breaks decoding): +```swift +public struct ChatItemInfo: Decodable, Hashable { + public var itemVersions: [ChatItemVersion] + public var memberDeliveryStatuses: [MemberDeliveryStatus]? + public var forwardedFromChatItem: AChatItem? + public var fileXftpServers: [String]? // NEW +} +``` + +### 9. iOS UI (`apps/ios/Shared/Views/Chat/ChatItemInfoView.swift`) +- In `details()`, after the `developerTools` block (always-visible when present): + ```swift + if ci.file != nil, let servers = chatItemInfo?.fileXftpServers, !servers.isEmpty { + infoRow("File servers", servers.map(serverHostname).joined(separator: "\n")) + } + ``` + `serverHostname(_:)` is public in `SimpleXChat` (`ErrorAlert.swift:142`). +- Add the same to `itemInfoShareText()` (joined with ", "). Strings are inline `NSLocalizedString` (no separate resource file to edit). + +--- + +## Edge cases / behavior +- **Inline / legacy files** (no XFTP description): `fileXftpServers == []` → section hidden. No crash. +- **Sent file before upload completes** (`private_snd_file_descr` not yet set): `[]` → hidden until available. +- **Received file not yet accepted/downloaded:** the rcv description exists as soon as the offer is received (it's what enables download), so servers are available even before download. ✓ +- **Multiple chunks across different servers:** deduped via `S.fromList`; all distinct servers listed. +- **Large files (any size up to the limits):** servers are accurate regardless of size. Chat-message files **never** use XFTP redirect — the description is split across multiple `XMsgFileDescr` messages (`splitFileDescr`) and reassembled in full by the recipient (`appendRcvFD`), so a received chat file always carries the real data-chunk servers. (`maxFileSize` = 1 GB soft, `maxFileSizeHard` = 5 GB; XFTP chunk sizes 64 KB / 256 KB / 1 MB / 4 MB.) +- **Redirect descriptions:** XFTP redirect (a small `redirect = Just` description whose chunks point to the relay hosting the real description) is used **only** for *standalone file links* (`SFDONE` `Nothing` branch → `xftpSndFileRedirect`), which have no chat item and never appear in message info. The chat-message branch never redirects, so no redirect description can reach `getChatItemFileServers` — no guard is needed. + +## Build / verification +- **Heaviest step:** rebuild the native core lib (`libsimplex`) consumed by the multiplatform app, since core types changed. Without the rebuild, the new JSON field is simply ignored by the old lib (the Kotlin default `emptyList()` keeps it backward-compatible — section just won't appear). +- Tests: a core unit/integration check that `APIGetChatItemInfo` returns a non-empty `fileXftpServers` for an XFTP file (sent and received), empty for a text message and for an inline file. +- Manual: send a file → open message info → confirm the server list; repeat on the receiving device for the received item. + +## Touch list +- `src/Simplex/Chat/Library/Internal.hs` — lift `fileDescrServers`, add `getChatItemFileServers`. +- `src/Simplex/Chat/Store/Files.hs` — add `getSndFTPrivateSndDescr` (+ export). +- `src/Simplex/Chat/Messages.hs` — add `fileXftpServers` to `ChatItemInfo`. +- `src/Simplex/Chat/Library/Commands.hs` — populate it in `APIGetChatItemInfo`. +- `apps/multiplatform/.../model/ChatModel.kt` — add Kotlin field. +- `apps/multiplatform/.../views/chat/ChatItemInfoView.kt` — UI section + share text. +- `apps/multiplatform/.../resources/MR/base/strings.xml` — new label key. +- `apps/ios/SimpleXChat/ChatTypes.swift` — add `fileXftpServers` to the Swift `ChatItemInfo`. +- `apps/ios/Shared/Views/Chat/ChatItemInfoView.swift` — UI row in `details()` + `itemInfoShareText()`. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c8786a92de..4d1de3bc72 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -693,7 +693,8 @@ processChatCommand cxt nm = \case (SCTGroup, SMDSnd) -> L.nonEmpty <$> withFastStore' (`getGroupSndStatuses` itemId) _ -> pure Nothing forwardedFromChatItem <- getForwardedFromItem user ci - pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem} + fileXftpServers <- getChatItemFileServers user dir ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem, fileXftpServers} where getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index f4d684a3c3..7bddfcaba5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -764,7 +764,7 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} if userApprovedRelays then receive' rd True else do - let srvs = fileServers rd + let srvs = fileDescrServers rd unknownSrvs <- getUnknownSrvs srvs let approved = null unknownSrvs ifM @@ -777,9 +777,6 @@ receiveViaCompleteFD user fileId RcvFileDescr {fileDescrText, fileDescrComplete} aFileId <- withAgent $ \a -> xftpReceiveFile a (aUserId user) rd cfArgs approved startReceivingFile user fileId withStore' $ \db -> updateRcvFileAgentId db fileId (Just $ AgentRcvFileId aFileId) - fileServers :: ValidFileDescription 'FRecipient -> [XFTPServer] - fileServers (FD.ValidFileDescription FD.FileDescription {chunks}) = - S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks getUnknownSrvs :: [XFTPServer] -> CM [XFTPServer] getUnknownSrvs srvs = do knownSrvs <- L.map protoServer' <$> getKnownAgentServers SPXFTP user @@ -1747,6 +1744,30 @@ parseFileDescription :: FilePartyI p => Text -> CM (ValidFileDescription p) parseFileDescription = liftEither . first (ChatError . CEInvalidFileDescription) . (strDecode . encodeUtf8) +-- | Unique XFTP servers hosting the file's chunks, parsed from a stored file description. +fileDescrServers :: ValidFileDescription p -> [XFTPServer] +fileDescrServers (FD.ValidFileDescription FD.FileDescription {chunks}) = + S.toList $ S.fromList $ concatMap (\FD.FileChunk {replicas} -> map (\FD.FileChunkReplica {server} -> server) replicas) chunks + +-- | XFTP servers the file's data chunks were uploaded to (sender's servers for sent items, +-- the same servers via the recipient description for received items). +-- Returns [] for non-XFTP/inline files or when no description is available; never fails the caller. +getChatItemFileServers :: User -> SMsgDirection d -> ChatItem c d -> CM [XFTPServer] +getChatItemFileServers user dir ci = case ci of + ChatItem {file = Just CIFile {fileId, fileProtocol = FPXFTP}} -> + itemFileServers fileId `catchAllErrors` \_ -> pure [] + _ -> pure [] + where + itemFileServers fileId = case dir of + SMDSnd -> do + sfd_ <- withStore' $ \db -> getSndFTPrivateSndDescr db user fileId + case sfd_ of + Just sfdText -> fileDescrServers <$> (parseFileDescription sfdText :: CM (ValidFileDescription 'FSender)) + Nothing -> pure [] + SMDRcv -> do + RcvFileDescr {fileDescrText} <- withStore $ \db -> getRcvFileDescrByRcvFileId db fileId + fileDescrServers <$> (parseFileDescription fileDescrText :: CM (ValidFileDescription 'FRecipient)) + sendDirectFileInline :: User -> Contact -> FileTransferMeta -> SharedMsgId -> CM () sendDirectFileInline user ct ft sharedMsgId = do msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage user ct diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b4709fb5c5..836fb004fe 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -55,7 +55,7 @@ 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, parseAll, sumTypeJSON) -import Simplex.Messaging.Protocol (BlockingInfo, MsgBody) +import Simplex.Messaging.Protocol (BlockingInfo, MsgBody, XFTPServer) import Simplex.Messaging.Util (eitherToMaybe, safeDecodeUtf8, (<$?>)) data ChatType = CTDirect | CTGroup | CTLocal | CTContactRequest | CTContactConnection @@ -1345,7 +1345,8 @@ instance TextEncoding CIForwardedFromTag where data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], memberDeliveryStatuses :: Maybe (NonEmpty MemberDeliveryStatus), - forwardedFromChatItem :: Maybe AChatItem + forwardedFromChatItem :: Maybe AChatItem, + fileXftpServers :: [XFTPServer] } deriving (Show) diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index 762583b7a5..5d862ea258 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -20,6 +20,7 @@ module Simplex.Chat.Store.Files createSndFileTransferXFTP, createSndFTDescrXFTP, setSndFTPrivateSndDescr, + getSndFTPrivateSndDescr, updateSndFTDescrXFTP, createExtraSndFTDescrs, updateSndFTDeliveryXFTP, @@ -210,6 +211,15 @@ setSndFTPrivateSndDescr db User {userId} fileId sfdText = do "UPDATE files SET private_snd_file_descr = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (sfdText, currentTs, userId, fileId) +getSndFTPrivateSndDescr :: DB.Connection -> User -> FileTransferId -> IO (Maybe Text) +getSndFTPrivateSndDescr db User {userId} fileId = + fmap (maybe Nothing fromOnly) $ + maybeFirstRow id $ + DB.query + db + "SELECT private_snd_file_descr FROM files WHERE user_id = ? AND file_id = ?" + (userId, fileId) + updateSndFTDescrXFTP :: DB.Connection -> User -> SndFileTransfer -> Text -> IO () updateSndFTDescrXFTP db user@User {userId} sft@SndFileTransfer {fileId, fileDescrId} rfdText = do currentTs <- getCurrentTime From 7fc2a6e6bdf63a8d5ec6e8acd753861a604f80ea Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:32:37 +0000 Subject: [PATCH 09/13] android, desktop: fix draft loss when switching to chat where user cannot send messages (#7239) * android, desktop: save draft when switching to chat where user cannot send messages On desktop, chat and chatId change in the same recomposition when another chat is opened from the always-visible chat list. The effect clearing compose state of a non-sendable chat (observer, channel subscriber, review by admins) was composed before the draft-saving KeyChangeEffect and ran first, wiping the live compose state before it could be saved and then clearing the previously saved draft via clearPrevDraft. Effects launch in composition order, so the clearing effect is moved after KeyChangeEffect: the previous chat's draft is saved first, and clearCurrentDraft is a no-op for it because draftChatId no longer matches the opened chat. Clearing the opened chat's own draft when it cannot send is preserved, as is clearing when the open chat itself becomes non-sendable (only the sendMsgEnabled key changes). * plans: investigation and justification for draft message loss fix --- .../simplex/common/views/chat/ComposeView.kt | 15 +++-- ...14-fix-draft-message-loss-on-restricted.md | 67 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 plans/2026-07-14-fix-draft-message-loss-on-restricted.md diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 4c84965b9e..875bf6d263 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 @@ -1308,13 +1308,6 @@ fun ComposeView( } } - LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { - if (!chat.chatInfo.sendMsgEnabled) { - clearCurrentDraft() - clearState() - } - } - KeyChangeEffect(chatModel.chatId.value) { prevChatId -> val cs = composeState.value if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) { @@ -1345,6 +1338,14 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + // Must be composed after KeyChangeEffect above (effects run in composition order), + // so that on chat switch the previous chat's draft is saved before it is cleared here. + LaunchedEffect(rememberUpdatedState(chat.chatInfo.sendMsgEnabled).value) { + if (!chat.chatInfo.sendMsgEnabled) { + clearCurrentDraft() + clearState() + } + } // keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented LaunchedEffect(chat.chatInfo) { val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() diff --git a/plans/2026-07-14-fix-draft-message-loss-on-restricted.md b/plans/2026-07-14-fix-draft-message-loss-on-restricted.md new file mode 100644 index 0000000000..f001e38f73 --- /dev/null +++ b/plans/2026-07-14-fix-draft-message-loss-on-restricted.md @@ -0,0 +1,67 @@ +# Fix draft loss when switching to a chat where user cannot send messages + +**Branch:** `nd/fix-draft-message-loss-on-restricted` · **PR:** [#7239](https://github.com/simplex-chat/simplex-chat/pull/7239) (draft) · **Commit:** `9d4bb2188` + +## Problem + +A draft typed in the current chat is lost when another chat is opened where the user +cannot send messages (observer, channel subscriber, review by admins). Reproduces on +desktop only: the chat list is always visible, so `chat` and `chatId` change in the +same recomposition (`CenterPartOfScreen` passes `ChatModel.chatId` itself as +`currentChatId`, App.kt:389). Android is unaffected — `AndroidScreen` updates +`currentChatId` via a `snapshotFlow` collector, so the draft-saving effect fires a +frame before the chat swaps. + +## Root cause + +In `ComposeView.kt`, the effect clearing compose state of a non-sendable chat +(`LaunchedEffect(sendMsgEnabled)`) was composed *before* the draft-saving +`KeyChangeEffect(chatModel.chatId)`. `LaunchedEffect` bodies run in composition +order, so on a same-recomposition switch the clear effect wiped the live compose +state before it could be saved, and `KeyChangeEffect` then fell into its +else-branch, destroying any previously saved draft via `clearPrevDraft` and deleting +attachment files. Present since the effect was introduced (`7b362ff65`, #5922) — +not a recent regression. + +## Fix + +Move the clearing effect after `KeyChangeEffect` (6 lines moved + 2-line comment). +The draft is saved first; `clearCurrentDraft()` is then a no-op for it because +`draftChatId` no longer matches the opened chat. Preserved behavior: the opened +chat's own draft is still cleared when it cannot send, and the draft is still +cleared when the open chat itself becomes non-sendable (only the `sendMsgEnabled` +key changes). Incidental improvements: an active live message is now sent on switch +instead of dropped, and the `inProgress` branch no longer resurrects cleared text. + +## Verification + +- Headless Compose-runtime harness (scratchpad `TmpDraftLossEffectOrderTest.kt` / + `TmpDraftFixVerifyTest.kt`): reproduced the bug (clear-before-save order observed), + verified the fix across 5 scenarios (switch to non-sendable, in-chat demotion, + non-sendable chat's own saved draft cleared, Android staggered path, sendable + control). All pass; `:common:desktopTest` and `:common:compileKotlinDesktop` green. +- Manual desktop repro confirmed the bug and fix. +- Works with `privacySaveLastDraft` off: text discarded per the setting's contract, + never saved; disabling purges the slot, so no stale-state interactions. + +## iOS + +Not reproduced on device (desktop repro path is safe on iOS: back-navigation fires +`ComposeView.onDisappear`, which saves the draft). A code-traced fix for in-place +chat switches (member info → "message", notification tap, "forwarded from" — where +`onDisappear` never runs) was removed from the PR and parked on +`nd/ios-draft-save-on-chat-switch` (commit `7b9b8d987`) pending device verification. +Decisive test: type a draft in a group → member info → "message" → back to group. +If the draft survives without the change, delete the side branch. + +## Pre-existing issues found (not addressed, both platforms) + +- Secondary (member support/reports) chat views share the group's chat id and race + the primary on the single draft slot (Kotlin: unguarded `KeyChangeEffect` in + secondary instances can clear the primary's just-saved draft when a modal is open + during a switch; iOS: secondary `onDisappear` saves under the swapped chat id). +- Kotlin: drafts are not saved when a chat is closed to `chatId = null` on desktop + (ChatView is disposed before `KeyChangeEffect` runs; only the forwarding case is + saved via `DisposableEffect`). +- Switching between two non-sendable chats restores the target's draft into the + disabled composer (clear effect does not re-fire on `false→false`). From 3615927db56e0c966e11cb652609d212ea76d24a Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:35:41 +0000 Subject: [PATCH 10/13] multiplatform: remove unused clipboard state polling that froze desktop UI (#7237) The desktop app polled full clipboard contents every second on the AWT event thread to update ChatModel.clipboardHasText. On X11, each read blocks up to sun.awt.datatransfer.timeout (10s) when the selection owner does not respond (e.g. after KeePassXC's clipboard auto-clear), making every click/scroll wait ~9s. clipboardHasText has had no readers since its only consumer was removed in #4398, so the whole mechanism (desktop poll, Android clip listener, onResume refresh, expect/actual, state field) is deleted. See plans/2026-07-11-fix-desktop-clipboard-freeze.md. --- .../java/chat/simplex/app/MainActivity.kt | 14 ----- .../common/views/helpers/Utils.android.kt | 16 ----- .../kotlin/chat/simplex/common/App.kt | 1 - .../chat/simplex/common/model/ChatModel.kt | 1 - .../simplex/common/views/helpers/Utils.kt | 3 - .../common/views/helpers/Utils.desktop.kt | 15 ----- apps/multiplatform/spec/client/navigation.md | 3 +- apps/multiplatform/spec/state.md | 1 - ...2026-07-11-fix-desktop-clipboard-freeze.md | 61 +++++++++++++++++++ 9 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 plans/2026-07-11-fix-desktop-clipboard-freeze.md 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 bacdfe70af..7415b6015d 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 @@ -1,6 +1,5 @@ package chat.simplex.app -import android.content.Context import android.content.Intent import android.net.Uri import android.os.* @@ -8,7 +7,6 @@ import android.view.View import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.ui.platform.ClipboardManager import androidx.fragment.app.FragmentActivity import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.getUserIdFromIntent @@ -21,7 +19,6 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.platform.* import chat.simplex.res.MR -import kotlinx.coroutines.* import java.lang.ref.WeakReference class MainActivity: FragmentActivity() { @@ -73,17 +70,6 @@ class MainActivity: FragmentActivity() { override fun onResume() { super.onResume() AppLock.recheckAuthState() - withApi { - delay(1000) - if (!isAppOnForeground) return@withApi - /** - * When the app calls [ClipboardManager.shareText] and a user copies text in clipboard, Android denies - * access to clipboard because the app considered in background. - * This will ensure that the app will get the event on resume - * */ - val service = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager - chatModel.clipboardHasText.value = service.hasPrimaryClip() - } } override fun onPause() { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 5cf17d304d..c98f8f9f89 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -1,7 +1,5 @@ package chat.simplex.common.views.helpers -import android.content.ClipboardManager -import android.content.Context import android.content.res.Resources import android.graphics.* import android.graphics.Typeface @@ -14,7 +12,6 @@ import android.text.SpannedString import android.text.style.* import android.util.Base64 import android.view.WindowManager -import androidx.compose.runtime.* import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.* @@ -58,19 +55,6 @@ fun keepScreenOn(on: Boolean) { } } -@Composable -actual fun SetupClipboardListener() { - DisposableEffect(Unit) { - val service = androidAppContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val listener = { chatModel.clipboardHasText.value = service.hasPrimaryClip() } - chatModel.clipboardHasText.value = service.hasPrimaryClip() - service.addPrimaryClipChangedListener(listener) - onDispose { - service.removePrimaryClipChangedListener(listener) - } - } -} - actual fun escapedHtmlToAnnotatedString(text: String, density: Density): AnnotatedString { return spannableStringToAnnotatedString(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY), density) } 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 7542a0b8c6..cb91c386ce 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 @@ -163,7 +163,6 @@ fun MainScreen() { userPickerState.value = AnimatedViewState.VISIBLE } } - SetupClipboardListener() if (appPlatform.isAndroid) { AndroidWrapInCallLayout { AndroidScreen(userPickerState) 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 284738fe14..58c455840e 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 @@ -210,7 +210,6 @@ object ChatModel { val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } - val clipboardHasText = mutableStateOf(false) val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) val conditions = mutableStateOf(ServerOperatorConditionsDetail.empty) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index eee97eeca9..70f4a1759b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -110,9 +110,6 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr } } -@Composable -expect fun SetupClipboardListener() - // maximum image file size to be auto-accepted // Spec: spec/services/files.md#MAX_IMAGE_SIZE const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 32b1d5cff0..3ccb915661 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -1,8 +1,6 @@ package chat.simplex.common.views.helpers -import androidx.compose.runtime.* import androidx.compose.ui.graphics.* -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight @@ -12,7 +10,6 @@ import chat.simplex.common.model.CIFile import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState -import kotlinx.coroutines.delay import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.File @@ -119,18 +116,6 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat AnnotatedString(text) } -@Composable -actual fun SetupClipboardListener() { - val clipboard = LocalClipboardManager.current - chatModel.clipboardHasText.value = clipboard.hasText() - LaunchedEffect(Unit) { - while (true) { - delay(1000) - chatModel.clipboardHasText.value = clipboard.hasText() - } - } -} - actual fun getAppFileUri(fileName: String): URI { val rh = chatModel.currentRemoteHost.value return if (rh == null) { diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md index c9939ea3c0..34a3b502ee 100644 --- a/apps/multiplatform/spec/client/navigation.md +++ b/apps/multiplatform/spec/client/navigation.md @@ -105,8 +105,7 @@ fun MainScreen() When onboarding is complete: 1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). -2. Sets up clipboard listener. -3. Routes to `AndroidScreen` or `DesktopScreen` based on platform. +2. Routes to `AndroidScreen` or `DesktopScreen` based on platform. ### Overlay Layers (bottom of MainScreen) diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 09457c4dd3..229c30d18e 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -155,7 +155,6 @@ Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex | [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState` | 147 | Notification content preview level | | [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | | [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list | -| [`clipboardHasText`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L185) | `MutableState` | 185 | System clipboard has text content | | [`networkInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L186) | `MutableState` | 186 | Network type and online status | | [`conditions`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L188) | `MutableState` | 188 | Server operator terms/conditions | | [`updatingProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L190) | `MutableState` | 190 | Progress indicator for app updates | diff --git a/plans/2026-07-11-fix-desktop-clipboard-freeze.md b/plans/2026-07-11-fix-desktop-clipboard-freeze.md new file mode 100644 index 0000000000..59d357446e --- /dev/null +++ b/plans/2026-07-11-fix-desktop-clipboard-freeze.md @@ -0,0 +1,61 @@ +# Fix desktop UI freeze caused by clipboard polling; remove dead `clipboardHasText` state + +## Problem + +User report: the Linux desktop app becomes extremely slow — every scroll or button press takes ~9 seconds. The lag disappears while the clipboard is filled and returns when it is emptied, reproducibly triggered by KeePassXC's clipboard auto-clear (10s safety timeout). Manual clearing via desktop tools does not trigger it. + +## Root cause + +`SetupClipboardListener()` (desktop actual, `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt`) ran `chatModel.clipboardHasText.value = clipboard.hasText()` once at composition and then every 1 second in a `LaunchedEffect` — i.e. on the Compose main dispatcher, which on desktop is the AWT Event Dispatch Thread that processes all input and rendering. + +The call chain of `hasText()` (verified in Compose 1.8.2 / skiko 0.9.4 bytecode): + +``` +PlatformClipboardManager.hasText() + → org.jetbrains.skiko.ClipboardManager_hasText() + → ClipboardManager_getText() + → java.awt.datatransfer.Clipboard.getData(DataFlavor.stringFlavor) +``` + +So "does the clipboard have text" was answered by fetching the full clipboard contents over an X11 selection conversion, every second. On X11, `getData` blocks in `sun.awt.X11.XSelection.waitForSelectionNotify()` until the selection owner replies or `sun.awt.datatransfer.timeout` expires — **default 10 000 ms** (OpenJDK `UNIXToolkit.getDatatransferTimeout()`). + +When the X11 CLIPBOARD selection owner does not answer conversion requests — the state KeePassXC's auto-clear leaves behind — each poll blocked the EDT for the full 10 s, then the loop slept 1 s and blocked again. The EDT was therefore blocked ~10 of every ~11 seconds; a random click or scroll waited ~9 s on average, exactly as reported. Any responsive clipboard owner (any normal app copying text) made the polls instant again, which is why filling the clipboard "cured" it. + +## Why deletion (not a workaround) is correct + +`chatModel.clipboardHasText` has had **zero readers since July 2024**: its only ever consumer — a conditional paste icon over the chat list search field — was removed in #4398 (commit `3e623684b`). Everything else was write-only plumbing for that state: + +- desktop 1s poll (`Utils.desktop.kt`) — the freeze source; +- Android `addPrimaryClipChangedListener` (`Utils.android.kt`, added in #3529); +- Android `onResume` re-read of `hasPrimaryClip()` (`MainActivity.kt`, #3758) — a workaround for Android 10+ denying clipboard access to backgrounded apps, needed only to keep the state fresh for that same paste icon; +- the `expect`/`actual` declarations and the `App.kt` call site; +- the `ChatModel.clipboardHasText` field itself. + +Verified before removal: + +- No direct reads anywhere in the repo (all source sets, all modules), on any remote branch, and none in any upstream PR (GitHub code search: only the writers + one spec table row). +- No indirect access: no reflection over `ChatModel`, no `"clipboardHasText"` string literal, no wholesale snapshot observation, no serialization (`ChatModel` is `@Stable`, not `@Serializable`), nothing crosses the mobile↔desktop remote protocol, no Gradle/lint/ProGuard/codegen references, no tests. +- Removing the `App.kt` call cannot affect sibling composition: the composable emits no UI node (Android: bare `DisposableEffect`; desktop: state writes only), and there is no `key()`/`movableContentOf`/positional logic nearby. +- Android side effects: none. `hasPrimaryClip()` does not read clip contents (no Android 12+ access toast); this was the only primary-clip listener (no ordering concerns); the `onResume` coroutine `Job` was never captured or awaited. +- All remaining paste features are unaffected because they read the clipboard directly at the moment of user action, not via this state: "tap to paste link" (`NewChatView.kt`), Ctrl+V in the composer (`PlatformTextField.desktop.kt`), "paste desktop address" (`ConnectDesktopView.kt`), migration link paste (`MigrateToDevice.kt`). + +After this change the desktop app touches the X11 clipboard only on explicit user paste, so no background clipboard state can stall the UI. (A paste attempt while the selection owner is unresponsive can still block once, up to the AWT timeout — inherent to AWT's synchronous X11 clipboard, out of scope.) + +## Change + +Delete the dead mechanism end to end: + +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt` — `expect fun SetupClipboardListener()` +- `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt` — desktop actual (the polling loop) + unused imports +- `common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt` — Android actual (clip listener) + unused imports +- `common/src/commonMain/kotlin/chat/simplex/common/App.kt` — call site +- `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` — `clipboardHasText` field +- `android/src/main/java/chat/simplex/app/MainActivity.kt` — `onResume` clipboard block + unused imports +- `spec/state.md` — stale table row +- `spec/client/navigation.md` — stale "sets up clipboard listener" step in the MainScreen description + +## Verification + +- `:common:compileKotlinDesktop`, `:common:compileReleaseKotlinAndroid`, `:android:compileReleaseKotlin` pass. +- Repo-wide grep for `clipboardHasText`/`SetupClipboardListener` returns no references outside this document. +- Linux AppImage and Android APK built from this branch. From 5a380a751faf946c7b5f8d46fc88abaebc3a0c9c Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:43:35 +0000 Subject: [PATCH 11/13] desktop: fix hand cursor over clickable commands; fix command clicks lost on quick successive clicks (#7249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * desktop: fix pointer cursor not changing to hand over clickable commands (dropped hover events) * desktop: set hover cursor directly on every hover move; compose pointerHoverIcon is edge-triggered and loses updates when chat items shift under the cursor * plans: investigation and justification for command hover cursor fix * desktop: harden hover cursor fix after adversarial review: refresh on release, ignore button-held events, reset icon state on exit, cache canvas lookup failures * desktop: document two-stage verification, exit icon asymmetry and cache guard rationale in hover cursor fix * plans: reconcile performance and testing claims with hardened code * plans: correct recomposition count and note commit subject scoping * plans: clarify user-facing problem description * desktop: shorten comments in hover cursor fix * desktop: clarify comments in hover cursor fix * multiplatform: fix command clicks lost on quick successive clicks (serialize gesture press scope, don't restart pointerInput on recomposition) * plans: document lost-click defect (press-scope race, pointerInput restart) and fixes * multiplatform: don't cancel command click when chat list shifts under the pointer A press is cancelled when it goes out of the node's bounds, but when a sent message inserts into the chat the node moves out from under a stationary pointer, which is not a drag-away. Exempt out-of-bounds cancellation when the pointer did not move in window coordinates (within touch slop). * multiplatform: stop pointer handler resets on chat item recomposition (lost clicks, cursor flicker) bigTouchSlop() created a new ViewConfiguration instance on every recomposition of every chat item; pointer input nodes observe ViewConfiguration and reset their handler when it changes, so each inserted message killed all in-flight presses (lost command clicks) and hover handlers (hand cursor flicker) in the viewport. Provide a remembered instance instead. * plans: document list-shift press cancellation and ViewConfiguration reset defects * desktop: remove imperative hover cursor workaround, superseded by ViewConfiguration fix The evidence that the declarative path was insufficient was gathered while every message insertion was resetting all hover handlers (the ViewConfiguration identity defect, fixed in 6d24bd5d4) — which alone explains those failures. With handlers stable, the full hover matrix passes on pointerHoverIcon plus the lossless detectCursorMove alone, so the AWT canvas write and its expect/actuals are removed; only the icon state reset on Exit remains. * plans: document hover workaround removal and stage-1 evidence contamination --- .../simplex/common/views/chat/ChatView.kt | 7 +- .../common/views/chat/item/TextItemView.kt | 44 ++-- .../common/views/helpers/GestureDetector.kt | 61 +++-- plans/2026-07-13-fix-command-hover-cursor.md | 240 ++++++++++++++++++ 4 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 plans/2026-07-13-fix-command-hover-cursor.md 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 e762c5610d..ea7d96059d 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 @@ -1909,10 +1909,13 @@ fun BoxScope.ChatItemsList( reveal: (Boolean) -> Unit ) { val itemScope = rememberCoroutineScope() + val viewConfiguration = LocalViewConfiguration.current 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() + // With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view. + // remember: pointerInput handlers observe ViewConfiguration and reset on any change, so a new + // instance per recomposition kills in-flight presses/hover whenever a message is inserted + LocalViewConfiguration provides remember(viewConfiguration) { viewConfiguration.bigTouchSlop() } ) { val provider = { providerForGallery(reversedChatItems.value.asReversed(), cItem.id) { indexInReversed -> 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 9d952eb10f..7e66c15937 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 @@ -12,8 +12,10 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.* import androidx.compose.ui.platform.* import androidx.compose.ui.text.* import androidx.compose.ui.text.AnnotatedString.Range @@ -394,12 +396,12 @@ fun MarkdownText ( }, onHover = { offset -> val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) } - icon.value = - if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") || hasAnnotation("MODAL")) { - PointerIcon.Hand - } else { - PointerIcon.Text - } + val hand = hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SIMPLEX_NAME") || hasAnnotation("SECRET") || hasAnnotation("COMMAND") || hasAnnotation("MODAL") + icon.value = if (hand) PointerIcon.Hand else PointerIcon.Text + }, + onHoverExit = { + // reset icon.value too, or pointerHoverIcon re-displays a stale Hand on the next Enter + icon.value = PointerIcon.Text }, shouldConsumeEvent = { offset -> annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) @@ -433,34 +435,45 @@ fun ClickableText( onClick: (Int) -> Unit, onLongClick: (Int) -> Unit = {}, onHover: (Int) -> Unit = {}, + onHoverExit: () -> Unit = {}, shouldConsumeEvent: (Int) -> Boolean ) { val layoutResult = remember { mutableStateOf(null) } - val pressIndicator = Modifier.pointerInput(onClick, onLongClick) { + // pointerInput keyed on these lambdas restarts on every recomposition (they are new + // instances each time), and a restart mid-gesture swallows the click/hover in flight; + // key on Unit and read the latest handlers via rememberUpdatedState instead + val currentOnClick = rememberUpdatedState(onClick) + val currentOnLongClick = rememberUpdatedState(onLongClick) + val currentOnHover = rememberUpdatedState(onHover) + val currentOnHoverExit = rememberUpdatedState(onHoverExit) + val currentShouldConsumeEvent = rememberUpdatedState(shouldConsumeEvent) + // to tell a moved pointer from text that shifted under a stationary pointer (see waitForUpOrCancellation) + val textCoordinates = remember { mutableStateOf(null) } + val pressIndicator = Modifier.pointerInput(Unit) { detectGesture(onLongPress = { pos -> layoutResult.value?.let { layoutResult -> - onLongClick(layoutResult.getOffsetForPosition(pos)) + currentOnLongClick.value(layoutResult.getOffsetForPosition(pos)) } }, onPress = { pos -> layoutResult.value?.let { layoutResult -> val res = tryAwaitRelease() if (res) { - onClick(layoutResult.getOffsetForPosition(pos)) + currentOnClick.value(layoutResult.getOffsetForPosition(pos)) } } - }, shouldConsumeEvent = { pos -> + }, positionInWindow = { textCoordinates.value?.positionInWindow() ?: Offset.Zero }, shouldConsumeEvent = { pos -> var consume = false layoutResult.value?.let { layoutResult -> - consume = shouldConsumeEvent(layoutResult.getOffsetForPosition(pos)) + consume = currentShouldConsumeEvent.value(layoutResult.getOffsetForPosition(pos)) } consume } ) - }.pointerInput(onHover) { + }.pointerInput(Unit) { if (appPlatform.isDesktop) { - detectCursorMove { pos -> + detectCursorMove(onExit = { currentOnHoverExit.value() }) { pos -> layoutResult.value?.let { layoutResult -> - onHover(layoutResult.getOffsetForPosition(pos)) + currentOnHover.value(layoutResult.getOffsetForPosition(pos)) } } } @@ -468,7 +481,8 @@ fun ClickableText( BasicText( text = text, - modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator), + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator) + .onGloballyPositioned { textCoordinates.value = it }, style = style, softWrap = softWrap, overflow = overflow, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt index 9252e8b032..d55f894491 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/GestureDetector.kt @@ -46,6 +46,7 @@ private val NoPressGesture: suspend PressGestureScope.(Offset) -> Unit = { } suspend fun PointerInputScope.detectGesture( onLongPress: ((Offset) -> Unit)? = null, onPress: suspend PressGestureScope.(Offset) -> Unit = NoPressGesture, + positionInWindow: (() -> Offset)? = null, shouldConsumeEvent: (Offset) -> Boolean ) = coroutineScope { val pressScope = PressGestureScopeImpl(this@detectGesture) @@ -57,8 +58,14 @@ suspend fun PointerInputScope.detectGesture( val shouldConsume = shouldConsumeEvent(down.position) if (shouldConsume) down.consumeDownChange() - pressScope.reset() + val downPositionInWindow = positionInWindow?.let { it() + down.position } + // reset() suspends until the previous gesture's press handler released the mutex, + // and release/cancel join resetJob, so flag writes can't be reordered across gestures + // (a fast second click would otherwise drop the first click's onClick). + // Mirrors detectTapGestures in Compose 1.8.2. + val resetJob = launch { pressScope.reset() } if (onPress !== NoPressGesture) launch { + resetJob.join() pressScope.onPress(down.position) } val longPressTimeout = onLongPress?.let { @@ -67,37 +74,45 @@ suspend fun PointerInputScope.detectGesture( try { val upOrCancel: PointerInputChange? = withTimeout(longPressTimeout) { - waitForUpOrCancellation() + waitForUpOrCancellation(downPositionInWindow, positionInWindow) } if (upOrCancel == null) { - pressScope.cancel() + launch { resetJob.join(); pressScope.cancel() } } else { if (shouldConsume) upOrCancel.consumeDownChange() - pressScope.release() + launch { resetJob.join(); pressScope.release() } } } catch (_: PointerEventTimeoutCancellationException) { if (onLongPress != null) { onLongPress(down.position) if (shouldConsume) consumeUntilUp() - pressScope.cancel() + launch { resetJob.join(); pressScope.cancel() } } else { if (shouldConsume) consumeUntilUp() - pressScope.release() + launch { resetJob.join(); pressScope.release() } } } } } } -suspend fun PointerInputScope.detectCursorMove(onMove: (Offset) -> Unit = {},) = coroutineScope { - forEachGesture { - awaitPointerEventScope { +suspend fun PointerInputScope.detectCursorMove(onExit: () -> Unit = {}, onMove: (Offset) -> Unit = {},) { + // One scope for all events: re-entering awaitPointerEventScope per event loses events. + // Enter/Release update hover when the pointer is stationary (content moved under it, or a click completed). + awaitPointerEventScope { + while (true) { val event = awaitPointerEvent() - if (event.type == PointerEventType.Move) { - onMove(event.changes[0].position) + if (event.type == PointerEventType.Move || event.type == PointerEventType.Enter || event.type == PointerEventType.Release) { + val pos = event.changes[0].position + // ignore events while a button is down or outside bounds: a pressed node receives them even after the pointer leaves it + if (event.changes.none { it.pressed } && pos.x >= 0 && pos.y >= 0 && pos.x < size.width && pos.y < size.height) { + onMove(pos) + } + } else if (event.type == PointerEventType.Exit) { + onExit() } } } @@ -130,7 +145,16 @@ internal suspend fun AwaitPointerEventScope.awaitFirstDownOnPass( return event.changes[0] } -suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange? { +suspend fun AwaitPointerEventScope.waitForUpOrCancellation( + downPositionInWindow: Offset? = null, + positionInWindow: (() -> Offset)? = null +): PointerInputChange? { + // out of bounds in local coordinates while stationary in window coordinates is the node + // moving under the pointer (chat list shifted after a sent message), not the pointer + // leaving the node — such a press stays valid + fun stationaryInWindow(change: PointerInputChange): Boolean = + downPositionInWindow != null && positionInWindow != null && + (positionInWindow() + change.position - downPositionInWindow).getDistance() <= viewConfiguration.touchSlop while (true) { val event = awaitPointerEvent(PointerEventPass.Main) if (event.changes.all { it.changedToUp() }) { @@ -138,7 +162,7 @@ suspend fun AwaitPointerEventScope.waitForUpOrCancellation(): PointerInputChange } if (event.changes.any { - it.consumed.downChange || it.isOutOfBounds(size, extendedTouchPadding) + it.consumed.downChange || (it.isOutOfBounds(size, extendedTouchPadding) && !stationaryInWindow(it)) } ) { return null @@ -159,16 +183,18 @@ private class PressGestureScopeImpl( fun cancel() { isCanceled = true - mutex.unlock() + if (mutex.isLocked) mutex.unlock() } fun release() { isReleased = true - mutex.unlock() + if (mutex.isLocked) mutex.unlock() } - fun reset() { - mutex.tryLock() + // suspends until the previous gesture's tryAwaitRelease finished (or was never started): + // the mutex is the serialization token between consecutive gestures + suspend fun reset() { + mutex.lock() isReleased = false isCanceled = false } @@ -176,6 +202,7 @@ private class PressGestureScopeImpl( override suspend fun tryAwaitRelease(): Boolean { if (!isReleased && !isCanceled) { mutex.lock() + mutex.unlock() } return isReleased && !isCanceled } diff --git a/plans/2026-07-13-fix-command-hover-cursor.md b/plans/2026-07-13-fix-command-hover-cursor.md new file mode 100644 index 0000000000..6f96abeee6 --- /dev/null +++ b/plans/2026-07-13-fix-command-hover-cursor.md @@ -0,0 +1,240 @@ +# Fix: hand cursor not appearing over clickable commands (desktop) + +## Problem + +On desktop, the mouse cursor should change to a hand over clickable commands in chat messages +(e.g. `/join 143556` in a bot's menu of commands) — the hand is what tells the user the command +can be clicked. This did not work reliably: hovering a command sometimes left the text/arrow +cursor in place, giving no indication that the command is clickable. It usually worked, but failed +intermittently — most often when clicking many commands in a row in a chat, because every click +inserts the sent message and shifts the list under the pointer — and once wrong, the cursor stayed +wrong even while moving within the same message; it only recovered after leaving the message text +and re-entering it. The same mechanism affects all clickable elements in message text (links, +simplex addresses, secrets), but command menus made it most visible. + +A second user-visible problem surfaced while testing this fix: a click on a command was sometimes +not registered at all when clicking two commands (e.g. two `/join`s) in short succession. That is +defect 3 below — two independent mechanisms in the click path, unrelated to the cursor layers. + +## Investigation + +The cursor is driven by two cooperating layers in `ClickableText` (TextItemView.kt): + +1. **Detection** — `Modifier.pointerInput { detectCursorMove { ... } }` maps the pointer position to + a character offset and checks for `COMMAND`/link annotations, storing `PointerIcon.Hand` or + `PointerIcon.Text` in per-item state (`onHover`). +2. **Display** — `Modifier.pointerHoverIcon(icon.value)` tells Compose which cursor to show. + +Both layers were traced against the Compose Multiplatform 1.8.2 sources (`ui-desktop`, +`foundation-desktop`). + +### Defect 1: detection layer lost events (commit "fix pointer cursor not changing to hand…") + +`detectCursorMove` (GestureDetector.kt) processed **one pointer event per +`awaitPointerEventScope` block**, exiting and re-entering the scope through `forEachGesture` for +every event. `forEachGesture` is deprecated in this exact Compose version with the message *"Use +awaitEachGesture instead. forEachGesture() can drop events between gestures."* — so the final Move +event, the one that should switch the cursor when the pointer comes to rest on a command, could be +silently dropped. It also ignored `Enter` events entirely, so when the chat list shifted under a +stationary cursor (every command click appends the sent message), the newly hovered item — which +receives `Enter`, not `Move` — never updated the icon state. + +**Fix:** one never-exiting `awaitPointerEventScope` loop reacting to `Move`, `Enter` and `Release` +(plus an optional `onExit` callback used by the cursor fix below). `Release` refreshes hover at +the final pointer position — after clicking a command the list shifts and the release is the only +event a stationary pointer gets. Button-held events are ignored (gated on *no* pointer pressed, +matching the old `forEachGesture` semantics which waited for all pointers up between events), and +so are out-of-bounds positions: while a button is pressed the hit path is locked, so the pressed +node keeps receiving events after the pointer leaves it — acting on those would apply hover +effects at clamped positions (e.g. a wrong cursor stuck after drag-releasing outside the text). +The other `detectCursorMove` call sites (scrollbar reveal in `ScrollableColumn.desktop.kt` and +`OperatorView.desktop.kt`) were audited: reacting to `Enter`/`Release` there is harmless or an +improvement, and `onExit` defaults to a no-op for them. + +### Defect 2: display layer silently swallows updates (commit "set hover cursor directly…") + +This made hovering reliable but the cursor could still get stuck, because Compose's +`pointerHoverIcon` display side is edge-triggered with several silent-drop guards +(all confirmed in the CMP 1.8.2 sources): + +- `HoverIconModifierNode` acts only on per-node `Enter`/`Exit` events (`PointerIcon.kt:253-259`); + Move events never refresh the displayed cursor. +- Its `icon` setter displays only if the value *changed* **and** the node is marked in-bounds + (`PointerIcon.kt:213-221`) — re-asserting the same value, or changing it while the node believes + the cursor is outside, is silently dropped. +- `setIcon` writes the AWT cursor immediately, last-write-wins, with no later reconciliation + (`RootNodeOwner.skiko.kt:738-744`); `onDetach`/`onCancelPointerInput` reset it to default from + recomposition, outside pointer dispatch (`PointerIcon.kt:278-288`). +- Whether a node gets `Enter` depends on `hasEntered` state stored separately in + `HitPathTracker.Node` (`HitPathTracker.kt:582-594`); confirmed windows exist where the two + desync (detach between `buildCache` and dispatch; `cleanUpHover` clearing `isIn` without + dispatching; Press/Release never remapping to Enter). Once desynced, the node receives only + Moves — which it ignores — so the hand can never be displayed again until the pointer physically + leaves and re-enters the text. +- Recovery after content shifts under a stationary cursor depends entirely on the synthetic Move + (`SyntheticEventSender`), which is skipped whenever the sender was reset or + `needUpdatePointerPosition` was not set for that frame. + +Clicking many commands maximizes exposure: each click inserts a message → recomposition burst + +list shift + (for `/join`) group-state changes — each transition can hit one of these edges. +Related upstream issues: JetBrains/compose-multiplatform #2091, #1314, #3750. + +**Fix (initial):** in `onHover` — which now fires reliably on every Move/Enter/Release over the +text — set the AWT cursor imperatively on desktop (`desktopSetHoverCursor`, writing to the same +Skia canvas component Compose writes to), and symmetrically reset both the cursor and the `icon` +state on the per-node `Exit` event. + +**Removed after defect 5 was fixed at the root.** The empirical justification for the imperative +layer (stage 1 under Testing) was contaminated: that test ran while defect 5 was live, i.e. while +every message insertion reset every hover handler in the viewport — which alone explains the +observed failure (a reset suspending handler restarts only on the next pointer event, leaving a +stationary cursor blind after every click). With defects 1 and 5 fixed, the full hover matrix +(rapid stationary-mouse command clicks, list shifting under a hovered pointer, fast sweeps across +commands/links/text, press-drag-out-release-re-enter, wheel-scroll over commands) passes without +the imperative layer, so it was deleted; `Modifier.kt`/`Modifier.desktop.kt`/`Modifier.android.kt` +are untouched by this PR again. What remains of this defect's fix is the `icon.value = Text` reset +on `Exit` in `MarkdownText` — without it, `pointerHoverIcon` re-displays a stale Hand on the next +Enter. The display-layer edges documented above are real in the sources, but with stable handlers +they are not practically reachable in these scenarios. + +### Residual limitation (known, strictly smaller than the fixed bug) + +If a hovered clickable text leaves the composition without the pointer ever getting an `Exit` +event (e.g. the hovered message is deleted, or a fast fling disposes the item within a frame), the +`onExit` reset does not run — the coroutine is simply cancelled. The retained `pointerHoverIcon` +modifier's `onDetach` reset covers this when its node is in the normal (non-desynced) state; in the +rare desynced state the cursor may stay a hand until the next hover write. This is the same event +class the fix reduces, bounded to one stale frame region, and self-corrects on any subsequent +hover. + +### Defect 3: command clicks intermittently lost (commit "multiplatform: fix command clicks lost on quick successive clicks") + +Clicking two commands in short succession sometimes lost a click. Two independent mechanisms, +each reproduced in an isolated harness before fixing and re-verified fixed with the same harness: + +1. **Press-scope race in `detectGesture`** (GestureDetector.kt — a 2022 copy of + `detectTapGestures` predating upstream's hardening; the file has not been resynced since). + The single `PressGestureScopeImpl` is shared across gestures, and its methods run in two + different lanes of the UI thread: `reset()`/`release()` synchronously inside pointer-event + dispatch, while the `onPress` handler (which awaits release, then calls `onClick`) runs in a + launched coroutine resumed through the dispatcher queue. With the old non-suspending + `reset()` (`mutex.tryLock()`), two fast clicks interleave as: up₁ `release()` sets + `isReleased` and unlocks — the first click's `tryAwaitRelease` resumption is only *queued* — + then down₂ `reset()` clears `isReleased` synchronously, so the queued resumption reads + `false` and the first click's `onClick` never fires. Reproduced deterministically in a + single-threaded coroutine harness replaying this ordering (`click1=false click2=true`). + + **Fix:** mirror the `detectTapGestures` serialization from Compose 1.8.2 (`foundation` + `TapGestureDetector.kt`): `reset()` is `suspend` and takes the mutex, so a new gesture cannot + clear the flags until the previous gesture's press handler finished; `release()`/`cancel()` + unlock guardedly (`if (mutex.isLocked)`) and are launched joining the reset job, so flag + writes cannot be reordered across gestures; `tryAwaitRelease()` releases the mutex after + acquiring it, which is what lets the next `reset()` proceed. The mutex becomes a + serialization token between consecutive gestures. `detectGesture` also runs for Android + touch, so the same quick-tap loss is fixed there. + +2. **`pointerInput` restart swallowing an in-flight click** (TextItemView.kt). `ClickableText` + keyed `pointerInput(onClick, onLongClick)` on lambdas that are new instances every + recomposition, so any recomposition of the message restarted the gesture coroutine and + destroyed a gesture in flight: the cancelled `onPress` never resumes, and the restarted + detector waits for a fresh down that never comes for the press already held. Clicking + command 1 sends a message whose insertion recomposes the item 50–300 ms later — exactly when + the second click tends to be pressed. Reproduced in a headless `ImageComposeScene` driving + the verbatim repo gesture code with synthetic pointer events: press → recomposition applies → + release lost the click, while control clicks on either side registered. + + **Fix:** key both `pointerInput` blocks on `Unit` and read the latest handlers through + `rememberUpdatedState` (the standard idiom for long-lived event coroutines). This also stops + recompositions from cancelling `detectCursorMove` mid-stream — the same event-loss class as + defect 1 — and gives all `ClickableText` callers latest-capture semantics for + `annotatedText` (previously the handlers could act on a stale capture until the restart). + +A theoretical residue remains: if three gestures' pointer events were all processed before the +dispatcher ran any of the second gesture's jobs, flags could still mispair. This exposure is +structurally identical in upstream Compose 1.8.2 (`launchAwaitingReset` joins only its own +gesture's reset job), is not reachable by human input, and fixing it would diverge from the +mirrored upstream — left as upstream parity. + +### Defect 4: press cancelled when the list shifts under the pointer (commit "multiplatform: don't cancel command click when chat list shifts under the pointer") + +Clicks were still lost after defect 3's fixes. `waitForUpOrCancellation` cancels a press when an +event's position is out of the node's bounds — correct for a pointer dragged away, wrong when the +*node* moved out from under a stationary pointer, which is exactly what happens when the sent +command's message inserts and shifts the list during the press. In node-local coordinates the two +cases are numerically identical; reproduced in a headless harness (60px shift under a held +pointer → synthetic `Exit` at out-of-bounds local position → press cancelled). + +**Fix:** the two cases separate in window coordinates. `ClickableText` tracks its +`LayoutCoordinates` (`onGloballyPositioned`) and passes a window-position lambda into +`detectGesture`; `waitForUpOrCancellation` exempts the out-of-bounds cancel when the pointer moved +≤ `touchSlop` in window space since the down. Real drag-aways (> slop) cancel as before; both +parameters default to null, reducing the check literally to the old expression for other callers. + +### Defect 5: every message insertion reset all pointer handlers (commit "multiplatform: stop pointer handler resets on chat item recomposition…") + +The dominant residual mechanism, found by instrumenting handler lifecycle: every insertion +produced a burst of gesture-handler cancellations for all visible clickable messages — with the +composables *not* disposed and the handler restarting on the *same* node. A press in flight during +such a burst died silently (`down` with no release/cancel), needing a second click; the hover +handler reset the same way, causing the residual hand-cursor flicker. + +Cause: `ChatViewListItem` provided `LocalViewConfiguration.current.bigTouchSlop()` — a **new +anonymous `ViewConfiguration` instance on every recomposition** of every item, and +`SuspendingPointerInputModifierNodeImpl.onViewConfigurationChange()` is +`resetPointerInputHandler()` (verified in the 1.8.2 sources). Insertions recompose all visible +items (shifting `index`), so each insertion reset every pointer handler in the viewport. + +**Fix:** provide a remembered instance — `remember(viewConfiguration) { +viewConfiguration.bigTouchSlop() }` — keyed on the parent `ViewConfiguration` so a real platform +change still regenerates it. Values are unchanged (the wrapper delegates); only the identity that +the reset machinery reacts to is stabilized. Verified in a harness: a press held across a +recomposition dies with the per-recomposition instance and survives with the remembered one. + +## Performance + +- `detectCursorMove` is now cheaper per event than before (no scope teardown/re-entry per event); + the pressed check allocates one list iterator per event. +- Up to two added recompositions per exit/re-enter cycle of a Hand region (the exit reset writes + `icon.value` Hand→Text, making the next re-enter a Text→Hand write where pre-fix both were + no-ops); otherwise the icon state logic is unchanged. Android is a no-op. +- The defect 3 fix launches two extra short-lived coroutines per gesture (reset job, release/cancel + job) — negligible at click rate. Not restarting `pointerInput` on every recomposition removes + per-recomposition coroutine churn that existed before. + +Framework line numbers cited above are from the official `ui-desktop-1.8.2`/`foundation-desktop-1.8.2` +sources jars on Maven Central; they will drift on upgrade. + +## Testing + +Verified on Linux (AppImage), in two stages (commit subjects name the defect layer each commit +fixes; the user-visible symptom needed both layers, per stage 1 below): + +1. A build with only the detection-layer fix (lossless `detectCursorMove`) was tested first and + the stale-cursor symptom **still reproduced** — this is the empirical justification for the + imperative display-layer workaround; the declarative-only fix is not sufficient. +2. With both layers (pre-hardening build, commit `df6b0655d`): hovering commands after clicking + several in a row, clicking stacked `/join` commands without moving the mouse, and sweeping + across commands quickly — the hand cursor tracks correctly in all cases. + +The later hardening commits (release refresh, pressed/bounds gating, exit resets, canvas cache) +are verified by compilation on both targets and multi-pass adversarial review; runtime +re-verification of the final build is pending. + +Defect 3 is verified by the two isolated repro harnesses described above (both mechanisms +reproduced pre-fix, both register every click post-fix — including a click held across a +recomposition and two fast clicks back-to-back through real Compose event dispatch in +`ImageComposeScene`), by compilation, and by multi-pass adversarial review (two clean passes +pre-commit, two post-commit). + +Stage 1's conclusion is retroactively invalidated by defect 5 (see defect 2's removal note): the +"detection fix alone is insufficient" evidence was gathered while insertions were resetting the +detection handler itself. After defect 5's fix, the imperative workaround was removed and the +hover scenarios re-verified passing on the framework path alone. + +Defects 4 and 5 were isolated with four rounds of temporary instrumentation in the real app +(logging every gesture exit path, composable disposal, and handler/node identity — removed before +commit), each mechanism reproduced and its fix verified in a headless harness, reviewed through +two clean adversarial passes, and confirmed fixed by runtime testing on Linux: fast successive +command clicks all register, and the residual hand-cursor flicker on message insertion is gone. +Known unfixable-at-this-layer residue: a click that lands where a command *was* before the list +shifted (pre-press shift — an aiming race, not an event-handling defect). From a61331639e204e3d8bd3b947614f61ffbb199a35 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:57:49 +0400 Subject: [PATCH 12/13] ios: fix broken string in zh-Hant.xliff (#7282) --- .../zh-Hant.xcloc/Localized Contents/zh-Hant.xliff | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 92580c1f4f..168d865b4b 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 @@ -10653,8 +10653,8 @@ More improvements are coming soon! Owner 擁有者 - - Owners< & contributors + + Owners & contributors 擁有者 From 4483957ca01a2a85a9901dd4675045b190e52116 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Wed, 22 Jul 2026 12:08:13 +0100 Subject: [PATCH 13/13] 7.0.0.10 --- simplex-chat.cabal | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index f410089bff..6e82981f48 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: 7.0.0.9 +version: 7.0.0.10 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat