From 67461d6971f278ea7404175af288a98e321a8ba4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Oct 2025 08:12:45 +0000 Subject: [PATCH 001/112] core: manage chat relays initial (#6369) --- bots/api/TYPES.md | 6 + cabal.project | 2 +- .../types/typescript/src/types.ts | 9 ++ scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 + src/Simplex/Chat.hs | 15 +- src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Core.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 58 ++++--- src/Simplex/Chat/Operators.hs | 146 +++++++++++++++--- src/Simplex/Chat/Operators/Presets.hs | 9 ++ src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 29 ++-- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20251016_chat_relays.hs | 46 ++++++ src/Simplex/Chat/Store/Profiles.hs | 80 ++++++++-- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20251016_chat_relays.hs | 43 ++++++ .../Store/SQLite/Migrations/chat_schema.sql | 19 ++- src/Simplex/Chat/Store/Shared.hs | 17 +- src/Simplex/Chat/Terminal.hs | 6 +- src/Simplex/Chat/Types.hs | 13 +- src/Simplex/Chat/View.hs | 21 ++- tests/ChatClient.hs | 2 +- tests/JSONFixtures.hs | 6 +- tests/MobileTests.hs | 2 +- tests/OperatorTests.hs | 75 +++++++-- 28 files changed, 508 insertions(+), 125 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20251016_chat_relays.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20251016_chat_relays.hs diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 923fab14c2..0c173df77c 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -958,6 +958,9 @@ UserExists: - type: "userExists" - contactName: string +ChatRelayExists: +- type: "chatRelayExists" + DifferentActiveUser: - type: "differentActiveUser" - commandUserId: int64 @@ -2215,6 +2218,7 @@ Known: - createdAt: UTCTime - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? +- isChatRelay: bool --- @@ -2664,6 +2668,7 @@ SubscribeError: **Record type**: - profile: [Profile](#profile)? - pastTimestamp: bool +- userChatRelay: bool --- @@ -3715,6 +3720,7 @@ Handshake: - autoAcceptMemberContacts: bool - userMemberProfileUpdatedAt: UTCTime? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? +- userChatRelay: bool --- diff --git a/cabal.project b/cabal.project index eeadf7c6fd..a6b9414ca3 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 1329fc726ffb2e773935ad10f024a137dd887867 + tag: 0fc19708bd63dc23ebbf7331c9392a0783750591 source-repository-package type: git diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index b8095affdf..993ca789b3 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -969,6 +969,7 @@ export type ChatErrorType = | ChatErrorType.UserUnknown | ChatErrorType.ActiveUserExists | ChatErrorType.UserExists + | ChatErrorType.ChatRelayExists | ChatErrorType.DifferentActiveUser | ChatErrorType.CantDeleteActiveUser | ChatErrorType.CantDeleteLastUser @@ -1045,6 +1046,7 @@ export namespace ChatErrorType { | "userUnknown" | "activeUserExists" | "userExists" + | "chatRelayExists" | "differentActiveUser" | "cantDeleteActiveUser" | "cantDeleteLastUser" @@ -1148,6 +1150,10 @@ export namespace ChatErrorType { contactName: string } + export interface ChatRelayExists extends Interface { + type: "chatRelayExists" + } + export interface DifferentActiveUser extends Interface { type: "differentActiveUser" commandUserId: number // int64 @@ -2504,6 +2510,7 @@ export interface GroupMember { createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat + isChatRelay: boolean } export interface GroupMemberAdmission { @@ -2952,6 +2959,7 @@ export namespace NetworkError { export interface NewUser { profile?: Profile pastTimestamp: boolean + userChatRelay: boolean } export interface NoteFolder { @@ -4394,6 +4402,7 @@ export interface User { autoAcceptMemberContacts: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp uiThemes?: UIThemeEntityOverrides + userChatRelay: boolean } export interface UserContact { diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1e3eda8f2b..fc0552319d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."1329fc726ffb2e773935ad10f024a137dd887867" = "0wlpwr464i8dif5a94mfx31y3fm44gkc3h357dx8l1ii9q3sy05i"; + "https://github.com/simplex-chat/simplexmq.git"."0fc19708bd63dc23ebbf7331c9392a0783750591" = "02h5g5cjskmvvkqqd60dc5am2zz6ic7d0sjsiy83vfc750qnvd03"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e8f43f5f07..53b5f8689e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -120,6 +120,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync + Simplex.Chat.Store.Postgres.Migrations.M20251016_chat_relays else exposed-modules: Simplex.Chat.Archive @@ -264,6 +265,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync + Simplex.Chat.Store.SQLite.Migrations.M20251016_chat_relays other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 9b711c2b50..8d70bd3bdd 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -73,14 +73,18 @@ defaultChatConfig = smp = simplexChatSMPServers, useSMP = 4, xftp = map (presetServer True) $ L.toList defaultXFTPServers, - useXFTP = 3 + useXFTP = 3, + chatRelays = simplexChatRelays, + useChatRelays = 2 }, PresetOperator { operator = Just operatorFlux, smp = fluxSMPServers, useSMP = 3, xftp = fluxXFTPServers, - useXFTP = 3 + useXFTP = 3, + chatRelays = [], + useChatRelays = 0 } ], ntf = _defaultNtfServers, @@ -239,7 +243,9 @@ newChatController smp = map newUserServer smpSrvs, useSMP = 0, xftp = map newUserServer xftpSrvs, - useXFTP = 0 + useXFTP = 0, + chatRelays = [], + useChatRelays = 0 } randomServerCfgs :: UserProtocol p => String -> SProtocolType p -> [(Text, ServerOperator)] -> [PresetOperator] -> IO (NonEmpty (ServerCfg p)) randomServerCfgs name p opDomains rndSrvs = @@ -260,7 +266,8 @@ newChatController getServers ops opDomains user' = do smpSrvs <- getProtocolServers db SPSMP user' xftpSrvs <- getProtocolServers db SPXFTP user' - uss <- groupByOperator' (ops, smpSrvs, xftpSrvs) + chatRelays <- getChatRelays db user' + uss <- groupByOperator' (ops, smpSrvs, xftpSrvs, chatRelays) ts <- getCurrentTime uss' <- mapM (setUserServers' db user' ts . updatedUserServers) uss let auId = aUserId user' diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8003f66324..ad824d20f6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -643,7 +643,7 @@ data ChatResponse | CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure} | CRServerOperatorConditions {conditions :: ServerOperatorConditions} | CRUserServers {user :: User, userServers :: [UserOperatorServers]} - | CRUserServersValidation {user :: User, serverErrors :: [UserServersError]} + | CRUserServersValidation {user :: User, serverErrors :: [UserServersError], serverWarnings :: [UserServersWarning]} | CRUsageConditions {usageConditions :: UsageConditions, conditionsText :: Text, acceptedConditions :: Maybe UsageConditions} | CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64} | CRNetworkConfig {networkConfig :: NetworkConfig} @@ -1250,6 +1250,7 @@ data ChatErrorType | CEUserUnknown | CEActiveUserExists -- TODO delete | CEUserExists {contactName :: ContactName} + | CEChatRelayExists | CEDifferentActiveUser {commandUserId :: UserId, activeUserId :: UserId} | CECantDeleteActiveUser {userId :: UserId} | CECantDeleteLastUser {userId :: UserId} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 131b420bd9..f9bf76e5b3 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -118,7 +118,7 @@ createActiveUser cc = \case createUser loop $ mkProfile displayName mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} createUser onError p = - execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False}) 0 `runReaderT` cc >>= \case + execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = False}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user r -> printResponseEvent (Nothing, Nothing) (config cc) r >> onError diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 67953897cf..d6688a939c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -327,19 +327,20 @@ parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace processChatCommand :: VersionRangeChat -> NetworkRequestMode -> ChatCommand -> CM ChatResponse processChatCommand vr nm = \case ShowActiveUser -> withUser' $ pure . CRActiveUser - CreateActiveUser NewUser {profile, pastTimestamp} -> do + CreateActiveUser NewUser {profile, pastTimestamp, userChatRelay} -> do forM_ profile $ \Profile {displayName} -> checkValidName displayName p@Profile {displayName} <- liftIO $ maybe generateRandomProfile pure profile u <- asks currentUser users <- withFastStore' getUsers - forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash} -> + forM_ users $ \User {localDisplayName = n, activeUser, viewPwdHash, userChatRelay = userChatRelay'} -> do when (n == displayName) . throwChatError $ if activeUser || isNothing viewPwdHash then CEUserExists displayName else CEInvalidDisplayName {displayName, validName = ""} + when (userChatRelay && isTrue userChatRelay') $ throwChatError CEChatRelayExists (uss, (smp', xftp')) <- chooseServers =<< readTVarIO u auId <- withAgent $ \a -> createUser a smp' xftp' ts <- liftIO $ getCurrentTime >>= if pastTimestamp then coupleDaysAgo else pure user <- withFastStore $ \db -> do - user <- createUserRecordAt db (AgentUserId auId) p True ts + user <- createUserRecordAt db (AgentUserId auId) p userChatRelay True ts mapM_ (setUserServers db user ts) uss createPresetContactCards db user `catchAllErrors` \_ -> pure () createNoteFolder db user @@ -365,9 +366,16 @@ processChatCommand vr nm = \case let RandomAgentServers {smpServers = smp', xftpServers = xftp'} = as pure (uss, (smp', xftp')) copyServers :: UserOperatorServers -> UpdatedUserOperatorServers - copyServers UserOperatorServers {operator, smpServers, xftpServers} = - let new srv = AUS SDBNew srv {serverId = DBNewEntity} - in UpdatedUserOperatorServers {operator, smpServers = map new smpServers, xftpServers = map new xftpServers} + copyServers UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = + let newSrv srv = AUS SDBNew srv {serverId = DBNewEntity} + newCRelay chatRelay = AUCR SDBNew chatRelay {chatRelayId = DBNewEntity} + in + UpdatedUserOperatorServers { + operator, + smpServers = map newSrv smpServers, + xftpServers = map newSrv xftpServers, + chatRelays = map newCRelay chatRelays + } coupleDaysAgo t = (`addUTCTime` t) . fromInteger . negate . (+ (2 * day)) <$> randomRIO (0, day) day = 86400 ListUsers -> CRUsersList <$> withFastStore' getUsersInfo @@ -1472,7 +1480,8 @@ processChatCommand vr nm = \case getServers db as ops opDomains user = do smpSrvs <- getProtocolServers db SPSMP user xftpSrvs <- getProtocolServers db SPXFTP user - uss <- groupByOperator (ops, smpSrvs, xftpSrvs) + chatRelays <- getChatRelays db user + uss <- groupByOperator (ops, smpSrvs, xftpSrvs, chatRelays) pure $ (aUserId user,) $ useServers as opDomains uss SetServerOperators operatorsRoles -> do ops <- serverOperators <$> withFastStore getServerOperators @@ -1487,8 +1496,9 @@ processChatCommand vr nm = \case APIGetUserServers userId -> withUserId userId $ \user -> withFastStore $ \db -> do CRUserServers user <$> (liftIO . groupByOperator =<< getUserServers db user) APISetUserServers userId userServers -> withUserId userId $ \user -> do - errors <- validateAllUsersServers userId $ L.toList userServers + (errors, warnings) <- validateAllUsersServers userId $ L.toList userServers unless (null errors) $ throwCmdError $ "user servers validation error(s): " <> show errors + unless (null warnings) $ logWarn $ "user servers validation warning(s): " <> tshow warnings uss <- withFastStore $ \db -> do ts <- liftIO getCurrentTime mapM (setUserServers db user ts) userServers @@ -1501,7 +1511,7 @@ processChatCommand vr nm = \case setProtocolServers a auId xftp' ok_ APIValidateServers userId userServers -> withUserId userId $ \user -> - CRUserServersValidation user <$> validateAllUsersServers userId userServers + uncurry (CRUserServersValidation user) <$> validateAllUsersServers userId userServers APIGetUsageConditions -> do (usageConditions, acceptedConditions) <- withFastStore $ \db -> do usageConditions <- getCurrentUsageConditions db @@ -3431,7 +3441,7 @@ processChatCommand vr nm = \case withServerProtocol p action = case userProtocol p of Just Dict -> action _ -> throwChatError $ CEServerProtocol $ AProtocolType p - validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM [UserServersError] + validateAllUsersServers :: UserServersClass u => Int64 -> [u] -> CM ([UserServersError], [UserServersWarning]) validateAllUsersServers currUserId userServers = withFastStore $ \db -> do users' <- filter (\User {userId} -> userId /= currUserId) <$> liftIO (getUsers db) others <- mapM (getUserOperatorServers db) users' @@ -4031,18 +4041,21 @@ data ConnectViaContactResult = CVRConnectedContact Contact | CVRSentInvitation Connection (Maybe Profile) -protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -protocolServers p (operators, smpServers, xftpServers) = case p of - SPSMP -> (operators, smpServers, []) - SPXFTP -> (operators, [], xftpServers) +-- TODO [chat relays] used for CLI specific APIs (same for `updatedServers` below) - add similar APIs for chat relays? +protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) +protocolServers p (operators, smpServers, xftpServers, _chatRelays) = case p of + SPSMP -> (operators, smpServers, [], []) + SPXFTP -> (operators, [], xftpServers, []) -- disable preset and replace custom servers (groupByOperator always adds custom) updatedServers :: forall p. UserProtocol p => SProtocolType p -> [AUserServer p] -> UserOperatorServers -> UpdatedUserOperatorServers -updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers} = case p' of - SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers) - SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers) +updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = case p' of + SPSMP -> u (updateSrvs smpServers, map (AUS SDBStored) xftpServers, map (AUCR SDBStored) chatRelays) + SPXFTP -> u (map (AUS SDBStored) smpServers, updateSrvs xftpServers, map (AUCR SDBStored) chatRelays) where - u = uncurry $ UpdatedUserOperatorServers operator + u = uncurry3 $ UpdatedUserOperatorServers operator + uncurry3 :: (a -> b -> c -> d) -> ((a, b, c) -> d) + uncurry3 f ~(a,b,c) = f a b c updateSrvs :: [UserServer p] -> [AUserServer p] updateSrvs pSrvs = map disableSrv pSrvs <> maybe srvs (const []) operator disableSrv srv@UserServer {preset} = @@ -4282,7 +4295,8 @@ chatCommandP = "/block #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False), "/unblock #" *> (SetShowMemberMessages <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True), "/_create user " *> (CreateActiveUser <$> jsonP), - "/create user " *> (CreateActiveUser <$> newUserP), + "/create user " *> (CreateActiveUser <$> newUserP False), + "/create chat relay user " *> (CreateActiveUser <$> newUserP True), "/create bot " *> (CreateActiveUser <$> newBotUserP), "/users" $> ListUsers, "/_user " *> (APISetActiveUser <$> A.decimal <*> optional (A.space *> jsonP)), @@ -4731,10 +4745,10 @@ chatCommandP = k : ws -> pure (k, if null ws then Nothing else Just $ T.unwords ws) pure CBCCommand {label, keyword, params} quoted = A.char '\'' *> A.takeTill (== '\'') <* A.char '\'' - newUserP = do + newUserP userChatRelay = do (cName, shortDescr) <- profileNameDescr let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} - pure NewUser {profile, pastTimestamp = False} + pure NewUser {profile, pastTimestamp = False, userChatRelay} newBotUserP = do files_ <- optional $ "files=" *> onOffP <* A.space (cName, shortDescr) <- profileNameDescr @@ -4742,7 +4756,7 @@ chatCommandP = Just True -> Nothing _ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}} profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences} - pure NewUser {profile, pastTimestamp = False} + pure NewUser {profile, pastTimestamp = False, userChatRelay = False} jsonP :: J.FromJSON a => Parser a jsonP = J.eitherDecodeStrict' <$?> A.takeByteString groupProfile = do diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 24baa37e4e..4a8ac65d5e 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -45,8 +46,9 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Types (User) +import Simplex.Chat.Types (ConnLinkContact, User) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) +import Simplex.Messaging.Agent.Protocol (sameConnLinkContact) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Encoding.String @@ -180,14 +182,16 @@ conditionsAccepted ServerOperator {conditionsAcceptance} = case conditionsAccept data UserOperatorServers = UserOperatorServers { operator :: Maybe ServerOperator, smpServers :: [UserServer 'PSMP], - xftpServers :: [UserServer 'PXFTP] + xftpServers :: [UserServer 'PXFTP], + chatRelays :: [UserChatRelay] } deriving (Show) data UpdatedUserOperatorServers = UpdatedUserOperatorServers { operator :: Maybe ServerOperator, smpServers :: [AUserServer 'PSMP], - xftpServers :: [AUserServer 'PXFTP] + xftpServers :: [AUserServer 'PXFTP], + chatRelays :: [AUserChatRelay] } deriving (Show) @@ -196,25 +200,34 @@ data ValidatedProtoServer p = ValidatedProtoServer {unVPS :: Either Text (ProtoS class UserServersClass u where type AServer u = (s :: ProtocolType -> Type) | s -> u + type AChatRelay u = (s :: Type) | s -> u operator' :: u -> Maybe ServerOperator aUserServer' :: AServer u p -> AUserServer p servers' :: UserProtocol p => SProtocolType p -> u -> [AServer u p] + chatRelays' :: u -> [AChatRelay u] + aUserChatRelay' :: AChatRelay u -> AUserChatRelay instance UserServersClass UserOperatorServers where type AServer UserOperatorServers = UserServer' 'DBStored + type AChatRelay UserOperatorServers = UserChatRelay' 'DBStored operator' UserOperatorServers {operator} = operator aUserServer' = AUS SDBStored servers' p UserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers + chatRelays' UserOperatorServers {chatRelays} = chatRelays + aUserChatRelay' = AUCR SDBStored instance UserServersClass UpdatedUserOperatorServers where type AServer UpdatedUserOperatorServers = AUserServer + type AChatRelay UpdatedUserOperatorServers = AUserChatRelay operator' UpdatedUserOperatorServers {operator} = operator aUserServer' = id servers' p UpdatedUserOperatorServers {smpServers, xftpServers} = case p of SPSMP -> smpServers SPXFTP -> xftpServers + chatRelays' UpdatedUserOperatorServers {chatRelays} = chatRelays + aUserChatRelay' = id type UserServer p = UserServer' 'DBStored p @@ -238,12 +251,34 @@ presetServerAddress :: UserServer' s p -> ProtocolServer p presetServerAddress UserServer {server = ProtoServerWithAuth srv _} = srv {-# INLINE presetServerAddress #-} +type UserChatRelay = UserChatRelay' 'DBStored + +type NewUserChatRelay = UserChatRelay' 'DBNew + +data AUserChatRelay = forall s. AUCR (SDBStored s) (UserChatRelay' s) + +deriving instance Show AUserChatRelay + +data UserChatRelay' s = UserChatRelay + { chatRelayId :: DBEntityId' s, + address :: ConnLinkContact, + name :: Text, + domains :: [Text], + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } + deriving (Show) + data PresetOperator = PresetOperator { operator :: Maybe NewServerOperator, smp :: [NewUserServer 'PSMP], useSMP :: Int, xftp :: [NewUserServer 'PXFTP], - useXFTP :: Int + useXFTP :: Int, + chatRelays :: [NewUserChatRelay], + useChatRelays :: Int } deriving (Show) @@ -262,17 +297,32 @@ operatorServersToUse p PresetOperator {useSMP, useXFTP} = case p of presetServer' :: Bool -> ProtocolServer p -> NewUserServer p presetServer' enabled = presetServer enabled . (`ProtoServerWithAuth` Nothing) +{-# INLINE presetServer' #-} presetServer :: Bool -> ProtoServerWithAuth p -> NewUserServer p presetServer = newUserServer_ True +{-# INLINE presetServer #-} newUserServer :: ProtoServerWithAuth p -> NewUserServer p newUserServer = newUserServer_ False True +{-# INLINE newUserServer #-} newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} +presetChatRelay :: Bool -> Text -> [Text] -> ConnLinkContact -> NewUserChatRelay +presetChatRelay = newChatRelay_ True +{-# INLINE presetChatRelay #-} + +newChatRelay :: Text -> [Text] -> ConnLinkContact -> NewUserChatRelay +newChatRelay = newChatRelay_ False True +{-# INLINE newChatRelay #-} + +newChatRelay_ :: Bool -> Bool -> Text -> [Text] -> ConnLinkContact -> NewUserChatRelay +newChatRelay_ preset enabled name domains !address = + UserChatRelay {chatRelayId = DBNewEntity, address, name, domains, preset, tested = Nothing, enabled, deleted = False} + -- This function should be used inside DB transaction to update conditions in the database -- it evaluates to (current conditions, and conditions to add) usageConditionsToAdd :: Bool -> UTCTime -> [UsageConditions] -> (UsageConditions, [UsageConditions]) @@ -300,8 +350,8 @@ usageConditionsToAdd' prevCommit sourceCommit newUser createdAt = \case presetUserServers :: [(Maybe PresetOperator, Maybe ServerOperator)] -> [UpdatedUserOperatorServers] presetUserServers = mapMaybe $ \(presetOp_, op) -> mkUS op <$> presetOp_ where - mkUS op PresetOperator {smp, xftp} = - UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) + mkUS op PresetOperator {smp, xftp, chatRelays} = + UpdatedUserOperatorServers op (map (AUS SDBNew) smp) (map (AUS SDBNew) xftp) (map (AUCR SDBNew) chatRelays) -- This function should be used inside DB transaction to update operators. -- It allows to add/remove/update preset operators in the database preserving enabled and roles settings, @@ -322,7 +372,7 @@ updatedServerOperators presetOps storedOps = -- This function should be used inside DB transaction to update servers. updatedUserServers :: (Maybe PresetOperator, UserOperatorServers) -> UpdatedUserOperatorServers updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpServers}) = - UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp'} + UpdatedUserOperatorServers {operator, smpServers = smp', xftpServers = xftp', chatRelays = []} where stored = map (AUS SDBStored) (smp', xftp') = case presetOp_ of @@ -335,7 +385,7 @@ updatedUserServers (presetOp_, UserOperatorServers {operator, smpServers, xftpSe storedSrvs :: Map (ProtoServerWithAuth p) (UserServer p) storedSrvs = foldl' (\ss srv@UserServer {server} -> M.insert server srv ss) M.empty srvs customServer :: UserServer p -> Bool - customServer srv = not (preset srv) && all (`S.notMember` presetHosts) (srvHost srv) + customServer srv@UserServer {preset} = not preset && all (`S.notMember` presetHosts) (srvHost srv) presetSrvs :: [NewUserServer p] presetSrvs = pServers p presetOp presetHosts :: Set TransportHost @@ -378,46 +428,58 @@ instance Box ((,) (Maybe a)) where box = (Nothing,) unbox = snd -groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [UserOperatorServers] -groupByOperator (ops, smpSrvs, xftpSrvs) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs) +groupByOperator :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> IO [UserOperatorServers] +groupByOperator (ops, smpSrvs, xftpSrvs, chatRelays) = map runIdentity <$> groupByOperator_ (map Identity ops, smpSrvs, xftpSrvs, chatRelays) -- For the initial app start this function relies on tuple being Functor/Box -- to preserve the information about operator being DBNew or DBStored -groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [(Maybe PresetOperator, UserOperatorServers)] +groupByOperator' :: ([(Maybe PresetOperator, Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> IO [(Maybe PresetOperator, UserOperatorServers)] groupByOperator' = groupByOperator_ {-# INLINE groupByOperator' #-} -groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP]) -> IO [f UserOperatorServers] -groupByOperator_ (ops, smpSrvs, xftpSrvs) = do +groupByOperator_ :: forall f. (Box f, Traversable f) => ([f (Maybe ServerOperator)], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> IO [f UserOperatorServers] +groupByOperator_ (ops, smpSrvs, xftpSrvs, cRelays) = do let ops' = mapMaybe sequence ops customOp_ = find (isNothing . unbox) ops ss <- mapM ((\op -> (serverDomains (unbox op),) <$> newIORef (mkUS . Just <$> op))) ops' custom <- newIORef $ maybe (box $ mkUS Nothing) (mkUS <$>) customOp_ mapM_ (addServer ss custom addSMP) (reverse smpSrvs) mapM_ (addServer ss custom addXFTP) (reverse xftpSrvs) + mapM_ (addChatRelay ss custom) cRelays opSrvs <- mapM (readIORef . snd) ss customSrvs <- readIORef custom pure $ opSrvs <> [customSrvs] where - mkUS op = UserOperatorServers op [] [] + mkUS op = UserOperatorServers op [] [] [] addServer :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> (UserServer p -> UserOperatorServers -> UserOperatorServers) -> UserServer p -> IO () addServer ss custom add srv = let v = maybe custom snd $ find (\(ds, _) -> any (\d -> any (matchingHost d) (srvHost srv)) ds) ss in atomicModifyIORef'_ v (add srv <$>) addSMP srv s@UserOperatorServers {smpServers} = (s :: UserOperatorServers) {smpServers = srv : smpServers} addXFTP srv s@UserOperatorServers {xftpServers} = (s :: UserOperatorServers) {xftpServers = srv : xftpServers} + addChatRelay :: [([Text], IORef (f UserOperatorServers))] -> IORef (f UserOperatorServers) -> UserChatRelay -> IO () + addChatRelay ss custom chatRelay = + let v = maybe custom snd $ find (\(ds, _) -> any (`elem` domains chatRelay) ds) ss + in atomicModifyIORef'_ v (addCRelay <$>) + where + addCRelay s@UserOperatorServers {chatRelays} = (s :: UserOperatorServers) {chatRelays = chatRelay : chatRelays} data UserServersError = USENoServers {protocol :: AProtocolType, user :: Maybe User} | USEStorageMissing {protocol :: AProtocolType, user :: Maybe User} | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} + | USEDuplicateChatRelayName {duplicateChatRelay :: Text} + | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ConnLinkContact} deriving (Show) -validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> [UserServersError] -validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others +data UserServersWarning = USWNoChatRelays {user :: Maybe User} + deriving (Show) + +validateUserServers :: UserServersClass u' => [u'] -> [(User, [UserOperatorServers])] -> ([UserServersError], [UserServersWarning]) +validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs others, currUserWarns <> concatMap otherUserWarns others) where - currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr + currUserErrs = noServersErrs SPSMP Nothing curr <> noServersErrs SPXFTP Nothing curr <> serverErrs SPSMP curr <> serverErrs SPXFTP curr <> chatRelayErrs curr otherUserErrs (user, uss) = noServersErrs SPSMP (Just user) uss <> noServersErrs SPXFTP (Just user) uss noServersErrs :: (UserServersClass u, ProtocolTypeI p, UserProtocol p) => SProtocolType p -> Maybe User -> [u] -> [UserServersError] noServersErrs p user uss @@ -426,7 +488,6 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others where p' = AProtocolType p noServers cond = not $ any srvEnabled $ userServers p $ filter cond uss - opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' 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] @@ -437,13 +498,42 @@ validateUserServers curr others = currUserErrs <> concatMap otherUserErrs others duplicateErr_ (AUS _ srv@UserServer {server}) = USEDuplicateServer p' (safeDecodeUtf8 $ strEncode server) <$> find (`S.member` duplicateHosts) (srvHost srv) - duplicateHosts = snd $ foldl' addHost (S.empty, S.empty) allHosts + duplicateHosts = snd $ foldl' addDuplicate (S.empty, S.empty) allHosts allHosts = concatMap (\(AUS _ srv) -> L.toList $ srvHost srv) srvs - addHost (hs, dups) h - | h `S.member` hs = (hs, S.insert h dups) - | otherwise = (S.insert h hs, dups) userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] userServers p = map aUserServer' . concatMap (servers' p) + chatRelayErrs :: UserServersClass u => [u] -> [UserServersError] + chatRelayErrs uss = concatMap duplicateErrs_ speers + where + speers = filter (\(AUCR _ UserChatRelay {deleted}) -> not deleted) $ userChatRelays uss + duplicateErrs_ (AUCR _ UserChatRelay {name, address}) = + [USEDuplicateChatRelayName name | name `elem` duplicateNames] + <> [USEDuplicateChatRelayAddress name address | address `elem` duplicateAddresses] + duplicateNames = snd $ foldl' addDuplicate (S.empty, S.empty) allNames + allNames = map (\(AUCR _ speer) -> name speer) speers + duplicateAddresses = snd $ foldl' addAddress ([], []) allAddresses + allAddresses = map (\(AUCR _ speer) -> address speer) speers + addAddress :: ([ConnLinkContact], [ConnLinkContact]) -> ConnLinkContact -> ([ConnLinkContact], [ConnLinkContact]) + addAddress (xs, dups) x + | any (sameConnLinkContact x) xs = (xs, x : dups) + | otherwise = (x : xs, dups) + currUserWarns = noChatRelaysWarns Nothing curr + otherUserWarns (user, uss) = noChatRelaysWarns (Just user) uss + noChatRelaysWarns :: UserServersClass u => Maybe User -> [u] -> [UserServersWarning] + noChatRelaysWarns user uss + | noChatRelays opEnabled = [USWNoChatRelays user] + | otherwise = [] + where + noChatRelays cond = not $ any speerEnabled $ userChatRelays $ filter cond uss + speerEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted + userChatRelays :: UserServersClass u => [u] -> [AUserChatRelay] + userChatRelays = map aUserChatRelay' . concatMap chatRelays' + opEnabled :: UserServersClass u => u -> Bool + opEnabled = maybe True (\ServerOperator {enabled} -> enabled) . operator' + addDuplicate :: Ord a => (Set a, Set a) -> a -> (Set a, Set a) + addDuplicate (xs, dups) x + | x `S.member` xs = (xs, S.insert x dups) + | otherwise = (S.insert x xs, dups) $(JQ.deriveJSON defaultJSON ''UsageConditions) @@ -470,9 +560,21 @@ instance (DBStoredI s, ProtocolTypeI p) => FromJSON (UserServer' s p) where instance ProtocolTypeI p => FromJSON (AUserServer p) where parseJSON v = (AUS SDBStored <$> parseJSON v) <|> (AUS SDBNew <$> parseJSON v) +instance ToJSON (UserChatRelay' s) where + toEncoding = $(JQ.mkToEncoding defaultJSON ''UserChatRelay') + toJSON = $(JQ.mkToJSON defaultJSON ''UserChatRelay') + +instance DBStoredI s => FromJSON (UserChatRelay' s) where + parseJSON = $(JQ.mkParseJSON defaultJSON ''UserChatRelay') + +instance FromJSON AUserChatRelay where + parseJSON v = (AUCR SDBStored <$> parseJSON v) <|> (AUCR SDBNew <$> parseJSON v) + $(JQ.deriveJSON defaultJSON ''UserOperatorServers) instance FromJSON UpdatedUserOperatorServers where parseJSON = $(JQ.mkParseJSON defaultJSON ''UpdatedUserOperatorServers) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USW") ''UserServersWarning) diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index d3d727ea05..d87cdbc18b 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -10,6 +10,7 @@ import qualified Data.List.NonEmpty as L import Simplex.Chat.Operators import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.Entity +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ProtocolType (..), SMPServer) operatorSimpleXChat :: NewServerOperator @@ -87,6 +88,14 @@ disabledSimplexChatSMPServers = "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ] +-- TODO [chat relays] real chat relays +simplexChatRelays :: [NewUserChatRelay] +simplexChatRelays = + [ presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp111.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D"), + presetChatRelay True "chat_relay_2" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp222.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D"), + presetChatRelay True "chat_relay_3" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp333.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D") + ] + fluxSMPServers :: [NewUserServer 'PSMP] fluxSMPServers = map (presetServer' True) (L.toList fluxSMPServers_) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 9467675272..2da8f183e7 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -143,13 +143,13 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 00aa527603..ddeb320212 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -190,11 +190,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus, Maybe BoolInt) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked', Just isChatRelay) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked', isChatRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -480,7 +480,8 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + isChatRelay = BoolDef False } where memberChatVRange@(VersionRange minV maxV) = vr @@ -1098,7 +1099,8 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, memberChatVRange = peerChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + isChatRelay = BoolDef False } where insertMember_ = @@ -1415,7 +1417,8 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memInvitedByGroupMemberId = Just $ groupMemberId' invitingMember, localDisplayName, memContactId = Nothing, - memProfileId + memProfileId, + isChatRelay = False } liftIO $ createNewMember_ db user gInfo newMember currentTs @@ -1443,7 +1446,8 @@ createNewMember_ memInvitedByGroupMemberId, localDisplayName, memContactId = memberContactId, - memProfileId = memberContactProfileId + memProfileId = memberContactProfileId, + isChatRelay } createdAt = do let invitedById = fromInvitedBy userContactId invitedBy @@ -1453,12 +1457,12 @@ createNewMember_ db [sql| INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + (group_id, member_id, member_role, member_category, member_status, member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, memberId, memberRole, memberCategory, memberStatus, memRestriction, invitedById, memInvitedByGroupMemberId) + ( (groupId, memberId, memberRole, memberCategory, memberStatus, memRestriction, BI isChatRelay, invitedById, memInvitedByGroupMemberId) :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) :. (minV, maxV) ) @@ -1483,7 +1487,8 @@ createNewMember_ memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + isChatRelay = BoolDef isChatRelay } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1703,7 +1708,7 @@ createIntroReMember memRestriction = restriction <$> memRestrictions_ currentTs <- liftIO getCurrentTime (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId, isChatRelay = False} liftIO $ do member <- createNewMember_ db user gInfo newMember currentTs conn@Connection {connId = groupConnId} <- createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 47aabd16ee..df5b800c65 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -675,7 +675,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -2999,7 +2999,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -3007,13 +3007,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.member_restriction, rm.is_chat_relay, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.is_chat_relay, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index c6c04b465b..917c80c7e2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -20,6 +20,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20250813_delivery_tasks import Simplex.Chat.Store.Postgres.Migrations.M20250919_group_summary import Simplex.Chat.Store.Postgres.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.Postgres.Migrations.M20251007_connections_sync +import Simplex.Chat.Store.Postgres.Migrations.M20251016_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -39,7 +40,8 @@ schemaMigrations = ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), - ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync) + ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), + ("20251016_chat_relays", m20251016_chat_relays, Just down_m20251016_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251016_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20251016_chat_relays.hs new file mode 100644 index 0000000000..aeaf75bda1 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20251016_chat_relays.hs @@ -0,0 +1,46 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20251016_chat_relays where + +import Data.Text (Text) +import qualified Data.Text as T +import Text.RawString.QQ (r) + +m20251016_chat_relays :: Text +m20251016_chat_relays = + T.pack + [r| +CREATE TABLE chat_relays( + chat_relay_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + address TEXT NOT NULL, + name TEXT NOT NULL, + domains TEXT NOT NULL, + preset SMALLINT NOT NULL DEFAULT 0, + tested SMALLINT, + enabled SMALLINT NOT NULL DEFAULT 1, + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (now()), + updated_at TEXT NOT NULL DEFAULT (now()), + UNIQUE(user_id, address), + UNIQUE(user_id, name) +); + +CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); + +ALTER TABLE users ADD COLUMN is_user_chat_relay SMALLINT NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN is_chat_relay SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20251016_chat_relays :: Text +down_m20251016_chat_relays = + T.pack + [r| +ALTER TABLE group_members DROP COLUMN is_chat_relay; + +ALTER TABLE users DROP COLUMN is_user_chat_relay; + +DROP INDEX idx_chat_relays_user_id; + +DROP TABLE chat_relays; +|] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index af46c14b83..097e38ab36 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -57,6 +57,7 @@ module Simplex.Chat.Store.Profiles getContactWithoutConnViaShortAddress, updateUserAddressSettings, getProtocolServers, + getChatRelays, insertProtocolServer, getUpdateServerOperators, getServerOperators, @@ -125,11 +126,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> ExceptT StoreError IO User -createUserRecord db auId p activeUser = createUserRecordAt db auId p activeUser =<< liftIO getCurrentTime +createUserRecord :: DB.Connection -> AgentUserId -> Profile -> Bool -> Bool -> ExceptT StoreError IO User +createUserRecord db auId p userChatRelay activeUser = createUserRecordAt db auId p userChatRelay activeUser =<< liftIO getCurrentTime -createUserRecordAt :: DB.Connection -> AgentUserId -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User -createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} activeUser currentTs = +createUserRecordAt :: DB.Connection -> AgentUserId -> Profile -> Bool -> Bool -> UTCTime -> ExceptT StoreError IO User +createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} userChatRelay activeUser currentTs = checkConstraint SEDuplicateName . liftIO $ do when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" let showNtfs = True @@ -157,7 +158,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing) + pure $ toUser $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, Nothing, BI userChatRelay) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -610,6 +611,49 @@ serverColumns p (ProtoServerWithAuth ProtocolServer {host, port, keyHash} auth_) auth = safeDecodeUtf8 . unBasicAuth <$> auth_ in (protocol, host, port, keyHash, auth) +getChatRelays :: DB.Connection -> User -> IO [UserChatRelay] +getChatRelays db User {userId} = + map toChatRelay + <$> DB.query + db + [sql| + SELECT chat_relay_id, address, name, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? + |] + (Only userId) + where + toChatRelay :: (DBEntityId, ConnLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay + toChatRelay (chatRelayId, address, name, domains, BI preset, tested, BI enabled) = + UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} + +insertChatRelay :: DB.Connection -> User -> UTCTime -> NewUserChatRelay -> IO UserChatRelay +insertChatRelay db User {userId} ts speer@UserChatRelay {address, name, domains, preset, tested, enabled} = do + crId <- + fromOnly . head + <$> DB.query + db + [sql| + INSERT INTO chat_relays + (address, name, domains, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + RETURNING chat_relay_id + |] + (address, name, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, userId, ts, ts) + pure speer {chatRelayId = DBEntityId crId} + +updateChatRelay :: DB.Connection -> UTCTime -> UserChatRelay -> IO () +updateChatRelay db ts UserChatRelay {chatRelayId, address, name, domains, preset, tested, enabled} = + DB.execute + db + [sql| + UPDATE chat_relays + SET address = ?, name = ?, domains = ?, + preset = ?, tested = ?, enabled = ?, updated_at = ? + WHERE chat_relay_id = ? + |] + (address, name, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, ts, chatRelayId) + getServerOperators :: DB.Connection -> ExceptT StoreError IO ServerOperatorConditions getServerOperators db = do currentConditions <- getCurrentUsageConditions db @@ -621,12 +665,13 @@ getServerOperators db = do let conditionsAction = usageConditionsAction ops currentConditions now pure ServerOperatorConditions {serverOperators = ops, currentConditions, conditionsAction} -getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP]) +getUserServers :: DB.Connection -> User -> ExceptT StoreError IO ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) getUserServers db user = - (,,) + (,,,) <$> (map Just . serverOperators <$> getServerOperators db) <*> liftIO (getProtocolServers db SPSMP user) <*> liftIO (getProtocolServers db SPXFTP user) + <*> liftIO (getChatRelays db user) setServerOperators :: DB.Connection -> NonEmpty ServerOperator -> IO () setServerOperators db ops = do @@ -839,20 +884,29 @@ setUserServers :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers setUserServers db user ts = checkConstraint SEUniqueID . liftIO . setUserServers' db user ts setUserServers' :: DB.Connection -> User -> UTCTime -> UpdatedUserOperatorServers -> IO UserOperatorServers -setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers} = do +setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, smpServers, xftpServers, chatRelays} = do mapM_ (updateServerOperator db ts) operator - smpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPSMP) smpServers - xftpSrvs' <- catMaybes <$> mapM (upsertOrDelete SPXFTP) xftpServers - pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs'} + smpSrvs' <- catMaybes <$> mapM (upsertOrDeleteSrv SPSMP) smpServers + xftpSrvs' <- catMaybes <$> mapM (upsertOrDeleteSrv SPXFTP) xftpServers + cRelays' <- catMaybes <$> mapM upsertOrDeleteCRelay chatRelays + pure UserOperatorServers {operator, smpServers = smpSrvs', xftpServers = xftpSrvs', chatRelays = cRelays'} where - upsertOrDelete :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) - upsertOrDelete p (AUS _ s@UserServer {serverId, deleted}) = case serverId of + upsertOrDeleteSrv :: ProtocolTypeI p => SProtocolType p -> AUserServer p -> IO (Maybe (UserServer p)) + upsertOrDeleteSrv p (AUS _ s@UserServer {serverId, deleted}) = case serverId of DBNewEntity | deleted -> pure Nothing | otherwise -> Just <$> insertProtocolServer db p user ts s DBEntityId srvId | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False) | otherwise -> Just s <$ updateProtocolServer db p ts s + upsertOrDeleteCRelay :: AUserChatRelay -> IO (Maybe UserChatRelay) + upsertOrDeleteCRelay (AUCR _ speer@UserChatRelay {chatRelayId, deleted}) = case chatRelayId of + DBNewEntity + | deleted -> pure Nothing + | otherwise -> Just <$> insertChatRelay db user ts speer + DBEntityId speerId + | deleted -> Nothing <$ DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, speerId, BI False) + | otherwise -> Just speer <$ updateChatRelay db ts speer createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index e568e2a663..e57f0284e6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -143,6 +143,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks import Simplex.Chat.Store.SQLite.Migrations.M20250919_group_summary import Simplex.Chat.Store.SQLite.Migrations.M20250922_remove_unused_connections import Simplex.Chat.Store.SQLite.Migrations.M20251007_connections_sync +import Simplex.Chat.Store.SQLite.Migrations.M20251016_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -285,7 +286,8 @@ schemaMigrations = ("20250813_delivery_tasks", m20250813_delivery_tasks, Just down_m20250813_delivery_tasks), ("20250919_group_summary", m20250919_group_summary, Just down_m20250919_group_summary), ("20250922_remove_unused_connections", m20250922_remove_unused_connections, Just down_m20250922_remove_unused_connections), - ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync) + ("20251007_connections_sync", m20251007_connections_sync, Just down_m20251007_connections_sync), + ("20251016_chat_relays", m20251016_chat_relays, Just down_m20251016_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251016_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20251016_chat_relays.hs new file mode 100644 index 0000000000..b3287320d9 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20251016_chat_relays.hs @@ -0,0 +1,43 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20251016_chat_relays where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20251016_chat_relays :: Query +m20251016_chat_relays = + [sql| +CREATE TABLE chat_relays( + chat_relay_id INTEGER PRIMARY KEY, + address TEXT NOT NULL, + name TEXT NOT NULL, + domains TEXT NOT NULL, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(user_id, address), + UNIQUE(user_id, name) +); + +CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); + +ALTER TABLE users ADD COLUMN is_user_chat_relay INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE group_members ADD COLUMN is_chat_relay INTEGER NOT NULL DEFAULT 0; +|] + +down_m20251016_chat_relays :: Query +down_m20251016_chat_relays = + [sql| +ALTER TABLE group_members DROP COLUMN is_chat_relay; + +ALTER TABLE users DROP COLUMN is_user_chat_relay; + +DROP INDEX idx_chat_relays_user_id; + +DROP TABLE chat_relays; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 7d8f9d0dcd..0523a71436 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -38,7 +38,8 @@ CREATE TABLE users( user_member_profile_updated_at TEXT, ui_themes TEXT, active_order INTEGER NOT NULL DEFAULT 0, - auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, -- 1 for active user + auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, + is_user_chat_relay INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -195,6 +196,7 @@ CREATE TABLE group_members( support_chat_last_msg_from_member_ts TEXT, member_xcontact_id BLOB, member_welcome_shared_msg_id BLOB, + is_chat_relay INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -721,6 +723,20 @@ CREATE TABLE connections_sync( should_sync INTEGER NOT NULL DEFAULT 0, last_sync_ts TEXT ); +CREATE TABLE chat_relays( + chat_relay_id INTEGER PRIMARY KEY, + address TEXT NOT NULL, + name TEXT NOT NULL, + domains TEXT NOT NULL, + preset INTEGER NOT NULL DEFAULT 0, + tested INTEGER, + enabled INTEGER NOT NULL DEFAULT 1, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')), + UNIQUE(user_id, address), + UNIQUE(user_id, name) +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1184,6 +1200,7 @@ CREATE INDEX idx_connections_to_subscribe ON connections( user_id, to_subscribe ); +CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 243db84da7..7fb3f72951 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -530,15 +530,15 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User -toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes} +toUser :: (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides, BoolInt) -> User +toUser ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes, BI userChatRelay)) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts = BoolDef autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, uiThemes, userChatRelay = BoolDef userChatRelay} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, peerType, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences @@ -656,7 +656,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow -type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) +type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus, BoolInt) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) @@ -678,13 +678,14 @@ toPreparedGroup = \case _ -> Nothing toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = +toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_, BI isCRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer + isChatRelay = BoolDef isCRelay supportChat = case supportChatTs_ of Just chatTs -> Just @@ -702,7 +703,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -743,7 +744,7 @@ groupInfoQueryFields = g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index e432343839..21781229e4 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -15,7 +15,7 @@ import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Library.Commands (_defaultNtfServers) import Simplex.Chat.Operators -import Simplex.Chat.Operators.Presets (operatorSimpleXChat) +import Simplex.Chat.Operators.Presets (operatorSimpleXChat, simplexChatRelays) import Simplex.Chat.Options import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Output @@ -50,7 +50,9 @@ terminalChatConfig = ], useSMP = 3, xftp = map (presetServer True) $ L.toList defaultXFTPServers, - useXFTP = 3 + useXFTP = 3, + chatRelays = simplexChatRelays, + useChatRelays = 2 } ], ntf = _defaultNtfServers, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index bb86cb2522..4cffd7ca4b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -118,6 +118,7 @@ instance ToField AgentUserId where toField (AgentUserId uId) = toField uId aUserId :: User -> UserId aUserId User {agentUserId = AgentUserId uId} = uId +-- TODO [chat relay] filter out chat relay users where necessary (e.g. loading list of users for UI) data User = User { userId :: UserId, agentUserId :: AgentUserId, @@ -133,13 +134,15 @@ data User = User sendRcptsSmallGroups :: Bool, autoAcceptMemberContacts :: BoolDef, userMemberProfileUpdatedAt :: Maybe UTCTime, - uiThemes :: Maybe UIThemeEntityOverrides + uiThemes :: Maybe UIThemeEntityOverrides, + userChatRelay :: BoolDef } deriving (Show) data NewUser = NewUser { profile :: Maybe Profile, - pastTimestamp :: Bool + pastTimestamp :: Bool, + userChatRelay :: Bool } deriving (Show) @@ -945,7 +948,8 @@ data GroupMember = GroupMember memberChatVRange :: VersionRangeChat, createdAt :: UTCTime, updatedAt :: UTCTime, - supportChat :: Maybe GroupSupportChat + supportChat :: Maybe GroupSupportChat, + isChatRelay :: BoolDef } deriving (Eq, Show) @@ -1027,7 +1031,8 @@ data NewGroupMember = NewGroupMember memInvitedByGroupMemberId :: Maybe GroupMemberId, localDisplayName :: ContactName, memProfileId :: Int64, - memContactId :: Maybe Int64 + memContactId :: Maybe Int64, + isChatRelay :: Bool } newtype MemberId = MemberId {unMemberId :: ByteString} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index bbccd514b4..71fbf843ce 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -123,7 +123,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats] CRChats chats -> viewChats ts tz chats CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat] - CRChatTags u tags -> ttyUser u $ [viewJSON tags] + CRChatTags u tags -> ttyUser u [viewJSON tags] CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp) @@ -1465,11 +1465,12 @@ subStatusStr = \case SSNoSub -> "no subscription" viewUserServers :: UserOperatorServers -> [StyledString] -viewUserServers (UserOperatorServers _ [] []) = [] -viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = +viewUserServers (UserOperatorServers _ [] [] []) = [] +viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = [plain $ maybe "Your servers" shortViewOperator operator] <> viewServers SPSMP smpServers <> viewServers SPXFTP xftpServers + <> viewChatRelays chatRelays where viewServers :: (ProtocolTypeI p, UserProtocol p) => SProtocolType p -> [UserServer p] -> [StyledString] viewServers _ [] = [] @@ -1492,6 +1493,19 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers} = | otherwise = "disabled (servers known)" where rs = operatorRoles p op + viewChatRelays :: [UserChatRelay] -> [StyledString] + viewChatRelays [] = [] + viewChatRelays cRelays + | maybe True (\ServerOperator {enabled} -> enabled) operator = + ["Chat relays"] <> map (plain . (" " <>) . viewChatRelay) cRelays + | otherwise = [] + where + viewChatRelay UserChatRelay {name, address, preset, tested, enabled} = name <> chatrelayAddress <> chatrelayInfo + where + chatrelayAddress = "(" <> safeDecodeUtf8 (strEncode address) <> ")" + chatrelayInfo = if null chatrelayInfo_ then "" else parens $ T.intercalate ", " chatrelayInfo_ + chatrelayInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] + testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested serversUserHelp :: [StyledString] serversUserHelp = @@ -2409,6 +2423,7 @@ viewChatError isCmd logLevel testView = \case CENoRcvFileUser aFileId -> ["error: rcv file user not found, file id: " <> sShow aFileId | logLevel <= CLLError] CEActiveUserExists -> ["error: active user already exists"] CEUserExists name -> ["user with the name " <> ttyContact name <> " already exists"] + CEChatRelayExists -> ["chat realy user already exists"] CEUserUnknown -> ["user does not exist or incorrect password"] CEDifferentActiveUser commandUserId activeUserId -> ["error: different active user, command user id: " <> sShow commandUserId <> ", active user id: " <> sShow activeUserId] CECantDeleteActiveUser _ -> ["cannot delete active user"] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 8fd8a5976d..5e5113d7a6 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -286,7 +286,7 @@ createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> I createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore - Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile True + Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile False True startTestChat_ ps db cfg opts user startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index f02d04491e..680eccb7b6 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}}}" chatStartedSwift :: LB.ByteString chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" @@ -29,7 +29,7 @@ chatStartedTagged :: LB.ByteString chatStartedTagged = "{\"result\":{\"type\":\"chatStarted\"}}" userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index d4f65b3b1c..84a4dd6408 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -136,7 +136,7 @@ testChatApi ps = do dbPrefix = tmp "1" f = dbPrefix <> chatSuffix Right st <- createChatStore (DBOpts f "myKey" False True DB.TQOff) (MigrationConfig MCYesUp Nothing) - Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} True + Right _ <- withTransaction st $ \db -> runExceptT $ createUserRecord db (AgentUserId 1) aliceProfile {preferences = Nothing} False True Right cc <- chatMigrateInit dbPrefix "myKey" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "" "yesUp" Left (DBMErrorNotADatabase _) <- chatMigrateInit dbPrefix "anotherKey" "yesUp" diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 656f0ae0e2..8e6ab69f36 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -24,6 +24,7 @@ import Simplex.Chat.Types import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent.Env.SQLite (ServerRoles (..), allRoles) import Simplex.Messaging.Agent.Store.Entity +import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol import Test.Hspec @@ -34,18 +35,36 @@ operatorTests = describe "managing server operators" $ do validateServersTest :: Spec validateServersTest = describe "validate user servers" $ do - it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` [] + it "should pass valid user servers" $ validateUserServers [valid] [] `shouldBe` ([], []) it "should fail without servers" $ do - validateUserServers [invalidNoServers] [] `shouldBe` [USENoServers aSMP Nothing] - validateUserServers [invalidDisabled] [] `shouldBe` [USENoServers aSMP Nothing] - validateUserServers [invalidDisabledOp] [] `shouldBe` [USENoServers aSMP Nothing, USENoServers aXFTP Nothing] + validateUserServers [invalidNoServers] [] `shouldBe` ([USENoServers aSMP Nothing], []) + validateUserServers [invalidDisabled] [] `shouldBe` ([USENoServers aSMP Nothing], []) + validateUserServers [invalidDisabledOp] [] `shouldBe` ([USENoServers aSMP Nothing, USENoServers aXFTP Nothing], [USWNoChatRelays Nothing]) it "should fail without servers with storage role" $ do - validateUserServers [invalidNoStorage] [] `shouldBe` [USEStorageMissing aSMP Nothing] + validateUserServers [invalidNoStorage] [] `shouldBe` ([USEStorageMissing aSMP Nothing], []) it "should fail with duplicate host" $ do - validateUserServers [invalidDuplicate] [] `shouldBe` - [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", - USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" - ] + validateUserServers [invalidDuplicateSrv] [] + `shouldBe` ( [ USEDuplicateServer aSMP "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im,beccx4yfxxbvyhqypaavemqurytl6hozr47wfc7uuecacjqdvwpw2xid.onion" "smp8.simplex.im", + USEDuplicateServer aSMP "smp://abcd@smp8.simplex.im" "smp8.simplex.im" + ], + [] + ) + it "should warn without chat relays" $ + validateUserServers [invalidNoChatRelays] [] `shouldBe` ([], [USWNoChatRelays Nothing]) + it "should fail with duplicate chat relay name" $ do + validateUserServers [invalidDuplicateChatRelayName] [] + `shouldBe` ( [ USEDuplicateChatRelayName "chat_relay_1", + USEDuplicateChatRelayName "chat_relay_1" + ], + [] + ) + it "should fail with duplicate chat relay address" $ do + validateUserServers [invalidDuplicateChatRelayAddress] [] + `shouldBe` ( [ USEDuplicateChatRelayAddress "chat_relay_1" duplicateAddr, + USEDuplicateChatRelayAddress "chat_relay_4" duplicateAddr + ], + [] + ) where aSMP = AProtocolType SPSMP aXFTP = AProtocolType SPXFTP @@ -59,7 +78,7 @@ updatedServersTest = describe "validate user servers" $ do all addedPreset ops' `shouldBe` True let ops'' :: [(Maybe PresetOperator, Maybe ServerOperator)] = saveOps ops' -- mock getUpdateServerOperators - uss <- groupByOperator' (ops'', [], []) -- no stored servers + uss <- groupByOperator' (ops'', [], [], []) -- no stored servers length uss `shouldBe` 3 [op1, op2, op3] <- pure $ map updatedUserServers uss [p1, p2] <- pure operators -- presets @@ -67,14 +86,15 @@ updatedServersTest = describe "validate user servers" $ do sameServers p2 op2 null (servers' SPSMP op3) `shouldBe` True null (servers' SPXFTP op3) `shouldBe` True - it "adding preset operators and assiging servers to operator for existing users" $ do + it "adding preset operators and assigning servers to operator for existing users" $ do let ops' = updatedServerOperators operators [] ops'' = saveOps ops' uss <- groupByOperator' ( ops'', saveSrvs $ take 3 simplexChatSMPServers <> [newUserServer "smp://abcd@smp.example.im"], - saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers + saveSrvs $ map (presetServer True) $ L.take 3 defaultXFTPServers, + [] ) [op1, op2, op3] <- pure $ map updatedUserServers uss [p1, p2] <- pure operators -- presets @@ -86,8 +106,8 @@ updatedServersTest = describe "validate user servers" $ do addedPreset = \case (Just PresetOperator {operator = Just op}, Just (ASO SDBNew op')) -> operatorTag op == operatorTag op' _ -> False - saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1..] - saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1..] + saveOps = zipWith (\i -> second ((\(ASO _ op) -> op {operatorId = DBEntityId i}) <$>)) [1 ..] + saveSrvs = zipWith (\i srv -> srv {serverId = DBEntityId i}) [1 ..] sameServers preset op = do map srvHost (pServers SPSMP preset) `shouldBe` map srvHost' (servers' SPSMP op) map srvHost (pServers SPXFTP preset) `shouldBe` map srvHost' (servers' SPXFTP op) @@ -98,12 +118,15 @@ deriving instance Eq User deriving instance Eq UserServersError +deriving instance Eq UserServersWarning + valid :: UpdatedUserOperatorServers valid = UpdatedUserOperatorServers { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1}, smpServers = map (AUS SDBNew) simplexChatSMPServers, - xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers + xftpServers = map (AUS SDBNew . presetServer True) $ L.toList defaultXFTPServers, + chatRelays = map (AUCR SDBNew) simplexChatRelays } invalidNoServers :: UpdatedUserOperatorServers @@ -127,8 +150,26 @@ invalidNoStorage = { operator = Just operatorSimpleXChat {operatorId = DBEntityId 1, smpRoles = allRoles {storage = False}} } -invalidDuplicate :: UpdatedUserOperatorServers -invalidDuplicate = +invalidDuplicateSrv :: UpdatedUserOperatorServers +invalidDuplicateSrv = (valid :: UpdatedUserOperatorServers) { smpServers = map (AUS SDBNew) $ simplexChatSMPServers <> [presetServer True "smp://abcd@smp8.simplex.im"] } + +invalidNoChatRelays :: UpdatedUserOperatorServers +invalidNoChatRelays = (valid :: UpdatedUserOperatorServers) {chatRelays = []} + +invalidDuplicateChatRelayName :: UpdatedUserOperatorServers +invalidDuplicateChatRelayName = + (valid :: UpdatedUserOperatorServers) + { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp444.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D")] + } + +invalidDuplicateChatRelayAddress :: UpdatedUserOperatorServers +invalidDuplicateChatRelayAddress = + (valid :: UpdatedUserOperatorServers) + { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True "chat_relay_4" ["simplex.im"] duplicateAddr] + } + +duplicateAddr :: ConnLinkContact +duplicateAddr = either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp111.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D" From 521f9c35642d06bf81ee09262ef9a3557bd7f485 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 20 Oct 2025 12:59:45 +0400 Subject: [PATCH 002/112] core: query plans --- .../SQLite/Migrations/chat_query_plans.txt | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 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 91db234fd1..bf7e3f18db 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -114,13 +114,13 @@ Query: g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -845,7 +845,7 @@ Query: SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -1065,7 +1065,7 @@ Query: i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -1073,13 +1073,13 @@ Query: ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.member_restriction, rm.is_chat_relay, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.is_chat_relay, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts @@ -1645,10 +1645,10 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members - (group_id, member_id, member_role, member_category, member_status, member_restriction, invited_by, invited_by_group_member_id, + (group_id, member_id, member_role, member_category, member_status, member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) @@ -3329,6 +3329,14 @@ Query: Plan: SEARCH chat_item_versions USING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) +Query: + SELECT chat_relay_id, address, name, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? + +Plan: +SEARCH chat_relays USING INDEX idx_chat_relays_user_id (user_id=?) + Query: SELECT command_id, connection_id, command_function, command_status FROM commands @@ -4947,7 +4955,7 @@ Query: g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -4981,7 +4989,7 @@ Query: g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -5008,7 +5016,7 @@ Query: g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -5056,7 +5064,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5083,7 +5091,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5102,7 +5110,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5121,7 +5129,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5140,7 +5148,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5159,7 +5167,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5178,7 +5186,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5320,7 +5328,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5332,7 +5340,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5345,7 +5353,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5358,7 +5366,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5372,7 +5380,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5385,7 +5393,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5398,7 +5406,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5411,7 +5419,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5424,7 +5432,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id @@ -5896,6 +5904,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) SEARCH chat_tags USING COVERING INDEX idx_chat_tags_user_id (user_id=?) SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) SEARCH received_probes USING COVERING INDEX idx_received_probes_user_id (user_id=?) From 5ddc454049c2db09fc28845f61aeededd0a3c226 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 27 Oct 2025 12:29:38 +0000 Subject: [PATCH 003/112] core: option to run client as chat relay; cli api to get, set relays (#6407) --- bots/src/API/Docs/Commands.hs | 2 + simplex-chat.cabal | 1 + src/Simplex/Chat/Controller.hs | 5 + src/Simplex/Chat/Core.hs | 117 +++++++++++++----- src/Simplex/Chat/Library/Commands.hs | 43 ++++++- src/Simplex/Chat/Mobile.hs | 1 + src/Simplex/Chat/Operators.hs | 19 ++- src/Simplex/Chat/Options.hs | 7 ++ src/Simplex/Chat/Store/Profiles.hs | 20 +-- .../SQLite/Migrations/chat_query_plans.txt | 14 ++- src/Simplex/Chat/View.hs | 11 +- tests/ChatClient.hs | 10 +- tests/ChatTests.hs | 2 + tests/ChatTests/ChatRelays.hs | 49 ++++++++ 14 files changed, 240 insertions(+), 61 deletions(-) create mode 100644 tests/ChatTests/ChatRelays.hs diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 4cce44e588..917226873b 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -434,6 +434,7 @@ undocumentedCommands = "GetChatItemTTL", "GetRemoteFile", "GetUserProtoServers", + "GetUserChatRelays", "ListRemoteCtrls", "ListRemoteHosts", "ReconnectAllServers", @@ -451,6 +452,7 @@ undocumentedCommands = "SetServerOperators", "SetTempFolder", "SetUserProtoServers", + "SetUserChatRelays", "SlowSQLQueries", "StartChat", "StartRemoteHost", diff --git a/simplex-chat.cabal b/simplex-chat.cabal index eafb57b512..e3ce7909b7 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -533,6 +533,7 @@ test-suite simplex-chat-test ChatClient ChatTests ChatTests.ChatList + ChatTests.ChatRelays ChatTests.Direct ChatTests.DBUtils ChatTests.Files diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ad824d20f6..bca2131d86 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -389,6 +389,11 @@ data ChatCommand | SetUserProtoServers AProtocolType [AProtoServerWithAuth] | APITestProtoServer UserId AProtoServerWithAuth | TestProtoServer AProtoServerWithAuth + | GetUserChatRelays + | SetUserChatRelays [CLINewRelay] + -- TODO [chat relays] commands to test chat relay + -- | APITestChatRelay UserId ConnLinkContact + -- | TestChatRelay ConnLinkContact | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) | SetServerOperators (NonEmpty ServerOperatorRoles) diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index f9bf76e5b3..40e4e61d6f 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -15,7 +15,9 @@ where import Control.Logger.Simple import Control.Monad +import Control.Monad.Except import Control.Monad.Reader +import qualified Data.ByteString.Char8 as B import Data.List (find) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -27,18 +29,21 @@ import Simplex.Chat.Library.Commands import Simplex.Chat.Options (ChatOpts (..), CoreChatOpts (..), CreateBotOpts (..)) import Simplex.Chat.Remote.Types (RemoteHostId) import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.Shared (StoreError (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences (FeatureAllowed (..), FilesPreference (..), Preferences (..), emptyChatPrefs) -import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse) +import Simplex.Chat.View (ChatResponseEvent, serializeChatError, serializeChatResponse, simplexChatContact) +import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..)) import Simplex.Messaging.Agent.Store.Common (DBStore, withTransaction) +import Simplex.Messaging.Encoding.String import System.Exit (exitFailure) import System.IO (hFlush, stdout) import Text.Read (readMaybe) import UnliftIO.Async simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath}, createBot, maintenance} chat = +simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {dbOptions, logAgent, yesToUpMigrations, migrationBackupPath}, createBot, maintenance} chat = case logAgent of Just level -> do setLogLevel level @@ -51,19 +56,21 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView, chatHooks} opts@Cha putStrLn $ "Error opening database: " <> show e exitFailure run db@ChatDatabase {chatStore} = do - u_ <- getSelectActiveUser chatStore + users <- withTransaction chatStore getUsers + u_ <- selectActiveUser coreOptions chatStore users let backgroundMode = not maintenance cc <- newChatController db u_ cfg opts backgroundMode - u <- maybe (createActiveUser cc createBot) pure u_ + u <- maybe (createActiveUser cc coreOptions createBot) pure u_ unless testView $ putStrLn $ "Current user: " <> userStr u unless maintenance $ forM_ (preStartHook chatHooks) ($ cc) - runSimplexChat opts u cc chat + runSimplexChat cfg opts u cc chat -runSimplexChat :: ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () -runSimplexChat ChatOpts {maintenance} u cc@ChatController {config = ChatConfig {chatHooks}} chat +runSimplexChat :: ChatConfig -> ChatOpts -> User -> ChatController -> (User -> ChatController -> IO ()) -> IO () +runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatRelay}, maintenance} u cc@ChatController {config = ChatConfig {chatHooks}} chat | maintenance = wait =<< async (chat u cc) | otherwise = do a1 <- runReaderT (startChatController True True) cc + when (chatRelay && not testView) $ askCreateRelayAddress cc u forM_ (postStartHook chatHooks) ($ cc) a2 <- async $ chat u cc waitEither_ a1 a2 @@ -74,24 +81,30 @@ sendChatCmdStr cc s = runReaderT (execChatCommand Nothing (encodeUtf8 $ T.pack s sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd 0) cc -getSelectActiveUser :: DBStore -> IO (Maybe User) -getSelectActiveUser st = do - users <- withTransaction st getUsers - case find activeUser users of - Just u -> pure $ Just u - Nothing -> selectUser users +selectActiveUser :: CoreChatOpts -> DBStore -> [User] -> IO (Maybe User) +selectActiveUser CoreChatOpts {chatRelay} st users + | chatRelay = + case find (\User {userChatRelay} -> isTrue userChatRelay) users of + Just u + | activeUser u -> pure $ Just u + | otherwise -> Just <$> withTransaction st (`setActiveUser` u) + Nothing -> pure Nothing + | otherwise = + case find activeUser users of + Just u -> pure $ Just u + Nothing -> selectUser where - selectUser :: [User] -> IO (Maybe User) - selectUser = \case + selectUser :: IO (Maybe User) + selectUser = case users of [] -> pure Nothing [user] -> Just <$> withTransaction st (`setActiveUser` user) - users -> do + _users -> do putStrLn "Select user profile:" forM_ (zip [1 :: Int ..] users) $ \(n, user) -> putStrLn $ show n <> ": " <> userStr user loop where loop = do - nStr <- getWithPrompt $ "user number (1 .. " <> show (length users) <> ")" + nStr <- withPrompt ("user number (1 .. " <> show (length users) <> "): ") getLine case readMaybe nStr :: Maybe Int of Nothing -> putStrLn "not a number" >> loop Just n @@ -100,39 +113,79 @@ getSelectActiveUser st = do let user = users !! (n - 1) in Just <$> withTransaction st (`setActiveUser` user) -createActiveUser :: ChatController -> Maybe CreateBotOpts -> IO User -createActiveUser cc = \case +createActiveUser :: ChatController -> CoreChatOpts -> Maybe CreateBotOpts -> IO User +createActiveUser cc CoreChatOpts {chatRelay} = \case Just CreateBotOpts {botDisplayName, allowFiles} -> do let preferences = if allowFiles then Nothing else Just emptyChatPrefs {files = Just FilesPreference {allow = FANo}} createUser exitFailure $ (mkProfile botDisplayName) {peerType = Just CPTBot, preferences} - Nothing -> do - putStrLn - "No user profiles found, it will be created now.\n\ - \Please choose your display name.\n\ - \It will be sent to your contacts when you connect.\n\ - \It is only stored on your device and you can change it later." - loop + Nothing + | chatRelay -> do + putStrLn + "No chat relay user profile found, it will be created now.\n\ + \Please choose chat relay display name." + loop + | otherwise -> do + putStrLn + "No user profiles found, it will be created now.\n\ + \Please choose your display name.\n\ + \It will be sent to your contacts when you connect.\n\ + \It is only stored on your device and you can change it later." + loop where loop = do - displayName <- T.pack <$> getWithPrompt "display name" + displayName <- T.pack <$> withPrompt "display name: " getLine createUser loop $ mkProfile displayName mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing} createUser onError p = - execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = False}) 0 `runReaderT` cc >>= \case + execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = chatRelay}) 0 `runReaderT` cc >>= \case Right (CRActiveUser user) -> pure user r -> printResponseEvent (Nothing, Nothing) (config cc) r >> onError +askCreateRelayAddress :: ChatController -> User -> IO () +askCreateRelayAddress cc@ChatController {chatStore} user = + withTransaction chatStore (\db -> runExceptT $ getUserAddress db user) >>= \case + Right _ -> pure () + Left SEUserContactLinkNotFound -> promptCreate + Left e -> printChatError (config cc) $ ChatErrorStore e + where + promptCreate :: IO () + promptCreate = do + ok <- onOffPrompt "Create relay address" True + when ok $ + execChatCommand' CreateMyAddress 0 `runReaderT` cc >>= \case + Right (CRUserContactLinkCreated _ address) -> do + putStrLn "Chat relay address is created:" + putStrLn $ addressStr address + r -> printResponseEvent (Nothing, Nothing) (config cc) r + addressStr :: CreatedLinkContact -> String + addressStr (CCLink cReq shortLink) = B.unpack $ maybe cReqStr strEncode shortLink + where + cReqStr = strEncode $ simplexChatContact cReq + printResponseEvent :: ChatResponseEvent r => (Maybe RemoteHostId, Maybe User) -> ChatConfig -> Either ChatError r -> IO () printResponseEvent hu cfg = \case Right r -> do ts <- getCurrentTime tz <- getCurrentTimeZone putStrLn $ serializeChatResponse hu cfg ts tz (fst hu) r - Left e -> do - putStrLn $ serializeChatError True cfg e + Left e -> printChatError cfg e -getWithPrompt :: String -> IO String -getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine +printChatError :: ChatConfig -> ChatError -> IO () +printChatError cfg e = putStrLn $ serializeChatError True cfg e + +withPrompt :: String -> IO a -> IO a +withPrompt s a = putStr s >> hFlush stdout >> a + +onOffPrompt :: String -> Bool -> IO Bool +onOffPrompt prompt def = + withPrompt (prompt <> if def then " (Yn): " else " (yN): ") $ + getLine >>= \case + "" -> pure def + "y" -> pure True + "Y" -> pure True + "n" -> pure False + "N" -> pure False + _ -> putStrLn "Invalid input, please enter 'y' or 'n'" >> onOffPrompt prompt def userStr :: User -> String userStr User {localDisplayName, profile = LocalProfile {fullName}} = diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d6688a939c..ddaaa9e294 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1442,7 +1442,7 @@ processChatCommand vr nm = \case pure $ CRConnNtfMessages ntfMsgs GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do srvs <- withFastStore (`getUserServers` user) - liftIO $ CRUserServers user <$> groupByOperator (protocolServers p srvs) + liftIO $ CRUserServers user <$> groupByOperator (onlyProtocolServers p srvs) SetUserProtoServers (AProtocolType (p :: SProtocolType p)) srvs -> withUser $ \user@User {userId} -> withServerProtocol p $ do userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) case L.nonEmpty userServers_ of @@ -1461,6 +1461,21 @@ processChatCommand vr nm = \case lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server) TestProtoServer srv -> withUser $ \User {userId} -> processChatCommand vr nm $ APITestProtoServer userId srv + GetUserChatRelays -> withUser $ \user -> do + srvs <- withFastStore (`getUserServers` user) + liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs) + SetUserChatRelays relays -> withUser $ \user@User {userId} -> do + userServers_ <- liftIO . groupByOperator =<< withFastStore (`getUserServers` user) + case L.nonEmpty userServers_ of + Nothing -> throwCmdError "no relays" + Just userServers -> case relays of + [] -> throwCmdError "no relays" + _ -> do + let relays' = map aUserRelay relays + processChatCommand vr nm $ APISetUserServers userId $ L.map (updatedRelays relays') userServers + where + aUserRelay :: CLINewRelay -> AUserChatRelay + aUserRelay CLINewRelay {address, name} = AUCR SDBNew $ newChatRelay name [""] address APIGetServerOperators -> CRServerOperatorConditions <$> withFastStore getServerOperators APISetServerOperators operators -> do as <- asks randomAgentServers @@ -2017,6 +2032,7 @@ processChatCommand vr nm = \case Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode + -- TODO [chat relays] add relay key, identity to link data let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userData) Nothing IKPQOn subMode @@ -4041,9 +4057,8 @@ data ConnectViaContactResult = CVRConnectedContact Contact | CVRSentInvitation Connection (Maybe Profile) --- TODO [chat relays] used for CLI specific APIs (same for `updatedServers` below) - add similar APIs for chat relays? -protocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -protocolServers p (operators, smpServers, xftpServers, _chatRelays) = case p of +onlyProtocolServers :: UserProtocol p => SProtocolType p -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) +onlyProtocolServers p (operators, smpServers, xftpServers, _chatRelays) = case p of SPSMP -> (operators, smpServers, [], []) SPXFTP -> (operators, [], xftpServers, []) @@ -4061,6 +4076,19 @@ updatedServers p' srvs UserOperatorServers {operator, smpServers, xftpServers, c disableSrv srv@UserServer {preset} = AUS SDBStored $ if preset then srv {enabled = False} else srv {deleted = True} +onlyRelays :: ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) -> ([Maybe ServerOperator], [UserServer 'PSMP], [UserServer 'PXFTP], [UserChatRelay]) +onlyRelays (operators, _smpServers, _xftpServers, chatRelays) = (operators, [], [], chatRelays) + +-- disable preset and replace custom chat relays (groupByOperator always adds custom) +updatedRelays :: [AUserChatRelay] -> UserOperatorServers -> UpdatedUserOperatorServers +updatedRelays relays UserOperatorServers {operator, smpServers, xftpServers, chatRelays} = + UpdatedUserOperatorServers operator (map (AUS SDBStored) smpServers) (map (AUS SDBStored) xftpServers) (updateRelays chatRelays) + where + updateRelays :: [UserChatRelay] -> [AUserChatRelay] + updateRelays pRelays = map disableRelay pRelays <> maybe relays (const []) operator + disableRelay relay@UserChatRelay {preset} = + AUCR SDBStored $ if preset then relay {enabled = False} else relay {deleted = True} + type ComposedMessageReq = (ComposedMessage, Maybe CIForwardedFrom, (Text, Maybe MarkdownList), Map MemberName CIMention) composedMessage :: Maybe CryptoFile -> MsgContent -> ComposedMessage @@ -4436,6 +4464,8 @@ chatCommandP = "/xftp " *> (SetUserProtoServers (AProtocolType SPXFTP) . map (AProtoServerWithAuth SPXFTP) <$> protocolServersP), "/smp" $> GetUserProtoServers (AProtocolType SPSMP), "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), + "/relays " *> (SetUserChatRelays <$> chatRelaysP), + "/relays" $> GetUserChatRelays, "/_operators" $> APIGetServerOperators, "/_operators " *> (APISetServerOperators <$> jsonP), "/operators " *> (SetServerOperators . L.fromList <$> operatorRolesP `A.sepBy1` A.char ','), @@ -4848,6 +4878,11 @@ chatCommandP = optional ("yes" *> A.space) *> (TMEEnableSetTTL <$> timedTTLP) <|> ("yes" $> TMEEnableKeepTTL) <|> ("no" $> TMEDisableKeepTTL) + chatRelaysP = chatRelayP `A.sepBy1` A.char ' ' + chatRelayP = do + name <- "name=" *> text1P + address <- _strP + pure CLINewRelay {name, address} operatorRolesP = do operatorId' <- A.decimal enabled' <- A.char ':' *> onOffP diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index b22cfebcdd..0d659923ca 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -253,6 +253,7 @@ mobileChatOpts dbOptions = logFile = Nothing, tbqSize = 4096, deviceName = Nothing, + chatRelay = False, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Just "" diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 4a8ac65d5e..2ba051c08b 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -271,6 +271,13 @@ data UserChatRelay' s = UserChatRelay } deriving (Show) +-- for setting chat relays via CLI API +data CLINewRelay = CLINewRelay + { address :: ConnLinkContact, + name :: Text + } + deriving (Show) + data PresetOperator = PresetOperator { operator :: Maybe NewServerOperator, smp :: [NewUserServer 'PSMP], @@ -503,16 +510,16 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other userServers :: (UserServersClass u, UserProtocol p) => SProtocolType p -> [u] -> [AUserServer p] userServers p = map aUserServer' . concatMap (servers' p) chatRelayErrs :: UserServersClass u => [u] -> [UserServersError] - chatRelayErrs uss = concatMap duplicateErrs_ speers + chatRelayErrs uss = concatMap duplicateErrs_ cRelays where - speers = filter (\(AUCR _ UserChatRelay {deleted}) -> not deleted) $ userChatRelays uss + cRelays = filter (\(AUCR _ UserChatRelay {deleted}) -> not deleted) $ userChatRelays uss duplicateErrs_ (AUCR _ UserChatRelay {name, address}) = [USEDuplicateChatRelayName name | name `elem` duplicateNames] <> [USEDuplicateChatRelayAddress name address | address `elem` duplicateAddresses] duplicateNames = snd $ foldl' addDuplicate (S.empty, S.empty) allNames - allNames = map (\(AUCR _ speer) -> name speer) speers + allNames = map (\(AUCR _ UserChatRelay {name}) -> name) cRelays duplicateAddresses = snd $ foldl' addAddress ([], []) allAddresses - allAddresses = map (\(AUCR _ speer) -> address speer) speers + allAddresses = map (\(AUCR _ UserChatRelay {address}) -> address) cRelays addAddress :: ([ConnLinkContact], [ConnLinkContact]) -> ConnLinkContact -> ([ConnLinkContact], [ConnLinkContact]) addAddress (xs, dups) x | any (sameConnLinkContact x) xs = (xs, x : dups) @@ -524,8 +531,8 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other | noChatRelays opEnabled = [USWNoChatRelays user] | otherwise = [] where - noChatRelays cond = not $ any speerEnabled $ userChatRelays $ filter cond uss - speerEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted + noChatRelays cond = not $ any relayEnabled $ userChatRelays $ filter cond uss + relayEnabled (AUCR _ UserChatRelay {deleted, enabled}) = enabled && not deleted userChatRelays :: UserServersClass u => [u] -> [AUserChatRelay] userChatRelays = map aUserChatRelay' . concatMap chatRelays' opEnabled :: UserServersClass u => u -> Bool diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index afc01e0493..90c8df5bd8 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -66,6 +66,7 @@ data CoreChatOpts = CoreChatOpts logFile :: Maybe FilePath, tbqSize :: Natural, deviceName :: Maybe Text, + chatRelay :: Bool, highlyAvailable :: Bool, yesToUpMigrations :: Bool, migrationBackupPath :: Maybe FilePath @@ -233,6 +234,11 @@ coreChatOptsP appDir defaultDbName = do <> metavar "DEVICE" <> help "Device name to use in connections with remote hosts and controller" ) + chatRelay <- + switch + ( long "relay" + <> help "Run as a chat relay client" + ) highlyAvailable <- switch ( long "ha" @@ -269,6 +275,7 @@ coreChatOptsP appDir defaultDbName = do logFile, tbqSize, deviceName, + chatRelay, highlyAvailable, yesToUpMigrations, migrationBackupPath diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 097e38ab36..c9083c02eb 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -140,8 +140,10 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, shortDe order <- getNextActiveOrder db DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?,?)" - (auId, displayName, BI activeUser, order, BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, currentTs, currentTs) + "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?)" + ( (auId, displayName, BI activeUser, BI userChatRelay, order) + :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, currentTs, currentTs) + ) userId <- insertedRowId db DB.execute db @@ -628,7 +630,7 @@ getChatRelays db User {userId} = UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} insertChatRelay :: DB.Connection -> User -> UTCTime -> NewUserChatRelay -> IO UserChatRelay -insertChatRelay db User {userId} ts speer@UserChatRelay {address, name, domains, preset, tested, enabled} = do +insertChatRelay db User {userId} ts relay@UserChatRelay {address, name, domains, preset, tested, enabled} = do crId <- fromOnly . head <$> DB.query @@ -640,7 +642,7 @@ insertChatRelay db User {userId} ts speer@UserChatRelay {address, name, domains, RETURNING chat_relay_id |] (address, name, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, userId, ts, ts) - pure speer {chatRelayId = DBEntityId crId} + pure relay {chatRelayId = DBEntityId crId} updateChatRelay :: DB.Connection -> UTCTime -> UserChatRelay -> IO () updateChatRelay db ts UserChatRelay {chatRelayId, address, name, domains, preset, tested, enabled} = @@ -900,13 +902,13 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False) | otherwise -> Just s <$ updateProtocolServer db p ts s upsertOrDeleteCRelay :: AUserChatRelay -> IO (Maybe UserChatRelay) - upsertOrDeleteCRelay (AUCR _ speer@UserChatRelay {chatRelayId, deleted}) = case chatRelayId of + upsertOrDeleteCRelay (AUCR _ relay@UserChatRelay {chatRelayId, deleted}) = case chatRelayId of DBNewEntity | deleted -> pure Nothing - | otherwise -> Just <$> insertChatRelay db user ts speer - DBEntityId speerId - | deleted -> Nothing <$ DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, speerId, BI False) - | otherwise -> Just speer <$ updateChatRelay db ts speer + | otherwise -> Just <$> insertChatRelay db user ts relay + DBEntityId relayId + | deleted -> Nothing <$ DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, relayId, BI False) + | otherwise -> Just relay <$ updateChatRelay db ts relay createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 5f7d1a44e5..df158b4a5c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -962,6 +962,14 @@ Query: Plan: +Query: + INSERT INTO chat_relays + (address, name, domains, preset, tested, enabled, user_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?) + RETURNING chat_relay_id + +Plan: + Query: INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, @@ -5667,6 +5675,10 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) +Query: DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ? +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM commands WHERE user_id = ? AND command_id = ? Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) @@ -6058,7 +6070,7 @@ Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?,?) +Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?) Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 9b58082b5d..37cd308d4b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -24,7 +24,6 @@ import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, sortOn) import Data.List.NonEmpty (NonEmpty (..)) -import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe) @@ -1506,14 +1505,14 @@ viewUserServers UserOperatorServers {operator, smpServers, xftpServers, chatRela viewChatRelays [] = [] viewChatRelays cRelays | maybe True (\ServerOperator {enabled} -> enabled) operator = - ["Chat relays"] <> map (plain . (" " <>) . viewChatRelay) cRelays + [" Chat relays"] <> map (plain . (" " <>) . viewChatRelay) cRelays | otherwise = [] where - viewChatRelay UserChatRelay {name, address, preset, tested, enabled} = name <> chatrelayAddress <> chatrelayInfo + viewChatRelay UserChatRelay {name, address, preset, tested, enabled} = name <> relayAddress <> relayInfo where - chatrelayAddress = "(" <> safeDecodeUtf8 (strEncode address) <> ")" - chatrelayInfo = if null chatrelayInfo_ then "" else parens $ T.intercalate ", " chatrelayInfo_ - chatrelayInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] + relayAddress = ": " <> safeDecodeUtf8 (strEncode address) + relayInfo = if null relayInfo_ then "" else parens $ T.intercalate ", " relayInfo_ + relayInfo_ = ["preset" | preset] <> testedInfo <> ["disabled" | not enabled] testedInfo = maybe [] (\t -> ["test: " <> if t then "passed" else "failed"]) tested serversUserHelp :: [StyledString] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 5e5113d7a6..0dde5f02f9 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -150,11 +150,15 @@ testCoreOpts = logFile = Nothing, tbqSize = 16, deviceName = Nothing, + chatRelay = False, highlyAvailable = False, yesToUpMigrations = False, migrationBackupPath = Nothing } +relayTestOpts :: ChatOpts +relayTestOpts = testOpts {coreOptions = testCoreOpts {chatRelay = True}} + #if !defined(dbPostgres) getTestOpts :: Bool -> ScrubbedBytes -> ChatOpts getTestOpts maintenance dbKey = testOpts {maintenance, coreOptions = testCoreOpts {dbOptions = (dbOptions testCoreOpts) {dbKey}}} @@ -283,10 +287,10 @@ nextVersion :: Version v -> Version v nextVersion (Version v) = Version (v + 1) createTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> Profile -> IO TestCC -createTestChat ps cfg opts@ChatOpts {coreOptions} dbPrefix profile = do +createTestChat ps cfg opts@ChatOpts {coreOptions = coreOptions@CoreChatOpts {chatRelay}} dbPrefix profile = do Right db@ChatDatabase {chatStore, agentStore} <- createDatabase ps coreOptions dbPrefix insertUser agentStore - Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile False True + Right user <- withTransaction chatStore $ \db' -> runExceptT $ createUserRecord db' (AgentUserId 1) profile chatRelay True startTestChat_ ps db cfg opts user startTestChat :: TestParams -> ChatConfig -> ChatOpts -> String -> IO TestCC @@ -316,7 +320,7 @@ startTestChat_ TestParams {printOutput} db cfg opts@ChatOpts {maintenance} user ct <- newChatTerminal t opts cc <- newChatController db (Just user) cfg opts False void $ execChatCommand' (SetTempFolder "tests/tmp/tmp") 0 `runReaderT` cc - chatAsync <- async $ runSimplexChat opts user cc $ \_u cc' -> runChatTerminal ct cc' opts + chatAsync <- async $ runSimplexChat cfg opts user cc $ \_u cc' -> runChatTerminal ct cc' opts unless maintenance $ atomically $ readTVar (agentAsync cc) >>= \a -> when (isNothing a) retry termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 20fccf6c64..ab532edaf2 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -1,6 +1,7 @@ module ChatTests where import ChatTests.ChatList +import ChatTests.ChatRelays import ChatTests.DBUtils import ChatTests.Direct import ChatTests.Files @@ -15,6 +16,7 @@ chatTests = do describe "direct tests" chatDirectTests describe "forward tests" chatForwardTests describe "group tests" chatGroupTests + describe "chat relay tests" chatRelayTests describe "local chats tests" chatLocalChatsTests describe "file tests" chatFileTests describe "profile tests" chatProfileTests diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs new file mode 100644 index 0000000000..3db731e261 --- /dev/null +++ b/tests/ChatTests/ChatRelays.hs @@ -0,0 +1,49 @@ +module ChatTests.ChatRelays where + +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import Test.Hspec hiding (it) + +chatRelayTests :: SpecWith TestParams +chatRelayTests = do + describe "configure chat relays" $ do + it "get and set chat relays" testGetSetChatRelays + +testGetSetChatRelays :: HasCallStack => TestParams -> IO () +testGetSetChatRelays ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + alice ##> ("/relays name=bob_relay " <> bobSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" bob_relay: " <> bobSLink) + + alice ##> ("/relays name=cath_relay " <> cathSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" cath_relay: " <> cathSLink) + + alice ##> ("/relays name=bob_relay " <> bobSLink <> " name=cath_relay " <> cathSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice + <### [ ConsoleString $ " bob_relay: " <> bobSLink, + ConsoleString $ " cath_relay: " <> cathSLink + ] From f79f67906511bfac69cb64a4c311d1cbafc273e4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 27 Oct 2025 14:24:53 +0000 Subject: [PATCH 004/112] core: change relay link type (#6411) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 11 +++++++---- src/Simplex/Chat/Library/Internal.hs | 6 ++++++ src/Simplex/Chat/Operators.hs | 20 ++++++++++---------- src/Simplex/Chat/Operators/Presets.hs | 6 +++--- src/Simplex/Chat/Store/Profiles.hs | 2 +- tests/OperatorTests.hs | 6 +++--- 8 files changed, 32 insertions(+), 23 deletions(-) diff --git a/cabal.project b/cabal.project index 713062cb80..46d8a43e55 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: cdbb4422b0f9c3eae614e4feb8f2b0eeb882018b + tag: 1ae3e8d0be957aa5090e88f25e6dc42d4af1a334 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1e9ca2bea2..927ea5c30b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."cdbb4422b0f9c3eae614e4feb8f2b0eeb882018b" = "11a7p8zcdxg9665d7l6ijlxdkj9qc9miscy3y6g6cbf2ma18hf20"; + "https://github.com/simplex-chat/simplexmq.git"."1ae3e8d0be957aa5090e88f25e6dc42d4af1a334" = "1cwahakq63jk7g0bbkdgpnnwa8i0i8s8j7azdpjral4d6cj4q4q0"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ddaaa9e294..89fd410502 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -2026,19 +2026,22 @@ processChatCommand vr nm = \case CRContactsList user <$> withFastStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand vr nm $ APIListContacts userId - APICreateMyAddress userId -> withUserId userId $ \user -> do + APICreateMyAddress userId -> withUserId userId $ \user@User {userChatRelay} -> do withFastStore' (\db -> runExceptT $ getUserAddress db user) >>= \case Left SEUserContactLinkNotFound -> pure () Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode - -- TODO [chat relays] add relay key, identity to link data + -- TODO [chat relays] relay address creation: + -- TODO - add relay key, identity to link data + -- TODO - validate short link is created (returned by agent) let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing -- TODO [certs rcv] (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userData) Nothing IKPQOn subMode ccLink' <- shortenCreatedLink ccLink - withFastStore $ \db -> createUserContactLink db user connId ccLink' subMode - pure $ CRUserContactLinkCreated user ccLink' + let ccLink'' = if isTrue userChatRelay then createdRelayLink ccLink' else ccLink' + withFastStore $ \db -> createUserContactLink db user connId ccLink'' subMode + pure $ CRUserContactLinkCreated user ccLink'' CreateMyAddress -> withUser $ \User {userId} -> processChatCommand vr nm $ APICreateMyAddress userId APIDeleteMyAddress userId -> withUserId userId $ \user@User {profile = p} -> do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 9ebdd00b81..b9fd0c7ada 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1272,6 +1272,12 @@ createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> sho toShortGroupLink :: ShortLinkContact -> ShortLinkContact toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k +createdRelayLink :: CreatedLinkContact -> CreatedLinkContact +createdRelayLink (CCLink cReq shortLink) = CCLink cReq (toShortRelayLink <$> shortLink) + +toShortRelayLink :: ShortLinkContact -> ShortLinkContact +toShortRelayLink (CSLContact sch _ srv k) = CSLContact sch CCTRelay srv k + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index 2ba051c08b..257bdb0ac5 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -46,9 +46,9 @@ import Data.Time (addUTCTime) import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions -import Simplex.Chat.Types (ConnLinkContact, User) +import Simplex.Chat.Types (ShortLinkContact, User) import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles) -import Simplex.Messaging.Agent.Protocol (sameConnLinkContact) +import Simplex.Messaging.Agent.Protocol (sameShortLinkContact) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Encoding.String @@ -261,7 +261,7 @@ deriving instance Show AUserChatRelay data UserChatRelay' s = UserChatRelay { chatRelayId :: DBEntityId' s, - address :: ConnLinkContact, + address :: ShortLinkContact, name :: Text, domains :: [Text], preset :: Bool, @@ -273,7 +273,7 @@ data UserChatRelay' s = UserChatRelay -- for setting chat relays via CLI API data CLINewRelay = CLINewRelay - { address :: ConnLinkContact, + { address :: ShortLinkContact, name :: Text } deriving (Show) @@ -318,15 +318,15 @@ newUserServer_ :: Bool -> Bool -> ProtoServerWithAuth p -> NewUserServer p newUserServer_ preset enabled server = UserServer {serverId = DBNewEntity, server, preset, tested = Nothing, enabled, deleted = False} -presetChatRelay :: Bool -> Text -> [Text] -> ConnLinkContact -> NewUserChatRelay +presetChatRelay :: Bool -> Text -> [Text] -> ShortLinkContact -> NewUserChatRelay presetChatRelay = newChatRelay_ True {-# INLINE presetChatRelay #-} -newChatRelay :: Text -> [Text] -> ConnLinkContact -> NewUserChatRelay +newChatRelay :: Text -> [Text] -> ShortLinkContact -> NewUserChatRelay newChatRelay = newChatRelay_ False True {-# INLINE newChatRelay #-} -newChatRelay_ :: Bool -> Bool -> Text -> [Text] -> ConnLinkContact -> NewUserChatRelay +newChatRelay_ :: Bool -> Bool -> Text -> [Text] -> ShortLinkContact -> NewUserChatRelay newChatRelay_ preset enabled name domains !address = UserChatRelay {chatRelayId = DBNewEntity, address, name, domains, preset, tested = Nothing, enabled, deleted = False} @@ -477,7 +477,7 @@ data UserServersError | USEProxyMissing {protocol :: AProtocolType, user :: Maybe User} | USEDuplicateServer {protocol :: AProtocolType, duplicateServer :: Text, duplicateHost :: TransportHost} | USEDuplicateChatRelayName {duplicateChatRelay :: Text} - | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ConnLinkContact} + | USEDuplicateChatRelayAddress {duplicateChatRelay :: Text, duplicateAddress :: ShortLinkContact} deriving (Show) data UserServersWarning = USWNoChatRelays {user :: Maybe User} @@ -520,9 +520,9 @@ validateUserServers curr others = (currUserErrs <> concatMap otherUserErrs other allNames = map (\(AUCR _ UserChatRelay {name}) -> name) cRelays duplicateAddresses = snd $ foldl' addAddress ([], []) allAddresses allAddresses = map (\(AUCR _ UserChatRelay {address}) -> address) cRelays - addAddress :: ([ConnLinkContact], [ConnLinkContact]) -> ConnLinkContact -> ([ConnLinkContact], [ConnLinkContact]) + addAddress :: ([ShortLinkContact], [ShortLinkContact]) -> ShortLinkContact -> ([ShortLinkContact], [ShortLinkContact]) addAddress (xs, dups) x - | any (sameConnLinkContact x) xs = (xs, x : dups) + | any (sameShortLinkContact x) xs = (xs, x : dups) | otherwise = (x : xs, dups) currUserWarns = noChatRelaysWarns Nothing curr otherUserWarns (user, uss) = noChatRelaysWarns (Just user) uss diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index d87cdbc18b..af6229e7c8 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -91,9 +91,9 @@ disabledSimplexChatSMPServers = -- TODO [chat relays] real chat relays simplexChatRelays :: [NewUserChatRelay] simplexChatRelays = - [ presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp111.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D"), - presetChatRelay True "chat_relay_2" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp222.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D"), - presetChatRelay True "chat_relay_3" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp333.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D") + [ presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "https://smp111.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y"), + presetChatRelay True "chat_relay_2" ["simplex.im"] (either error id $ strDecode "https://smp222.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y"), + presetChatRelay True "chat_relay_3" ["simplex.im"] (either error id $ strDecode "https://smp333.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y") ] fluxSMPServers :: [NewUserServer 'PSMP] diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c9083c02eb..87086f6bd4 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -625,7 +625,7 @@ getChatRelays db User {userId} = |] (Only userId) where - toChatRelay :: (DBEntityId, ConnLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay + toChatRelay :: (DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay toChatRelay (chatRelayId, address, name, domains, BI preset, tested, BI enabled) = UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} diff --git a/tests/OperatorTests.hs b/tests/OperatorTests.hs index 8e6ab69f36..b63898ed68 100644 --- a/tests/OperatorTests.hs +++ b/tests/OperatorTests.hs @@ -162,7 +162,7 @@ invalidNoChatRelays = (valid :: UpdatedUserOperatorServers) {chatRelays = []} invalidDuplicateChatRelayName :: UpdatedUserOperatorServers invalidDuplicateChatRelayName = (valid :: UpdatedUserOperatorServers) - { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp444.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D")] + { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "https://smp444.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y")] } invalidDuplicateChatRelayAddress :: UpdatedUserOperatorServers @@ -171,5 +171,5 @@ invalidDuplicateChatRelayAddress = { chatRelays = map (AUCR SDBNew) $ simplexChatRelays <> [presetChatRelay True "chat_relay_4" ["simplex.im"] duplicateAddr] } -duplicateAddr :: ConnLinkContact -duplicateAddr = either error id $ strDecode "simplex:/contact#/?v=2-7&smp=smp%3A%2F%2FLcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI%3D%40smp111.simplex.im%2Fu8A5BHVvIPOf83Qk%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAiyjKN0nmkp3mFzQxHiLTtRkX3rcp_BKfYF4xtwF9g1o%253D" +duplicateAddr :: ShortLinkContact +duplicateAddr = either error id $ strDecode "https://smp111.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y" From 6eb20722fce43e0d1c9050e3a7b943194cf5be0a Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:01:06 +0400 Subject: [PATCH 005/112] move relays migration --- simplex-chat.cabal | 4 ++-- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 ++-- ...0251212_chat_relays.hs => M20260106_chat_relays.hs} | 10 +++++----- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 ++-- ...0251212_chat_relays.hs => M20260106_chat_relays.hs} | 10 +++++----- .../Chat/Store/SQLite/Migrations/chat_query_plans.txt | 4 ++++ .../Chat/Store/SQLite/Migrations/chat_schema.sql | 2 +- 7 files changed, 21 insertions(+), 17 deletions(-) rename src/Simplex/Chat/Store/Postgres/Migrations/{M20251212_chat_relays.hs => M20260106_chat_relays.hs} (84%) rename src/Simplex/Chat/Store/SQLite/Migrations/{M20251212_chat_relays.hs => M20260106_chat_relays.hs} (84%) diff --git a/simplex-chat.cabal b/simplex-chat.cabal index b2541f612c..cd337f93cd 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -125,7 +125,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables - Simplex.Chat.Store.Postgres.Migrations.M20251212_chat_relays + Simplex.Chat.Store.Postgres.Migrations.M20260106_chat_relays else exposed-modules: Simplex.Chat.Archive @@ -274,7 +274,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables - Simplex.Chat.Store.SQLite.Migrations.M20251212_chat_relays + Simplex.Chat.Store.SQLite.Migrations.M20260106_chat_relays other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 03f4190796..f74b95b7ef 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -24,7 +24,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20251017_chat_tags_cascade import Simplex.Chat.Store.Postgres.Migrations.M20251117_member_relations_vector import Simplex.Chat.Store.Postgres.Migrations.M20251128_migrate_member_relations import Simplex.Chat.Store.Postgres.Migrations.M20251230_strict_tables -import Simplex.Chat.Store.Postgres.Migrations.M20251212_chat_relays +import Simplex.Chat.Store.Postgres.Migrations.M20260106_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -49,7 +49,7 @@ schemaMigrations = ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector), ("20251128_migrate_member_relations", m20251128_migrate_member_relations, Just down_m20251128_migrate_member_relations), ("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables), - ("20251212_chat_relays", m20251212_chat_relays, Just down_m20251212_chat_relays) + ("20260106_chat_relays", m20260106_chat_relays, Just down_m20260106_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20251212_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260106_chat_relays.hs similarity index 84% rename from src/Simplex/Chat/Store/Postgres/Migrations/M20251212_chat_relays.hs rename to src/Simplex/Chat/Store/Postgres/Migrations/M20260106_chat_relays.hs index da2b808794..08e4f2beee 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20251212_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260106_chat_relays.hs @@ -1,13 +1,13 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.Postgres.Migrations.M20251212_chat_relays where +module Simplex.Chat.Store.Postgres.Migrations.M20260106_chat_relays where import Data.Text (Text) import qualified Data.Text as T import Text.RawString.QQ (r) -m20251212_chat_relays :: Text -m20251212_chat_relays = +m20260106_chat_relays :: Text +m20260106_chat_relays = T.pack [r| CREATE TABLE chat_relays( @@ -32,8 +32,8 @@ ALTER TABLE users ADD COLUMN is_user_chat_relay SMALLINT NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN is_chat_relay SMALLINT NOT NULL DEFAULT 0; |] -down_m20251212_chat_relays :: Text -down_m20251212_chat_relays = +down_m20260106_chat_relays :: Text +down_m20260106_chat_relays = T.pack [r| ALTER TABLE group_members DROP COLUMN is_chat_relay; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 684d47355d..1ef45e7892 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -147,7 +147,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20251017_chat_tags_cascade import Simplex.Chat.Store.SQLite.Migrations.M20251117_member_relations_vector import Simplex.Chat.Store.SQLite.Migrations.M20251128_migrate_member_relations import Simplex.Chat.Store.SQLite.Migrations.M20251230_strict_tables -import Simplex.Chat.Store.SQLite.Migrations.M20251212_chat_relays +import Simplex.Chat.Store.SQLite.Migrations.M20260106_chat_relays import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -295,7 +295,7 @@ schemaMigrations = ("20251117_member_relations_vector", m20251117_member_relations_vector, Just down_m20251117_member_relations_vector), ("20251128_migrate_member_relations", m20251128_migrate_member_relations, Just down_m20251128_migrate_member_relations), ("20251230_strict_tables", m20251230_strict_tables, Just down_m20251230_strict_tables), - ("20251212_chat_relays", m20251212_chat_relays, Just down_m20251212_chat_relays) + ("20260106_chat_relays", m20260106_chat_relays, Just down_m20260106_chat_relays) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20251212_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260106_chat_relays.hs similarity index 84% rename from src/Simplex/Chat/Store/SQLite/Migrations/M20251212_chat_relays.hs rename to src/Simplex/Chat/Store/SQLite/Migrations/M20260106_chat_relays.hs index bc0241da45..6bd0f2632f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20251212_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260106_chat_relays.hs @@ -1,12 +1,12 @@ {-# LANGUAGE QuasiQuotes #-} -module Simplex.Chat.Store.SQLite.Migrations.M20251212_chat_relays where +module Simplex.Chat.Store.SQLite.Migrations.M20260106_chat_relays where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) -m20251212_chat_relays :: Query -m20251212_chat_relays = +m20260106_chat_relays :: Query +m20260106_chat_relays = [sql| CREATE TABLE chat_relays( chat_relay_id INTEGER PRIMARY KEY, @@ -30,8 +30,8 @@ ALTER TABLE users ADD COLUMN is_user_chat_relay INTEGER NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN is_chat_relay INTEGER NOT NULL DEFAULT 0; |] -down_m20251212_chat_relays :: Query -down_m20251212_chat_relays = +down_m20260106_chat_relays :: Query +down_m20260106_chat_relays = [sql| ALTER TABLE group_members DROP COLUMN is_chat_relay; 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 a87d3507f7..e85dd19b95 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -5656,6 +5656,10 @@ SEARCH chat_items USING COVERING INDEX idx_chat_items_fwd_from_chat_item_id (fwd SEARCH files USING COVERING INDEX idx_files_chat_item_id (chat_item_id=?) SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) +Query: DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ? +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: DELETE FROM commands WHERE user_id = ? AND command_id = ? Plan: SEARCH commands USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 32399a8c42..bb3a34e8cd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -738,7 +738,7 @@ CREATE TABLE chat_relays( updated_at TEXT NOT NULL DEFAULT(datetime('now')), UNIQUE(user_id, address), UNIQUE(user_id, name) -) STRICT; +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name From bd8ba4d5c61f3a559a5ad48c703ec53b9fbfca36 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:19:06 +0000 Subject: [PATCH 006/112] core: chat relays protocol (#6383) * core: chat relays protocol wip * types, notes * remove file * removal protocol * schema * status * update * recovery * update * formatting * rename * more types * comment * more docs * decrease number of steps * format * correct * update * update protocol * update * typo * todo * update doc * update * update * remove added * update * update * XGrpRelayReady * link to chat relays * update * remove from protocol * update * json * wip * remove comment * wip * update * wip * wip * update * wip * wip * plans * better view * fix * fix * relay acceptance * rework api * add relays to link * comment * active on con, fix send * comments * direct in group plan * prepare * member connection wip * comments * member connection wip * fix forwarding * introduce moderators to new member * enable relay tests * plans * security objectives * refactor * add to threat model * stress test wip * stress test wip * Revert "stress test wip" This reverts commit acde8a1fb38c3136a7440c79683c40f54f9ae146. * Revert "stress test wip" This reverts commit 6435808438b6833cb0f6a0118de42ec9b8a70390. * remove stress test * improve output * invert relay fkey * postgres schema * comments * group in progress, remove auto-select relays commented code * comments * corrections * comment * lint * redundant import * core: chat relay request worker (#6509) * update plans * strict tables * core: update group link asynchronously with relay link (#6548) * update simplexmq * docs: connection to chat relays rfc (#6554) * add test for 2 relays (doesn't pass) * create unknown member in same transaction as checking * fix relays choosing different memberId (XContactRelay) * plans, api * use same incognito profile for relays, connect concurrently, save correct link for plan * test * don't duplicate items on group connection * check relay record exists when joining * use mapConcurrently when adding relays, update schemas * fix multi-relay join for postgres (savepoint) * core: async retry connection to chat relays (#6584) * update simplexmq * fix api tests * prefer throwing temp error on connection * check group relays when deleting from configuration * relay_request_err_reason * relay role * rename, fix syntax * plans * rename, style --------- Co-authored-by: Evgeny Poberezkin --- .../src/Directory/Store/Migrate.hs | 3 +- bots/api/COMMANDS.md | 53 +- bots/api/EVENTS.md | 15 + bots/api/TYPES.md | 48 +- bots/src/API/Docs/Commands.hs | 2 + bots/src/API/Docs/Events.hs | 3 +- bots/src/API/Docs/Responses.hs | 1 + bots/src/API/Docs/Types.hs | 6 +- cabal.project | 2 +- docs/rfcs/2025-10-20-chat-relays.md | 304 ++++++++ ...2026-01-08-relays-new-member-connection.md | 99 +++ .../types/typescript/src/commands.ts | 17 + .../types/typescript/src/events.ts | 10 + .../types/typescript/src/responses.ts | 10 + .../types/typescript/src/types.ts | 53 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 4 + src/Simplex/Chat/Controller.hs | 17 +- src/Simplex/Chat/Library/Commands.hs | 360 +++++++--- src/Simplex/Chat/Library/Internal.hs | 178 +++-- src/Simplex/Chat/Library/Subscriber.hs | 657 ++++++++++++------ src/Simplex/Chat/Operators/Presets.hs | 2 +- src/Simplex/Chat/Protocol.hs | 24 +- src/Simplex/Chat/Store/Connections.hs | 7 +- src/Simplex/Chat/Store/Delivery.hs | 4 +- src/Simplex/Chat/Store/Direct.hs | 57 +- src/Simplex/Chat/Store/Groups.hs | 434 ++++++++++-- src/Simplex/Chat/Store/Messages.hs | 20 +- .../Migrations/M20260109_chat_relays.hs | 64 +- .../Store/Postgres/Migrations/chat_schema.sql | 93 ++- src/Simplex/Chat/Store/Profiles.hs | 39 +- src/Simplex/Chat/Store/RelayRequests.hs | 104 +++ .../Migrations/M20260109_chat_relays.hs | 83 ++- .../SQLite/Migrations/chat_query_plans.txt | 446 ++++++++++-- .../Store/SQLite/Migrations/chat_schema.sql | 45 +- src/Simplex/Chat/Store/Shared.hs | 56 +- src/Simplex/Chat/Types.hs | 97 ++- src/Simplex/Chat/Types/Shared.hs | 24 +- src/Simplex/Chat/View.hs | 57 +- tests/ChatClient.hs | 2 +- tests/ChatTests/ChatRelays.hs | 1 + tests/ChatTests/Groups.hs | 404 ++++++++--- tests/ChatTests/Profiles.hs | 24 +- tests/ChatTests/Utils.hs | 3 + tests/ProtocolTests.hs | 2 +- 46 files changed, 3198 insertions(+), 739 deletions(-) create mode 100644 docs/rfcs/2025-10-20-chat-relays.md create mode 100644 docs/rfcs/2026-01-08-relays-new-member-connection.md create mode 100644 src/Simplex/Chat/Store/RelayRequests.hs diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index e22f4ed470..aa101d7bf7 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -22,8 +22,9 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store.Groups (getGroupInfo, getHostMember) +import Simplex.Chat.Store.Groups (getHostMember) import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Store.Shared (getGroupInfo) import Simplex.Chat.Types import Simplex.Messaging.Agent.Store.Common import qualified Simplex.Messaging.Agent.Store.DB as DB diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index b408d8eb30..54ffc977d5 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -30,6 +30,7 @@ This file is generated automatically. - [APILeaveGroup](#apileavegroup) - [APIListMembers](#apilistmembers) - [APINewGroup](#apinewgroup) +- [APINewPublicGroup](#apinewpublicgroup) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -593,7 +594,7 @@ Add contact to group. Requires bot to have Admin role. **Syntax**: ``` -/_add # observer|author|member|moderator|admin|owner +/_add # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -672,7 +673,7 @@ Accept group member. Requires Admin role. **Syntax**: ``` -/_accept member # observer|author|member|moderator|admin|owner +/_accept member # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -715,7 +716,7 @@ Set members role. Requires Admin role. **Syntax**: ``` -/_member role # [,...] observer|author|member|moderator|admin|owner +/_member role # [,...] relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -940,6 +941,48 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APINewPublicGroup + +Create public group. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- incognito: bool +- relayIds: [int64] +- groupProfile: [GroupProfile](./TYPES.md#groupprofile) + +**Syntax**: + +``` +/_public group [ incognito=on] [,...] +``` + +```javascript +'/_public group ' + userId + (incognito ? ' incognito=on' : '') + ' ' + relayIds.join(',') + ' ' + JSON.stringify(groupProfile) // JavaScript +``` + +```python +'/_public group ' + str(userId) + (' incognito=on' if incognito else '') + ' ' + ','.join(map(str, relayIds)) + ' ' + json.dumps(groupProfile) # Python +``` + +**Responses**: + +PublicGroupCreated: Public group created. +- type: "publicGroupCreated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ### APIUpdateGroupProfile Update group profile. @@ -998,7 +1041,7 @@ Create group link. **Syntax**: ``` -/_create link # observer|author|member|moderator|admin|owner +/_create link # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1037,7 +1080,7 @@ Set member role for group link. **Syntax**: ``` -/_set link role # observer|author|member|moderator|admin|owner +/_set link role # relay|observer|author|member|moderator|admin|owner ``` ```javascript diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index d7405ef846..a71a4540f5 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -38,6 +38,7 @@ This file is generated automatically. - [MemberAcceptedByOther](#memberacceptedbyother) - [MemberBlockedForAll](#memberblockedforall) - [GroupMemberUpdated](#groupmemberupdated) + - [GroupLinkRelaysUpdated](#grouplinkrelaysupdated) [File events](#file-events) - Main events @@ -445,6 +446,20 @@ Another group member profile updated. --- +### GroupLinkRelaysUpdated + +Group link relays updated. + +**Record type**: +- type: "groupLinkRelaysUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +--- + + ## File events Bots that send or receive files may process these events to track delivery status and to process completion. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 2797d8d6b1..398a4afbff 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -102,6 +102,7 @@ This file is generated automatically. - [GroupPreference](#grouppreference) - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) +- [GroupRelay](#grouprelay) - [GroupShortLinkData](#groupshortlinkdata) - [GroupSummary](#groupsummary) - [GroupSupportChat](#groupsupportchat) @@ -140,6 +141,7 @@ This file is generated automatically. - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) +- [RelayStatus](#relaystatus) - [ReportReason](#reportreason) - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) @@ -2135,6 +2137,7 @@ MemberSupport: **Record type**: - groupId: int64 - useRelays: bool +- relayOwnStatus: [RelayStatus](#relaystatus)? - localDisplayName: string - groupProfile: [GroupProfile](#groupprofile) - localAlias: string @@ -2177,6 +2180,7 @@ MemberSupport: Ok: - type: "ok" +- direct: bool - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? OwnLink: @@ -2220,7 +2224,6 @@ Known: - createdAt: UTCTime - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? -- isChatRelay: bool --- @@ -2257,6 +2260,7 @@ Known: ## GroupMemberRole **Enum type**: +- "relay" - "observer" - "author" - "member" @@ -2331,10 +2335,23 @@ Known: - shortDescr: string? - description: string? - image: string? +- groupLink: string? - groupPreferences: [GroupPreferences](#grouppreferences)? - memberAdmission: [GroupMemberAdmission](#groupmemberadmission)? +--- + +## GroupRelay + +**Record type**: +- groupRelayId: int64 +- groupMemberId: int64 +- userChatRelayId: int64 +- relayStatus: [RelayStatus](#relaystatus) +- relayLink: string? + + --- ## GroupShortLinkData @@ -3043,6 +3060,17 @@ NewMemberPendingReview: - type: "newMemberPendingReview" +--- + +## RelayStatus + +**Enum type**: +- "new" +- "invited" +- "accepted" +- "active" + + --- ## ReportReason @@ -3281,6 +3309,9 @@ UserNotFound: - type: "userNotFound" - userId: int64 +RelayUserNotFound: +- type: "relayUserNotFound" + UserNotFoundByName: - type: "userNotFoundByName" - contactName: string @@ -3384,6 +3415,9 @@ GroupWithoutUser: DuplicateGroupMember: - type: "duplicateGroupMember" +DuplicateMemberId: +- type: "duplicateMemberId" + GroupAlreadyJoined: - type: "groupAlreadyJoined" @@ -3572,6 +3606,18 @@ OperatorNotFound: UsageConditionsNotFound: - type: "usageConditionsNotFound" +UserChatRelayNotFound: +- type: "userChatRelayNotFound" +- chatRelayId: int64 + +GroupRelayNotFound: +- type: "groupRelayNotFound" +- groupRelayId: int64 + +GroupRelayNotFoundByMemberId: +- type: "groupRelayNotFoundByMemberId" +- groupMemberId: int64 + InvalidQuote: - type: "invalidQuote" diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 0094b7d348..fa5dc49c9c 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -117,6 +117,7 @@ chatCommandsDocsData = ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), + ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), @@ -240,6 +241,7 @@ cliCommands = "MemberRole", "MuteUser", "NewGroup", + "NewPublicGroup", "QuitChat", "ReactToMessage", "RejectContact", diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 130ea89846..ec58985ceb 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -97,7 +97,8 @@ chatEventsDocsData = [ ("CEvtConnectedToGroupMember", "Connected to another group member."), ("CEvtMemberAcceptedByOther", "Another group owner, admin or moderator accepted member to the group after review (\"knocking\")."), ("CEvtMemberBlockedForAll", "Another member blocked for all members."), - ("CEvtGroupMemberUpdated", "Another group member profile updated.") + ("CEvtGroupMemberUpdated", "Another group member profile updated."), + ("CEvtGroupLinkRelaysUpdated", "Group link relays updated.") ] ), ( "File events", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 60fe129cdb..321fac1d9c 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -68,6 +68,7 @@ chatResponsesDocsData = ("CRGroupLinkCreated", ""), ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), + ("CRPublicGroupCreated", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 73ad90e91b..77201172f5 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -275,12 +275,13 @@ chatTypesDocsData = (sti @GroupMemberAdmission, STRecord, "", [], "", ""), (sti @GroupMemberCategory, (STEnum' $ dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), - (sti @GroupMemberRole, STEnum, "GR", [], "", ""), + (sti @GroupMemberRole, (STEnum' $ dropPfxSfx "GR" ""), "", ["GRUnknown"], "", ""), (sti @GroupMemberSettings, STRecord, "", [], "", ""), (sti @GroupMemberStatus, (STEnum' $ (\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), (sti @GroupPreference, STRecord, "", [], "", ""), (sti @GroupPreferences, STRecord, "", [], "", ""), (sti @GroupProfile, STRecord, "", [], "", ""), + (sti @GroupRelay, STRecord, "", [], "", ""), (sti @GroupShortLinkData, STRecord, "", [], "", ""), (sti @GroupSummary, STRecord, "", [], "", ""), (sti @GroupSupportChat, STRecord, "", [], "", ""), @@ -320,6 +321,7 @@ chatTypesDocsData = (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), + (sti @RelayStatus, STEnum, "RS", [], "", ""), (sti @ReportReason, (STEnum' $ dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), @@ -468,6 +470,7 @@ deriving instance Generic GroupMemberStatus deriving instance Generic GroupPreference deriving instance Generic GroupPreferences deriving instance Generic GroupProfile +deriving instance Generic GroupRelay deriving instance Generic GroupShortLinkData deriving instance Generic GroupSummary deriving instance Generic GroupSupportChat @@ -513,6 +516,7 @@ deriving instance Generic RcvFileDescr deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent +deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexLinkType diff --git a/cabal.project b/cabal.project index 390890d258..a03d492bd6 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: ca26c69937083deee43b8b2200ec9ef4c004ceac + tag: 89b81d151fa0378196d923c5d7fa0aea08462136 source-repository-package type: git diff --git a/docs/rfcs/2025-10-20-chat-relays.md b/docs/rfcs/2025-10-20-chat-relays.md new file mode 100644 index 0000000000..d1a3180b70 --- /dev/null +++ b/docs/rfcs/2025-10-20-chat-relays.md @@ -0,0 +1,304 @@ +# Chat relays + +## Security objectives + +Group relay protocol should achieve following objectives: +1. Stable message delivery between group members. +2. No possibility for relay to substitute group. +3. No possibility for relay to impersonate owner(s). +4. Prevent relay from altering member roster (member removal, role change, etc.). +5. Prevent relay from terminally destabilizing group by stopping to serve it. At the same time, allow owner to remove (last) relay with possibility to restore group functionality. +6. Allow owner(s) to send messages as "message from channel", hiding specific sender out of multiple owners from members. +7. Prevent relays from altering/dropping messages. + +## Protocol for adding chat relays to group + +Activations (execution bars) with looped arrows indicate internal calls/steps. + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay(s) + participant RSMP as Chat relays'
SMP server(s) + +note over O, RSMP: Owner creates new group, adds chat relays + +activate O +O ->> O: 1. Create new group
(user action) +O ->> O: 2. Prepare group link,
owner key,
group ID (agent) +O ->> O: 3. Add link, owner key
to group profile, sign +O ->> OSMP: 4. Create group link,
signed profile as data +deactivate O +OSMP -->> O: Group link created +activate O +O ->> O: 5. Choose chat relays
(automatic/user choice) +note left of O: Relay status: New +par With each relay + O ->> R: 6. Contact request
(x.grp.relay.inv
incl. group link) + deactivate O + activate R + note left of O: Relay status: Invited + note right of R: Relay status: Invited + R ->> OSMP: 7. Retrieve group link data + deactivate R + OSMP -->> R: Group link data + activate R + R ->> R: 8. Validate group profile,
verify profile signature + opt Bad profile or signature + R -x R: Abort (reject) + end + R ->> RSMP: 9. Create relay link,
set group ID
in immutable data + deactivate R + RSMP -->> R: Relay link created + activate R + R ->> O: 10. Accept request
(x.grp.relay.acpt
incl. relay link) + deactivate R + activate O + note right of R: Relay status: Accepted + note left of O: Relay status: Accepted + note over O, R: RPC connection
with relay is ready + opt Protocol extension - 2 connections + O ->> R: * Connect via relay link
(share same owner key) + deactivate O + R -->> O: Accept messaging connection + activate O + note right of R: Relay status: Accepted,
"Connected" implied from
messaging connection + note left of O: Relay status: Accepted,
"Connected" implied from
messaging connection + note over O, R: Owner: Messaging connection with relay is ready,
relay link is tested + end + create participant M as Member + R --> M: + note over R, M: At this point relay can accept
connection requests from members + O ->> RSMP: 11. Retrieve relay link data + deactivate O + RSMP -->> O: Relay link data + activate O + O ->> O: 12. Validate group ID
in relay link data + opt Bad group ID + O -x O: Abort for relay (don't add) + end + O ->> OSMP: 13. Update group link
(add relay link) + deactivate O + OSMP -->> O: Group link updated + note left of O: Relay status: Active +end + +note over O, M: Chat relay checks link - monitoring + +loop Periodically + R ->> OSMP: Retrieve group link data for served gorup + OSMP -->> R: Group link data + activate R + R ->> R: Check relay link present + deactivate R + note right of R: Relay status: Active +end + +note over O, M: New member connects + +O -->> M: 14. Share group link
(social, out-of-band) +M ->> OSMP: 15. Retrieve short link data +par RPC connection + M ->> R: 16a. Connect via relay link +and + opt Protocol extension - Messaging connection + M ->> R: 16b*. Connect via relay link
(share same member key/
identifier to correlate) + end +end + +note over O, M: Message forwarding + +O ->> R: 17. Send message +R ->> M: 18. Forward message +activate M +M ->> M: 19. Deduplicate message +deactivate M +``` + +Notes: + +- Group ID - unique group identifier (not globally unique) baked in immutable part of group link data, and repeated by chat relays in immutable parts of respective relay links. + + Owner can validate they're adding relay link to the group link specifically for their group. + + Members can validate they join relay links corresponding to group link they connected to. + +- Protocol extension: Create connections pairs between relay and members with different priority for passing regular messages and for relay responding to member requests. + + Invitation sent in step 12 should contain same key as in group link, for relay to match connection to the same owner and "active" relay link (add to `XContact` message). + + Add new connection entity, special for groups with relay, referencing member record - parallel to first member connection. + +- Client can "know" link that will be created before creating it on server - so we can add it to profile before adding profile to group short link data. + + Agent to return link that will be created upon preparing connection record. + +- On adding group short link to group profile. + + Strengthens association between link and profile. Link already contains profile in attached data, but from perspective of group profile link itself is detached. All members "see" the same link they joined via in group profile. Chat relays "see" the same link they created relay links for, and can check it for presence of their relay link at any point. + + Link is recoverable from profile, e.g. for purpose of restoring connection with group via new chat relays. + + Overall it just seems a natural and convenient way to store group link for all members, rather than having it separately. + +- On updating group link data with one relay link at a time vs waiting for all links. + + Overhead is minimal - one request to owner's SMP server per relay. + + Waiting for a relay to send relay link can take indefinitely long. + + In proposed protocol owner doesn't have to wait for links from all relays for simplicity and to minimize wait time - it allows owner to conclude group creation potentially earlier, in case some relays are stuck or offline (owner can add their links later, once they successfully send it). + +- Lock owner group link from accepting connection on SMP server, possibly has some implementation gaps. + + Reject in owner code for foolproofing. + +- What should be in relay link user data: + + - Relay key for group. + - Relay identity if provided. + Operator relays want to provide identity for trust. + User relays may not want to provide identity. + Relay identity: profile, certificate, relay identity key (global across groups). + +## Protocol for removing chat relay from group, restoring connection to group + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay + participant RSMP as Chat relay
SMP server + participant M as Member + +note over O, M: Owner deletes chat relay, notifies relay + +O ->> OSMP: Remove relay link
(update group link data) +O ->> R: Delete chat relay
(x.grp.mem.del)
over RPC connection +par Chat relay to SMP + R ->> RSMP: Delete relay link +and Chat relay to members + R ->> M: Forward relay is deleted
over RPC connection +end + +note over O, M: Scenario 2. Owner deletes chat relay, fails to notify relay + +O ->> OSMP: Remove relay link
(update group link data) +O --x R: Fail to notify relay +opt Chat relay identifies
connection with owner is deleted + par Chat relay to SMP + destroy RSMP + R ->> RSMP: Delete relay link + and Chat relay to members + destroy R + R ->> M: Notify relay is deleted
over RPC connection + end +end + +note over O, M: Last relay is deleted + +O --x M: Owner can't send messages to members +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +activate M +M -x M: Members can't restore connection to group +deactivate M + +note over O, M: Restore connection to group + +create participant NR as New chat relay +O <<->> NR: Add new relay, relay creates and sends link +O <<->> OSMP: Update group link
(add relay link) +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +par RPC connection + M ->> NR: Connect via relay link +and Messaging connection + M ->> NR: Connect via relay link
(share same member key/
identifier to correlate) +end +O ->> NR: Send message +NR ->> M: Forward message +activate M +M ->> M: Deduplicate message +deactivate M +``` + +Notes: + +- New relay doesn't have group history. + + - We can prohibit to remove last relay without adding new one. + - Relays can synchronize history. + - Can be considered after MVP. + +## Correlation of design objectives with design elements + +1. Redundant delivery by multiple relays. High availability of relay clients. +2. Same group ID baked in immutable data of group link and relay links. +3. Owner public key in group link. +4. Actions altering member roster can be signed by owner key, verified by members. +5. Protocol for restoring connection to group by checking group link for new relays. +6. XMsgNew protocol extension - "message from channel" flag - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). +7. Redundant delivery by multiple relays, highlighting deduplicated messages differences - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). + +## Threat model + +**Single compromised chat relay / Colluding chat relays** + +can: +- effectively substitute group bar group ID and signed profile, by sending unsigned content from other group (or any arbitrary content), that doesn't require signature verification, such as regular messages. + - one way this could be further mitigated is requiring owner to sign all messages. + - owner could periodically sign message history as merkle dag. +- selectively drop any content or service messages from owner, including actions altering member roster. +- selectively drop messages for some of members. + +cannot: +- technically, redirect newly joining member to a different group. +- substitute group profile. +- impersonate owner, send any member message that requires signature. + +**Compromised chat relay (in situation where not all relays are compromised/colluding)** + +can: +- in case number of compromised relays is same as number of uncompromised ones, compromised relay(s) can drop messages or send arbitrary unsigned messages, misleading members from identifying which relays are compromised. +- ignore "message from channel" directive from owner, revealing which owner sent message. + - this can be revealed to owner by members out-of-band. +- fabricate new members, possibly inflating counts/costs for owner (depends on implementation). + - it can be identified that these imaginary members don't connect to other relays. + +**Member** + +can: +- infer which owner sent message as "message from channel", if group has a single owner. + - owner client should prohibit this option if group has a single owner. + +**Any client** + +can: +- connect to group unlimited number of times, inflating real counts/costs. + +## TODO list + +- Chat commands for creating group with relays. +- Protocol events processing. +- Recovery for both owner and relay when adding relay to group. +- On each subscription retrieve group link data for all groups, actualize connections for present relay links. +- Agent `prepareConnectionToJoin` api to return link that will be created. +- Asynchronous version of agent `setConnShortLink` api, correlation in chat. +- Agent to support adding relays to link (it has stub `relays :: [ConnShortLink 'CMContact]`). +- New connection entity for secondary member-in-relayed-group connection - priority/messages connections. +- Differentiate connection usage by priority in chat logic (receiving messages vs sending requests to relay). +- Finalize model - statuses, schema. +- UI for relay management (user level, similar to list of servers). +- UI for creating group with relays. +- UI for managing relays in group. +- Relay status updates events on adding relays for UI integration. +- Relay removal. +- Relay periodic checks for monitoring relay link presence. diff --git a/docs/rfcs/2026-01-08-relays-new-member-connection.md b/docs/rfcs/2026-01-08-relays-new-member-connection.md new file mode 100644 index 0000000000..471f4ed53f --- /dev/null +++ b/docs/rfcs/2026-01-08-relays-new-member-connection.md @@ -0,0 +1,99 @@ +# Connection of new member to chat relays + +## Problem + +Naive implementation of new member connection to chat relays can lead to partial failures (some relays fail to connect), or requires recovery or clean up. + +After group record is prepared from short link, naive flow is as follows (APIConnectPreparedGroup): + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: + -> Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Join connection (sync joinConnection) +``` + +Orthogonal smaller problem: + +If new member chooses to connect to group incognito, same incognito profile should be sent to all group relays. + +## Solution + +### Join Connection step + +"Join connection" is the main step, let's consider it first. + +#### Option 1: Synchronous approach with catches + recovery + +Keep all relay connections synchronous, catch on failure to continue for remaining relays, recovery for failed relays. All relays failing would mean full command failure, offer user retry. + +For partial failures it would require to track which relays succeeded/failed, then trigger recovery, basically recreating what asynchronous command processing already does. + +#### Option 2: First relay sync, then async + +Connect to first relay synchronously, connect to remaining asynchronously (using joinConnectionAsync). + +Choice of "first" relay is arbitrary and we may be choosing the one with worse network. + +Mixed (double) implementation - for "first" and remaining relays. + +#### Option 3: All relays async + +In this case agent already handles connection reliability, downside is no immediate failure visible to user on temporary network errors for all relays (for example, client is offline). + +UI already handles "connecting..." state, so async path doesn't hurt UX much other than in mentioned case. UI stays in "connecting..." until at least one relay connection succeeds. + +If all relay connections permanently fail, update state for UI - requires permanent error handling for connection creation on continuation (agent responses in Subscriber). Track relay connection states to detect "all failed", possibly on connection status, TBC at implementation. + +Pros: +- Simple flow: loop through relays, start async connections. +- Async agent commands provide recovery. + +### Link fetches + +We considered handling retries for Join step, but no retry mechanism for link fetch. If it's synchronous and fails for a given relay, it would result in permanent failure to connect to relay, without additional recovery logic. + +#### Option 1: Asynchronous command with continuation + +New agent asynchronous command + complexity in chat Subscriber logic. Seems overkill. + +#### Option 2: Per-relay "relay connection" worker + +An additional state machine, possibly based on relay member records as work items. Also overkill. + +#### Option 3: Make all link fetches synchronously before proceeding + +To avoid adding background recovery mechanisms for link fetching per relay, we could fetch all links data synchronously, and only then connect to relays asynchronously. + +In case any relay link fetch fails, user would be given option to retry. (Whole operation fails and is retried) + +Group link fetch is also synchronous (retrieve list of relay links), and also leads to immediate user retry. + +### On the incognito profile issue + +This should be addressed regardless of which approach to connection we choose. The incognito profile should be: + +1. Created once before starting any relay connections; +2. Passed to all relays on connection attempts. + +In case of synchronous approach and re-use of existing logic, it means `connectViaContact` should accept an optional profile (not just flag). + +### Overall proposed connection flow + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Once all links are resolved, proceed - create incognito profile ONCE for all relays, if needed + -> For each relay: Start async connection attempt (joinConnectionAsync) + -> Agent handles connection retries internally + -> Subscriber handles JOINED events and errors for each relay + - At least one relay JOINED -> group becomes functional + - All relays permanently fail -> show failure to user +``` + +Link fetches being synchronous in conjunction with asynchronous relay connections allows for similar UI reactivity to current single-connection flows: +- Network failures during link fetches require user retry; +- Connection attempts are retried by agent on network failures; +- Link fetches passing ensures client is not offline when starting async connection attempts (unless user goes offline in-between, but window is very small, and connections would be retried anyway). diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 66f4f6ec5f..742f5b8dd2 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -341,6 +341,23 @@ export namespace APINewGroup { } } +// Create public group. +// Network usage: interactive. +export interface APINewPublicGroup { + userId: number // int64 + incognito: boolean + relayIds: number[] // int64, non-empty + groupProfile: T.GroupProfile +} + +export namespace APINewPublicGroup { + export type Response = CR.PublicGroupCreated | CR.ChatCmdError + + export function cmdString(self: APINewPublicGroup): string { + return '/_public group ' + self.userId + (self.incognito ? ' incognito=on' : '') + ' ' + self.relayIds.join(',') + ' ' + JSON.stringify(self.groupProfile) + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index cb6ba85c8b..f7b1725843 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -29,6 +29,7 @@ export type ChatEvent = | CEvt.MemberAcceptedByOther | CEvt.MemberBlockedForAll | CEvt.GroupMemberUpdated + | CEvt.GroupLinkRelaysUpdated | CEvt.RcvFileDescrReady | CEvt.RcvFileComplete | CEvt.SndFileCompleteXFTP @@ -80,6 +81,7 @@ export namespace CEvt { | "memberAcceptedByOther" | "memberBlockedForAll" | "groupMemberUpdated" + | "groupLinkRelaysUpdated" | "rcvFileDescrReady" | "rcvFileComplete" | "sndFileCompleteXFTP" @@ -296,6 +298,14 @@ export namespace CEvt { toMember: T.GroupMember } + export interface GroupLinkRelaysUpdated extends Interface { + type: "groupLinkRelaysUpdated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + } + export interface RcvFileDescrReady extends Interface { type: "rcvFileDescrReady" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 684aeec7af..de80b8666d 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -27,6 +27,7 @@ export type ChatResponse = | CR.GroupLinkCreated | CR.GroupLinkDeleted | CR.GroupCreated + | CR.PublicGroupCreated | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -78,6 +79,7 @@ export namespace CR { | "groupLinkCreated" | "groupLinkDeleted" | "groupCreated" + | "publicGroupCreated" | "groupMembers" | "groupUpdated" | "groupsList" @@ -245,6 +247,14 @@ export namespace CR { groupInfo: T.GroupInfo } + export interface PublicGroupCreated extends Interface { + type: "publicGroupCreated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5b0a4ff6b5..a05d549d17 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2421,6 +2421,7 @@ export enum GroupFeatureEnabled { export interface GroupInfo { groupId: number // int64 useRelays: boolean + relayOwnStatus?: RelayStatus localDisplayName: string groupProfile: GroupProfile localAlias: string @@ -2467,6 +2468,7 @@ export namespace GroupLinkPlan { export interface Ok extends Interface { type: "ok" + direct: boolean groupSLinkData_?: GroupShortLinkData } @@ -2511,7 +2513,6 @@ export interface GroupMember { createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat - isChatRelay: boolean } export interface GroupMemberAdmission { @@ -2532,6 +2533,7 @@ export interface GroupMemberRef { } export enum GroupMemberRole { + Relay = "relay", Observer = "observer", Author = "author", Member = "member", @@ -2586,10 +2588,19 @@ export interface GroupProfile { shortDescr?: string description?: string image?: string + groupLink?: string groupPreferences?: GroupPreferences memberAdmission?: GroupMemberAdmission } +export interface GroupRelay { + groupRelayId: number // int64 + groupMemberId: number // int64 + userChatRelayId: number // int64 + relayStatus: RelayStatus + relayLink?: string +} + export interface GroupShortLinkData { groupProfile: GroupProfile } @@ -3444,6 +3455,13 @@ export namespace RcvGroupEvent { } } +export enum RelayStatus { + New = "new", + Invited = "invited", + Accepted = "accepted", + Active = "active", +} + export enum ReportReason { Spam = "spam", Content = "content", @@ -3724,6 +3742,7 @@ export namespace SrvError { export type StoreError = | StoreError.DuplicateName | StoreError.UserNotFound + | StoreError.RelayUserNotFound | StoreError.UserNotFoundByName | StoreError.UserNotFoundByContactId | StoreError.UserNotFoundByGroupId @@ -3751,6 +3770,7 @@ export type StoreError = | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember + | StoreError.DuplicateMemberId | StoreError.GroupAlreadyJoined | StoreError.GroupInvitationNotFound | StoreError.NoteFolderAlreadyExists @@ -3799,6 +3819,9 @@ export type StoreError = | StoreError.ProhibitedDeleteUser | StoreError.OperatorNotFound | StoreError.UsageConditionsNotFound + | StoreError.UserChatRelayNotFound + | StoreError.GroupRelayNotFound + | StoreError.GroupRelayNotFoundByMemberId | StoreError.InvalidQuote | StoreError.InvalidMention | StoreError.InvalidDeliveryTask @@ -3811,6 +3834,7 @@ export namespace StoreError { export type Tag = | "duplicateName" | "userNotFound" + | "relayUserNotFound" | "userNotFoundByName" | "userNotFoundByContactId" | "userNotFoundByGroupId" @@ -3838,6 +3862,7 @@ export namespace StoreError { | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" + | "duplicateMemberId" | "groupAlreadyJoined" | "groupInvitationNotFound" | "noteFolderAlreadyExists" @@ -3886,6 +3911,9 @@ export namespace StoreError { | "prohibitedDeleteUser" | "operatorNotFound" | "usageConditionsNotFound" + | "userChatRelayNotFound" + | "groupRelayNotFound" + | "groupRelayNotFoundByMemberId" | "invalidQuote" | "invalidMention" | "invalidDeliveryTask" @@ -3907,6 +3935,10 @@ export namespace StoreError { userId: number // int64 } + export interface RelayUserNotFound extends Interface { + type: "relayUserNotFound" + } + export interface UserNotFoundByName extends Interface { type: "userNotFoundByName" contactName: string @@ -4037,6 +4069,10 @@ export namespace StoreError { type: "duplicateGroupMember" } + export interface DuplicateMemberId extends Interface { + type: "duplicateMemberId" + } + export interface GroupAlreadyJoined extends Interface { type: "groupAlreadyJoined" } @@ -4273,6 +4309,21 @@ export namespace StoreError { type: "usageConditionsNotFound" } + export interface UserChatRelayNotFound extends Interface { + type: "userChatRelayNotFound" + chatRelayId: number // int64 + } + + export interface GroupRelayNotFound extends Interface { + type: "groupRelayNotFound" + groupRelayId: number // int64 + } + + export interface GroupRelayNotFoundByMemberId extends Interface { + type: "groupRelayNotFoundByMemberId" + groupMemberId: number // int64 + } + export interface InvalidQuote extends Interface { type: "invalidQuote" } diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 31166762bc..8e3f039472 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."ca26c69937083deee43b8b2200ec9ef4c004ceac" = "1p7jhxcbn95kddfwa5rjpzfx78fzic03wmy9dmh1mj3j14vyfn02"; + "https://github.com/simplex-chat/simplexmq.git"."89b81d151fa0378196d923c5d7fa0aea08462136" = "033vzd7f62plb9ncf8lbdn894682phxp53ysvry9ch79mlf68yqf"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 2063a9f9fc..f9871c87fb 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -80,6 +80,7 @@ library Simplex.Chat.Store.Messages Simplex.Chat.Store.NoteFolders Simplex.Chat.Store.Profiles + Simplex.Chat.Store.RelayRequests Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared Simplex.Chat.Styled diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7074364a61..0125a75fc1 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -170,9 +170,11 @@ newChatController chatStoreChanged <- newTVarIO False deliveryTaskWorkers <- TM.emptyIO deliveryJobWorkers <- TM.emptyIO + relayRequestWorkers <- TM.emptyIO expireCIThreads <- TM.emptyIO expireCIFlags <- TM.emptyIO cleanupManagerAsync <- newTVarIO Nothing + relayGroupLinkChecksAsync <- newTVarIO Nothing timedItemThreads <- TM.emptyIO chatActivated <- newTVarIO True showLiveItems <- newTVarIO False @@ -211,9 +213,11 @@ newChatController filesFolder, deliveryTaskWorkers, deliveryJobWorkers, + relayRequestWorkers, expireCIThreads, expireCIFlags, cleanupManagerAsync, + relayGroupLinkChecksAsync, timedItemThreads, chatActivated, showLiveItems, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 96c3c2c80e..de35e4d730 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -248,9 +248,11 @@ data ChatController = ChatController filesFolder :: TVar (Maybe FilePath), -- path to files folder for mobile apps, deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker, deliveryJobWorkers :: TMap DeliveryWorkerKey Worker, + relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework expireCIThreads :: TMap UserId (Maybe (Async ())), expireCIFlags :: TMap UserId Bool, cleanupManagerAsync :: TVar (Maybe (Async ())), + relayGroupLinkChecksAsync :: TVar (Maybe (Async ())), chatActivated :: TVar Bool, timedItemThreads :: TMap (ChatRef, ChatItemId) (TVar (Maybe (Weak ThreadId))), showLiveItems :: TVar Bool, @@ -393,7 +395,7 @@ data ChatCommand | TestProtoServer AProtoServerWithAuth | GetUserChatRelays | SetUserChatRelays [CLINewRelay] - -- TODO [chat relays] commands to test chat relay + -- TODO [relays] commands to test chat relay -- | APITestChatRelay UserId ConnLinkContact -- | TestChatRelay ConnLinkContact | APIGetServerOperators @@ -465,7 +467,7 @@ data ChatCommand | APIChangeConnectionUser Int64 UserId -- new user id to switch connection to | APIConnectPlan {userId :: UserId, connectionLink :: Maybe AConnectionLink} -- Maybe is used to report link parsing failure as special error | APIPrepareContact UserId ACreatedConnLink ContactShortLinkData - | APIPrepareGroup UserId CreatedLinkContact GroupShortLinkData + | APIPrepareGroup UserId CreatedLinkContact DirectLink GroupShortLinkData | APIChangePreparedContactUser ContactId UserId | APIChangePreparedGroupUser GroupId UserId | APIConnectPreparedContact {contactId :: ContactId, incognito :: IncognitoEnabled, msgContent_ :: Maybe MsgContent} @@ -507,6 +509,9 @@ data ChatCommand | ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text} | APINewGroup {userId :: UserId, incognito :: IncognitoEnabled, groupProfile :: GroupProfile} | NewGroup IncognitoEnabled GroupProfile + -- TODO [relays] owner: TBC group link's default member role for APINewPublicGroup + | APINewPublicGroup {userId :: UserId, incognito :: IncognitoEnabled, relayIds :: NonEmpty Int64, groupProfile :: GroupProfile} + | NewPublicGroup IncognitoEnabled (NonEmpty Int64) GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} | AcceptMember GroupName ContactName GroupMemberRole @@ -681,6 +686,7 @@ data ChatResponse | CRChatHelp {helpSection :: HelpSection} | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} + | CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRGroupMembers {user :: User, group :: Group} | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} -- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]} @@ -839,6 +845,7 @@ data ChatEvent | CEvtHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CEvtReceivedGroupInvitation {user :: User, groupInfo :: GroupInfo, contact :: Contact, fromMemberRole :: GroupMemberRole, memberRole :: GroupMemberRole} | CEvtUserJoinedGroup {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember} + | CEvtGroupLinkRelaysUpdated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CEvtJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} -- there is the same command response | CEvtJoinedGroupMemberConnecting {user :: User, groupInfo :: GroupInfo, hostMember :: GroupMember, member :: GroupMember} | CEvtMemberAcceptedByOther {user :: User, groupInfo :: GroupInfo, acceptingMember :: GroupMember, member :: GroupMember} @@ -987,13 +994,15 @@ data ContactAddressPlan deriving (Show) data GroupLinkPlan - = GLPOk {groupSLinkData_ :: Maybe GroupShortLinkData} + = GLPOk {direct :: DirectLink, groupSLinkData_ :: Maybe GroupShortLinkData} | GLPOwnLink {groupInfo :: GroupInfo} | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} | GLPKnown {groupInfo :: GroupInfo} deriving (Show) +type DirectLink = Bool + connectionPlanProceed :: ConnectionPlan -> Bool connectionPlanProceed = \case CPInvitationLink ilp -> case ilp of @@ -1007,7 +1016,7 @@ connectionPlanProceed = \case CAPContactViaAddress _ -> True _ -> False CPGroupLink glp -> case glp of - GLPOk _ -> True + GLPOk _direct _ -> True GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True _ -> False diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6746239752..b7a6d8d2d3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -122,13 +122,13 @@ import UnliftIO.IO (hClose) import UnliftIO.STM #if defined(dbPostgres) import Data.Bifunctor (bimap, second) -import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +import Simplex.Messaging.Agent.Client (SubInfo (..), getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) #else import Data.Bifunctor (bimap, first, second) import qualified Data.ByteArray as BA import qualified Database.SQLite.Simple as SQL import Simplex.Chat.Archive -import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary) +import Simplex.Messaging.Agent.Client (SubInfo (..), agentClientStore, getAgentQueuesInfo, getAgentWorkersDetails, getAgentWorkersSummary, temporaryOrHostError) import Simplex.Messaging.Agent.Store.Common (withConnection) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) #endif @@ -190,8 +190,10 @@ startChatController mainApp enableSndFiles = do startXFTP xftpStartWorkers void $ forkIO $ startFilesToReceive users startDeliveryWorkers + startRelayRequestWorker_ startCleanupManager void $ forkIO $ mapM_ startExpireCIs users + startRelayChecks users else when enableSndFiles $ startXFTP xftpStartSndWorkers pure a1 startXFTP startWorkers = do @@ -203,6 +205,10 @@ startChatController mainApp enableSndFiles = do runExceptT (startDeliveryTaskWorkers >> startDeliveryJobWorkers) >>= \case Left e -> liftIO $ putStrLn $ "Error starting delivery workers: " <> show e Right _ -> pure () + startRelayRequestWorker_ = + runExceptT startRelayRequestWorker >>= \case + Left e -> liftIO $ putStrLn $ "Error starting relay request worker: " <> show e + Right _ -> pure () startCleanupManager = do cleanupAsync <- asks cleanupManagerAsync readTVarIO cleanupAsync >>= \case @@ -210,6 +216,15 @@ startChatController mainApp enableSndFiles = do a <- Just <$> async (void $ runExceptT cleanupManager) atomically $ writeTVar cleanupAsync a _ -> pure () + startRelayChecks users = do + let relayUser_ = find (\User {userChatRelay} -> isTrue userChatRelay) users + forM_ relayUser_ $ \relayUser -> do + relayAsync <- asks relayGroupLinkChecksAsync + readTVarIO relayAsync >>= \case + Nothing -> do + a <- Just <$> async (void $ runExceptT $ runRelayGroupLinkChecks relayUser) + atomically $ writeTVar relayAsync a + _ -> pure () startExpireCIs user = whenM shouldExpireChats $ do startExpireCIThread user setExpireCIFlag user True @@ -1176,8 +1191,7 @@ processChatCommand vr nm = \case pure $ CRContactConnectionDeleted user conn CTGroup | isNothing scope -> do gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user chatId - let GroupMember {memberRole = membershipMemRole} = membership - let isOwner = membershipMemRole == GROwner + let isOwner = memberRole' membership == GROwner canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo @@ -1195,9 +1209,9 @@ processChatCommand vr nm = \case withFastStore' $ \db -> deleteGroup db user gInfo pure $ CRGroupDeletedUser user gInfo where - getRecipients gInfo@GroupInfo {useRelays} - | isTrue useRelays = do - relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + getRecipients gInfo + | useRelays' gInfo = do + relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo pure (relays, relays) | otherwise = do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo @@ -1630,8 +1644,8 @@ processChatCommand vr nm = \case withAgent (\a -> toggleConnectionNtfs a connId $ chatHasNtfs chatSettings) `catchAllErrors` eToView ok user where - getMembers db gInfo@GroupInfo {useRelays} - | isTrue useRelays = getGroupRelays db vr user gInfo + getMembers db gInfo + | useRelays' gInfo = getGroupRelayMembers db vr user gInfo | otherwise = getGroupMembers db vr user gInfo _ -> throwCmdError "not supported" APISetMemberSettings gId gMemberId settings -> withUser $ \user -> do @@ -1864,7 +1878,8 @@ processChatCommand vr nm = \case let Profile {preferences} = profile groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user groupProfile True ccLink welcomeSharedMsgId + gVar <- asks random + (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing @@ -1888,11 +1903,17 @@ processChatCommand vr nm = \case Just (AChatItem SCTDirect dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTDirect chat - APIPrepareGroup userId ccLink groupSLinkData -> withUserId userId $ \user -> do + APIPrepareGroup userId ccLink direct groupSLinkData -> withUserId userId $ \user -> do let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db vr user gp False ccLink welcomeSharedMsgId + let useRelays = not direct + gVar <- asks random + (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) + -- TODO [relays] member: TBC save items as message from channel + -- TODO - hostMember to later be associated with owner profile when relays send it + -- TODO - pick any owner at random from initial introductions, find unknown member in group? + -- TODO - alternatively support not having a member in CDGroupRcv direction? let cd = CDGroupRcv gInfo Nothing hostMember cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo @@ -1964,9 +1985,65 @@ processChatCommand vr nm = \case CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup groupId incognito msgContent_ -> withUser $ \user -> do (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId - case preparedGroup gInfo of - Nothing -> throwCmdError "group doesn't have link to connect" - Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId} -> do + case gInfo of + GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" + GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do + sLnk <- case toShortLinkContact connLinkToConnect of + Just sl -> pure sl + Nothing -> throwChatError $ CEException "failed to retrieve relays: no short link" + (mainCReq@(CRContactUri crData), ContactLinkData _ UserContactData {relays}) <- getShortLinkConnReq nm user sLnk + -- Set group link info and incognito profile once before connecting to relays + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} + gInfo' <- withFastStore $ \db -> setPreparedGroupLinkInfo db vr user gInfo mainCReq cReqHash incognitoProfile + rs <- mapConcurrently (connectToRelay gInfo') relays + let relayFailed = \case (_, _, Left _) -> True; _ -> False + (failed, succeeded) = partition relayFailed rs + if null succeeded + then do + -- Updated group info (connLinkPreparedConnection) - in UI it would lock ability to change + -- user or incognito profile for group, in case server received request while client got network error + toView $ CEvtChatInfoUpdated user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) + -- Prefer throwing temporary network connection error to enable retry + case find isTempErr failed <|> listToMaybe failed of + Just (_, _, Left e) -> throwError e + _ -> throwChatError $ CEException "no relay connection results" -- shouldn't happen + else do + withFastStore' $ \db -> setPreparedGroupStartedConnection db groupId + -- Async retry failed relays with temporary errors + let retryable = [(l, m) | r@(l, m, _) <- failed, isTempErr r] + void $ mapConcurrently (uncurry $ retryRelayConnectionAsync gInfo') retryable + -- TODO [relays] member: TBC response type for UI to display state of relays connection + -- TODO - differentiate success, temporary failure, permanent failure + -- TODO - possibly, additional status on relay member record + pure $ CRStartedConnectionToGroup user gInfo' incognitoProfile + where + isTempErr = \case + (_, _, Left ChatErrorAgent {agentError = e}) -> temporaryOrHostError e + _ -> False + connectToRelay gInfo' relayLink = do + gVar <- asks random + -- TODO [relays] member: set relay profile before/during connection + -- TODO - on fetching relay link data? (-> relay should add profile to relay link) + -- TODO - or update upon connection, as in regular prepared groups + -- TODO (current logic mimics insertHost_ from createPreparedGroup) + -- Save relayLink to re-use relay member record on retry (check by relayLink) + relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo' relayLink + r <- tryAllErrors $ do + (cReq, _cData) <- getShortLinkConnReq nm user relayLink + let relayLinkToConnect = CCLink cReq (Just relayLink) + void $ connectViaContact user (Just $ PCEGroup gInfo' relayMember) incognito relayLinkToConnect Nothing Nothing + -- Re-read member to get updated activeConn + relayMember' <- withFastStore $ \db -> getGroupMember db vr user groupId (groupMemberId' relayMember) + pure (relayLink, relayMember', r) + retryRelayConnectionAsync gInfo' relayLink relayMember@GroupMember {activeConn} = do + forM_ activeConn $ \conn -> do + deleteAgentConnectionAsync $ aConnId conn + withStore' $ \db -> deleteConnectionRecord db user (dbConnId conn) + subMode <- chatReadVar subscriptionMode + newConnIds <- getAgentConnShortLinkAsync user relayLink + withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode + GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) Nothing -> do @@ -2001,6 +2078,8 @@ processChatCommand vr nm = \case CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile APIConnect _ _ Nothing -> throwChatError CEInvalidConnReq Connect incognito (Just cLink@(ACL m cLink')) -> withUser $ \user -> do + -- TODO [relays] member: /c api to support groups with relays + -- TODO - possibly by going through APIPrepareGroup -> APIConnectPreparedGroup (ccLink, plan) <- connectPlan user cLink `catchAllErrors` \e -> case cLink' of CLFull cReq -> pure (ACCL m (CCLink cReq Nothing), CPInvitationLink (ILPOk Nothing)); _ -> throwError e connectWithPlan user incognito ccLink plan Connect _ Nothing -> throwChatError CEInvalidConnReq @@ -2009,7 +2088,7 @@ processChatCommand vr nm = \case ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do - (cReq, _cData) <- getShortLinkConnReq user sLnk + (cReq, _cData) <- getShortLinkConnReq nm user sLnk pure $ CCLink cReq $ Just sLnk Nothing -> throwCmdError "no address in contact profile" connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do @@ -2033,9 +2112,9 @@ processChatCommand vr nm = \case Left e -> throwError $ ChatErrorStore e Right _ -> throwError $ ChatErrorStore SEDuplicateContactLink subMode <- chatReadVar subscriptionMode - -- TODO [chat relays] relay address creation: - -- TODO - add relay key, identity to link data - -- TODO - validate short link is created (returned by agent) + -- TODO [relays] relay: address creation + -- TODO - add relay key, identity to link data + -- TODO - validate short link is created (returned by agent) let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} -- TODO [certs rcv] @@ -2230,19 +2309,53 @@ processChatCommand vr nm = \case chatRef <- getChatRef user chatName chatItemId <- getChatItemIdByText user chatRef msg processChatCommand vr nm $ APIChatItemReaction chatRef chatItemId add reaction - APINewGroup userId incognito gProfile@GroupProfile {displayName} -> withUserId userId $ \user -> do - checkValidName displayName - gVar <- asks random - -- [incognito] generate incognito profile for group membership - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - gInfo <- withFastStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile - let cd = CDGroupSnd gInfo Nothing - createInternalChatItem user cd CIChatBanner (Just epochStart) - createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing - createGroupFeatureItems user cd CISndGroupFeature gInfo + APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do + gInfo <- newGroup user incognito gProfile False pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand vr nm $ APINewGroup userId incognito gProfile + APINewPublicGroup userId incognito relayIds gProfile -> withUserId userId $ \user -> do + gInfo <- newGroup user incognito gProfile True + (gInfo', gLink, groupRelays) <- setupLink user gInfo `catchAllErrors` \e -> do + deleteInProgressGroup user gInfo + throwError e + pure $ CRPublicGroupCreated user gInfo' gLink groupRelays + where + setupLink :: User -> GroupInfo -> CM (GroupInfo, GroupLink, [GroupRelay]) + setupLink user gInfo = do + (gInfo', gLink, sLnk) <- newGroupLink user gInfo + relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) + groupRelays <- addRelays user gInfo' sLnk relays + pure (gInfo', gLink, groupRelays) + newGroupLink :: User -> GroupInfo -> CM (GroupInfo, GroupLink, ShortLinkContact) + newGroupLink user gInfo@GroupInfo {groupProfile} = do + groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + subMode <- chatReadVar subscriptionMode + -- TODO [relays] owner: prepare group link without initially creating on server + -- TODO - add link and owner key to group profile, sign profile + -- TODO - create group link on server with signed profile as data + -- / link creation + let userData = encodeShortLinkData $ GroupShortLinkData groupProfile + userLinkData = UserContactLinkData UserContactData {direct = False, owners = [], relays = [], userData} + crClientData = encodeJSON $ CRDataGroup groupLinkId + (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) IKPQOff subMode + ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink + sLnk <- case toShortLinkContact ccLink' of + Just sl -> pure sl + Nothing -> throwChatError $ CEException "failed to create relayed group link: no short link" + let groupProfile' = (groupProfile :: GroupProfile) {groupLink = Just sLnk} + userData' = encodeShortLinkData $ GroupShortLinkData groupProfile' + userLinkData' = UserContactLinkData UserContactData {direct = False, owners = [], relays = [], userData = userData'} + void $ withAgent (\a -> setConnShortLink a nm connId SCMContact userLinkData' (Just crClientData)) + -- link creation / + gVar <- asks random + (gInfo', gLink) <- withFastStore $ \db -> do + gLink <- createGroupLink db gVar user gInfo connId ccLink' groupLinkId GRMember subMode + gInfo' <- updateGroupProfile db user gInfo groupProfile' + pure (gInfo', gLink) + pure (gInfo', gLink, sLnk) + NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> + processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId @@ -2603,9 +2716,9 @@ processChatCommand vr nm = \case withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} where - getRecipients user gInfo@GroupInfo {useRelays} - | isTrue useRelays = do - relays <- withFastStore' $ \db -> getGroupRelays db vr user gInfo + getRecipients user gInfo + | useRelays' gInfo = do + relays <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo pure (relays, relays) | otherwise = do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo @@ -3167,15 +3280,15 @@ processChatCommand vr nm = \case when (isJust msg_ && isJust groupLinkId) $ throwChatError CEConnReqMessageProhibited case preparedEntity_ of Just (PCEContact ct@Contact {activeConn}) -> case activeConn of - Nothing -> connect' Nothing Nothing + Nothing -> connect' Nothing Nothing Nothing Just conn@Connection {connStatus, xContactId} -> case connStatus of ConnPrepared -> joinPreparedConn' xContactId conn Nothing _ -> pure $ CVRConnectedContact ct Just (PCEGroup gInfo GroupMember {activeConn}) -> case activeConn of - Nothing -> connect' groupLinkId Nothing + Nothing -> connect' groupLinkId Nothing (Just $ Just gInfo) Just conn@Connection {connStatus, xContactId} -> case connStatus of - ConnPrepared -> joinPreparedConn' xContactId conn $ Just (Just gInfo) - _ -> connect' groupLinkId xContactId -- why not "already connected" for host member? + ConnPrepared -> joinPreparedConn' xContactId conn (Just $ Just gInfo) + _ -> connect' groupLinkId xContactId (Just $ Just gInfo) -- why not "already connected" for host member? Nothing -> withFastStore' (\db -> getConnReqContactXContactId db vr user cReqHash1 cReqHash2) >>= \case Right ct@Contact {activeConn} -> case groupLinkId of @@ -3185,35 +3298,38 @@ processChatCommand vr nm = \case Just gLinkId -> -- allow repeat contact request -- TODO [short links] is this branch needed? it probably remained from the time we created host contact - connect' (Just gLinkId) Nothing + connect' (Just gLinkId) Nothing (Just Nothing) Left conn_ -> case conn_ of - Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn $ groupLinkId $> Nothing + Just conn@Connection {connStatus = ConnPrepared, xContactId} -> joinPreparedConn' xContactId conn (groupLinkId $> Nothing) -- TODO [short links] this is executed on repeat request after success -- it probably should send the second message without creating the second connection? - Just Connection {xContactId} -> connect' groupLinkId xContactId - Nothing -> connect' groupLinkId Nothing + Just Connection {xContactId} -> connect' groupLinkId xContactId (groupLinkId $> Nothing) + Nothing -> connect' groupLinkId Nothing (groupLinkId $> Nothing) where - cReqHash = ConnReqUriHash . C.sha256Hash . strEncode - cReqHash1 = cReqHash $ CRContactUri crData {crScheme = SSSimplex} - cReqHash2 = cReqHash $ CRContactUri crData {crScheme = simplexChat} + cReqHash1 = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} + cReqHash2 = contactCReqHash $ CRContactUri crData {crScheme = simplexChat} joinPreparedConn' xContactId_ conn@Connection {customUserProfileId} gInfo_ = do when (incognito /= isJust customUserProfileId) $ throwCmdError "incognito mode is different from prepared connection" + -- TODO [relays] member: refactor joinContact and up avoiding parallel ifs, xContactId is not used xContactId <- mkXContactId xContactId_ localIncognitoProfile <- forM customUserProfileId $ \pId -> withFastStore $ \db -> getProfileById db userId pId let incognitoProfile = fromLocalProfile <$> localIncognitoProfile conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ PQSupportOn pure $ CVRSentInvitation conn' incognitoProfile - connect' groupLinkId xContactId_ = do + connect' groupLinkId xContactId_ gInfo_ = do let inGroup = isJust groupLinkId pqSup = if inGroup then PQSupportOff else PQSupportOn (connId, chatV) <- prepareContact user cReq pqSup xContactId <- mkXContactId xContactId_ - -- [incognito] generate profile to send - incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + -- [incognito] generate profile to send, or use membership profile for relay groups + incognitoProfile_ <- case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> pure $ ExistingIncognito <$> incognitoMembershipProfile gInfo + _ -> if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + let incognitoProfile = fromIncognitoProfile <$> incognitoProfile_ subMode <- chatReadVar subscriptionMode let sLnk' = serverShortLink <$> sLnk - conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile groupLinkId subMode chatV pqSup - conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ (groupLinkId $> Nothing) pqSup + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId preparedEntity_ cReq cReqHash1 sLnk' xContactId incognitoProfile_ groupLinkId subMode chatV pqSup + conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ pqSup pure $ CVRSentInvitation conn' incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = @@ -3227,7 +3343,7 @@ processChatCommand vr nm = \case incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode let cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq - conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId incognitoProfile Nothing subMode chatV pqSup + conn <- withFastStore' $ \db -> createConnReqConnection db userId connId (Just $ PCEContact ct) cReq cReqHash shortLink newXContactId (NewIncognito <$> incognitoProfile) Nothing subMode chatV pqSup void $ joinContact user conn cReq incognitoProfile newXContactId Nothing Nothing Nothing pqSup ct' <- withStore $ \db -> getContact db vr user contactId pure $ CRSentInvitationToContact user ct' incognitoProfile @@ -3262,7 +3378,12 @@ processChatCommand vr nm = \case let allowSimplexLinks = maybe True (groupFeatureUserAllowed SGFSimplexLinks) gInfo_' in userProfileInGroup' user allowSimplexLinks incognitoProfile Nothing -> userProfileDirect user incognitoProfile Nothing True - dm <- encodeConnInfoPQ pqSup chatV (XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_) + chatEvent = case gInfo_ of + Just (Just gInfo) | useRelays' gInfo -> + let GroupInfo {membership = GroupMember {memberId}} = gInfo + in XMember profileToSend memberId + _ -> XContact profileToSend (Just xContactId) welcomeSharedMsgId msg_ + dm <- encodeConnInfoPQ pqSup chatV chatEvent subMode <- chatReadVar subscriptionMode void $ withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm pqSup subMode withFastStore' $ \db -> updateConnectionStatusFromTo db conn ConnPrepared ConnJoined @@ -3382,12 +3503,12 @@ processChatCommand vr nm = \case recipients = filter memberCurrentOrPending newMs sendGroupMessage user gInfo' Nothing recipients $ XGrpPrefs ps' Nothing -> do - setGroupLinkData' nm user gInfo' + void $ setGroupLinkData' nm user gInfo' recipients <- getRecipients sendGroupMessage user gInfo' Nothing recipients (XGrpInfo p') where getRecipients - | isTrue (useRelays gInfo') = withFastStore' $ \db -> getGroupRelays db vr user gInfo' + | useRelays' gInfo' = withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo' | otherwise = do ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo' pure $ filter memberCurrentOrPending ms @@ -3404,8 +3525,7 @@ processChatCommand vr nm = \case when (displayName /= validName) $ throwChatError CEInvalidDisplayName {displayName, validName} assertUserGroupRole :: GroupInfo -> GroupMemberRole -> CM () assertUserGroupRole g@GroupInfo {membership} requiredRole = do - let GroupMember {memberRole = membershipMemRole} = membership - when (membershipMemRole < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole + when (memberRole' membership < requiredRole) $ throwChatError $ CEGroupUserRole g requiredRole when (memberStatus membership == GSMemInvited) $ throwChatError (CEGroupNotJoined g) when (memberRemoved membership) $ throwChatError CEGroupMemberUserRemoved unless (memberActive membership) $ throwChatError CEGroupMemberNotActive @@ -3419,13 +3539,13 @@ processChatCommand vr nm = \case delGroupChatItems user gInfo chatScopeInfo items True where assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM () - assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items' = + assertDeletable GroupInfo {membership} items' = unless (all itemDeletable items') $ throwChatError CEInvalidChatItemDelete where itemDeletable :: CChatItem 'CTGroup -> Bool itemDeletable (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = case chatDir of - CIGroupRcv GroupMember {memberRole} -> membershipMemRole >= memberRole && isJust itemSharedMsgId + CIGroupRcv GroupMember {memberRole} -> memberRole' membership >= memberRole && isJust itemSharedMsgId CIGroupSnd -> isJust itemSharedMsgId itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds @@ -3504,6 +3624,18 @@ processChatCommand vr nm = \case groupId <- getGroupIdByName db user gName groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) + newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> CM GroupInfo + newGroup user incognito gProfile@GroupProfile {displayName} useRelays = do + checkValidName displayName + gVar <- asks random + -- [incognito] generate incognito profile for group membership + incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing + gInfo <- withFastStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile useRelays + let cd = CDGroupSnd gInfo Nothing + createInternalChatItem user cd CIChatBanner (Just epochStart) + createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + createGroupFeatureItems user cd CISndGroupFeature gInfo + pure gInfo sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -3525,8 +3657,44 @@ processChatCommand vr nm = \case toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct) ci] forM_ (timed_ >>= timedDeleteAt') $ startProximateTimedItemThread user (ChatRef CTDirect contactId Nothing, chatItemId' ci) - drgRandomBytes :: Int -> CM ByteString - drgRandomBytes n = asks random >>= atomically . C.randomBytes n + addRelays :: User -> GroupInfo -> ShortLinkContact -> [UserChatRelay] -> CM [GroupRelay] + addRelays user gInfo@GroupInfo {membership} groupSLink relays = + mapConcurrently addRelay relays + where + addRelay :: UserChatRelay -> CM GroupRelay + addRelay relay@UserChatRelay {address} = do + -- TODO [relays] owner: track and reuse relay profiles + -- TODO - single profile linked to relay configuration record (chat_relays) + -- TODO - update when fetching link data from relay address + (cReq, _cData) <- getShortLinkConnReq nm user address + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + gVar <- asks random + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + (relayMember, conn, groupRelay) <- withFastStore $ \db -> do + relayMember <- createRelayForOwner db vr gVar user gInfo relay + groupRelay <- createGroupRelayRecord db gInfo relayMember relay + conn <- createRelayConnection db vr user (groupMemberId' relayMember) connId ConnPrepared chatV subMode + pure (relayMember, conn, groupRelay) + let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo + membershipProfile = redactedMemberProfile allowSimplexLinks $ fromLocalProfile $ memberProfile membership + GroupMember {memberId = relayMemberId} = relayMember + relayInv = GroupRelayInvitation { + fromMember = MemberIdRole userMemberId userRole, + fromMemberProfile = membershipProfile, + relayMemberId, + groupLink = groupSLink + } + dm <- encodeConnInfo $ XGrpRelayInv relayInv + (sqSecured, _serviceId) <- withAgent $ \a -> joinConnection a nm (aUserId user) (aConnId conn) True cReq dm PQSupportOff subMode + let newConnStatus = if sqSecured then ConnSndReady else ConnJoined + withFastStore' $ \db -> do + void $ updateConnectionStatusFromTo db conn ConnPrepared newConnStatus + updateRelayStatusFromTo db groupRelay RSNew RSInvited privateGetUser :: UserId -> CM User privateGetUser userId = tryAllErrors (withStore (`getUser` userId)) >>= \case @@ -3598,8 +3766,8 @@ processChatCommand vr nm = \case knownLinkPlans l' >>= \case Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq user l' - contactSLinkData_ <- liftIO $ decodeShortLinkData cData + (cReq, cData) <- getShortLinkConnReq nm user l' + contactSLinkData_ <- liftIO $ decodeLinkUserData cData invitationReqAndPlan cReq (Just l') contactSLinkData_ where knownLinkPlans l' = withFastStore $ \db -> do @@ -3624,11 +3792,11 @@ processChatCommand vr nm = \case knownLinkPlans >>= \case Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq user l' + (cReq, cData) <- getShortLinkConnReq nm user l' withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do - contactSLinkData_ <- liftIO $ decodeShortLinkData cData + contactSLinkData_ <- liftIO $ decodeLinkUserData cData plan <- contactRequestPlan user cReq contactSLinkData_ pure (con cReq, plan) where @@ -3643,9 +3811,9 @@ processChatCommand vr nm = \case knownLinkPlans >>= \case Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq user l' - groupSLinkData_ <- liftIO $ decodeShortLinkData cData - plan <- groupJoinRequestPlan user cReq groupSLinkData_ + (cReq, cData@(ContactLinkData _ UserContactData {direct})) <- getShortLinkConnReq nm user l' + groupSLinkData_ <- liftIO $ decodeLinkUserData cData + plan <- groupJoinRequestPlan user cReq direct groupSLinkData_ pure (con cReq, plan) where knownLinkPlans = withFastStore $ \db -> @@ -3690,7 +3858,7 @@ processChatCommand vr nm = \case groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli case groupLinkId of Nothing -> contactRequestPlan user cReq Nothing - Just _ -> groupJoinRequestPlan user cReq Nothing + Just _ -> groupJoinRequestPlan user cReq True Nothing contactRequestPlan :: User -> ConnReqContact -> Maybe ContactShortLinkData -> CM ConnectionPlan contactRequestPlan user (CRContactUri crData) contactSLinkData_ = do let cReqSchemas = contactCReqSchemas crData @@ -3711,10 +3879,10 @@ processChatCommand vr nm = \case | contactDeleted ct -> pure $ CPContactAddress (CAPOk contactSLinkData_) | otherwise -> pure $ CPContactAddress (CAPKnown ct) -- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?) - Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing + Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo True Nothing Just _ -> throwCmdError "found connection entity is not RcvDirectMsgConnection or RcvGroupMsgConnection" - groupJoinRequestPlan :: User -> ConnReqContact -> Maybe GroupShortLinkData -> CM ConnectionPlan - groupJoinRequestPlan user (CRContactUri crData) groupSLinkData_ = do + groupJoinRequestPlan :: User -> ConnReqContact -> Bool -> Maybe GroupShortLinkData -> CM ConnectionPlan + groupJoinRequestPlan user (CRContactUri crData) direct groupSLinkData_ = do let cReqSchemas = contactCReqSchemas crData cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case @@ -3723,43 +3891,33 @@ processChatCommand vr nm = \case connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db vr user cReqHashes gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db vr user cReqHashes case (gInfo_, connEnt_) of - (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk groupSLinkData_) + (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk direct groupSLinkData_) -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) - | otherwise -> pure $ CPGroupLink (GLPOk groupSLinkData_) + | otherwise -> pure $ CPGroupLink (GLPOk direct groupSLinkData_) (Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection" - (Just gInfo, _) -> groupPlan gInfo groupSLinkData_ - groupPlan :: GroupInfo -> Maybe GroupShortLinkData -> CM ConnectionPlan - groupPlan gInfo@GroupInfo {membership} groupSLinkData_ + (Just gInfo, _) -> groupPlan gInfo direct groupSLinkData_ + groupPlan :: GroupInfo -> Bool -> Maybe GroupShortLinkData -> CM ConnectionPlan + groupPlan gInfo@GroupInfo {membership} direct groupSLinkData_ | memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo) | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo) - | otherwise = pure $ CPGroupLink (GLPOk groupSLinkData_) + | otherwise = pure $ CPGroupLink (GLPOk direct groupSLinkData_) contactCReqSchemas :: ConnReqUriData -> (ConnReqContact, ConnReqContact) contactCReqSchemas crData = ( CRContactUri crData {crScheme = SSSimplex}, CRContactUri crData {crScheme = simplexChat} ) - contactCReqHash :: ConnReqContact -> ConnReqUriHash - contactCReqHash = ConnReqUriHash . C.sha256Hash . strEncode - getShortLinkConnReq :: User -> ConnShortLink m -> CM (ConnectionRequestUri m, ConnLinkData m) - getShortLinkConnReq user l = do - l' <- restoreShortLink' l - (cReq, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l' - case cData of - ContactLinkData _ UserContactData {direct} | not direct -> throwChatError CEUnsupportedConnReq - _ -> pure () - pure (cReq, cData) + -- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser, -- and short links are stored with server hostname schema, so they wouldn't match without it. serverShortLink :: ConnShortLink m -> ConnShortLink m serverShortLink = \case CSLInvitation _ srv lnkId linkKey -> CSLInvitation SLSServer srv lnkId linkKey CSLContact _ ct srv linkKey -> CSLContact SLSServer ct srv linkKey - restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) contactShortLinkData :: Profile -> Maybe AddressSettings -> UserLinkData contactShortLinkData p settings = let msg = autoReply =<< settings @@ -4246,6 +4404,8 @@ cleanupManager = do -- TODO remove in future versions: legacy step - contacts are no longer marked as deleted cleanupDeletedContacts user `catchAllErrors` eToView liftIO $ threadDelay' stepDelay + cleanupInProgressGroups user `catchAllErrors` eToView + liftIO $ threadDelay' stepDelay cleanupTimedItems cleanupInterval user = do ts <- liftIO getCurrentTime let startTimedThreadCutoff = addUTCTime cleanupInterval ts @@ -4257,6 +4417,14 @@ cleanupManager = do forM_ contacts $ \ct -> withStore (\db -> deleteContactWithoutGroups db user ct) `catchAllErrors` eToView + cleanupInProgressGroups user = do + vr <- chatVersionRange + ts <- liftIO getCurrentTime + -- older than 30 minutes to avoid deleting a newly created group + let cutoffTs = addUTCTime (- 1800) ts + inProgressGroups <- withStore' $ \db -> getInProgressGroups db vr user cutoffTs + forM_ inProgressGroups $ \gInfo -> + deleteInProgressGroup user gInfo `catchAllErrors` eToView cleanupMessages = do ts <- liftIO getCurrentTime let cutoffTs = addUTCTime (-(30 * nominalDay)) ts @@ -4274,6 +4442,20 @@ cleanupManager = do let cutoffTs = addUTCTime (-(14 * nominalDay)) ts withStore' (`deleteOldProbes` cutoffTs) +deleteInProgressGroup :: User -> GroupInfo -> CM () +deleteInProgressGroup user gInfo = do + deleteGroupLinkIfExists user gInfo + deleteGroupConnections user gInfo False + withFastStore' $ \db -> deleteGroup db user gInfo + +runRelayGroupLinkChecks :: User -> CM () +runRelayGroupLinkChecks _user = do + -- TODO [relays] relay: periodically check presence of relay link in group links of served groups + -- TODO - retrieve group link data + -- TODO - if relay link is present, update relay status to RSActive + -- TODO - if relay link is absent and status was RSActive -> update to new "Removed" status? + pure () + expireChatItems :: User -> Int64 -> Bool -> CM () expireChatItems user@User {userId} globalTTL sync = do currentTs <- liftIO getCurrentTime @@ -4557,6 +4739,8 @@ chatCommandP = ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), + ("/public group" <|> "/pg") *> (NewPublicGroup <$> incognitoP <* " relays=" <*> strP <* A.space <* char_ '#' <*> groupProfile), + "/_public group " *> (APINewPublicGroup <$> A.decimal <*> incognitoOnOffP <*> _strP <* A.space <*> jsonP), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), "/accept member " *> char_ '#' *> (AcceptMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), @@ -4599,7 +4783,7 @@ chatCommandP = "/contacts" $> ListContacts, "/_connect plan " *> (APIConnectPlan <$> A.decimal <* A.space <*> ((Just <$> strP) <|> A.takeTill (== ' ') $> Nothing)), "/_prepare contact " *> (APIPrepareContact <$> A.decimal <* A.space <*> connLinkP <* A.space <*> jsonP), - "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <* A.space <*> jsonP), + "/_prepare group " *> (APIPrepareGroup <$> A.decimal <* A.space <*> connLinkP' <*> (" direct=" *> onOffP <|> pure True) <* A.space <*> jsonP), "/_set contact user @" *> (APIChangePreparedContactUser <$> A.decimal <* A.space <*> A.decimal), "/_set group user #" *> (APIChangePreparedGroupUser <$> A.decimal <* A.space <*> A.decimal), "/_connect contact @" *> (APIConnectPreparedContact <$> A.decimal <*> incognitoOnOffP <*> optional (A.space *> msgContentP)), @@ -4818,7 +5002,7 @@ chatCommandP = { directMessages = Just DirectMessagesGroupPreference {enable = FEOn, role = Nothing}, history = Just HistoryGroupPreference {enable = FEOn} } - pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, groupPreferences, memberAdmission = Nothing} + pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, groupLink = Nothing, groupPreferences, memberAdmission = Nothing} memberCriteriaP = ("all" $> Just MCAll) <|> ("off" $> Nothing) shortDescrP = do descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure "" diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 14d5c711dc..04f13b3a53 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -90,6 +90,7 @@ import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..)) import Simplex.Messaging.Compression (compressionLevel) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) @@ -550,7 +551,6 @@ markGroupCIsDeleted user gInfo chatScopeInfo items byGroupMember_ deletedTs = do (errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (markDeleted db) items) unless (null errs) $ toView $ CEvtChatErrors errs pure deletions - -- pure $ CRChatItemsDeleted user deletions byUser False where markDeleted db (CChatItem md ci) = do ci' <- markGroupChatItemDeleted db user gInfo ci byGroupMember_ deletedTs @@ -910,7 +910,7 @@ acceptContactRequestAsync liftIO $ setCommandConnId db user cmdId connId getContact db vr user contactId -acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember +acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> CM GroupMember acceptGroupJoinRequestAsync user uclId @@ -919,6 +919,7 @@ acceptGroupJoinRequestAsync cReqChatVRange cReqProfile cReqXContactId_ + cReqMemberId_ welcomeMsgId_ gAccepted gLinkMemRole @@ -928,7 +929,7 @@ acceptGroupJoinRequestAsync ((groupMemberId, memberId), currentMemCount) <- withStore $ \db -> liftM2 (,) - (createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus) + (createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ cReqMemberId_ welcomeMsgId_ gLinkMemRole initialStatus) (liftIO $ getGroupCurrentMembersCount db user gInfo) let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership @@ -963,7 +964,7 @@ acceptGroupJoinSendRejectAsync rejectionReason = do gVar <- asks random (groupMemberId, memberId) <- withStore $ \db -> - createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing GRObserver GSMemRejected + createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ Nothing Nothing GRObserver GSMemRejected let GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = XGrpLinkReject $ @@ -1019,9 +1020,29 @@ acceptBusinessJoinRequestAsync -- TODO [short links] get updated business chat group and member? (currently not used) pure (gInfo, clientMember) +acceptRelayJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> InvitationId -> VersionRangeChat -> ShortLinkContact -> CM (GroupInfo, GroupMember) +acceptRelayJoinRequestAsync + user + uclId + gInfo + _ownerMember@GroupMember {groupMemberId} + cReqInvId + cReqChatVRange + relayLink = do + let msg = XGrpRelayAcpt relayLink + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` cReqChatVRange + connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV + withStore $ \db -> do + liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode + gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted + ownerMember' <- getGroupMemberById db vr user groupMemberId + pure (gInfo', ownerMember') + businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = - GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, groupPreferences = Just groupPreferences, memberAdmission = Nothing} + GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, groupLink = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} introduceToModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceToModerators vr user gInfo@GroupInfo {groupId} m@GroupMember {memberRole, memberId} = do @@ -1071,11 +1092,11 @@ introduceMember user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn shuffledReMembers <- liftIO $ shuffleMembers reMembers if toMember `supportsVersion` batchSendVersion then do - let events = map memberIntro shuffledReMembers + let events = map (memberIntroEvt gInfo) shuffledReMembers forM_ (L.nonEmpty events) $ \events' -> sendGroupMemberMessages user conn events' groupId else forM_ shuffledReMembers $ \reMember -> - void $ sendDirectMemberMessage conn (memberIntro reMember) groupId + void $ sendDirectMemberMessage conn (memberIntroEvt gInfo reMember) groupId updateToMemberVector :: [GroupMember] -> CM () updateToMemberVector reMembers = do let relations = map (\GroupMember {indexInGroup} -> (indexInGroup, (IDReferencedIntroduced, MRIntroduced))) reMembers @@ -1084,11 +1105,6 @@ introduceMember user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn updateReMembersVectors reMembers = do let GroupMember {indexInGroup} = toMember withStore' $ \db -> setMembersVectorsNewRelation db reMembers indexInGroup IDSubjectIntroduced MRIntroduced - memberIntro :: GroupMember -> ChatMsgEvent 'Json - memberIntro reMember = - let mInfo = memberInfo gInfo reMember - mRestrictions = memberRestrictions reMember - in XGrpMemIntro mInfo mRestrictions shuffleMembers :: [GroupMember] -> IO [GroupMember] shuffleMembers reMembers = do let (admins, others) = partition isAdmin reMembers @@ -1099,6 +1115,26 @@ introduceMember user gInfo@GroupInfo {groupId} toMember@GroupMember {activeConn isAdmin GroupMember {memberRole} = memberRole >= GRAdmin hasPicture GroupMember {memberProfile = LocalProfile {image}} = isJust image +memberIntroEvt :: GroupInfo -> GroupMember -> ChatMsgEvent 'Json +memberIntroEvt gInfo reMember = + let mInfo = memberInfo gInfo reMember + mRestrictions = memberRestrictions reMember + in XGrpMemIntro mInfo mRestrictions + +-- Used in groups with relays to introduce moderators and above to a new member. +-- Member is not introduced to anybody: +-- - in channels member will be prohibited to send, so it doesn't matter; +-- - if member does send, recipients will create unknown member record; +-- - later - to do member profile request protocol. +-- This doesn't create introduction records in db, compared to above methods. +introduceModerators :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () +introduceModerators _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" +introduceModerators vr user gInfo@GroupInfo {groupId} GroupMember {activeConn = Just conn} = do + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + let events = map (memberIntroEvt gInfo) modMs + forM_ (L.nonEmpty events) $ \events' -> + sendGroupMemberMessages user conn events' groupId + userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks {-# INLINE userProfileInGroup #-} @@ -1235,23 +1271,55 @@ splitFileDescr partSize rfdText = splitParts 1 rfdText then fileDescr :| [] else fileDescr <| splitParts (partNo + 1) rest -setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM () +setGroupLinkData' :: NetworkRequestMode -> User -> GroupInfo -> CM (Maybe GroupLink) setGroupLinkData' nm user gInfo = withFastStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case Right gLink@GroupLink {shortLinkDataSet} - | shortLinkDataSet -> void $ setGroupLinkData nm user gInfo gLink - _ -> pure () + | shortLinkDataSet -> Just <$> setGroupLinkData nm user gInfo gLink + _ -> pure Nothing setGroupLinkData :: NetworkRequestMode -> User -> GroupInfo -> GroupLink -> CM GroupLink -setGroupLinkData nm user gInfo@GroupInfo {groupProfile} gLink@GroupLink {groupLinkId} = do +setGroupLinkData nm user gInfo gLink = do vr <- chatVersionRange - conn <- withFastStore $ \db -> getGroupLinkConnection db vr user gInfo - let userData = encodeShortLinkData $ GroupShortLinkData groupProfile - userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} - crClientData = encodeJSON $ CRDataGroup groupLinkId + (conn, groupRelays) <- withFastStore $ \db -> + (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getGroupRelays db gInfo) + let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays sLnk <- shortenShortLink' . toShortGroupLink =<< withAgent (\a -> setConnShortLink a nm (aConnId conn) SCMContact userLinkData (Just crClientData)) withFastStore' $ \db -> setGroupLinkShortLink db gLink sLnk +setGroupLinkDataAsync :: User -> GroupInfo -> GroupLink -> CM () +setGroupLinkDataAsync user gInfo gLink = do + vr <- chatVersionRange + (conn, groupRelays) <- withStore $ \db -> + (,) <$> getGroupLinkConnection db vr user gInfo <*> liftIO (getGroupRelays db gInfo) + let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays + setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) + +-- TODO [relays] owner: set owners on updating link data +groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) +groupLinkData gInfo@GroupInfo {groupProfile} GroupLink {groupLinkId} groupRelays = + let direct = not $ useRelays' gInfo + relays = mapMaybe relayLink groupRelays + userData = encodeShortLinkData $ GroupShortLinkData groupProfile + userLinkData = UserContactLinkData UserContactData {direct, owners = [], relays, userData} + crClientData = encodeJSON $ CRDataGroup groupLinkId + in (userLinkData, crClientData) + +restoreShortLink' :: ConnShortLink m -> CM (ConnShortLink m) +restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) + +getShortLinkConnReq :: NetworkRequestMode -> User -> ConnShortLink m -> CM (ConnectionRequestUri m, ConnLinkData m) +getShortLinkConnReq nm user@User {userChatRelay} l = do + l' <- restoreShortLink' l + (fd, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l' + case cData of + ContactLinkData _ UserContactData {direct, relays} + | not supported -> throwChatError CEUnsupportedConnReq + where + supported = direct || not (null relays) || isTrue userChatRelay + _ -> pure () + pure (linkConnReq fd, cData) + encodeShortLinkData :: J.ToJSON a => a -> UserLinkData encodeShortLinkData d = let s = LB.toStrict $ J.encode d @@ -1260,10 +1328,10 @@ encodeShortLinkData d = s' | B.length s > 10240 = B.cons 'X' $ Z1.compress compressionLevel s | otherwise = s - in UserLinkData s' + in UserLinkData s' -decodeShortLinkData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) -decodeShortLinkData cData +decodeLinkUserData :: J.FromJSON a => ConnLinkData c -> IO (Maybe a) +decodeLinkUserData cData | B.null s = pure Nothing | B.head s == 'X' = case Z1.decompress $ B.drop 1 s of Z1.Error e -> Nothing <$ logError ("Error decompressing link data: " <> tshow e) @@ -1294,6 +1362,9 @@ createdRelayLink (CCLink cReq shortLink) = CCLink cReq (toShortRelayLink <$> sho toShortRelayLink :: ShortLinkContact -> ShortLinkContact toShortRelayLink (CSLContact sch _ srv k) = CSLContact sch CCTRelay srv k +toShortLinkContact :: CreatedLinkContact -> Maybe ShortLinkContact +toShortLinkContact (CCLink _cReq sLink) = sLink + deleteGroupLink' :: User -> GroupInfo -> CM () deleteGroupLink' user gInfo = do vr <- chatVersionRange @@ -1472,10 +1543,10 @@ getChatScopeInfo vr user = \case pure $ GCSIMemberSupport (Just supportMem) getGroupRecipients :: VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> VersionChat -> CM [GroupMember] -getGroupRecipients vr user gInfo@GroupInfo {useRelays, membership} scopeInfo modsCompatVersion - | isTrue useRelays && not (isMemberRelay membership) = do +getGroupRecipients vr user gInfo@GroupInfo {membership} scopeInfo modsCompatVersion + | useRelays' gInfo && not (isRelay membership) = do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" - withFastStore' $ \db -> getGroupRelays db vr user gInfo + withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo | otherwise = case scopeInfo of Nothing -> do unless (memberCurrent membership && memberActive membership) $ throwChatError $ CECommandError "not current member" @@ -2026,14 +2097,14 @@ sendGroupMessages_ _user gInfo@GroupInfo {groupId} recipientMembers events = do data MemberSendAction = MSASend Connection | MSASendBatched Connection | MSAPending | MSAForwarded memberSendAction :: GroupInfo -> NonEmpty (ChatMsgEvent e) -> [GroupMember] -> GroupMember -> Maybe MemberSendAction -memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember {memberRole, memberStatus} +memberSendAction gInfo@GroupInfo {membership} events members m@GroupMember {memberRole, memberStatus} -- groups with relays require newer version - we don't need to check member version for batching and forwarding support - | isTrue useRelays = + | useRelays' gInfo = if -- if user is chat relay, send to all non chat relay members - | isMemberRelay membership && not (isMemberRelay m) -> MSASendBatched . snd <$> readyMemberConn m + | isRelay membership && not (isRelay m) -> MSASendBatched . snd <$> readyMemberConn m -- if user is not chat relay, send only to chat relays - | not (isMemberRelay membership) && isMemberRelay m -> MSASendBatched . snd <$> readyMemberConn m + | not (isRelay membership) && isRelay m -> MSASendBatched . snd <$> readyMemberConn m | otherwise -> Nothing -- TODO [channels fwd] MSAForwarded to create GSSForwarded snd statuses? | otherwise = case memberConn m of Nothing -> pendingOrForwarded @@ -2077,9 +2148,9 @@ memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember readyMemberConn :: GroupMember -> Maybe (GroupMemberId, Connection) readyMemberConn GroupMember {groupMemberId, activeConn = Just conn@Connection {connStatus}, memberStatus} | (connStatus == ConnReady || connStatus == ConnSndReady) - && not (connDisabled conn) - && not (connInactive conn) - && memberStatus /= GSMemRejected = + && not (connDisabled conn) + && not (connInactive conn) + && memberStatus /= GSMemRejected = Just (groupMemberId, conn) | otherwise = Nothing readyMemberConn GroupMember {activeConn = Nothing} = Nothing @@ -2134,7 +2205,7 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta pure (am', conn', msg) saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage) -saveGroupFwdRcvMsg user GroupInfo {groupId, useRelays} forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do +saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do let newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} fwdMemberId = Just $ groupMemberId' forwardingMember refAuthorId = Just $ groupMemberId' refAuthorMember @@ -2142,7 +2213,7 @@ saveGroupFwdRcvMsg user GroupInfo {groupId, useRelays} forwardingMember refAutho withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case Right msg -> pure $ Just msg Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} - | isTrue useRelays -> pure Nothing -- with chat relays, duplicates are expected + | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of (Just authorGMId, Nothing) -> do vr <- chatVersionRange @@ -2189,7 +2260,7 @@ saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime vr <- chatVersionRange when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - void $ withStore' (\db -> updateChatTsStats db vr user cd createdAt Nothing) + void (withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing) lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) @@ -2225,9 +2296,10 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) - cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) - then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) - else pure $ toChatInfo cd + cInfo' <- + if (ciRequiresAttention content || contactChatDeleted cd) + then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) + else pure $ toChatInfo cd (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt @@ -2261,11 +2333,11 @@ createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode pure (cmdId, connId) -joinAgentConnectionAsync :: User -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId) -joinAgentConnectionAsync user enableNtfs cReqUri cInfo subMode = do - cmdId <- withStore' $ \db -> createCommand db user Nothing CFJoinConn - connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cReqUri cInfo PQSupportOff subMode - pure (cmdId, connId) +joinAgentConnectionAsync :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId) +joinAgentConnectionAsync user conn_ enableNtfs cReqUri cInfo subMode = do + cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn + connId' <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) (aConnId <$> conn_) enableNtfs cReqUri cInfo PQSupportOff subMode + pure (cmdId, connId') allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM () allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersion} confId msg = do @@ -2298,6 +2370,18 @@ deleteAgentConnectionsAsync' [] _ = pure () deleteAgentConnectionsAsync' acIds waitDelivery = do withAgent (\a -> deleteConnectionsAsync a waitDelivery acIds) `catchAllErrors` eToView +setAgentConnShortLinkAsync :: User -> Connection -> UserConnLinkData 'CMContact -> Maybe CRClientData -> CM () +setAgentConnShortLinkAsync user conn@Connection {connId} userLinkData crClientData_ = do + cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFSetShortLink + withAgent $ \a -> setConnShortLinkAsync a (aCorrId cmdId) (aConnId conn) userLinkData crClientData_ + +getAgentConnShortLinkAsync :: User -> ShortLinkContact -> CM (CommandId, ConnId) +getAgentConnShortLinkAsync user shortLink = do + shortLink' <- restoreShortLink' shortLink + cmdId <- withStore' $ \db -> createCommand db user Nothing CFGetShortLink + connId <- withAgent $ \a -> getConnShortLinkAsync a (aUserId user) (aCorrId cmdId) shortLink' + pure (cmdId, connId) + agentXFTPDeleteRcvFile :: RcvFileId -> FileTransferId -> CM () agentXFTPDeleteRcvFile aFileId fileId = do lift $ withAgent' (`xftpDeleteRcvFile` aFileId) @@ -2590,6 +2674,9 @@ adminContactReq :: ConnReqContact adminContactReq = either error id $ strDecode "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" +contactCReqHash :: ConnReqContact -> ConnReqUriHash +contactCReqHash = ConnReqUriHash . C.sha256Hash . strEncode + simplexTeamContactProfile :: Profile simplexTeamContactProfile = Profile @@ -2625,3 +2712,6 @@ timeItToView s action = do epochStart :: UTCTime epochStart = UTCTime (fromGregorian 1970 1 1) (secondsToDiffTime 0) + +drgRandomBytes :: Int -> CM ByteString +drgRandomBytes n = asks random >>= atomically . C.randomBytes n diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e10bf2a081..a5e5c0fcd7 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -54,12 +54,13 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Connections import Simplex.Chat.Store.ContactRequest -import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Delivery +import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Files import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles +import Simplex.Chat.Store.RelayRequests import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations @@ -71,12 +72,13 @@ import Simplex.FileTransfer.Protocol (FilePartyI) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Client (getAgentWorker, waitForWork, withWork_, withWorkItems) -import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) +import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForUserNetwork, waitForWork, waitWhileSuspended, withWorkItems, withWork_) +import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) +import Simplex.Messaging.Agent.RetryInterval (withRetryInterval) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Client (ProxyClientError (..), NetworkRequestMode (..)) +import Simplex.Messaging.Client (NetworkRequestMode (..), ProxyClientError (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern PQEncOff, pattern PQEncOn, pattern PQSupportOff, pattern PQSupportOn) @@ -84,6 +86,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ErrorType (..), MsgFlags (..)) import qualified Simplex.Messaging.Protocol as SMP +import Simplex.Messaging.ServiceScheme (ServiceScheme (..)) import qualified Simplex.Messaging.TMap as TM import Simplex.Messaging.Transport (TransportError (..)) import Simplex.Messaging.Util @@ -221,7 +224,7 @@ processAgentMsgSndFile _corrId aFileId msg = do toView $ CEvtSndFileCompleteXFTP user ci' ft where getRecipients - | isTrue (useRelays g) = withStore' $ \db -> getGroupRelays db vr user g + | useRelays' g = withStore' $ \db -> getGroupRelayMembers db vr user g | otherwise = withStore' $ \db -> getGroupMembers db vr user g memberFTs :: [GroupMember] -> [(Connection, SndFileTransfer)] memberFTs ms = M.elems $ M.intersectionWith (,) (M.fromList mConns') (M.fromList sfts') @@ -363,7 +366,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvGroupMsgConnection conn gInfo m -> processGroupMessage agentMessage entity conn gInfo m UserContactConnection conn uc -> - processUserContactRequest agentMessage entity conn uc + processContactConnMessage agentMessage entity conn uc where updateConnStatus :: ConnectionEntity -> CM ConnectionEntity updateConnStatus acEntity = case agentMsgConnStatus agentMessage of @@ -586,7 +589,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode gVar <- asks random withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode - -- TODO REMOVE LEGACY ^^^ + -- TODO REMOVE LEGACY ^^^ SENT msgId proxy -> do void $ continueSending connEntity conn sentMsgDeliveryEvent conn msgId @@ -730,6 +733,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- [async agent commands] no continuation needed, but command should be asynchronous for stability allowAgentConnectionAsync user conn' confId XOk | otherwise -> messageError "x.grp.acpt: memberId is different from expected" + XGrpRelayAcpt relayLink + | memberRole' membership == GROwner && isRelay m -> do + withStore $ \db -> do + relay <- getGroupRelayByGMId db (groupMemberId' m) + liftIO $ updateGroupMemberStatus db userId m GSMemAccepted + void $ liftIO $ setRelayLinkAccepted db relay relayLink + allowAgentConnectionAsync user conn' confId XOk + | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" _ -> messageError "CONF from invited member must have x.grp.acpt" GCHostMember -> case chatMsgEvent of @@ -794,41 +805,63 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (m {memberStatus = GSMemConnected}, gInfo') toView $ CEvtUserJoinedGroup user gInfo' m' (gInfo'', m'', scopeInfo) <- mkGroupChatScope gInfo' m' - let cd = CDGroupRcv gInfo'' scopeInfo m'' - createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing - let prepared = preparedGroup gInfo'' - unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' - memberConnectedChatItem gInfo'' scopeInfo m'' - let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> prepared - unless (memberPending membership || isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo'' m'' - GCInviteeMember -> do - (gInfo', mStatus) <- - if not (memberPending m) - then do - mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected - pure (gInfo, mStatus) - else do - gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo - pure (gInfo', memberStatus m) - (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - memberConnectedChatItem gInfo'' scopeInfo m' - case scopeInfo of - Just (GCSIMemberSupport _) -> do - createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing - _ -> pure () - toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} - let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' - when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing) - case mStatus of - GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators vr user gInfo'' m' - _ -> do - introduceToAll vr user gInfo'' m' - let memberIsCustomer = case businessChat gInfo'' of - Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId - _ -> False - when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m' + -- Create e2ee, feature and group description chat items only on first connected relay + ifM + firstConnectedHost + ( do + let cd = CDGroupRcv gInfo'' scopeInfo m'' + createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing + let prepared = preparedGroup gInfo'' + unless (isJust prepared) $ createGroupFeatureItems user cd CIRcvGroupFeature gInfo'' + memberConnectedChatItem gInfo'' scopeInfo m'' + let welcomeMsgId_ = (\PreparedGroup {welcomeSharedMsgId = mId} -> mId) <$> prepared + unless (memberPending membership || isJust welcomeMsgId_) $ maybeCreateGroupDescrLocal gInfo'' m'' + ) + (memberConnectedChatItem gInfo'' scopeInfo m'') + where + firstConnectedHost + | useRelays' gInfo = do + relayMems <- withStore' $ \db -> getGroupRelayMembers db vr user gInfo + let numConnected = length $ filter (\GroupMember {memberStatus = ms} -> ms == GSMemConnected) relayMems + pure $ numConnected == 1 + | otherwise = pure True + GCInviteeMember + | isRelay m -> do + withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected + gLink <- withStore $ \db -> getGroupLink db user gInfo + setGroupLinkDataAsync user gInfo gLink + | otherwise -> do + (gInfo', mStatus) <- + if not (memberPending m) + then do + mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected + pure (gInfo, mStatus) + else do + gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo + pure (gInfo', memberStatus m) + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + memberConnectedChatItem gInfo'' scopeInfo m' + case scopeInfo of + Just (GCSIMemberSupport _) -> do + createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing + _ -> pure () + toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} + let Connection {viaUserContactLink} = conn + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' + when (connChatVersion < batchSend2Version) $ getAutoReplyMsg >>= mapM_ (\mc -> sendGroupAutoReply mc Nothing) + if useRelays' gInfo'' + then do + introduceModerators vr user gInfo'' m' + when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' + else case mStatus of + GSMemPendingApproval -> pure () + GSMemPendingReview -> introduceToModerators vr user gInfo'' m' + _ -> do + introduceToAll vr user gInfo'' m' + let memberIsCustomer = case businessChat gInfo'' of + Just BusinessChatInfo {chatType = BCCustomer, customerId} -> memberId' m' == customerId + _ -> False + when (groupFeatureAllowed SGFHistory gInfo'' && not memberIsCustomer) $ sendHistory user gInfo'' m' where sendXGrpLinkMem gInfo'' = do let incognitoProfile = ExistingIncognito <$> incognitoMembershipProfile gInfo'' @@ -873,13 +906,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = aChatMsgs = parseChatMessages msgBody brokerTs = metaBrokerTs msgMeta processAChatMsg :: - GroupInfo - -> GroupMember - -> TVar [Text] - -> Text - -> [NewMessageDeliveryTask] - -> Either String AChatMessage - -> CM [NewMessageDeliveryTask] + GroupInfo -> + GroupMember -> + TVar [Text] -> + Text -> + [NewMessageDeliveryTask] -> + Either String AChatMessage -> + CM [NewMessageDeliveryTask] processAChatMsg gInfo' m' tags eInfo newDeliveryTasks = \case Right (ACMsg SJson chatMsg) -> do newTask_ <- processEvent gInfo' m' tags eInfo chatMsg `catchAllErrors` \e -> eToView e $> Nothing @@ -902,7 +935,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- ! see isForwardedGroupMsg: processing functions should return DeliveryJobScope for same events deliveryJobScope_ <- case event of XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False - where ExtMsgContent {scope} = mcExtMsgContent mc + where + ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live @@ -1034,6 +1068,28 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ mc_ $ \mc -> do connReq_ <- withStore' $ \db -> getBusinessContactRequest db user groupId sendGroupAutoReply mc connReq_ + LDATA FixedLinkData {linkConnReq = cReq} _cData -> + -- [async agent commands] CFGetConnShortLink continuation - join relay connection with resolved link + withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> + case cmdFunction of + CFGetShortLink -> case cReq of + CRContactUri crData@ConnReqUriData {crClientData} -> do + let pqSup = PQSupportOff + lift (withAgent' $ \a -> connRequestPQSupport a pqSup cReq) >>= \case + Nothing -> throwChatError CEInvalidConnReq + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli + cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} + -- Update connection with data derived from cReq, now available after getConnShortLinkAsync + withStore' $ \db -> updateConnLinkData db user conn cReq cReqHash groupLinkId chatV pqSup + let GroupMember {memberId = membershipMemId} = membership + incognitoProfile = fromLocalProfile <$> incognitoMembershipProfile gInfo + profileToSend = userProfileInGroup user gInfo incognitoProfile + dm <- encodeConnInfo $ XMember profileToSend membershipMemId + subMode <- chatReadVar subscriptionMode + void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode + _ -> throwChatError $ CECommandError "unexpected cmdFunction" QCONT -> do continued <- continueSending connEntity conn when continued $ sendPendingGroupMessages user m conn @@ -1135,15 +1191,44 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = RcvChunkDuplicate -> withAckMessage' "file msg" agentConnId meta $ pure () RcvChunkError -> badRcvFileChunk ft $ "incorrect chunk number " <> show chunkNo - processUserContactRequest :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () - processUserContactRequest agentMsg connEntity conn UserContact {userContactLinkId = uclId} = case agentMsg of + processContactConnMessage :: AEvent e -> ConnectionEntity -> Connection -> UserContact -> CM () + processContactConnMessage agentMsg connEntity conn UserContact {userContactLinkId = uclId, groupId = ucGroupId_} = case agentMsg of REQ invId pqSupport _ connInfo -> do ChatMessage {chatVRange, chatMsgEvent} <- parseChatMessage conn connInfo case chatMsgEvent of XContact p xContactId_ welcomeMsgId_ requestMsg_ -> profileContactRequest invId chatVRange p xContactId_ welcomeMsgId_ requestMsg_ pqSupport + XMember p joiningMemberId -> memberJoinRequestViaRelay invId chatVRange p joiningMemberId XInfo p -> profileContactRequest invId chatVRange p Nothing Nothing Nothing pqSupport + XGrpRelayInv groupRelayInv -> xGrpRelayInv invId chatVRange groupRelayInv -- TODO show/log error, other events in contact request _ -> pure () + LINK _link auData -> + withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> + case cmdFunction of + CFSetShortLink -> + case (ucGroupId_, auData) of + (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do + (gInfo, gLink, relays) <- withStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + gLink <- getGroupLink db user gInfo + relays <- liftIO $ getGroupRelays db gInfo + relays' <- liftIO $ mapM (updateRelay db) relays + liftIO $ setGroupInProgressDone db gInfo + pure (gInfo, gLink, relays') + -- TODO [relays] owner: "relays updated" chat item? + toView $ CEvtGroupLinkRelaysUpdated user gInfo gLink relays + where + -- TODO [relays] owner: on relay deletion (link absent from relayLinks) + -- TODO move status RSActive to new "Removed" status / remove relay record + updateRelay :: DB.Connection -> GroupRelay -> IO GroupRelay + updateRelay db relay@GroupRelay {relayLink, relayStatus} = + case relayLink of + Just rLink + | rLink `elem` relayLinks && relayStatus == RSAccepted -> + updateRelayStatus db relay RSActive + _ -> pure relay + _ -> throwChatError $ CECommandError "LINK event expected for a group link only" + _ -> throwChatError $ CECommandError "unexpected cmdFunction" MERR _ err -> do eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) processConnMERR connEntity conn err @@ -1243,9 +1328,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getDirectChatItemBySharedMsgId db user contactId sharedMsgId) >>= \case Right (cci@(CChatItem SMDRcv _)) -> do currentTs <- liftIO getCurrentTime - deletions <- if featureAllowed SCFFullDelete forContact ct - then deleteDirectCIs user ct [cci] - else markDirectCIsDeleted user ct [cci] currentTs + deletions <- + if featureAllowed SCFFullDelete forContact ct + then deleteDirectCIs user ct [cci] + else markDirectCIsDeleted user ct [cci] currentTs toView $ CEvtChatItemsDeleted user deletions False False _ -> pure () upsertBusinessRequestItem :: ChatDirection 'CTGroup 'MDRcv -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem) @@ -1272,9 +1358,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right cci@(CChatItem SMDRcv ChatItem {chatDir = CIGroupRcv m'}) | sameMemberId (memberId' clientMember) m' -> do currentTs <- liftIO getCurrentTime - deletions <- if groupFeatureMemberAllowed SGFFullDelete clientMember gInfo - then deleteGroupCIs user gInfo Nothing [cci] Nothing currentTs - else markGroupCIsDeleted user gInfo Nothing [cci] Nothing currentTs + deletions <- + if groupFeatureMemberAllowed SGFFullDelete clientMember gInfo + then deleteGroupCIs user gInfo Nothing [cci] Nothing currentTs + else markGroupCIsDeleted user gInfo Nothing [cci] Nothing currentTs toView $ CEvtChatItemsDeleted user deletions False False _ -> pure () createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem @@ -1285,8 +1372,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = upsertRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> ((SharedMsgId, MsgContent) -> CM (Maybe AChatItem)) -> (SharedMsgId -> CM ()) -> (Maybe (SharedMsgId, MsgContent), Maybe SharedMsgId) -> CM (Maybe AChatItem) upsertRequestItem cd update delete = \case (Just msg, Nothing) -> Just <$> createRequestItem cd msg - (Just msg@(sharedMsgId, _), Just prevSharedMsgId) | sharedMsgId == prevSharedMsgId -> - update msg `catchCINotFound` \_ -> Just <$> createRequestItem cd msg + (Just msg@(sharedMsgId, _), Just prevSharedMsgId) + | sharedMsgId == prevSharedMsgId -> + update msg `catchCINotFound` \_ -> Just <$> createRequestItem cd msg (Nothing, Just prevSharedMsgId) -> Nothing <$ delete prevSharedMsgId _ -> pure Nothing -- ##### Group link join requests (don't create contact requests) ##### @@ -1297,19 +1385,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case Right (acceptance, useRole) | v < groupFastLinkJoinVersion -> - messageError "processUserContactRequest: chat version range incompatible for accepting group join request" + messageError "processContactConnMessage: chat version range incompatible for accepting group join request" | otherwise -> do let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ welcomeMsgId_ acceptance useRole profileMode + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' Left rjctReason | v < groupJoinRejectVersion -> - messageWarning $ "processUserContactRequest (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" + messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" | otherwise -> do mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason + xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () + xGrpRelayInv invId chatVRange groupRelayInv = do + (_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange + lift $ void $ getRelayRequestWorker True + -- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays + -- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember) + memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> CM () + memberJoinRequestViaRelay invId chatVRange p joiningMemberId = do + (_ucl, gLinkInfo_) <- withStore $ \db -> getUserContactLinkById db userId uclId + case gLinkInfo_ of + Just GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p Nothing (Just joiningMemberId) Nothing GAAccepted gLinkMemRole Nothing + (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' + Nothing -> + messageError "memberJoinRequestViaRelay: no group link info for relay link" memberCanSend :: GroupMember -> @@ -1344,7 +1450,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (connInactive conn) $ do quotaErrCounter' <- withStore' $ \db -> incQuotaErrCounter db user conn when (quotaErrCounter' >= quotaErrInactiveCount) $ - toView $ CEvtConnectionInactive connEntity True + toView (CEvtConnectionInactive connEntity True) _ -> pure () continueSending :: ConnectionEntity -> Connection -> CM Bool @@ -1565,8 +1671,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- we catch error, so that even if processFDMessage fails, message can still be forwarded. processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure () pure $ Just $ infoToDeliveryScope g scopeInfo - else - messageError "x.msg.file.descr: file of another member" $> Nothing + else messageError "x.msg.file.descr: file of another member" $> Nothing _ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> CM () @@ -1652,9 +1757,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case msgDir of SMDRcv | rcvItemDeletable ci brokerTs -> do - deletions <- if featureAllowed SCFFullDelete forContact ct - then deleteDirectCIs user ct [cci] - else markDirectCIsDeleted user ct [cci] brokerTs + deletions <- + if featureAllowed SCFFullDelete forContact ct + then deleteDirectCIs user ct [cci] + else markDirectCIsDeleted user ct [cci] brokerTs toView $ CEvtChatItemsDeleted user deletions False False | otherwise -> messageError "x.msg.del: contact attempted invalid message delete" SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" @@ -1734,18 +1840,17 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ if blockedByAdmin m' then createBlockedByAdmin gInfo' m' scopeInfo $> Nothing - else - case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of - Just f -> rejected gInfo' m' scopeInfo f $> Nothing - Nothing -> - withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration gInfo' m' scopeInfo ciModeration - withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ - pure Nothing - Nothing -> do - createContentItem gInfo' m' scopeInfo - pure $ Just $ infoToDeliveryScope gInfo scopeInfo + else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' m' scopeInfo f $> Nothing + Nothing -> + withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration gInfo' m' scopeInfo ciModeration + withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ + pure Nothing + Nothing -> do + createContentItem gInfo' m' scopeInfo + pure $ Just $ infoToDeliveryScope gInfo scopeInfo where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL @@ -1892,9 +1997,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM DeliveryJobScope delete cci byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) - deletions <- if groupFeatureMemberAllowed SGFFullDelete m gInfo - then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs - else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs + deletions <- + if groupFeatureMemberAllowed SGFFullDelete m gInfo + then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs + else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs toView $ CEvtChatItemsDeleted user deletions False False pure $ infoToDeliveryScope gInfo scopeInfo archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () @@ -2047,8 +2153,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = cancelRcvFileTransfer user ft toView $ CEvtRcvFileSndCancelled user aci ft pure $ Just $ infoToDeliveryScope g scopeInfo - else - -- shouldn't happen now that query includes group member id + else -- shouldn't happen now that query includes group member id messageError "x.file.cancel: group member attempted to cancel file of another member" $> Nothing _ -> messageError "x.file.cancel: group member attempted invalid file cancel" $> Nothing @@ -2096,7 +2201,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do subMode <- chatReadVar subscriptionMode dm <- encodeConnInfo $ XGrpAcpt membershipMemId - connIds <- joinAgentConnectionAsync user True connRequest dm subMode + connIds <- joinAgentConnectionAsync user Nothing True connRequest dm subMode withStore' $ \db -> do setViaGroupLinkUri db groupId connId createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode @@ -2545,9 +2650,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do (updatedMember, gInfo') <- withStore $ \db -> do updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus - gInfo' <- if memberPending updatedMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo + gInfo' <- + if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo pure (updatedMember, gInfo') toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember memberAnnouncedToView updatedMember gInfo' @@ -2556,9 +2662,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Left _ -> do (newMember, gInfo') <- withStore $ \db -> do newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus - gInfo' <- if memberPending newMember - then liftIO $ increaseGroupMembersRequireAttention db user gInfo - else pure gInfo + gInfo' <- + if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo pure (newMember, gInfo') memberAnnouncedToView newMember gInfo' pure $ deliveryJobScope newMember @@ -2591,19 +2698,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case memberCategory m of GCHostMember -> withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right _ -> messageError "x.grp.mem.intro ignored: member already exists" - Left _ -> do - when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) - case memChatVRange of - Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" - Just (ChatVersionRange mcvr) - | maxVersion mcvr >= groupDirectInvVersion -> do - subMode <- chatReadVar subscriptionMode - -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second - groupConnIds <- createConn subMode - let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange - void $ withStore $ \db -> createIntroReMember db user gInfo m chatV memInfo memRestrictions groupConnIds subMode - | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" + Right _ -> + unless (useRelays' gInfo) $ + messageError "x.grp.mem.intro ignored: member already exists" + Left _ + | useRelays' gInfo -> + void $ withStore $ \db -> createIntroReMember db user gInfo memInfo memRestrictions + | otherwise -> do + when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole c) + case memChatVRange of + Nothing -> messageError "x.grp.mem.intro: member chat version range incompatible" + Just (ChatVersionRange mcvr) + | maxVersion mcvr >= groupDirectInvVersion -> do + subMode <- chatReadVar subscriptionMode + -- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second + groupConnIds <- createConn subMode + let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange + void $ withStore $ \db -> do + reMember <- createIntroReMember db user gInfo memInfo memRestrictions + createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode + | otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible" _ -> messageError "x.grp.mem.intro can be only sent by host member" where createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode @@ -2629,14 +2743,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let GroupMember {memberId = membershipMemId} = membership checkHostRole m memRole toMember <- withStore $ \db -> do - toMember <- getGroupMemberByMemberId db vr user gInfo memId - -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent - -- the situation when member does not exist is an error - -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. - -- For now, this branch compensates for the lack of delayed message delivery. - `catchError` \case - SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced - e -> throwError e + toMember <- + getGroupMemberByMemberId db vr user gInfo memId + -- TODO if the missed messages are correctly sent as soon as there is connection before anything else is sent + -- the situation when member does not exist is an error + -- member receiving x.grp.mem.fwd should have also received x.grp.mem.new prior to that. + -- For now, this branch compensates for the lack of delayed message delivery. + `catchError` \case + SEGroupMemberNotFoundByMemberId _ -> createNewGroupMember db user gInfo m memInfo GCPostMember GSMemAnnounced + e -> throwError e -- TODO [knocking] separate pending statuses from GroupMemberStatus? -- TODO add GSMemIntroInvitedPending, GSMemConnectedPending, etc.? -- TODO keep as is? (GSMemIntroInvited has no purpose) @@ -2649,8 +2764,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile -- [async agent commands] no continuation needed, but commands should be asynchronous for stability - groupConnIds <- joinAgentConnectionAsync user (chatHasNtfs chatSettings) groupConnReq dm subMode - directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user True dcr dm subMode + groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode + directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo mcvr = maybe chatInitialVRange fromChatVRange memChatVRange chatV = vr `peerConnChatVersion` mcvr @@ -2691,27 +2806,23 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = msg brokerTs | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency - | otherwise = - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case - Right bm@GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} - | blockedByAdmin == mrsBlocked restriction -> pure Nothing - | senderRole < GRModerator || senderRole < memberRole -> - messageError "x.grp.mem.restrict with insufficient member permissions" $> Nothing - | otherwise -> do - bm' <- setMemberBlocked bm - toggleNtf bm' (not blocked) - let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent - groupMsgToView cInfo ci - toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked} - pure $ memberEventDeliveryScope bm - Left (SEGroupMemberNotFoundByMemberId _) -> do - bm <- createUnknownMember gInfo memId Nothing - bm' <- setMemberBlocked bm - toView $ CEvtUnknownMemberBlocked user gInfo m bm' - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} - Left e -> throwError $ ChatErrorStore e + | otherwise = do + (bm, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memId Nothing + let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm + if + | blockedByAdmin == mrsBlocked restriction -> pure Nothing + | senderRole < GRModerator || senderRole < memberRole -> + messageError "x.grp.mem.restrict with insufficient member permissions" $> Nothing + | otherwise -> do + bm' <- setMemberBlocked bm + toggleNtf bm' (not blocked) + let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked + (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent + when unknown $ toView $ CEvtUnknownMemberBlocked user gInfo m bm' + groupMsgToView cInfo ci + toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm', blocked} + pure $ memberEventDeliveryScope bm where setMemberBlocked bm = withStore' $ \db -> updateGroupMemberBlocked db user gInfo restriction bm blocked = mrsBlocked restriction @@ -2784,11 +2895,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = event = XGrpMsgForward memberId memberName chatMsg brokerTs sendGroupMemberMessage gInfo member event - -- TODO [channels fwd] base on differentiation between groups and channels isUserGrpFwdRelay :: GroupInfo -> Bool - isUserGrpFwdRelay GroupInfo {useRelays, membership = membership@GroupMember {memberRole}} - | isTrue useRelays = isMemberRelay membership - | otherwise = memberRole >= GRAdmin + isUserGrpFwdRelay gInfo@GroupInfo {membership} + | useRelays' gInfo = isRelay membership + | otherwise = memberRole' membership >= GRAdmin + + isMemberGrpFwdRelay :: GroupInfo -> GroupMember -> Bool + isMemberGrpFwdRelay gInfo m + | useRelays' gInfo = isRelay m + | otherwise = memberRole' m >= GRAdmin xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpLeave gInfo m msg brokerTs = do @@ -2826,7 +2941,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') groupMsgToView cInfo ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' - void $ forkIO $ setGroupLinkData' NRMBackground user g'' + void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = True}} @@ -2868,13 +2983,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else joinExistingContact subMode mCt where groupDirectInv = - GroupDirectInvitation { - groupDirectInvLink = connReq, - fromGroupId_ = Just groupId, - fromGroupMemberId_ = Just (groupMemberId' m), - fromGroupMemberConnId_ = Just mConnId, - groupDirectInvStartedConnection = isTrue $ autoAcceptMemberContacts user - } + GroupDirectInvitation + { groupDirectInvLink = connReq, + fromGroupId_ = Just groupId, + fromGroupMemberId_ = Just (groupMemberId' m), + fromGroupMemberConnId_ = Just mConnId, + groupDirectInvStartedConnection = isTrue $ autoAcceptMemberContacts user + } joinExistingContact subMode mCt@Contact {contactId = mContactId} | isTrue (autoAcceptMemberContacts user) = do (cmdId, acId) <- joinConn subMode @@ -2919,7 +3034,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True -- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ) dm <- encodeConnInfo $ XInfo p - joinAgentConnectionAsync user True connReq dm subMode + joinAgentConnectionAsync user Nothing True connReq dm subMode createItems mCt' m' = do (g', m'', scopeInfo) <- mkGroupChatScope g m' createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing @@ -2934,15 +3049,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> UTCTime -> CM () - xGrpMsgForward gInfo m@GroupMember {memberRole, localDisplayName} memberId memberName chatMsg msgTs brokerTs = do - when (memberRole < GRAdmin) $ throwChatError (CEGroupContactRole localDisplayName) - withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case - Right author -> processForwardedMsg author - Left (SEGroupMemberNotFoundByMemberId _) -> do - unknownAuthor <- createUnknownMember gInfo memberId memberName - toView $ CEvtUnknownMemberCreated user gInfo m unknownAuthor - processForwardedMsg unknownAuthor - Left e -> throwError $ ChatErrorStore e + xGrpMsgForward gInfo m@GroupMember {localDisplayName} memberId memberName chatMsg msgTs brokerTs = do + unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) + (author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName + when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author + processForwardedMsg author where -- ! see isForwardedGroupMsg: forwarded group events should include msgId to be deduplicated processForwardedMsg :: GroupMember -> CM () @@ -2951,7 +3062,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author body chatMsg brokerTs forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of XMsgNew mc -> void $ memberCanSend author scope $ (const Nothing) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True - where ExtMsgContent {scope} = mcExtMsgContent mc + where + ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (const Nothing) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live @@ -2968,11 +3080,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XGrpPrefs ps' -> void $ xGrpPrefs gInfo author ps' _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) - createUnknownMember :: GroupInfo -> MemberId -> Maybe ContactName -> CM GroupMember - createUnknownMember gInfo memberId memberName = do - let name = fromMaybe (nameFromMemberId memberId) memberName - withStore $ \db -> createNewUnknownGroupMember db vr user gInfo memberId name - directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta `catchAllErrors` \_ -> pure () @@ -3065,7 +3172,7 @@ deleteGroupConnections user gInfo waitDelivery = do deleteMembersConnections' user members waitDelivery where getMembers vr - | isTrue (useRelays gInfo) = withStore' $ \db -> getGroupRelays db vr user gInfo + | useRelays' gInfo = withStore' $ \db -> getGroupRelayMembers db vr user gInfo | otherwise = withStore' $ \db -> getGroupMembers db vr user gInfo startDeliveryTaskWorkers :: CM () @@ -3189,65 +3296,65 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job sendBodyToMembers :: CM () sendBodyToMembers - | isTrue (useRelays gInfo) = -- channel - case jobScope of - -- there's no member review in channels, so job spec includePending is ignored - DJSGroup {} -> do - bucketSize <- asks $ deliveryBucketSize . config - sendLoop bucketSize startingCursor - where - sendLoop :: Int -> Maybe GroupMemberId -> CM () - sendLoop bucketSize cursorGMId_ = do - mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize - unless (null mems) $ do - deliver body mems - let cursorGMId' = groupMemberId' $ last mems - withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' - unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') - DJSMemberSupport scopeGMId -> do - -- for member support scope we just load all recipients in one go, without cursor - modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo - let moderatorFilter m = - memberCurrent m - && maxVersion (memberChatVRange m) >= groupKnockingVersion - && Just (groupMemberId' m) /= singleSenderGMId_ - modMs' = filter moderatorFilter modMs - mems <- - if Just scopeGMId == singleSenderGMId_ - then pure modMs' - else do - scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId - pure $ scopeMem : modMs' - unless (null mems) $ deliver body mems - | otherwise = -- fully connected group - case singleSenderGMId_ of - Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" - Just singleSenderGMId -> do - sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId - ms <- buildMemberList sender - unless (null ms) $ deliver body ms - where - buildMemberList sender = do - vec <- withStore (`getMemberRelationsVector` sender) - -- this excludes the sender - let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec - case jobScope of - DJSGroup {jobSpec} -> do - ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs - pure $ filter shouldForwardTo ms - where - shouldForwardTo m - | jobSpecImpliedPending jobSpec = memberCurrentOrPending m - | otherwise = memberCurrent m - DJSMemberSupport scopeGMId -> do - ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs - pure $ filter shouldForwardTo ms - where - shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m - currentModerator m@GroupMember {memberRole} = - memberRole >= GRModerator - && memberCurrent m - && maxVersion (memberChatVRange m) >= groupKnockingVersion + -- channel + | useRelays' gInfo = case jobScope of + -- there's no member review in channels, so job spec includePending is ignored + DJSGroup {} -> do + bucketSize <- asks $ deliveryBucketSize . config + sendLoop bucketSize startingCursor + where + sendLoop :: Int -> Maybe GroupMemberId -> CM () + sendLoop bucketSize cursorGMId_ = do + mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + unless (null mems) $ do + deliver body mems + let cursorGMId' = groupMemberId' $ last mems + withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' + unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + DJSMemberSupport scopeGMId -> do + -- for member support scope we just load all recipients in one go, without cursor + modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo + let moderatorFilter m = + memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion + && Just (groupMemberId' m) /= singleSenderGMId_ + modMs' = filter moderatorFilter modMs + mems <- + if Just scopeGMId == singleSenderGMId_ + then pure modMs' + else do + scopeMem <- withStore $ \db -> getGroupMemberById db vr user scopeGMId + pure $ scopeMem : modMs' + unless (null mems) $ deliver body mems + -- fully connected group + | otherwise = case singleSenderGMId_ of + Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" + Just singleSenderGMId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + ms <- buildMemberList sender + unless (null ms) $ deliver body ms + where + buildMemberList sender = do + vec <- withStore (`getMemberRelationsVector` sender) + -- this excludes the sender + let introducedMemsIdxs = getRelationsIndexes MRIntroduced vec + case jobScope of + DJSGroup {jobSpec} -> do + ms <- withStore' $ \db -> getGroupMembersByIndexes db vr user gInfo introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m + | jobSpecImpliedPending jobSpec = memberCurrentOrPending m + | otherwise = memberCurrent m + DJSMemberSupport scopeGMId -> do + ms <- withStore' $ \db -> getSupportScopeMembersByIndexes db vr user gInfo scopeGMId introducedMemsIdxs + pure $ filter shouldForwardTo ms + where + shouldForwardTo m = groupMemberId' m == scopeGMId || currentModerator m + currentModerator m@GroupMember {memberRole} = + memberRole >= GRModerator + && memberCurrent m + && maxVersion (memberChatVRange m) >= groupKnockingVersion where deliver :: ByteString -> [GroupMember] -> CM () deliver msgBody mems = @@ -3268,3 +3375,103 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body Just 1 -> VRValue (Just 1) msgBody Just _ -> VRRef 1 + +-- Single worker processes all relay requests (XGrpRelayInv). +-- We use map with a single key 1 to fit into existing worker management framework. +relayRequestWorkerKey :: Int +relayRequestWorkerKey = 1 + +startRelayRequestWorker :: CM () +startRelayRequestWorker = do + hasPending <- withStore' hasPendingRelayRequests + when hasPending $ lift resumeRelayRequestWork + +resumeRelayRequestWork :: CM' () +resumeRelayRequestWork = void $ getRelayRequestWorker False + +getRelayRequestWorker :: Bool -> CM' Worker +getRelayRequestWorker hasWork = do + ws <- asks relayRequestWorkers + a <- asks smpAgent + getAgentWorker "relay_request" hasWork a relayRequestWorkerKey ws $ + runRelayRequestWorker a + +runRelayRequestWorker :: AgentClient -> Worker -> CM () +runRelayRequestWorker a Worker {doWork} = do + vr <- chatVersionRange + (user, uclId) <- withStore $ \db -> do + user <- getRelayUser db + UserContactLink {userContactLinkId} <- getUserAddress db user + pure (user, userContactLinkId) + forever $ do + lift $ waitForWork doWork + runRelayRequestOperation vr user uclId + where + runRelayRequestOperation :: VersionRangeChat -> User -> Int64 -> CM () + runRelayRequestOperation vr user uclId = + withWork_ a doWork (withStore' getNextPendingRelayRequest) $ + \(groupId, rrd) -> do + ri <- asks $ reconnectInterval . agentConfig . config + withRetryInterval ri $ \_ loop -> do + liftIO $ waitWhileSuspended a + liftIO $ waitForUserNetwork a + processRelayRequest groupId rrd `catchAllErrors` retryTmpError loop groupId + where + retryTmpError :: CM () -> GroupId -> ChatError -> CM () + retryTmpError loop groupId = \case + ChatErrorAgent {agentError} | temporaryOrHostError agentError -> loop + e -> do + withStore' $ \db -> setRelayRequestErr db groupId (tshow e) + eToView e + processRelayRequest :: GroupId -> RelayRequestData -> CM () + processRelayRequest groupId rrd = do + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + -- Check if relay link already exists (recovery case) + withStore' (\db -> runExceptT $ getGroupLink db user gInfo) >>= \case + Right GroupLink {connLinkContact = CCLink _ sLnk_} -> + case sLnk_ of + Just sLnk -> acceptOwnerConnection rrd gInfo sLnk + Nothing -> throwChatError $ CEException "processRelayRequest: relay link doesn't have short link" + Left _ -> do + (gInfo', sLnk) <- getLinkDataCreateRelayLink rrd gInfo + acceptOwnerConnection rrd gInfo' sLnk + where + getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfo -> CM (GroupInfo, ShortLinkContact) + getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} gInfo = do + (_cReq, cData) <- getShortLinkConnReq NRMBackground user reqGroupLink + liftIO (decodeLinkUserData cData) >>= \case + Nothing -> throwChatError $ CEException "getLinkDataCreateRelayLink: no group link data" + Just (GroupShortLinkData gp) -> do + validateGroupProfile gp + gInfo' <- withStore $ \db -> updateGroupProfile db user gInfo gp + sLnk <- createRelayLink gInfo' + pure (gInfo', sLnk) + where + validateGroupProfile :: GroupProfile -> CM () + validateGroupProfile _groupProfile = do + -- TODO [relays] relay: validate group profile, verify owner's signature + pure () + createRelayLink :: GroupInfo -> CM ShortLinkContact + createRelayLink gi@GroupInfo {groupProfile} = do + -- TODO [relays] relay: set relay link data + -- TODO - link data: relay key for group, relay identity (profile, certificate, relay identity key) + -- TODO - TBC link's member role - owner to communicate in invitation? + groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + subMode <- chatReadVar subscriptionMode + let userData = encodeShortLinkData $ GroupShortLinkData groupProfile + userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} + crClientData = encodeJSON $ CRDataGroup groupLinkId + (connId, (ccLink, _serviceId)) <- withAgent $ \a' -> createConnection a' NRMBackground (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) CR.IKPQOff subMode + ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink + sLnk <- case toShortLinkContact ccLink' of + Just sl -> pure sl + Nothing -> throwChatError $ CEException "failed to create relay link: no short link" + gVar <- asks random + void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId GRMember subMode + pure sLnk + acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () + acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do + ownerMember <- withStore $ \db -> getHostMember db vr user groupId + void $ acceptRelayJoinRequestAsync user uclId gi ownerMember relayInvId reqChatVRange relayLink + -- TODO [relays] relay: group invite accepted event, chat item (?) + pure () diff --git a/src/Simplex/Chat/Operators/Presets.hs b/src/Simplex/Chat/Operators/Presets.hs index af6229e7c8..682e0cff55 100644 --- a/src/Simplex/Chat/Operators/Presets.hs +++ b/src/Simplex/Chat/Operators/Presets.hs @@ -88,7 +88,7 @@ disabledSimplexChatSMPServers = "smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im,bylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion" ] --- TODO [chat relays] real chat relays +-- TODO [relays] real chat relays simplexChatRelays :: [NewUserChatRelay] simplexChatRelays = [ presetChatRelay True "chat_relay_1" ["simplex.im"] (either error id $ strDecode "https://smp111.simplex.im/r#Pz9qz7ZVljMofoRxiDDpL_w2DZSazK8IgafxqnWKv6Y"), diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 40b667365e..5d501fffb7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -227,8 +227,7 @@ instance StrEncoding AppMessageBinary where let msgId = if B.null msgId' then Nothing else Just (SharedMsgId msgId') pure AppMessageBinary {tag, msgId, body} -data MsgScope - = MSMember {memberId :: MemberId} -- Admins can use any member id; members can use only their own id +data MsgScope = MSMember {memberId :: MemberId} -- Admins can use any member id; members can use only their own id deriving (Eq, Show) $(JQ.deriveJSON (taggedObjectJSON $ dropPrefix "MS") ''MsgScope) @@ -321,6 +320,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json XContact :: {profile :: Profile, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json + XMember :: {profile :: Profile, newMemberId :: MemberId} -> ChatMsgEvent 'Json XDirectDel :: ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json @@ -328,6 +328,8 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpLinkReject :: GroupLinkRejection -> ChatMsgEvent 'Json XGrpLinkMem :: Profile -> ChatMsgEvent 'Json XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json + XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json + XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -811,6 +813,7 @@ data CMEventTag (e :: MsgEncoding) where XFileCancel_ :: CMEventTag 'Json XInfo_ :: CMEventTag 'Json XContact_ :: CMEventTag 'Json + XMember_ :: CMEventTag 'Json XDirectDel_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json @@ -818,6 +821,8 @@ data CMEventTag (e :: MsgEncoding) where XGrpLinkReject_ :: CMEventTag 'Json XGrpLinkMem_ :: CMEventTag 'Json XGrpLinkAcpt_ :: CMEventTag 'Json + XGrpRelayInv_ :: CMEventTag 'Json + XGrpRelayAcpt_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -864,6 +869,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XFileCancel_ -> "x.file.cancel" XInfo_ -> "x.info" XContact_ -> "x.contact" + XMember_ -> "x.member" XDirectDel_ -> "x.direct.del" XGrpInv_ -> "x.grp.inv" XGrpAcpt_ -> "x.grp.acpt" @@ -871,6 +877,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpLinkReject_ -> "x.grp.link.reject" XGrpLinkMem_ -> "x.grp.link.mem" XGrpLinkAcpt_ -> "x.grp.link.acpt" + XGrpRelayInv_ -> "x.grp.relay.inv" + XGrpRelayAcpt_ -> "x.grp.relay.acpt" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -918,6 +926,7 @@ instance StrEncoding ACMEventTag where "x.file.cancel" -> XFileCancel_ "x.info" -> XInfo_ "x.contact" -> XContact_ + "x.member" -> XMember_ "x.direct.del" -> XDirectDel_ "x.grp.inv" -> XGrpInv_ "x.grp.acpt" -> XGrpAcpt_ @@ -925,6 +934,8 @@ instance StrEncoding ACMEventTag where "x.grp.link.reject" -> XGrpLinkReject_ "x.grp.link.mem" -> XGrpLinkMem_ "x.grp.link.acpt" -> XGrpLinkAcpt_ + "x.grp.relay.inv" -> XGrpRelayInv_ + "x.grp.relay.acpt" -> XGrpRelayAcpt_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -968,6 +979,7 @@ toCMEventTag msg = case msg of XFileCancel _ -> XFileCancel_ XInfo _ -> XInfo_ XContact {} -> XContact_ + XMember {} -> XMember_ XDirectDel -> XDirectDel_ XGrpInv _ -> XGrpInv_ XGrpAcpt _ -> XGrpAcpt_ @@ -975,6 +987,8 @@ toCMEventTag msg = case msg of XGrpLinkReject _ -> XGrpLinkReject_ XGrpLinkMem _ -> XGrpLinkMem_ XGrpLinkAcpt {} -> XGrpLinkAcpt_ + XGrpRelayInv _ -> XGrpRelayInv_ + XGrpRelayAcpt _ -> XGrpRelayAcpt_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1085,6 +1099,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do reqContent <- opt "content" let requestMsg = (,) <$> reqMsgId <*> reqContent pure XContact {profile, contactReqId, welcomeMsgId, requestMsg} + XMember_ -> XMember <$> p "profile" <*> p "newMemberId" XDirectDel_ -> pure XDirectDel XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpAcpt_ -> XGrpAcpt <$> p "memberId" @@ -1092,6 +1107,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpLinkReject_ -> XGrpLinkReject <$> p "groupLinkRejection" XGrpLinkMem_ -> XGrpLinkMem <$> p "profile" XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId" + XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation" + XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1144,6 +1161,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XInfo profile -> o ["profile" .= profile] XContact {profile, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ["profile" .= profile] + XMember {profile, newMemberId} -> o ["profile" .= profile, "newMemberId" .= newMemberId] XDirectDel -> JM.empty XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpAcpt memId -> o ["memberId" .= memId] @@ -1151,6 +1169,8 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpLinkReject groupLinkRjct -> o ["groupLinkRejection" .= groupLinkRjct] XGrpLinkMem profile -> o ["profile" .= profile] XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId] + XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv] + XGrpRelayAcpt relayLink -> o ["relayLink" .= relayLink] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 131a66c465..9a007d85fe 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -135,21 +135,22 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_link, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index c1da436f04..f6e0a0c77c 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -161,11 +161,11 @@ getNextDeliveryTasks :: DB.Connection -> GroupInfo -> MessageDeliveryTask -> IO getNextDeliveryTasks db gInfo task = getWorkItems "message delivery task" getTaskIds (getMsgDeliveryTask_ db) (markDeliveryTaskFailed_ db) where - GroupInfo {groupId, useRelays} = gInfo + GroupInfo {groupId} = gInfo MessageDeliveryTask {jobScope, senderGMId} = task getTaskIds :: IO [Int64] getTaskIds - | isTrue useRelays = + | useRelays' gInfo = map fromOnly <$> DB.query db diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index a517a7365b..3254ae817a 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -30,6 +30,8 @@ module Simplex.Chat.Store.Direct createDirectConnection, createIncognitoProfile, createConnReqConnection, + createRelayMemberConnectionAsync, + updateConnLinkData, setPreparedGroupStartedConnection, getProfileById, getConnReqContactXContactId, @@ -153,10 +155,12 @@ deletePendingContactConnection db userId connId = |] (userId, connId, ConnContact) -createConnReqConnection :: DB.Connection -> UserId -> ConnId -> Maybe PreparedChatEntity -> ConnReqContact -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe Profile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO Connection +createConnReqConnection :: DB.Connection -> UserId -> ConnId -> Maybe PreparedChatEntity -> ConnReqContact -> ConnReqUriHash -> Maybe ShortLinkContact -> XContactId -> Maybe IncognitoProfile -> Maybe GroupLinkId -> SubscriptionMode -> VersionChat -> PQSupport -> IO Connection createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xContactId incognitoProfile groupLinkId subMode chatV pqSup = do currentTs <- getCurrentTime - customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile + customUserProfileId <- forM incognitoProfile $ \case + NewIncognito p -> createIncognitoProfile_ db userId currentTs p + ExistingIncognito LocalProfile {profileId = pId} -> pure pId let connStatus = ConnPrepared DB.execute db @@ -175,7 +179,9 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta ) connId <- insertedRowId db case preparedEntity_ of - Just (PCEGroup gInfo _) -> updatePreparedGroup gInfo customUserProfileId currentTs + -- For relay groups, setPreparedGroupLinkInfo is called before the relay loop + Just (PCEGroup gInfo _) | not (useRelays' gInfo) -> + setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs _ -> pure () pure Connection @@ -213,16 +219,41 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta Just (PCEContact Contact {contactId}) -> (ConnContact, Just contactId, Nothing, Just contactId) Just (PCEGroup _ GroupMember {groupMemberId}) -> (ConnMember, Nothing, Just groupMemberId, Just groupMemberId) Nothing -> (ConnContact, Nothing, Nothing, Nothing) - updatePreparedGroup GroupInfo {groupId, membership} customUserProfileId currentTs = do - DB.execute - db - "UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ?, conn_link_prepared_connection = ?, updated_at = ? WHERE group_id = ?" - (cReq, cReqHash, BI True, currentTs, groupId) - when (isJust customUserProfileId) $ - DB.execute - db - "UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE group_member_id = ?" - (customUserProfileId, currentTs, groupMemberId' membership) + +createRelayMemberConnectionAsync :: DB.Connection -> User -> GroupInfo -> GroupMember -> ShortLinkContact -> (CommandId, ConnId) -> SubscriptionMode -> IO () +createRelayMemberConnectionAsync db user@User {userId} gInfo GroupMember {groupMemberId} relayLink (cmdId, agentConnId) subMode = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + group_member_id, via_short_link_contact, custom_user_profile_id, via_group_link, + created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, ConnNew, ConnMember, BI True) + :. (groupMemberId, relayLink, customUserProfileId_, BI True) + :. (currentTs, currentTs, BI (subMode == SMOnlyCreate)) + ) + connId <- insertedRowId db + setCommandConnId db user cmdId connId + where + customUserProfileId_ = localProfileId <$> incognitoMembershipProfile gInfo + +updateConnLinkData :: DB.Connection -> User -> Connection -> ConnReqContact -> ConnReqUriHash -> Maybe GroupLinkId -> VersionChat -> PQSupport -> IO () +updateConnLinkData db User {userId} Connection {connId} cReq cReqHash groupLinkId_ chatV pqSup = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE connections + SET via_contact_uri = ?, via_contact_uri_hash = ?, group_link_id = ?, + conn_chat_version = ?, pq_support = ?, pq_encryption = ?, + updated_at = ? + WHERE user_id = ? AND connection_id = ? + |] + (cReq, cReqHash, groupLinkId_, chatV, pqSup, pqSup, currentTs, userId, connId) setPreparedGroupStartedConnection :: DB.Connection -> GroupId -> IO () setPreparedGroupStartedConnection db groupId = do diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 338f42666e..8564354a97 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -41,7 +41,6 @@ module Simplex.Chat.Store.Groups createGroupRejectedViaLink, setGroupInvitationChatItemId, getGroup, - getGroupInfo, getGroupInfoByUserContactLinkConnReq, getGroupInfoViaUserShortLink, getGroupViaShortLinkToConnect, @@ -60,23 +59,38 @@ module Simplex.Chat.Store.Groups getGroupMemberById, getGroupMemberByIndex, getGroupMemberByMemberId, + getCreateUnknownGMByMemberId, getGroupMemberIdViaMemberId, getScopeMemberIdViaMemberId, getGroupMembers, getGroupMembersByIndexes, getSupportScopeMembersByIndexes, getGroupModerators, - getGroupRelays, + getGroupRelayMembers, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, deleteGroup, + getInProgressGroups, getBaseGroupDetails, getContactGroupPreferences, getGroupInvitation, createNewContactMember, + createGroupRelayRecord, + getGroupRelayById, + getGroupRelayByGMId, + getGroupRelays, + createRelayForOwner, + getCreateRelayForMember, + createRelayConnection, + updateRelayStatus, + updateRelayStatusFromTo, + setRelayLinkAccepted, + setGroupInProgressDone, + createRelayRequestGroup, + updateRelayOwnStatusFromTo, createNewContactMemberAsync, createJoiningMember, getMemberJoinRequest, @@ -104,6 +118,7 @@ module Simplex.Chat.Store.Groups setMemberVectorRelationConnected, getMemberRelationsVector, createIntroReMember, + createIntroReMemberConn, createIntroToMemberContact, getMatchingContacts, getMatchingMembers, @@ -156,6 +171,7 @@ import Data.ByteString (ByteString) import qualified Data.ByteString as B import Data.Char (toLower) import Data.Either (rights) +import Data.Functor (($>)) import Data.Int (Int64) import Data.List (partition, sortOn) import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing) @@ -165,6 +181,7 @@ import qualified Data.Text as T import Data.Time.Clock (UTCTime (..), getCurrentTime) import Data.Text.Encoding (encodeUtf8) import Simplex.Chat.Messages +import Simplex.Chat.Operators import Simplex.Chat.Protocol hiding (Binary) import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared @@ -173,7 +190,7 @@ import Simplex.Chat.Types.MemberRelations (IntroductionDirection (..), MemberRel import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), UserId) +import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), InvitationId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -191,11 +208,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus, Maybe BoolInt) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked', Just isChatRelay) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked', isChatRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -310,8 +327,8 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> ExceptT StoreError IO GroupInfo -createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = ExceptT $ do +createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> Bool -> ExceptT StoreError IO GroupInfo +createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile useRelays = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime @@ -327,11 +344,11 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc db [sql| INSERT INTO groups - (local_display_name, user_id, group_profile_id, enable_ntfs, + (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?) |] - (ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) + (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs vr @@ -339,7 +356,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc pure GroupInfo { groupId, - useRelays = BoolDef False, + useRelays = BoolDef useRelays, + relayOwnStatus = Nothing, localDisplayName = ldn, groupProfile, localAlias = "", @@ -371,9 +389,9 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ gInfo@GroupInfo {membership, groupProfile = p'} <- getGroupInfo db vr user gId hostId <- getHostMemberId_ db user gId let GroupMember {groupMemberId, memberId, memberRole} = membership - MemberIdRole {memberId = invMemberId, memberRole = memberRole'} = invitedMember - liftIO . when (memberId /= invMemberId || memberRole /= memberRole') $ - DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (invMemberId, memberRole', groupMemberId) + MemberIdRole {memberId = invMemberId, memberRole = invMemberRole} = invitedMember + liftIO . when (memberId /= invMemberId || memberRole /= invMemberRole) $ + DB.execute db "UPDATE group_members SET member_id = ?, member_role = ? WHERE group_member_id = ?" (invMemberId, invMemberRole, groupMemberId) gInfo' <- if p' == groupProfile then pure gInfo @@ -415,6 +433,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ ( GroupInfo { groupId, useRelays = BoolDef False, + relayOwnStatus = Nothing, localDisplayName, groupProfile, localAlias = "", @@ -498,8 +517,7 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing, - isChatRelay = BoolDef False + supportChat = Nothing } where memberChatVRange@(VersionRange minV maxV) = vr @@ -548,13 +566,17 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> ExceptT StoreError IO (GroupInfo, GroupMember) -createPreparedGroup db vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> ExceptT StoreError IO (GroupInfo, GroupMember) +createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) - (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing currentTs + (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing currentTs hostMemberId <- insertHost_ currentTs groupId groupLDN - let userMember = MemberIdRole (MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id") GRMember + userMemberId <- + if useRelays + then liftIO $ MemberId <$> encodedRandomBytes gVar 12 + else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" + let userMember = MemberIdRole userMemberId GRMember membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing currentTs vr hostMember <- getGroupMember db vr user groupId hostMemberId when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember @@ -562,8 +584,9 @@ createPreparedGroup db vr user@User {userId, userContactId} groupProfile busines pure (g, hostMember) where insertHost_ currentTs groupId groupLDN = do - let memberId = MemberId $ encodeUtf8 groupLDN <> "_host_unknown_id" - hostProfile = profileFromName $ nameFromMemberId memberId + randHostId <- liftIO $ encodedRandomBytes gVar 12 + let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_host_" <> randHostId + hostProfile = profileFromName $ nameFromBS randHostId (localDisplayName, profileId) <- createNewMemberProfile_ db user hostProfile currentTs indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ do @@ -750,7 +773,7 @@ createGroupViaLink' business membershipStatus = do currentTs <- liftIO getCurrentTime - (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business currentTs + (groupId, _groupLDN) <- createGroup_ db userId groupProfile Nothing business False Nothing currentTs hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact @@ -776,8 +799,8 @@ createGroupViaLink' ) insertedRowId db -createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> UTCTime -> ExceptT StoreError IO (GroupId, Text) -createGroup_ db userId groupProfile prepared business currentTs = ExceptT $ do +createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> UTCTime -> ExceptT StoreError IO (GroupId, Text) +createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus currentTs = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do liftIO $ do @@ -792,10 +815,10 @@ createGroup_ db userId groupProfile prepared business currentTs = ExceptT $ do INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business) + ((profileId, localDisplayName, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. toPreparedGroupRow prepared :. businessChatInfoRow business :. (BI useRelays, relayOwnStatus)) groupId <- insertedRowId db pure (groupId, localDisplayName) @@ -903,6 +926,15 @@ deleteGroupProfile_ db userId groupId = |] (userId, groupId) +getInProgressGroups :: DB.Connection -> VersionRangeChat -> User -> UTCTime -> IO [GroupInfo] +getInProgressGroups db vr user@User {userId} createdAtCutoff = do + groupIds <- map fromOnly <$> + DB.query + db + "SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ?" + (userId, createdAtCutoff) + rights <$> mapM (runExceptT . getGroupInfo db vr user) groupIds + getBaseGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe Text -> IO [GroupInfo] getBaseGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do map (toGroupInfo vr userContactId []) @@ -1019,6 +1051,16 @@ getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") (groupId, memberId) +getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe ContactName -> ExceptT StoreError IO (GroupMember, Bool) +getCreateUnknownGMByMemberId db vr user gInfo memberId memberName = do + liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case + Right m -> pure (m, False) + Left (SEGroupMemberNotFoundByMemberId _) -> do + let name = fromMaybe (nameFromMemberId memberId) memberName + m <- createNewUnknownGroupMember db vr user gInfo memberId name + pure (m, True) + Left e -> throwError e + getScopeMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberId -> ExceptT StoreError IO GroupMemberId getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberId | scopeMemberId == memberId' membership = pure $ groupMemberId' membership @@ -1075,14 +1117,13 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) --- TODO [channels fwd] retrieve relays based on knowledge about member from protocol, not role (isMemberRelay) -getGroupRelays :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getGroupRelays db vr user@User {userId, userContactId} GroupInfo {groupId} = do +getGroupRelayMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] +getGroupRelayMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) <$> DB.query db (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ?") - (userId, groupId, userContactId, GRAdmin) + (userId, groupId, userContactId, GRRelay) getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do @@ -1166,8 +1207,7 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, memberChatVRange = peerChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing, - isChatRelay = BoolDef False + supportChat = Nothing } where insertMember_ = do @@ -1188,6 +1228,241 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, ) pure indexInGroup +createGroupRelayRecord :: DB.Connection -> GroupInfo -> GroupMember -> UserChatRelay -> ExceptT StoreError IO GroupRelay +createGroupRelayRecord db GroupInfo {groupId} GroupMember {groupMemberId} UserChatRelay {chatRelayId} = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_relays + (group_id, group_member_id, chat_relay_id, relay_status, created_at, updated_at) + VALUES (?,?,?,?,?,?) + |] + (groupId, groupMemberId, chatRelayId, RSNew, currentTs, currentTs) + relayId <- liftIO $ insertedRowId db + getGroupRelayById db relayId + +getGroupRelayById :: DB.Connection -> Int64 -> ExceptT StoreError IO GroupRelay +getGroupRelayById db relayId = + ExceptT . firstRow toGroupRelay (SEGroupRelayNotFound relayId) $ + DB.query + db + (groupRelayQuery <> " WHERE group_relay_id = ?") + (Only relayId) + +getGroupRelayByGMId :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO GroupRelay +getGroupRelayByGMId db groupMemberId = + ExceptT . firstRow toGroupRelay (SEGroupRelayNotFoundByMemberId groupMemberId) $ + DB.query + db + (groupRelayQuery <> " WHERE group_member_id = ?") + (Only groupMemberId) + +getGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay] +getGroupRelays db GroupInfo {groupId} = + map toGroupRelay + <$> DB.query + db + (groupRelayQuery <> " WHERE group_id = ?") + (Only groupId) + +groupRelayQuery :: Query +groupRelayQuery = + [sql| + SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link + FROM group_relays + |] + +toGroupRelay :: (Int64, GroupMemberId, Int64, RelayStatus, Maybe ShortLinkContact) -> GroupRelay +toGroupRelay (groupRelayId, groupMemberId, userChatRelayId, relayStatus, relayLink) = + GroupRelay {groupRelayId, groupMemberId, userChatRelayId, relayStatus, relayLink} + +createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember +createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {name} = do + currentTs <- liftIO getCurrentTime + let relayProfile = profileFromName name + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user relayProfile currentTs + groupMemberId <- createWithRandomId' db gVar $ \memId -> runExceptT $ do + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + :. (userId, localDisplayName, memProfileId, currentTs, currentTs) + ) + liftIO $ insertedRowId db + getGroupMemberById db vr user groupMemberId + +getCreateRelayForMember :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> ShortLinkContact -> ExceptT StoreError IO GroupMember +getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo {groupId, localDisplayName = groupLDN} relayLink = + liftIO getGroupMemberByRelayLink >>= maybe createRelayMember pure + where + getGroupMemberByRelayLink = + maybeFirstRow (toContactMember vr user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.relay_link = ?") + (groupId, relayLink) + createRelayMember = do + currentTs <- liftIO getCurrentTime + randRelayId <- liftIO $ encodedRandomBytes gVar 12 + let memberId = MemberId $ encodeUtf8 groupLDN <> "_unknown_relay_" <> randRelayId + relayProfile = profileFromName $ nameFromBS randRelayId + (localDisplayName, profileId) <- createNewMemberProfile_ db user relayProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + groupMemberId <- liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) + ) + insertedRowId db + getGroupMember db vr user groupId groupMemberId + +createRelayConnection :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayConnection db vr user@User {userId} groupMemberId agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + group_member_id, conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnMember) + :. (groupMemberId, chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId + +updateRelayStatus :: DB.Connection -> GroupRelay -> RelayStatus -> IO GroupRelay +updateRelayStatus db relay@GroupRelay {groupRelayId} relayStatus = + updateRelayStatus_ db groupRelayId relayStatus $> relay {relayStatus} + +updateRelayStatusFromTo :: DB.Connection -> GroupRelay -> RelayStatus -> RelayStatus -> IO GroupRelay +updateRelayStatusFromTo db relay@GroupRelay {groupRelayId} fromStatus toStatus = do + maybeFirstRow fromOnly (DB.query db "SELECT relay_status FROM group_relays WHERE group_relay_id = ?" (Only groupRelayId)) >>= \case + Just status | status == fromStatus -> updateRelayStatus_ db groupRelayId toStatus $> relay {relayStatus = toStatus} + _ -> pure relay + +updateRelayStatus_ :: DB.Connection -> Int64 -> RelayStatus -> IO () +updateRelayStatus_ db relayId relayStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ?" (relayStatus, currentTs, relayId) + +setRelayLinkAccepted :: DB.Connection -> GroupRelay -> ShortLinkContact -> IO GroupRelay +setRelayLinkAccepted db relay@GroupRelay {groupRelayId, groupMemberId} relayLink = do + currentTs <- getCurrentTime + DB.execute + db + [sql| + UPDATE group_relays + SET relay_link = ?, relay_status = ?, updated_at = ? + WHERE group_relay_id = ? + |] + (relayLink, RSAccepted, currentTs, groupRelayId) + DB.execute + db + [sql| + UPDATE group_members + SET relay_link = ?, updated_at = ? + WHERE group_member_id = ? + |] + (relayLink, currentTs, groupMemberId) + pure relay {relayStatus = RSAccepted, relayLink = Just relayLink} + +setGroupInProgressDone :: DB.Connection -> GroupInfo -> IO () +setGroupInProgressDone db GroupInfo {groupId} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" + (currentTs, groupId) + +createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange = do + currentTs <- liftIO getCurrentTime + -- Create group with placeholder profile + let Profile {displayName = fromMemberLDN} = fromMemberProfile + placeholderProfile = GroupProfile + { displayName = "relay_request_" <> fromMemberLDN, + fullName = "", + shortDescr = Nothing, + description = Nothing, + image = Nothing, + groupLink = Nothing, + groupPreferences = Nothing, + memberAdmission = Nothing + } + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) currentTs + -- Store relay request data for recovery + liftIO $ setRelayRequestData_ groupId + ownerMemberId <- insertOwner_ currentTs groupId + let relayMember = MemberIdRole relayMemberId GRRelay + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing currentTs vr + ownerMember <- getGroupMember db vr user groupId ownerMemberId + g <- getGroupInfo db vr user groupId + pure (g, ownerMember) + where + setRelayRequestData_ groupId = + DB.execute + db + [sql| + UPDATE groups + SET relay_request_inv_id = ?, + relay_request_group_link = ?, + relay_request_peer_chat_min_version = ?, + relay_request_peer_chat_max_version = ? + WHERE group_id = ? + |] + (Binary invId, groupLink, minVersion reqChatVRange, maxVersion reqChatVRange, groupId) + insertOwner_ currentTs groupId = do + let MemberIdRole {memberId, memberRole} = fromMember + (localDisplayName, profileId) <- createNewMemberProfile_ db user fromMemberProfile currentTs + indexInGroup <- getUpdateNextIndexInGroup_ db groupId + liftIO $ do + DB.execute + db + [sql| + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + |] + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted) + :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) + ) + insertedRowId db + +updateRelayOwnStatusFromTo :: DB.Connection -> GroupInfo -> RelayStatus -> RelayStatus -> IO GroupInfo +updateRelayOwnStatusFromTo db gInfo@GroupInfo {groupId} fromStatus toStatus = do + maybeFirstRow fromOnly (DB.query db "SELECT relay_own_status FROM groups WHERE group_id = ?" (Only groupId)) >>= \case + Just status | status == fromStatus -> updateRelayOwnStatus_ db gInfo toStatus $> gInfo {relayOwnStatus = Just toStatus} + _ -> pure gInfo + +updateRelayOwnStatus_ :: DB.Connection -> GroupInfo -> RelayStatus -> IO () +updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do + currentTs <- getCurrentTime + DB.execute db "UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ?" (relayStatus, currentTs, groupId) + createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO () createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode = createWithRandomId' db gVar $ \memId -> runExceptT $ do @@ -1215,7 +1490,7 @@ createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo :. (minV, maxV) ) -createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) +createJoiningMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupMemberRole -> GroupMemberStatus -> ExceptT StoreError IO (GroupMemberId, MemberId) createJoiningMember db gVar @@ -1224,6 +1499,7 @@ createJoiningMember cReqChatVRange Profile {displayName, fullName, shortDescr, image, contactLink, preferences} cReqXContactId_ + cReqMemberId_ welcomeMsgId_ memberRole memberStatus = do @@ -1235,12 +1511,24 @@ createJoiningMember "INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" (displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) profileId <- liftIO $ insertedRowId db - createWithRandomId' db gVar $ \memId -> runExceptT $ do - insertMember_ ldn profileId (MemberId memId) currentTs - groupMemberId <- liftIO $ insertedRowId db - pure (groupMemberId, MemberId memId) + case cReqMemberId_ of + Just memberId -> do + checkMemberNotExists memberId + insertMember_ ldn profileId memberId currentTs + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, memberId) + Nothing -> + createWithRandomId' db gVar $ \memId -> runExceptT $ do + insertMember_ ldn profileId (MemberId memId) currentTs + groupMemberId <- liftIO $ insertedRowId db + pure (groupMemberId, MemberId memId) where VersionRange minV maxV = cReqChatVRange + -- TODO [relays] relay: TBC communicate rejection + checkMemberNotExists :: MemberId -> ExceptT StoreError IO () + checkMemberNotExists memberId = do + exists <- liftIO $ fromOnly . head <$> DB.query db "SELECT EXISTS (SELECT 1 FROM group_members WHERE group_id = ? AND member_id = ?)" (groupId, memberId) + when exists $ throwError SEDuplicateMemberId insertMember_ ldn profileId memberId currentTs = do indexInGroup <- getUpdateNextIndexInGroup_ db groupId liftIO $ @@ -1488,8 +1776,7 @@ createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} m memInvitedByGroupMemberId = Just $ groupMemberId' invitingMember, localDisplayName, memContactId = Nothing, - memProfileId, - isChatRelay = False + memProfileId } createNewMember_ db user gInfo newMember currentTs @@ -1517,8 +1804,7 @@ createNewMember_ memInvitedByGroupMemberId, localDisplayName, memContactId = memberContactId, - memProfileId = memberContactProfileId, - isChatRelay + memProfileId = memberContactProfileId } createdAt = do let invitedById = fromInvitedBy userContactId invitedBy @@ -1531,13 +1817,13 @@ createNewMember_ [sql| INSERT INTO group_members (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, - member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, + member_restriction, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty) - :. (memRestriction, BI isChatRelay, invitedById, memInvitedByGroupMemberId) + :. (memRestriction, invitedById, memInvitedByGroupMemberId) :. (userId, localDisplayName, memberContactId, memberContactProfileId, createdAt, createdAt) :. (minV, maxV) ) @@ -1563,8 +1849,7 @@ createNewMember_ memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing, - isChatRelay = BoolDef isChatRelay + supportChat = Nothing } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -1669,27 +1954,35 @@ getMemberRelationsVector db GroupMember {groupMemberId} = "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" (Only groupMemberId) -createIntroReMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> VersionChat -> MemberInfo -> Maybe MemberRestrictions -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMember :: DB.Connection -> User -> GroupInfo -> MemberInfo -> Maybe MemberRestrictions -> ExceptT StoreError IO GroupMember createIntroReMember db - user@User {userId} + user gInfo + memInfo@(MemberInfo _ _ _ memberProfile) + memRestrictions_ = do + currentTs <- liftIO getCurrentTime + (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs + let memRestriction = restriction <$> memRestrictions_ + newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId} + createNewMember_ db user gInfo newMember currentTs + +createIntroReMemberConn :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> MemberInfo -> (CommandId, ConnId) -> SubscriptionMode -> ExceptT StoreError IO GroupMember +createIntroReMemberConn + db + user@User {userId} _host@GroupMember {memberContactId, activeConn} + reMember@GroupMember {groupMemberId} chatV - memInfo@(MemberInfo _ _ memChatVRange memberProfile) - memRestrictions_ + (MemberInfo _ _ memChatVRange _) (groupCmdId, groupAgentConnId) subMode = do let mcvr = maybe chatInitialVRange fromChatVRange memChatVRange cLevel = 1 + maybe 0 (\Connection {connLevel} -> connLevel) activeConn - memRestriction = restriction <$> memRestrictions_ currentTs <- liftIO getCurrentTime - (localDisplayName, memProfileId) <- createNewMemberProfile_ db user memberProfile currentTs - let newMember = NewGroupMember {memInfo, memCategory = GCPreMember, memStatus = GSMemIntroduced, memRestriction, memInvitedBy = IBUnknown, memInvitedByGroupMemberId = Nothing, localDisplayName, memContactId = Nothing, memProfileId, isChatRelay = False} - member <- createNewMember_ db user gInfo newMember currentTs - conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId (groupMemberId' member) groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode + conn@Connection {connId = groupConnId} <- liftIO $ createMemberConnection_ db userId groupMemberId groupAgentConnId chatV mcvr memberContactId cLevel currentTs subMode liftIO $ setCommandConnId db user groupCmdId groupConnId - pure (member :: GroupMember) {activeConn = Just conn} + pure (reMember :: GroupMember) {activeConn = Just conn} createIntroToMemberContact :: DB.Connection -> User -> GroupMember -> GroupMember -> VersionChat -> VersionRangeChat -> (CommandId, ConnId) -> Maybe (CommandId, ConnId) -> Maybe ProfileId -> SubscriptionMode -> IO () createIntroToMemberContact db user@User {userId} GroupMember {memberContactId = viaContactId, activeConn} _to@GroupMember {groupMemberId, localDisplayName} chatV mcvr (groupCmdId, groupAgentConnId) directConnIds customUserProfileId subMode = do @@ -1733,7 +2026,7 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -1751,14 +2044,16 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, db [sql| UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_link = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups WHERE user_id = ? AND group_id = ? ) |] - (newName, fullName, shortDescr, description, image, groupPreferences, memberAdmission, currentTs, userId, groupId) + ( (newName, fullName, shortDescr, description, image, groupLink) + :. (groupPreferences, memberAdmission, currentTs, userId, groupId) + ) updateGroup_ ldn currentTs = do DB.execute db @@ -1796,23 +2091,14 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName DB.query db [sql| - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.preferences, gp.member_admission + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_link, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) - toGroupProfile (displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission) = - GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} - -getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo -getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do - chatTags <- getGroupChatTags db groupId - firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ - DB.query - db - (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") - (groupId, userId, userContactId) + toGroupProfile (displayName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission) = + GroupProfile {displayName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission} getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 91b9a74555..28ecbc829a 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -676,7 +676,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -889,7 +889,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> do - let q = baseQuery <> " WHERE g.user_id = ?" + let q = baseQuery <> " WHERE g.user_id = ? AND g.creating_in_progress = 0" p = baseParams :. Only userId queryWithPagination q p CLQFilters {favorite = True, unread = False} -> do @@ -897,7 +897,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseQuery <> " " <> [sql| - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 |] p = baseParams :. Only userId @@ -907,7 +907,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseQuery <> " " <> [sql| - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] p = baseParams :. Only userId @@ -917,7 +917,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = baseQuery <> " " <> [sql| - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.favorite = 1 OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) |] @@ -929,7 +929,7 @@ findGroupChatPreviews_ db User {userId} pagination clq = <> " " <> [sql| JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND ( LOWER(g.local_display_name) LIKE '%' || ? || '%' OR LOWER(gp.display_name) LIKE '%' || ? || '%' @@ -2966,7 +2966,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -2974,20 +2974,18 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.member_restriction, rm.is_chat_relay, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.is_chat_relay, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id - LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs index 8b59053e91..4059008b02 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260109_chat_relays.hs @@ -12,35 +12,81 @@ m20260109_chat_relays = [r| CREATE TABLE chat_relays( chat_relay_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, - address TEXT NOT NULL, + address BYTEA NOT NULL, name TEXT NOT NULL, domains TEXT NOT NULL, preset SMALLINT NOT NULL DEFAULT 0, tested SMALLINT, enabled SMALLINT NOT NULL DEFAULT 1, user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + deleted SMALLINT NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (now()), - updated_at TEXT NOT NULL DEFAULT (now()), - UNIQUE(user_id, address), - UNIQUE(user_id, name) + updated_at TEXT NOT NULL DEFAULT (now()) ); - CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays(user_id, address); +CREATE UNIQUE INDEX idx_chat_relays_user_id_name ON chat_relays(user_id, name); ALTER TABLE users ADD COLUMN is_user_chat_relay SMALLINT NOT NULL DEFAULT 0; -ALTER TABLE group_members ADD COLUMN is_chat_relay SMALLINT NOT NULL DEFAULT 0; +ALTER TABLE groups + ADD COLUMN use_relays SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN creating_in_progress SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN relay_own_status TEXT, + ADD COLUMN relay_request_inv_id BYTEA, + ADD COLUMN relay_request_group_link BYTEA, + ADD COLUMN relay_request_peer_chat_min_version INTEGER, + ADD COLUMN relay_request_peer_chat_max_version INTEGER, + ADD COLUMN relay_request_failed SMALLINT DEFAULT 0, + ADD COLUMN relay_request_err_reason TEXT; + +ALTER TABLE group_profiles ADD COLUMN group_link BYTEA; + +CREATE TABLE group_relays( + group_relay_id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + group_id BIGINT NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id BIGINT NOT NULL REFERENCES group_members ON DELETE CASCADE, + chat_relay_id BIGINT NOT NULL REFERENCES chat_relays ON DELETE CASCADE, + relay_status TEXT NOT NULL, + relay_link BYTEA, + created_at TEXT NOT NULL DEFAULT (now()), + updated_at TEXT NOT NULL DEFAULT (now()) +); +CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays(group_member_id); +CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); + +ALTER TABLE group_members ADD COLUMN relay_link BYTEA; |] down_m20260109_chat_relays :: Text down_m20260109_chat_relays = T.pack [r| -ALTER TABLE group_members DROP COLUMN is_chat_relay; - ALTER TABLE users DROP COLUMN is_user_chat_relay; -DROP INDEX idx_chat_relays_user_id; +ALTER TABLE groups + DROP COLUMN use_relays, + DROP COLUMN creating_in_progress, + DROP COLUMN relay_own_status, + DROP COLUMN relay_request_inv_id, + DROP COLUMN relay_request_group_link, + DROP COLUMN relay_request_peer_chat_min_version, + DROP COLUMN relay_request_peer_chat_max_version, + DROP COLUMN relay_request_failed, + DROP COLUMN relay_request_err_reason; +ALTER TABLE group_profiles DROP COLUMN group_link; + +DROP INDEX idx_group_relays_group_id; +DROP INDEX idx_group_relays_group_member_id; +DROP INDEX idx_group_relays_chat_relay_id; +DROP TABLE group_relays; + +DROP INDEX idx_chat_relays_user_id; +DROP INDEX idx_chat_relays_user_id_address; +DROP INDEX idx_chat_relays_user_id_name; DROP TABLE chat_relays; + +ALTER TABLE group_members DROP COLUMN relay_link; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 3474d03ad9..f41f193c4d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -360,13 +360,14 @@ ALTER TABLE test_chat_schema.chat_items ALTER COLUMN chat_item_id ADD GENERATED CREATE TABLE test_chat_schema.chat_relays ( chat_relay_id bigint NOT NULL, - address text NOT NULL, + address bytea NOT NULL, name text NOT NULL, domains text NOT NULL, preset smallint DEFAULT 0 NOT NULL, tested smallint, enabled smallint DEFAULT 1 NOT NULL, user_id bigint NOT NULL, + deleted smallint DEFAULT 0 NOT NULL, created_at text DEFAULT now() NOT NULL, updated_at text DEFAULT now() NOT NULL ); @@ -809,7 +810,7 @@ CREATE TABLE test_chat_schema.group_members ( member_welcome_shared_msg_id bytea, index_in_group bigint DEFAULT 0 NOT NULL, member_relations_vector bytea, - is_chat_relay smallint DEFAULT 0 NOT NULL + relay_link bytea ); @@ -837,7 +838,8 @@ CREATE TABLE test_chat_schema.group_profiles ( preferences text, description text, member_admission text, - short_descr text + short_descr text, + group_link bytea ); @@ -853,6 +855,30 @@ ALTER TABLE test_chat_schema.group_profiles ALTER COLUMN group_profile_id ADD GE +CREATE TABLE test_chat_schema.group_relays ( + group_relay_id bigint NOT NULL, + group_id bigint NOT NULL, + group_member_id bigint NOT NULL, + chat_relay_id bigint NOT NULL, + relay_status text NOT NULL, + relay_link bytea, + created_at text DEFAULT now() NOT NULL, + updated_at text DEFAULT now() NOT NULL +); + + + +ALTER TABLE test_chat_schema.group_relays ALTER COLUMN group_relay_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.group_relays_group_relay_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.group_snd_item_statuses ( group_snd_item_status_id bigint NOT NULL, chat_item_id bigint NOT NULL, @@ -909,7 +935,16 @@ CREATE TABLE test_chat_schema.groups ( conn_link_prepared_connection smallint DEFAULT 0 NOT NULL, via_group_link_uri bytea, summary_current_members_count bigint DEFAULT 0 NOT NULL, - member_index bigint DEFAULT 0 NOT NULL + member_index bigint DEFAULT 0 NOT NULL, + use_relays smallint DEFAULT 0 NOT NULL, + creating_in_progress smallint DEFAULT 0 NOT NULL, + relay_own_status text, + relay_request_inv_id bytea, + relay_request_group_link bytea, + relay_request_peer_chat_min_version integer, + relay_request_peer_chat_max_version integer, + relay_request_failed smallint DEFAULT 0, + relay_request_err_reason text ); @@ -1466,16 +1501,6 @@ ALTER TABLE ONLY test_chat_schema.chat_relays -ALTER TABLE ONLY test_chat_schema.chat_relays - ADD CONSTRAINT chat_relays_user_id_address_key UNIQUE (user_id, address); - - - -ALTER TABLE ONLY test_chat_schema.chat_relays - ADD CONSTRAINT chat_relays_user_id_name_key UNIQUE (user_id, name); - - - ALTER TABLE ONLY test_chat_schema.chat_tags ADD CONSTRAINT chat_tags_pkey PRIMARY KEY (chat_tag_id); @@ -1591,6 +1616,11 @@ ALTER TABLE ONLY test_chat_schema.group_profiles +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_pkey PRIMARY KEY (group_relay_id); + + + ALTER TABLE ONLY test_chat_schema.group_snd_item_statuses ADD CONSTRAINT group_snd_item_statuses_pkey PRIMARY KEY (group_snd_item_status_id); @@ -1968,6 +1998,14 @@ CREATE INDEX idx_chat_relays_user_id ON test_chat_schema.chat_relays USING btree +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON test_chat_schema.chat_relays USING btree (user_id, address); + + + +CREATE UNIQUE INDEX idx_chat_relays_user_id_name ON test_chat_schema.chat_relays USING btree (user_id, name); + + + CREATE INDEX idx_chat_tags_chats_chat_tag_id ON test_chat_schema.chat_tags_chats USING btree (chat_tag_id); @@ -2240,6 +2278,18 @@ CREATE INDEX idx_group_profiles_user_id ON test_chat_schema.group_profiles USING +CREATE INDEX idx_group_relays_chat_relay_id ON test_chat_schema.group_relays USING btree (chat_relay_id); + + + +CREATE INDEX idx_group_relays_group_id ON test_chat_schema.group_relays USING btree (group_id); + + + +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON test_chat_schema.group_relays USING btree (group_member_id); + + + CREATE INDEX idx_group_snd_item_statuses_chat_item_id ON test_chat_schema.group_snd_item_statuses USING btree (chat_item_id); @@ -2919,6 +2969,21 @@ ALTER TABLE ONLY test_chat_schema.group_profiles +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_chat_relay_id_fkey FOREIGN KEY (chat_relay_id) REFERENCES test_chat_schema.chat_relays(chat_relay_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; + + + +ALTER TABLE ONLY test_chat_schema.group_relays + ADD CONSTRAINT group_relays_group_member_id_fkey FOREIGN KEY (group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.group_snd_item_statuses ADD CONSTRAINT group_snd_item_statuses_chat_item_id_fkey FOREIGN KEY (chat_item_id) REFERENCES test_chat_schema.chat_items(chat_item_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 87086f6bd4..c58099d2b0 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -26,6 +26,7 @@ module Simplex.Chat.Store.Profiles getUsers, setActiveUser, getUser, + getRelayUser, getUserIdByName, getUserByAConnId, getUserByASndFileId, @@ -58,6 +59,7 @@ module Simplex.Chat.Store.Profiles updateUserAddressSettings, getProtocolServers, getChatRelays, + getChatRelayById, insertProtocolServer, getUpdateServerOperators, getServerOperators, @@ -216,6 +218,11 @@ getUser db userId = ExceptT . firstRow toUser (SEUserNotFound userId) $ DB.query db (userQuery <> " WHERE u.user_id = ?") (Only userId) +getRelayUser :: DB.Connection -> ExceptT StoreError IO User +getRelayUser db = + ExceptT . firstRow toUser SERelayUserNotFound $ + DB.query_ db (userQuery <> " WHERE u.is_user_chat_relay = 1") + getUserIdByName :: DB.Connection -> UserName -> ExceptT StoreError IO Int64 getUserIdByName db uName = ExceptT . firstRow fromOnly (SEUserNotFoundByName uName) $ @@ -621,13 +628,25 @@ getChatRelays db User {userId} = [sql| SELECT chat_relay_id, address, name, domains, preset, tested, enabled FROM chat_relays - WHERE user_id = ? + WHERE user_id = ? AND deleted = 0 |] (Only userId) - where - toChatRelay :: (DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay - toChatRelay (chatRelayId, address, name, domains, BI preset, tested, BI enabled) = - UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} + +toChatRelay :: (DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt) -> UserChatRelay +toChatRelay (chatRelayId, address, name, domains, BI preset, tested, BI enabled) = + UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted = False} + +getChatRelayById :: DB.Connection -> User -> Int64 -> ExceptT StoreError IO UserChatRelay +getChatRelayById db User {userId} relayId = + ExceptT . firstRow toChatRelay (SEUserChatRelayNotFound relayId) $ + DB.query + db + [sql| + SELECT chat_relay_id, address, name, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? AND chat_relay_id = ? AND deleted = 0 + |] + (userId, relayId) insertChatRelay :: DB.Connection -> User -> UTCTime -> NewUserChatRelay -> IO UserChatRelay insertChatRelay db User {userId} ts relay@UserChatRelay {address, name, domains, preset, tested, enabled} = do @@ -642,7 +661,7 @@ insertChatRelay db User {userId} ts relay@UserChatRelay {address, name, domains, RETURNING chat_relay_id |] (address, name, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, userId, ts, ts) - pure relay {chatRelayId = DBEntityId crId} + pure (relay :: NewUserChatRelay) {chatRelayId = DBEntityId crId} updateChatRelay :: DB.Connection -> UTCTime -> UserChatRelay -> IO () updateChatRelay db ts UserChatRelay {chatRelayId, address, name, domains, preset, tested, enabled} = @@ -907,7 +926,13 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s | deleted -> pure Nothing | otherwise -> Just <$> insertChatRelay db user ts relay DBEntityId relayId - | deleted -> Nothing <$ DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, relayId, BI False) + | deleted -> do + -- If relay is referenced in group_relays, mark it as deleted instead of deleting + referenced <- fromOnly . head <$> DB.query db "SELECT EXISTS (SELECT 1 FROM group_relays WHERE chat_relay_id = ?)" (Only relayId) + if referenced + then DB.execute db "UPDATE chat_relays SET deleted = 1, updated_at = ? WHERE chat_relay_id = ?" (ts, relayId) + else DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, relayId, BI False) + pure Nothing | otherwise -> Just relay <$ updateChatRelay db ts relay createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () diff --git a/src/Simplex/Chat/Store/RelayRequests.hs b/src/Simplex/Chat/Store/RelayRequests.hs new file mode 100644 index 0000000000..04731d8ef3 --- /dev/null +++ b/src/Simplex/Chat/Store/RelayRequests.hs @@ -0,0 +1,104 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE ScopedTypeVariables #-} + +module Simplex.Chat.Store.RelayRequests + ( hasPendingRelayRequests, + getNextPendingRelayRequest, + setRelayRequestErr, + ) +where + +import Data.Maybe (fromMaybe) +import Data.Text (Text) +import Data.Time.Clock (getCurrentTime) +import Simplex.Chat.Store.Shared +import Simplex.Chat.Types +import Simplex.Messaging.Agent.Protocol (InvitationId) +import Simplex.Messaging.Agent.Store.AgentStore (getWorkItem, maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Util (firstRow') +import Simplex.Messaging.Version +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) +#else +import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) +#endif + +hasPendingRelayRequests :: DB.Connection -> IO Bool +hasPendingRelayRequests db = + fromOnly . head + <$> DB.query + db + [sql| + SELECT EXISTS ( + SELECT 1 + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + LIMIT 1 + ) + |] + (Only RSInvited) + +getNextPendingRelayRequest :: DB.Connection -> IO (Either StoreError (Maybe (GroupId, RelayRequestData))) +getNextPendingRelayRequest db = + getWorkItem "relay request" getNextRequestGroupId getRelayRequestData (markRelayRequestFailed db) + where + getNextRequestGroupId :: IO (Maybe GroupId) + getNextRequestGroupId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT group_id + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + ORDER BY group_id ASC + LIMIT 1 + |] + (Only RSInvited) + getRelayRequestData :: GroupId -> IO (Either StoreError (GroupId, RelayRequestData)) + getRelayRequestData groupId = + firstRow' toRelayRequestData (SEGroupNotFound groupId) $ + DB.query + db + [sql| + SELECT + relay_request_inv_id, relay_request_group_link, + relay_request_peer_chat_min_version, relay_request_peer_chat_max_version + FROM groups + WHERE group_id = ? + |] + (Only groupId) + where + toRelayRequestData :: (Maybe InvitationId, Maybe ShortLinkContact, Maybe VersionChat, Maybe VersionChat) -> Either StoreError (GroupId, RelayRequestData) + toRelayRequestData = \case + (Just relayInvId, Just reqGroupLink, Just minV, Just maxV) -> + Right (groupId, RelayRequestData {relayInvId, reqGroupLink, reqChatVRange = fromMaybe (versionToRange maxV) $ safeVersionRange minV maxV}) + _ -> Left $ SEInternalError "missing relay request data" + +markRelayRequestFailed :: DB.Connection -> GroupId -> IO () +markRelayRequestFailed db groupId = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET relay_request_failed = 1, updated_at = ? WHERE group_id = ?" + (currentTs, groupId) + +setRelayRequestErr :: DB.Connection -> GroupId -> Text -> IO () +setRelayRequestErr db groupId errReason = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET relay_request_err_reason = ?, updated_at = ? WHERE group_id = ?" + (errReason, currentTs, groupId) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs index 677b373c0f..6ebfede31d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260109_chat_relays.hs @@ -5,39 +5,102 @@ module Simplex.Chat.Store.SQLite.Migrations.M20260109_chat_relays where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) +-- TODO [relays] TBC schema improvement - relay_link is duplicate on group_relays and group_members for owner +-- - chat_relays - user's list of chat relays to choose from (similar to protocol_servers) +-- - users.is_user_chat_relay - indicates that the user can serve as a chat relay +-- (TBC usage, e.g. agree to invitations to be relay) +-- - group_relays - group owner's list of relays for a group +-- - group_relays.relay_link - links for all relays of a group are included in GroupShortLinkData +-- - group_relays.relay_status - group owner's status for each relay (RelayStatus) +-- - group_relays.chat_relay_id - associates group_relays record with a chat_relays record, +-- chat_relays.deleted is to keep associated record if user removes chat relay from configuration, +-- but has group relays using it +-- - group_members.relay_link - relay link, saved on member record for user joining group +-- - groups.relay_own_status - indicates for a relay client that it is chat relay for the group (RelayStatus) +-- - groups.relay_request_* - relay request "work item" fields m20260109_chat_relays :: Query m20260109_chat_relays = [sql| CREATE TABLE chat_relays( chat_relay_id INTEGER PRIMARY KEY, - address TEXT NOT NULL, + address BLOB NOT NULL, name TEXT NOT NULL, domains TEXT NOT NULL, preset INTEGER NOT NULL DEFAULT 0, tested INTEGER, enabled INTEGER NOT NULL DEFAULT 1, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT(datetime('now')), - updated_at TEXT NOT NULL DEFAULT(datetime('now')), - UNIQUE(user_id, address), - UNIQUE(user_id, name) -); - + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays(user_id, address); +CREATE UNIQUE INDEX idx_chat_relays_user_id_name ON chat_relays(user_id, name); ALTER TABLE users ADD COLUMN is_user_chat_relay INTEGER NOT NULL DEFAULT 0; -ALTER TABLE group_members ADD COLUMN is_chat_relay INTEGER NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN use_relays INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN creating_in_progress INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE groups ADD COLUMN relay_own_status TEXT; + +ALTER TABLE groups ADD COLUMN relay_request_inv_id BLOB; +ALTER TABLE groups ADD COLUMN relay_request_group_link BLOB; +ALTER TABLE groups ADD COLUMN relay_request_peer_chat_min_version INTEGER; +ALTER TABLE groups ADD COLUMN relay_request_peer_chat_max_version INTEGER; +ALTER TABLE groups ADD COLUMN relay_request_failed INTEGER DEFAULT 0; +ALTER TABLE groups ADD COLUMN relay_request_err_reason TEXT; + +ALTER TABLE group_profiles ADD COLUMN group_link BLOB; + +CREATE TABLE group_relays( + group_relay_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + chat_relay_id INTEGER NOT NULL REFERENCES chat_relays ON DELETE CASCADE, + relay_status TEXT NOT NULL, + relay_link BLOB, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays(group_member_id); +CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); + +ALTER TABLE group_members ADD COLUMN relay_link BLOB; |] down_m20260109_chat_relays :: Query down_m20260109_chat_relays = [sql| -ALTER TABLE group_members DROP COLUMN is_chat_relay; - ALTER TABLE users DROP COLUMN is_user_chat_relay; -DROP INDEX idx_chat_relays_user_id; +ALTER TABLE groups DROP COLUMN use_relays; +ALTER TABLE groups DROP COLUMN creating_in_progress; + +ALTER TABLE groups DROP COLUMN relay_own_status; + +ALTER TABLE groups DROP COLUMN relay_request_inv_id; +ALTER TABLE groups DROP COLUMN relay_request_group_link; +ALTER TABLE groups DROP COLUMN relay_request_peer_chat_min_version; +ALTER TABLE groups DROP COLUMN relay_request_peer_chat_max_version; +ALTER TABLE groups DROP COLUMN relay_request_failed; +ALTER TABLE groups DROP COLUMN relay_request_err_reason; + +ALTER TABLE group_profiles DROP COLUMN group_link; + +DROP INDEX idx_group_relays_group_id; +DROP INDEX idx_group_relays_group_member_id; +DROP INDEX idx_group_relays_chat_relay_id; +DROP TABLE group_relays; + +DROP INDEX idx_chat_relays_user_id; +DROP INDEX idx_chat_relays_user_id_address; +DROP INDEX idx_chat_relays_user_id_name; DROP TABLE chat_relays; + +ALTER TABLE group_members DROP COLUMN relay_link; |] 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 8f9df6aeb3..f5eae94ac2 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -30,6 +30,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -82,6 +83,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -140,21 +142,22 @@ SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) LEFT-JOIN Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_link, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, -- from GroupMember - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -175,6 +178,21 @@ SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT delivery_task_id + FROM delivery_tasks + WHERE group_id = ? + AND worker_scope = ? + AND job_scope_spec_tag IS NOT DISTINCT FROM ? + AND job_scope_include_pending IS NOT DISTINCT FROM ? + AND job_scope_support_gm_id IS NOT DISTINCT FROM ? + AND failed = 0 + AND task_status = ? + ORDER BY delivery_task_id ASC + +Plan: +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next_for_job_scope (group_id=? AND worker_scope=? AND job_scope_spec_tag=? AND job_scope_include_pending=? AND job_scope_support_gm_id=? AND failed=? AND task_status=?) + Query: SELECT delivery_task_id FROM delivery_tasks @@ -265,6 +283,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -299,6 +318,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -333,6 +353,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -490,6 +511,75 @@ Query: Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, + user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, @@ -497,6 +587,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -531,6 +622,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -590,6 +682,16 @@ Query: Plan: SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT + relay_request_inv_id, relay_request_group_link, + relay_request_peer_chat_min_version, relay_request_peer_chat_max_version + FROM groups + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT COUNT(1) FROM ( @@ -864,7 +966,7 @@ Plan: SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? AND worker_scope=? AND failed=? AND task_status=?) Query: - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.preferences, gp.member_admission + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_link, gp.preferences, gp.member_admission FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? @@ -873,6 +975,18 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT group_id + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + ORDER BY group_id ASC + LIMIT 1 + +Plan: +SCAN groups + Query: SELECT i.chat_item_id FROM chat_items i @@ -892,7 +1006,7 @@ Query: SELECT i.chat_item_id, -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts @@ -1016,16 +1130,52 @@ Query: RETURNING chat_relay_id Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_relay_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + user_id, local_display_name, contact_profile_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) Query: INSERT INTO group_members (group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, - member_restriction, is_chat_relay, invited_by, invited_by_group_member_id, + member_restriction, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1056,16 +1206,16 @@ Query: INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, - business_chat, business_member_id, customer_member_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + business_chat, business_member_id, customer_member_id, use_relays, relay_own_status) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO groups - (local_display_name, user_id, group_profile_id, enable_ntfs, + (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?) Plan: @@ -1121,7 +1271,7 @@ Query: i.forwarded_by_group_member_id, i.show_group_as_sender, -- GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, - m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, + m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -1129,20 +1279,18 @@ Query: ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, - rm.member_status, rm.show_messages, rm.member_restriction, rm.is_chat_relay, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, + rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, - dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.is_chat_relay, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, + dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id - LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id - LEFT JOIN contact_profiles gsp ON gsp.contact_profile_id = COALESCE(gsm.member_profile_id, gsm.contact_profile_id) LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN chat_items ri ON ri.shared_msg_id = i.quoted_shared_msg_id AND ri.group_id = i.group_id @@ -1155,7 +1303,6 @@ Query: Plan: SEARCH i USING INTEGER PRIMARY KEY (rowid=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) LEFT-JOIN -SEARCH gsm USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH p USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH ri USING INDEX idx_chat_items_group_id_shared_msg_id (group_id=? AND shared_msg_id=?) LEFT-JOIN @@ -1568,7 +1715,7 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE group_profiles - SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? + SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_link = ?, preferences = ?, member_admission = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -1580,6 +1727,26 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET relay_request_inv_id = ?, + relay_request_group_link = ?, + relay_request_peer_chat_min_version = ?, + relay_request_peer_chat_max_version = ? + WHERE group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + group_member_id, conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO connections ( user_id, agent_conn_id, conn_level, conn_status, conn_type, contact_id, custom_user_profile_id, @@ -1613,6 +1780,7 @@ Query: VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -1639,6 +1807,13 @@ SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_mem SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) +Query: + INSERT INTO group_relays + (group_id, group_member_id, chat_relay_id, relay_status, created_at, updated_at) + VALUES (?,?,?,?,?,?) + +Plan: + Query: INSERT INTO msg_deliveries (message_id, connection_id, agent_msg_id, agent_msg_meta, chat_ts, created_at, updated_at, delivery_status) @@ -2237,7 +2412,7 @@ Query: ) ReportCount ON ReportCount.group_id = g.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND ( LOWER(g.local_display_name) LIKE '%' || ? || '%' OR LOWER(gp.display_name) LIKE '%' || ? || '%' @@ -2289,7 +2464,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.favorite = 1 OR g.unread_chat = 1 OR ChatStats.UnreadCount > 0) ORDER BY g.chat_ts DESC LIMIT ? @@ -2335,7 +2510,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2380,7 +2555,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: @@ -2425,7 +2600,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2470,7 +2645,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2515,7 +2690,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: @@ -2560,7 +2735,7 @@ Query: GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.favorite = 1 ORDER BY g.chat_ts DESC LIMIT ? Plan: @@ -2604,7 +2779,7 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id>?) @@ -2646,7 +2821,7 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 AND g.chat_ts > ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id>?) @@ -2688,7 +2863,7 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? + WHERE g.user_id = ? AND g.creating_in_progress = 0 ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id>?) @@ -3065,6 +3240,21 @@ Query: Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_notes (user_id=? AND note_folder_id=? AND item_status=?) +Query: + SELECT EXISTS ( + SELECT 1 + FROM groups + WHERE relay_own_status = ? + AND relay_request_failed = 0 + AND relay_request_err_reason IS NULL + LIMIT 1 + ) + +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SCAN groups + Query: SELECT agent_conn_id FROM connections @@ -3230,7 +3420,15 @@ SEARCH chat_item_versions USING INDEX idx_chat_item_versions_chat_item_id (chat_ Query: SELECT chat_relay_id, address, name, domains, preset, tested, enabled FROM chat_relays - WHERE user_id = ? + WHERE user_id = ? AND chat_relay_id = ? AND deleted = 0 + +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) + +Query: + SELECT chat_relay_id, address, name, domains, preset, tested, enabled + FROM chat_relays + WHERE user_id = ? AND deleted = 0 Plan: SEARCH chat_relays USING INDEX idx_chat_relays_user_id (user_id=?) @@ -3364,6 +3562,30 @@ Query: Plan: SEARCH files USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT group_member_id + FROM group_members + WHERE group_id = ? + AND contact_id IS DISTINCT FROM ? + AND group_member_id IS DISTINCT FROM ? + AND member_status IN (?,?,?,?,?,?) + AND group_member_id > ? ORDER BY group_member_id ASC LIMIT ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +USE TEMP B-TREE FOR ORDER BY + +Query: + SELECT group_member_id + FROM group_members + WHERE group_id = ? + AND contact_id IS DISTINCT FROM ? + AND group_member_id IS DISTINCT FROM ? + AND member_status IN (?,?,?,?,?,?) + ORDER BY group_member_id ASC LIMIT ? +Plan: +SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +USE TEMP B-TREE FOR ORDER BY + Query: SELECT group_member_id, group_snd_item_status, via_proxy FROM group_snd_item_statuses @@ -4664,6 +4886,14 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET relay_link = ?, updated_at = ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET show_messages = ?, updated_at = ? @@ -4699,6 +4929,14 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_relays + SET relay_link = ?, relay_status = ?, updated_at = ? + WHERE group_relay_id = ? + +Plan: +SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_snd_item_statuses SET group_snd_item_status = ?, updated_at = ? @@ -4858,15 +5096,16 @@ SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_me Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_link, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -4892,15 +5131,16 @@ SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_link, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -4919,15 +5159,16 @@ SEARCH pu USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_link, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -4975,7 +5216,7 @@ SEARCH p USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5002,7 +5243,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5021,7 +5262,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5040,7 +5281,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5059,7 +5300,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5078,7 +5319,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5097,7 +5338,26 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.group_id = ? AND m.relay_link = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5116,7 +5376,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5135,7 +5395,7 @@ SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JO Query: SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -5152,6 +5412,25 @@ SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + WHERE m.user_id = ? AND m.group_id = ? AND m.contact_id IS DISTINCT FROM ? AND m.member_role = ? +Plan: +SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -5206,6 +5485,27 @@ Plan: SEARCH i USING COVERING INDEX idx_chat_items_notes_created_at (user_id=? AND note_folder_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) +Query: + SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link + FROM group_relays + WHERE group_id = ? +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_id (group_id=?) + +Query: + SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link + FROM group_relays + WHERE group_member_id = ? +Plan: +SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) + +Query: + SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link + FROM group_relays + WHERE group_relay_id = ? +Plan: +SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias FROM group_members m @@ -5379,6 +5679,18 @@ SEARCH u USING INTEGER PRIMARY KEY (rowid=?) SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay + FROM users u + JOIN contacts uct ON uct.contact_id = u.contact_id + JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id + WHERE u.is_user_chat_relay = 1 +Plan: +SCAN u +SEARCH uct USING INTEGER PRIMARY KEY (rowid=?) +SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes, u.is_user_chat_relay @@ -5651,6 +5963,7 @@ SEARCH groups USING COVERING INDEX idx_groups_chat_item_id (chat_item_id=?) Query: DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ? Plan: SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_relay_id=?) Query: DELETE FROM commands WHERE user_id = ? AND command_id = ? Plan: @@ -5746,6 +6059,7 @@ SEARCH files USING COVERING INDEX idx_files_redirect_file_id (redirect_file_id=? Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -5775,6 +6089,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) @@ -5804,6 +6119,7 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: DELETE FROM groups WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_id (group_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_group_id (group_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_group_id (group_id=?) SEARCH chat_item_mentions USING COVERING INDEX idx_chat_item_mentions_group_id (group_id=?) @@ -6091,6 +6407,18 @@ SCAN CONSTANT ROW SCALAR SUBQUERY 1 SEARCH chat_items USING COVERING INDEX idx_chat_items_contacts_created_at (user_id=? AND contact_id=?) +Query: SELECT EXISTS (SELECT 1 FROM group_members WHERE group_id = ? AND member_id = ?) +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SEARCH group_members USING COVERING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) + +Query: SELECT EXISTS (SELECT 1 FROM group_relays WHERE chat_relay_id = ?) +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_relay_id=?) + Query: SELECT accepted_at FROM operator_usage_conditions WHERE server_operator_id = ? AND conditions_commit = ? Plan: SEARCH operator_usage_conditions USING INDEX idx_operator_usage_conditions_conditions_commit (conditions_commit=? AND server_operator_id=?) @@ -6223,6 +6551,10 @@ Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR ch Plan: SCAN groups +Query: SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ? +Plan: +SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) + Query: SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ? Plan: SEARCH groups USING COVERING INDEX sqlite_autoindex_groups_1 (user_id=? AND local_display_name=?) @@ -6283,6 +6615,14 @@ Query: SELECT quota_err_counter FROM connections WHERE user_id = ? AND connectio Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT relay_own_status FROM groups WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: SELECT relay_status FROM group_relays WHERE group_relay_id = ? +Plan: +SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT sent_inv_queue_info FROM group_members WHERE group_member_id = ? AND user_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6435,6 +6775,10 @@ Query: UPDATE contacts SET xcontact_id = ? WHERE contact_id = ? Plan: SEARCH contacts USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE delivery_jobs SET cursor_group_member_id = ?, updated_at = ? WHERE delivery_job_id = ? +Plan: +SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ? Plan: SEARCH delivery_jobs USING INTEGER PRIMARY KEY (rowid=?) @@ -6519,6 +6863,10 @@ Query: UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE g Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_relays SET relay_status = ?, updated_at = ? WHERE group_relay_id = ? +Plan: +SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET business_member_id = ?, customer_member_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) @@ -6539,6 +6887,10 @@ Query: UPDATE groups SET conn_link_started_connection = ?, updated_at = ? WHERE Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET enable_ntfs = ?, send_rcpts = ?, favorite = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) @@ -6555,6 +6907,10 @@ Query: UPDATE groups SET members_require_attention=1 WHERE group_id=? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 45da1dff77..82f7a3b38f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -122,7 +122,8 @@ CREATE TABLE group_profiles( preferences TEXT, description TEXT NULL, member_admission TEXT, - short_descr TEXT + short_descr TEXT, + group_link BLOB ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY, -- local group ID @@ -157,7 +158,16 @@ CREATE TABLE groups( conn_link_prepared_connection INTEGER NOT NULL DEFAULT 0, via_group_link_uri BLOB, summary_current_members_count INTEGER NOT NULL DEFAULT 0, - member_index INTEGER NOT NULL DEFAULT 0, -- received + member_index INTEGER NOT NULL DEFAULT 0, + use_relays INTEGER NOT NULL DEFAULT 0, + creating_in_progress INTEGER NOT NULL DEFAULT 0, + relay_own_status TEXT, + relay_request_inv_id BLOB, + relay_request_group_link BLOB, + relay_request_peer_chat_min_version INTEGER, + relay_request_peer_chat_max_version INTEGER, + relay_request_failed INTEGER DEFAULT 0, + relay_request_err_reason TEXT, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -199,7 +209,7 @@ CREATE TABLE group_members( member_welcome_shared_msg_id BLOB, index_in_group INTEGER NOT NULL DEFAULT 0, member_relations_vector BLOB, - is_chat_relay INTEGER NOT NULL DEFAULT 0, + relay_link BLOB, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -727,18 +737,27 @@ CREATE TABLE connections_sync( ) STRICT; CREATE TABLE chat_relays( chat_relay_id INTEGER PRIMARY KEY, - address TEXT NOT NULL, + address BLOB NOT NULL, name TEXT NOT NULL, domains TEXT NOT NULL, preset INTEGER NOT NULL DEFAULT 0, tested INTEGER, enabled INTEGER NOT NULL DEFAULT 1, user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT(datetime('now')), - updated_at TEXT NOT NULL DEFAULT(datetime('now')), - UNIQUE(user_id, address), - UNIQUE(user_id, name) -); + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; +CREATE TABLE group_relays( + group_relay_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + chat_relay_id INTEGER NOT NULL REFERENCES chat_relays ON DELETE CASCADE, + relay_status TEXT NOT NULL, + relay_link BLOB, + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1216,6 +1235,16 @@ CREATE INDEX idx_chat_items_note_folder_msg_content_tag_created_at ON chat_items created_at ); CREATE INDEX idx_chat_relays_user_id ON chat_relays(user_id); +CREATE UNIQUE INDEX idx_chat_relays_user_id_address ON chat_relays( + user_id, + address +); +CREATE UNIQUE INDEX idx_chat_relays_user_id_name ON chat_relays(user_id, name); +CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); +CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays( + group_member_id +); +CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 7f4a338376..15c2c4d41c 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -75,6 +75,7 @@ data ChatLockEntity data StoreError = SEDuplicateName | SEUserNotFound {userId :: UserId} + | SERelayUserNotFound | SEUserNotFoundByName {contactName :: ContactName} | SEUserNotFoundByContactId {contactId :: ContactId} | SEUserNotFoundByGroupId {groupId :: GroupId} @@ -102,6 +103,7 @@ data StoreError | SEInvalidMemberRelationUpdate | SEGroupWithoutUser | SEDuplicateGroupMember + | SEDuplicateMemberId | SEGroupAlreadyJoined | SEGroupInvitationNotFound | SENoteFolderAlreadyExists {noteFolderId :: NoteFolderId} @@ -150,6 +152,9 @@ data StoreError | SEProhibitedDeleteUser {userId :: UserId, contactId :: ContactId} | SEOperatorNotFound {serverOperatorId :: Int64} | SEUsageConditionsNotFound + | SEUserChatRelayNotFound {chatRelayId :: Int64} + | SEGroupRelayNotFound {groupRelayId :: Int64} + | SEGroupRelayNotFoundByMemberId {groupMemberId :: GroupMemberId} | SEInvalidQuote | SEInvalidMention | SEInvalidDeliveryTask {taskId :: Int64} @@ -657,22 +662,22 @@ type PreparedGroupRow = (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt, type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow -type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus, BoolInt) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) +type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupLink) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences - groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} + groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission, groupLink} businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers} - in GroupInfo {groupId, useRelays = BoolDef False, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -681,14 +686,13 @@ toPreparedGroup = \case _ -> Nothing toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_, BI isCRelay) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = +toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - isChatRelay = BoolDef isCRelay supportChat = case supportChatTs_ of Just chatTs -> Just @@ -706,7 +710,7 @@ groupMemberQuery :: Query groupMemberQuery = [sql| SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.is_chat_relay, + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, @@ -739,15 +743,16 @@ groupInfoQueryFields = [sql| SELECT -- GroupInfo - g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_link, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, + g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, - mu.member_status, mu.show_messages, mu.member_restriction, mu.is_chat_relay, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, + mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts @@ -835,6 +840,37 @@ addGroupChatTags db g@GroupInfo {groupId} = do chatTags <- getGroupChatTags db groupId pure (g :: GroupInfo) {chatTags} +getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo +getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do + chatTags <- getGroupChatTags db groupId + firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $ + DB.query + db + (groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?") + (groupId, userId, userContactId) + +-- Set group link info and optionally incognito profile before connecting to relays. +-- This is called once before connecting to relays, unlike createConnReqConnection -> setPreparedGroupLinkInfo_, +-- which is used in single-connection flows. +setPreparedGroupLinkInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Profile -> ExceptT StoreError IO GroupInfo +setPreparedGroupLinkInfo db vr user@User {userId} gInfo@GroupInfo {groupId} cReq cReqHash incognitoProfile = do + currentTs <- liftIO getCurrentTime + customUserProfileId <- liftIO $ mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile + liftIO $ setPreparedGroupLinkInfo_ db gInfo cReq cReqHash customUserProfileId currentTs + getGroupInfo db vr user groupId + +setPreparedGroupLinkInfo_ :: DB.Connection -> GroupInfo -> ConnReqContact -> ConnReqUriHash -> Maybe Int64 -> UTCTime -> IO () +setPreparedGroupLinkInfo_ db GroupInfo {groupId, membership} cReq cReqHash customUserProfileId currentTs = do + DB.execute + db + "UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ?, conn_link_prepared_connection = ?, updated_at = ? WHERE group_id = ?" + (cReq, cReqHash, BI True, currentTs, groupId) + when (isJust customUserProfileId) $ + DB.execute + db + "UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE group_member_id = ?" + (customUserProfileId, currentTs, groupMemberId' membership) + setViaGroupLinkUri :: DB.Connection -> GroupId -> Int64 -> IO () setViaGroupLinkUri db groupId connId = do r <- diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 67b6779bdd..28896329f9 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -119,7 +119,7 @@ instance ToField AgentUserId where toField (AgentUserId uId) = toField uId aUserId :: User -> UserId aUserId User {agentUserId = AgentUserId uId} = uId --- TODO [chat relay] filter out chat relay users where necessary (e.g. loading list of users for UI) +-- TODO [relays] filter out chat relay users where necessary (e.g. loading list of users for UI) data User = User { userId :: UserId, agentUserId :: AgentUserId, @@ -450,6 +450,7 @@ type GroupId = Int64 data GroupInfo = GroupInfo { groupId :: GroupId, useRelays :: BoolDef, + relayOwnStatus :: Maybe RelayStatus, -- status of the relay itself related to the group localDisplayName :: GroupName, groupProfile :: GroupProfile, localAlias :: Text, @@ -472,6 +473,9 @@ data GroupInfo = GroupInfo } deriving (Eq, Show) +useRelays' :: GroupInfo -> Bool +useRelays' GroupInfo {useRelays} = isTrue useRelays + data BusinessChatType = BCBusiness -- used on the customer side | BCCustomer -- used on the business side @@ -732,6 +736,7 @@ data GroupProfile = GroupProfile shortDescr :: Maybe Text, -- short description limited to 160 characters description :: Maybe Text, -- this has been repurposed as welcome message image :: Maybe ImageData, + groupLink :: Maybe ShortLinkContact, groupPreferences :: Maybe GroupPreferences, memberAdmission :: Maybe GroupMemberAdmission } @@ -814,6 +819,15 @@ data GroupLinkRejection = GroupLinkRejection } deriving (Eq, Show) +-- sent by owner to relay when adding it to group +data GroupRelayInvitation = GroupRelayInvitation + { fromMember :: MemberIdRole, + fromMemberProfile :: Profile, + relayMemberId :: MemberId, + groupLink :: ShortLinkContact + } + deriving (Eq, Show) + data GroupRejectionReason = GRRLongName | GRRBlockedName @@ -949,11 +963,57 @@ data GroupMember = GroupMember memberChatVRange :: VersionRangeChat, createdAt :: UTCTime, updatedAt :: UTCTime, - supportChat :: Maybe GroupSupportChat, - isChatRelay :: BoolDef + supportChat :: Maybe GroupSupportChat } deriving (Eq, Show) +data GroupRelay = GroupRelay + { groupRelayId :: Int64, + groupMemberId :: GroupMemberId, + userChatRelayId :: Int64, -- ID of configured UserChatRelay + relayStatus :: RelayStatus, + relayLink :: Maybe ShortLinkContact + } + deriving (Eq, Show) + +data RelayStatus + = RSNew -- only for owner + | RSInvited + | RSAccepted + | RSActive + deriving (Eq, Show) + +data RelayRequestData = RelayRequestData + { relayInvId :: InvitationId, + reqGroupLink :: ShortLinkContact, + reqChatVRange :: VersionRangeChat + } + deriving (Eq, Show) + +relayStatusText :: RelayStatus -> Text +relayStatusText = \case + RSNew -> "new" + RSInvited -> "invited" + RSAccepted -> "accepted" + RSActive -> "active" + +instance TextEncoding RelayStatus where + textEncode = \case + RSNew -> "new" + RSInvited -> "invited" + RSAccepted -> "accepted" + RSActive -> "active" + textDecode = \case + "new" -> Just RSNew + "invited" -> Just RSInvited + "accepted" -> Just RSAccepted + "active" -> Just RSActive + _ -> Nothing + +instance FromField RelayStatus where fromField = fromTextField_ textDecode + +instance ToField RelayStatus where toField = toField . textEncode + data GroupSupportChat = GroupSupportChat { chatTs :: UTCTime, unread :: Int64, @@ -977,10 +1037,11 @@ groupMemberRef :: GroupMember -> GroupMemberRef groupMemberRef GroupMember {groupMemberId, memberProfile = p} = GroupMemberRef {groupMemberId, profile = fromLocalProfile p} --- TODO [channels fwd] knowledge whether member is a relay should come from protocol, not implicitly via role --- TODO - in channels members should directly connect only to relays -isMemberRelay :: GroupMember -> Bool -isMemberRelay GroupMember {memberRole} = memberRole == GRAdmin +isRelay :: GroupMember -> Bool +isRelay m = memberRole' m == GRRelay + +memberRole' :: GroupMember -> GroupMemberRole +memberRole' GroupMember {memberRole} = memberRole memberConn :: GroupMember -> Maybe Connection memberConn GroupMember {activeConn} = activeConn @@ -1032,8 +1093,7 @@ data NewGroupMember = NewGroupMember memInvitedByGroupMemberId :: Maybe GroupMemberId, localDisplayName :: ContactName, memProfileId :: Int64, - memContactId :: Maybe Int64, - isChatRelay :: Bool + memContactId :: Maybe Int64 } newtype MemberId = MemberId {unMemberId :: ByteString} @@ -1055,7 +1115,10 @@ instance ToJSON MemberId where toEncoding = strToJEncoding nameFromMemberId :: MemberId -> ContactName -nameFromMemberId = T.take 7 . safeDecodeUtf8 . B64.encode . unMemberId +nameFromMemberId = nameFromBS . unMemberId + +nameFromBS :: ByteString -> ContactName +nameFromBS = T.take 7 . safeDecodeUtf8 . B64.encode data InvitedBy = IBContact {byContactId :: Int64} | IBUser | IBUnknown deriving (Eq, Show) @@ -1790,6 +1853,8 @@ data CommandFunction | CFAcceptContact | CFAckMessage -- not used | CFDeleteConn -- not used + | CFSetShortLink + | CFGetShortLink deriving (Eq, Show) instance FromField CommandFunction where fromField = fromTextField_ textDecode @@ -1807,6 +1872,8 @@ instance TextEncoding CommandFunction where "accept_contact" -> Just CFAcceptContact "ack_message" -> Just CFAckMessage "delete_conn" -> Just CFDeleteConn + "set_short_link" -> Just CFSetShortLink + "get_short_link" -> Just CFGetShortLink _ -> Nothing textEncode = \case CFCreateConnGrpMemInv -> "create_conn" @@ -1818,6 +1885,8 @@ instance TextEncoding CommandFunction where CFAcceptContact -> "accept_contact" CFAckMessage -> "ack_message" CFDeleteConn -> "delete_conn" + CFSetShortLink -> "set_short_link" + CFGetShortLink -> "get_short_link" commandExpectedResponse :: CommandFunction -> AEvtTag commandExpectedResponse = \case @@ -1830,6 +1899,8 @@ commandExpectedResponse = \case CFAcceptContact -> t JOINED_ CFAckMessage -> t OK_ CFDeleteConn -> t OK_ + CFSetShortLink -> t LINK_ + CFGetShortLink -> t LDATA_ where t = AEvtTag SAEConn @@ -1946,6 +2017,10 @@ $(JQ.deriveJSON defaultJSON ''PendingContactConnection) $(JQ.deriveJSON defaultJSON ''GroupSupportChat) +$(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) + +$(JQ.deriveJSON defaultJSON ''GroupRelay) + $(JQ.deriveJSON defaultJSON ''GroupMember) $(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) @@ -1986,6 +2061,8 @@ $(JQ.deriveJSON defaultJSON ''GroupLinkInvitation) $(JQ.deriveJSON defaultJSON ''GroupLinkRejection) +$(JQ.deriveJSON defaultJSON ''GroupRelayInvitation) + $(JQ.deriveJSON defaultJSON ''IntroInvitation) $(JQ.deriveJSON defaultJSON ''MemberRestrictions) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index 280fc32ea4..fafac46da8 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -6,13 +6,16 @@ module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B +import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole - = GRObserver -- connects to all group members and receives all messages, can't send messages + = GRUnknown Text -- unknown role from a newer client + | GRRelay -- chat relay: forwards messages, can't send its own messages + | GRObserver -- connects to all group members and receives all messages, can't send messages | GRAuthor -- reserved, unused | GRMember -- + can send messages to all group members | GRModerator -- + moderate messages and block members (excl. Admins and Owners) @@ -32,14 +35,17 @@ instance TextEncoding GroupMemberRole where GRMember -> "member" GRAuthor -> "author" GRObserver -> "observer" - textDecode = \case - "owner" -> Just GROwner - "admin" -> Just GRAdmin - "moderator" -> Just GRModerator - "member" -> Just GRMember - "author" -> Just GRAuthor - "observer" -> Just GRObserver - r -> Nothing + GRRelay -> "relay" + GRUnknown t -> t + textDecode = Just . \case + "owner" -> GROwner + "admin" -> GRAdmin + "moderator" -> GRModerator + "member" -> GRMember + "author" -> GRAuthor + "observer" -> GRObserver + "relay" -> GRRelay + t -> GRUnknown t instance FromJSON GroupMemberRole where parseJSON = textParseJSON "GroupMemberRole" diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 897d21ce1d..4192bf5f7f 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -178,6 +178,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRUserContactLinkUpdated u UserContactLink {addressSettings} -> ttyUser u $ viewAddressSettings addressSettings CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView + CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] @@ -417,7 +418,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtAcceptingBusinessRequest u g -> ttyUser u $ viewAcceptingBusinessRequest g CEvtContactRequestAlreadyAccepted u c -> ttyUser u [ttyFullContact c <> ": sent you a duplicate contact request, but you are already connected, no action needed"] CEvtBusinessRequestAlreadyAccepted u g -> ttyUser u [ttyFullGroup g <> ": sent you a duplicate connection request, but you are already connected, no action needed"] - CEvtGroupLinkConnecting u g _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] + CEvtGroupLinkConnecting u g m -> ttyUser u $ viewUserJoiningGroup g m CEvtBusinessLinkConnecting u g _ _ -> ttyUser u [ttyGroup' g <> ": joining the group..."] CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] @@ -459,7 +460,8 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} in ttyUser u [sShow connId <> ": END"] CEvtSubscriptionStatus srv status conns -> [plain $ subStatusStr status <> " " <> show (length conns) <> " connections on server " <> showSMPServer srv] CEvtReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r - CEvtUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g + CEvtUserJoinedGroup u g m -> ttyUser u $ viewUserJoinedGroup g m + CEvtGroupLinkRelaysUpdated u g groupLink relays -> ttyUser u $ viewGroupLinkRelaysUpdated g groupLink relays CEvtJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CEvtHostConnected p h -> [plain $ "connected to " <> viewHostEvent p h] CEvtHostDisconnected p h -> [plain $ "disconnected from " <> viewHostEvent p h] @@ -1149,6 +1151,20 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} = "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)" ] +viewGroupLinkRelaysUpdated :: GroupInfo -> GroupLink -> [GroupRelay] -> [StyledString] +viewGroupLinkRelaysUpdated g groupLink relays = + [ttyFullGroup g <> ": group link relays updated, current relays:"] + <> map showRelay relays + <> + [ "group link:", + plain $ maybe cReqStr strEncode shortLink + ] + where + showRelay GroupRelay {groupRelayId, relayStatus} = + " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) + GroupLink {connLinkContact = CCLink cReq shortLink} = groupLink + cReqStr = strEncode $ simplexChatContact cReq + viewGroupCreated :: GroupInfo -> Bool -> [StyledString] viewGroupCreated g testView = case incognitoMembershipProfile g of @@ -1159,12 +1175,21 @@ viewGroupCreated g testView = profile = fromLocalProfile localProfile message = [ "group " <> ttyFullGroup g <> " is created, your incognito profile for this group is " <> incognitoProfile' profile, - "to add members use " <> highlight ("/create link #" <> viewGroupName g) + instruction ] + instruction + | useRelays' g = relaysInstruction + | otherwise = "to add members use " <> highlight ("/create link #" <> viewGroupName g) Nothing -> [ "group " <> ttyFullGroup g <> " is created", - "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + instruction ] + where + instruction + | useRelays' g = relaysInstruction + | otherwise = "to add members use " <> highlight ("/a " <> viewGroupName g <> " ") <> " or " <> highlight ("/create link #" <> viewGroupName g) + where + relaysInstruction = "wait for selected relay(s) to join, then you can invite members via group link" viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString] viewCannotResendInvitation g c = @@ -1176,12 +1201,22 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] -viewUserJoinedGroup :: GroupInfo -> [StyledString] -viewUserJoinedGroup g@GroupInfo {membership} = - case incognitoMembershipProfile g of - Just mp -> [ttyGroup' g <> ": you joined the group incognito as " <> incognitoProfile' (fromLocalProfile mp) <> pendingApproval_] - Nothing -> [ttyGroup' g <> ": you joined the group" <> pendingApproval_] +viewUserJoiningGroup :: GroupInfo -> GroupMember -> [StyledString] +viewUserJoiningGroup g m + | isRelay m = [ttyGroup' g <> ": joining the group (connecting to relay " <> ttyMember m <> ")..."] + | otherwise = [ttyGroup' g <> ": joining the group..."] + +viewUserJoinedGroup :: GroupInfo -> GroupMember -> [StyledString] +viewUserJoinedGroup g@GroupInfo {membership} m + | isRelay membership = [ttyGroup' g <> ": you joined the group as relay"] + | otherwise = + case incognitoMembershipProfile g of + Just mp -> [ttyGroup' g <> ": you joined the group" <> connectedToRelay_ <> " incognito as " <> incognitoProfile' (fromLocalProfile mp) <> pendingApproval_] + Nothing -> [ttyGroup' g <> ": you joined the group" <> connectedToRelay_ <> pendingApproval_] where + connectedToRelay_ + | isRelay m = " (connected to relay " <> ttyMember m <> ")" + | otherwise = "" pendingApproval_ = case memberStatus membership of GSMemPendingApproval -> ", pending approval" GSMemPendingReview -> ", connecting to group moderators for admission to group" @@ -1990,7 +2025,9 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("contact address: " <>) CPGroupLink glp -> case glp of - GLPOk groupSLinkData -> [grpLink "ok to connect"] <> [viewJSON groupSLinkData | testView] + GLPOk direct groupSLinkData -> + [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"] + <> [viewJSON groupSLinkData] -- | testView] -- TODO [relays] disable link data output in cli (uncomment testView) GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] GLPConnectingConfirmReconnect -> [grpLink "connecting, allowed to reconnect"] GLPConnectingProhibit Nothing -> [grpLink "connecting"] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 4722a0b299..7738e1ef9b 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -53,7 +53,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 (srvHostnamesSMPClientVersion, sndAuthKeySMPClientVersion) +import Simplex.Messaging.Protocol (sndAuthKeySMPClientVersion) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) import Simplex.Messaging.Server.MsgStore.STM (STMMsgStore) diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 3db731e261..2a45f79a73 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -5,6 +5,7 @@ import ChatTests.DBUtils import ChatTests.Utils import Test.Hspec hiding (it) +-- TODO [relays] test deleting relay (from configuration), referenced in group_relays. chatRelayTests :: SpecWith TestParams chatRelayTests = do describe "configure chat relays" $ do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 16cf968437..fd7d3ae8c7 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -37,7 +37,6 @@ import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.DB (Binary (..)) -import Simplex.Messaging.Encoding.String import Simplex.Messaging.Server.Env.STM hiding (subscriptions) import Simplex.Messaging.Transport import Simplex.Messaging.Version @@ -229,19 +228,25 @@ chatGroupTests = do it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves - -- TODO [channels fwd] enable tests (requires communicating useRelays to members) -- TODO [channels fwd] add tests for channels - -- TODO - tests with multiple relays (all relays should deliver messages, members should deduplicate) -- TODO - tests with delivery loop over members restored after restart -- TODO - delivery in support scopes inside channels - xdescribe "channels" $ do + -- TODO - connect plans for relay groups + -- TODO - cancellation on failure to create relay group (for owner) + -- TODO - async retry connecting to relay (for members) + -- TODO - test relay privileges + describe "channels" $ do describe "relay delivery" $ do - it "should deliver messages to members" testChannelsRelayDeliver - describe "should deliver messages in a loop over members" $ do - it "number of recipients is multiple of bucket size (3/1)" (testChannelsRelayDeliverLoop 1) - it "number of recipients is NOT multiple of bucket size (3/2)" (testChannelsRelayDeliverLoop 2) - it "number of recipients is equal to bucket size (3/3)" (testChannelsRelayDeliverLoop 3) - it "sender should deduplicate their own messages" testChannelsSenderDeduplicateOwn + describe "single relay" $ do + it "should deliver messages to members" testChannels1RelayDeliver + describe "should deliver messages in a loop over members" $ do + it "number of recipients is multiple of bucket size (3/1)" (testChannels1RelayDeliverLoop 1) + it "number of recipients is NOT multiple of bucket size (3/2)" (testChannels1RelayDeliverLoop 2) + it "number of recipients is equal to bucket size (3/3)" (testChannels1RelayDeliverLoop 3) + it "sender should deduplicate their own messages" testChannelsSenderDeduplicateOwn + describe "multiple relays" $ do + it "2 relays: should deliver messages to members" testChannels2RelaysDeliver + it "should share same incognito profile with all relays" testChannels2RelaysIncognito testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -2495,12 +2500,12 @@ testPlanGroupLinkLeaveRejoin = threadDelay 100000 bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" _sLinkData <- getTermLine bob let gLinkSchema2 = linkAnotherSchema gLink bob ##> ("/_connect plan 1 " <> gLinkSchema2) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" _sLinkData <- getTermLine bob bob ##> ("/c " <> gLink) @@ -3531,7 +3536,7 @@ testPlanGroupLinkKnown = gLink <- getGroupLink alice "team" GRMember True bob ##> ("/_connect plan 1 " <> gLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" _sLinkData <- getTermLine bob bob ##> ("/c " <> gLink) @@ -8356,107 +8361,198 @@ testScopedSupportMemberLeaves = testOpts { markRead = False } -testChannelsRelayDeliver :: HasCallStack => TestParams -> IO () -testChannelsRelayDeliver = - testChat5 aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do - createChannel5 alice bob cath dan eve GRObserver - alice #> "#team hi" - bob <# "#team alice> hi" - [cath, dan, eve] *<# "#team alice> hi [>>]" +testChannels1RelayDeliver :: HasCallStack => TestParams -> IO () +testChannels1RelayDeliver ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve - cath ##> "+1 #team hi" - cath <## "added 👍" - bob <# "#team cath> > alice hi" - bob <## " + 👍" - alice <# "#team cath> > alice hi" - alice <## " + 👍" - dan <# "#team cath> > alice hi" - dan <## " + 👍" - eve <# "#team cath> > alice hi" - eve <## " + 👍" + alice #> "#team hi" + bob <# "#team alice> hi" + [cath, dan, eve] *<# "#team alice> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > alice hi" + bob <## " + 👍" + alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + alice <# "#team cath> > alice hi" + alice <## " + 👍" + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> > alice hi" + dan <## " + 👍" + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> > alice hi" + eve <## " + 👍" + +createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () +createChannel1Relay gName owner relay cath dan eve = do + (shortLink, fullLink) <- prepareChannel1Relay gName owner relay + forM_ [cath, dan, eve] $ \member -> + memberJoinChannel gName [relay] shortLink fullLink member + +prepareChannel1Relay :: String -> TestCC -> TestCC -> IO (String, String) +prepareChannel1Relay gName owner relay = do + rName <- userName relay + + relay ##> "/ad" + (relaySLink, _cLink) <- getContactLinks relay True + + owner ##> ("/relays name=" <> rName <> " " <> relaySLink) + owner <## "ok" + + owner ##> ("/public group relays=1 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" --- TODO [channels fwd] correctly setup channel with relay forwarding --- TODO - alice to create group as channel --- TODO - add bob as relay --- TODO - alice to manage group link, but members to connect to relay (bob) -createChannel5 :: TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> GroupMemberRole -> IO () -createChannel5 alice bob cath dan eve mRole = do - createGroup2 "team" alice bob - bob ##> ("/create link #team " <> T.unpack (textEncode mRole)) - gLink <- getGroupLink bob "team" mRole True - cath ##> ("/c " <> gLink) - cath <## "connection request sent!" - bob <## "cath (Catherine): accepting request to join group #team..." concurrentlyN_ - [ bob <## "#team: cath joined the group", - do - cath <## "#team: joining the group..." - cath <## "#team: you joined the group" - cath <## "#team: member alice (Alice) is connected", - do - alice <## "#team: bob added cath (Catherine) to the group (connecting...)" - alice <## "#team: new member cath is connected" - ] - dan ##> ("/c " <> gLink) - dan <## "connection request sent!" - bob <## "dan (Daniel): accepting request to join group #team..." - concurrentlyN_ - [ bob <## "#team: dan joined the group", - do - dan <## "#team: joining the group..." - dan <## "#team: you joined the group" - dan <## "#team: member alice (Alice) is connected" - dan <## "#team: member cath (Catherine) is connected", - do - alice <## "#team: bob added dan (Daniel) to the group (connecting...)" - alice <## "#team: new member dan is connected", - do - cath <## "#team: bob added dan (Daniel) to the group (connecting...)" - cath <## "#team: new member dan is connected" - ] - eve ##> ("/c " <> gLink) - eve <## "connection request sent!" - bob <## "eve (Eve): accepting request to join group #team..." - concurrentlyN_ - [ bob <## "#team: eve joined the group", - eve - <### [ "#team: joining the group...", - "#team: you joined the group", - "#team: member alice (Alice) is connected", - "#team: member cath (Catherine) is connected", - "#team: member dan (Daniel) is connected" - ], - do - alice <## "#team: bob added eve (Eve) to the group (connecting...)" - alice <## "#team: new member eve is connected", - do - cath <## "#team: bob added eve (Eve) to the group (connecting...)" - cath <## "#team: new member eve is connected", - do - dan <## "#team: bob added eve (Eve) to the group (connecting...)" - dan <## "#team: new member eve is connected" + [ do + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner <## " - relay id 1: active" + owner <## "group link:" + _ <- getTermLine owner + pure (), + relay <## ("#" <> gName <> ": you joined the group as relay") ] -testChannelsRelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () -testChannelsRelayDeliverLoop deliveryBucketSize = - testChatCfg5 cfg aliceProfile bobProfile cathProfile danProfile eveProfile $ \alice bob cath dan eve -> do - createChannel5 alice bob cath dan eve GRObserver + owner ##> ("/show link #" <> gName) + getGroupLinks owner gName GRMember False - alice #> "#team hi" - bob <# "#team alice> hi" - [cath, dan, eve] *<# "#team alice> hi [>>]" +createChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () +createChannel2Relays gName owner relay1 relay2 dan eve frank = do + (shortLink, fullLink) <- prepareChannel2Relays gName owner relay1 relay2 + forM_ [dan, eve, frank] $ \member -> + memberJoinChannel gName [relay1, relay2] shortLink fullLink member - cath ##> "+1 #team hi" - cath <## "added 👍" - bob <# "#team cath> > alice hi" - bob <## " + 👍" - alice <# "#team cath> > alice hi" - alice <## " + 👍" - dan <# "#team cath> > alice hi" - dan <## " + 👍" - eve <# "#team cath> > alice hi" - eve <## " + 👍" +prepareChannel2Relays :: String -> TestCC -> TestCC -> TestCC -> IO (String, String) +prepareChannel2Relays gName owner relay1 relay2 = do + r1Name <- userName relay1 + r2Name <- userName relay2 + + relay1 ##> "/ad" + (r1SLink, _cLink) <- getContactLinks relay1 True + relay2 ##> "/ad" + (r2SLink, _cLink) <- getContactLinks relay2 True + + owner ##> ("/relays name=" <> r1Name <> " " <> r1SLink <> " name=" <> r2Name <> " " <> r2SLink) + owner <## "ok" + + owner ##> ("/public group relays=1,2 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + + concurrentlyN_ + [ owner + <### [ -- one relay connects + ConsoleString ("#" <> gName <> ": group link relays updated, current relays:"), + StartsWith " - relay id 1: ", + StartsWith " - relay id 2: ", + "group link:", + Predicate (const True), -- consume group link line + -- second relay connects + ConsoleString ("#" <> gName <> ": group link relays updated, current relays:"), + " - relay id 1: active", + " - relay id 2: active", + "group link:", + Predicate (const True) -- consume group link line + ], + relay1 <## ("#" <> gName <> ": you joined the group as relay"), + relay2 <## ("#" <> gName <> ": you joined the group as relay") + ] + + owner ##> ("/show link #" <> gName) + getGroupLinks owner gName GRMember False + +memberJoinChannel :: String -> [TestCC] -> String -> String -> TestCC -> IO () +memberJoinChannel gName relays shortLink fullLink member = do + mName <- userName member + mFullName <- showName member + relayNames <- mapM userName relays + + member ##> ("/_connect plan 1 " <> shortLink) + member <## "group link: ok to connect via relays" + groupSLinkData <- getTermLine member + + member ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " direct=off " <> groupSLinkData) + member <## ("#" <> gName <> ": group is prepared") + + member ##> "/_connect group #1" + member <## ("#" <> gName <> ": connection started") + concurrentlyN_ $ + [ member + <### concat + [ [ ConsoleString ("#" <> gName <> ": joining the group (connecting to relay " <> rName <> ")..."), + ConsoleString ("#" <> gName <> ": you joined the group (connected to relay " <> rName <> ")") + ] + | rName <- relayNames + ] + ] + <> [ do + relay <## (mFullName <> ": accepting request to join group #team...") + relay <## ("#" <> gName <> ": " <> mName <> " joined the group") + | relay <- relays + ] + +memberJoinChannelIncognito :: String -> [TestCC] -> String -> String -> TestCC -> IO String +memberJoinChannelIncognito gName relays shortLink fullLink member = do + relayNames <- mapM userName relays + + member ##> ("/_connect plan 1 " <> shortLink) + member <## "group link: ok to connect via relays" + groupSLinkData <- getTermLine member + + member ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " direct=off " <> groupSLinkData) + member <## ("#" <> gName <> ": group is prepared") + + member ##> "/_connect group #1 incognito=on" + memIncognito <- getTermLine member + member <## ("#" <> gName <> ": connection started incognito") + concurrentlyN_ $ + [ member + <### concat + [ [ ConsoleString ("#" <> gName <> ": joining the group (connecting to relay " <> rName <> ")..."), + ConsoleString ("#" <> gName <> ": you joined the group (connected to relay " <> rName <> ") incognito as " <> memIncognito) + ] + | rName <- relayNames + ] + ] + <> [ do + relay <## (memIncognito <> ": accepting request to join group #team...") + relay <## ("#" <> gName <> ": " <> memIncognito <> " joined the group") + | relay <- relays + ] + pure memIncognito + +testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () +testChannels1RelayDeliverLoop deliveryBucketSize ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + alice #> "#team hi" + bob <# "#team alice> hi" + [cath, dan, eve] *<# "#team alice> hi [>>]" + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > alice hi" + bob <## " + 👍" + alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + alice <# "#team cath> > alice hi" + alice <## " + 👍" + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> > alice hi" + dan <## " + 👍" + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> > alice hi" + eve <## " + 👍" where cfg = testCfg {deliveryBucketSize} @@ -8466,8 +8562,8 @@ testChannelsSenderDeduplicateOwn ps = do withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> withNewTestChat ps "eve" eveProfile $ \eve -> do - withNewTestChatCfg ps cfg "bob" bobProfile $ \bob -> - createChannel5 alice bob cath dan eve GRMember + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + createChannel1Relay "team" alice bob cath dan eve -- chat relay bob is offline alice #> "#team 1" @@ -8477,8 +8573,8 @@ testChannelsSenderDeduplicateOwn ps = do cath #> "#team 5" dan #> "#team 6" - withTestChatCfg ps cfg "bob" $ \bob -> do - bob <## "subscribed 6 connections server localhost" + withTestChatCfgOpts ps cfg relayTestOpts "bob" $ \bob -> do + bob <## "subscribed 6 connections on server localhost" bob <### [ WithTime "#team alice> 1", WithTime "#team alice> 2", @@ -8488,25 +8584,31 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6" ] alice - <### [ WithTime "#team cath> 4 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + WithTime "#team cath> 4 [>>]", WithTime "#team cath> 5 [>>]", WithTime "#team dan> 6 [>>]" ] cath - <### [ WithTime "#team alice> 1 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + WithTime "#team alice> 1 [>>]", WithTime "#team alice> 2 [>>]", WithTime "#team alice> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ WithTime "#team alice> 1 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + WithTime "#team alice> 1 [>>]", WithTime "#team alice> 2 [>>]", WithTime "#team alice> 3 [>>]", WithTime "#team cath> 4 [>>]", WithTime "#team cath> 5 [>>]" ] eve - <### [ WithTime "#team alice> 1 [>>]", + <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + WithTime "#team alice> 1 [>>]", WithTime "#team alice> 2 [>>]", WithTime "#team alice> 3 [>>]", WithTime "#team cath> 4 [>>]", @@ -8515,3 +8617,77 @@ testChannelsSenderDeduplicateOwn ps = do ] where cfg = testCfg {deliveryWorkerDelay = 250000} + +testChannels2RelaysDeliver :: HasCallStack => TestParams -> IO () +testChannels2RelaysDeliver ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + withNewTestChat ps "frank" frankProfile $ \frank -> do + createChannel2Relays "team" alice bob cath dan eve frank + + alice #> "#team hi" + [bob, cath] *<# "#team alice> hi" + [dan, eve, frank] *<# "#team alice> hi [>>]" + + dan ##> "+1 #team hi" + dan <## "added 👍" + bob <# "#team dan> > alice hi" + bob <## " + 👍" + cath <# "#team dan> > alice hi" + cath <## " + 👍" + alice .<## " forwarded a message from an unknown member, creating unknown member record dan" + alice <# "#team dan> > alice hi" + alice <## " + 👍" + eve .<## " forwarded a message from an unknown member, creating unknown member record dan" + eve <# "#team dan> > alice hi" + eve <## " + 👍" + frank .<## " forwarded a message from an unknown member, creating unknown member record dan" + frank <# "#team dan> > alice hi" + frank <## " + 👍" + + -- remove below if default role is changed to observer + dan #> "#team hey" + [bob, cath] *<# "#team dan> hey" + [alice, eve, frank] *<# "#team dan> hey [>>]" + +testChannels2RelaysIncognito :: HasCallStack => TestParams -> IO () +testChannels2RelaysIncognito ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + withNewTestChat ps "frank" frankProfile $ \frank -> do + (shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath + danIncognito <- memberJoinChannelIncognito "team" [bob, cath] shortLink fullLink dan + forM_ [eve, frank] $ \member -> + memberJoinChannel "team" [bob, cath] shortLink fullLink member + + alice #> "#team hi" + [bob, cath] *<# "#team alice> hi" + dan ?<# "#team alice> hi [>>]" + [eve, frank] *<# "#team alice> hi [>>]" + + dan ##> "+1 #team hi" + dan <## "added 👍" + bob <# ("#team " <> danIncognito <> "> > alice hi") + bob <## " + 👍" + cath <# ("#team " <> danIncognito <> "> > alice hi") + cath <## " + 👍" + alice .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + alice <# ("#team " <> danIncognito <> "> > alice hi") + alice <## " + 👍" + eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + eve <# ("#team " <> danIncognito <> "> > alice hi") + eve <## " + 👍" + frank .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + frank <# ("#team " <> danIncognito <> "> > alice hi") + frank <## " + 👍" + + -- remove below if default role is changed to observer + dan ?#> "#team hey" + [bob, cath] *<# ("#team " <> danIncognito <> "> hey") + [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 3fdadc3b64..d4be2704a4 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -2905,7 +2905,7 @@ testShortLinkJoinGroup = name <- userName cc sName <- showName cc cc ##> ("/_connect plan 1 " <> link) - cc <## "group link: ok to connect" + cc <## "group link: ok to connect directly" _sLinkData <- getTermLine cc cc ##> ("/c " <> link) cc <## "connection request sent!" @@ -3377,7 +3377,7 @@ testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile test alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3411,7 +3411,7 @@ testShortLinkPrepareGroup = testChat3 aliceProfile bobProfile cathProfile test alice <## "#team: bob left the group" cath <## "#team: bob left the group" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" void $ getTermLine bob testShortLinkPrepareGroupReject :: HasCallStack => TestParams -> IO () @@ -3422,7 +3422,7 @@ testShortLinkPrepareGroupReject = testChatCfg3 cfg aliceProfile bobProfile cathP alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3455,7 +3455,7 @@ testGroupShortLinkWelcome = testChat2 aliceProfile bobProfile test alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3488,7 +3488,7 @@ testShortLinkGroupRetry ps = testChatOpts2 opts' aliceProfile bobProfile test ps alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3703,7 +3703,7 @@ testShortLinkConnectPreparedGroupIncognito = testChat3 aliceProfile bobProfile c alice ##> "/create link #team" (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3747,7 +3747,7 @@ testShortLinkChangePreparedGroupUser = testChat3 aliceProfile bobProfile cathPro showActiveUser bob "bob (Bob)" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#team: group is prepared" @@ -3803,7 +3803,7 @@ testShortLinkChangePreparedGroupUserDuplicate = testChat3 aliceProfile bobProfil showActiveUser bob "robert" bob ##> ("/_connect plan 2 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData1 <- getTermLine bob bob ##> ("/_prepare group 2 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData1) bob <## "#team: group is prepared" @@ -3812,7 +3812,7 @@ testShortLinkChangePreparedGroupUserDuplicate = testChat3 aliceProfile bobProfil showActiveUser bob "bob (Bob)" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData2 <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData2) bob <## "#team: group is prepared" @@ -4075,7 +4075,7 @@ testShortLinkGroupChangeProfile = testChat3 aliceProfile bobProfile cathProfile cath <## "changed to #club" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#club: group is prepared" @@ -4113,7 +4113,7 @@ testShortLinkGroupChangeProfileReceived = testChat3 aliceProfile bobProfile cath alice <## "changed to #club" bob ##> ("/_connect plan 1 " <> shortLink) - bob <## "group link: ok to connect" + bob <## "group link: ok to connect directly" groupSLinkData <- getTermLine bob bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) bob <## "#club: group is prepared" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 756ee47727..b60e954478 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -75,6 +75,9 @@ danProfile = mkProfile "dan" "Daniel" Nothing eveProfile :: Profile eveProfile = mkProfile "eve" "Eve" Nothing +frankProfile :: Profile +frankProfile = mkProfile "frank" "Frank" Nothing + businessProfile :: Profile businessProfile = mkProfile "biz" "Biz Inc" Nothing diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2332fa429c..d61f1350d5 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -107,7 +107,7 @@ testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences} testGroupProfile :: GroupProfile -testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} +testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, groupLink = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do From 7d5768cf3a9ca6420ab7b8be91073018dd46a2f3 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 27 Jan 2026 17:56:31 +0000 Subject: [PATCH 007/112] core: prepare group link before creating the group (#6600) * core: prepare group link before creating the group * update group creation flow * refactor * comments * update plan, schema, api docs/types * store shared group ID and keys when joining relay groups * query plans, api docs --- bots/api/TYPES.md | 42 ++++++- bots/src/API/Docs/Types.hs | 16 ++- bots/src/API/TypeInfo.hs | 5 +- cabal.project | 2 +- docs/contributing/CODE.md | 5 + .../types/typescript/src/types.ts | 36 +++++- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 13 +- src/Simplex/Chat/Library/Commands.hs | 113 +++++++++--------- src/Simplex/Chat/Library/Internal.hs | 4 +- src/Simplex/Chat/Library/Subscriber.hs | 2 +- src/Simplex/Chat/Store/Connections.hs | 5 +- src/Simplex/Chat/Store/Groups.hs | 93 +++++++++----- src/Simplex/Chat/Store/Messages.hs | 8 +- .../Migrations/M20260222_chat_relays.hs | 20 +++- .../Store/Postgres/Migrations/chat_schema.sql | 9 +- .../Migrations/M20260222_chat_relays.hs | 10 ++ .../SQLite/Migrations/chat_query_plans.txt | 75 +++++++----- .../Store/SQLite/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/Shared.hs | 26 ++-- src/Simplex/Chat/Types.hs | 27 ++++- src/Simplex/Chat/View.hs | 7 +- 22 files changed, 371 insertions(+), 156 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 02ec262986..25a6565f45 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -90,6 +90,7 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) +- [GroupKeys](#groupkeys) - [GroupLink](#grouplink) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) @@ -103,7 +104,9 @@ This file is generated automatically. - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) - [GroupRelay](#grouprelay) +- [GroupRootKey](#grouprootkey) - [GroupShortLinkData](#groupshortlinkdata) +- [GroupShortLinkInfo](#groupshortlinkinfo) - [GroupSummary](#groupsummary) - [GroupSupportChat](#groupsupportchat) - [HandshakeError](#handshakeerror) @@ -2158,6 +2161,17 @@ MemberSupport: - groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? +- groupKeys: [GroupKeys](#groupkeys)? + + +--- + +## GroupKeys + +**Record type**: +- sharedGroupId: string +- groupRootKey: [GroupRootKey](#grouprootkey) +- memberPrivKey: string --- @@ -2181,7 +2195,7 @@ MemberSupport: Ok: - type: "ok" -- direct: bool +- groupSLinkInfo_: [GroupShortLinkInfo](#groupshortlinkinfo)? - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? OwnLink: @@ -2225,6 +2239,7 @@ Known: - createdAt: UTCTime - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? +- memberPubKey: string? --- @@ -2353,6 +2368,21 @@ Known: - relayLink: string? +--- + +## GroupRootKey + +**Discriminated union type**: + +Private: +- type: "private" +- rootPrivKey: string + +Public: +- type: "public" +- rootPubKey: string + + --- ## GroupShortLinkData @@ -2361,6 +2391,16 @@ Known: - groupProfile: [GroupProfile](#groupprofile) +--- + +## GroupShortLinkInfo + +**Record type**: +- direct: bool +- groupRelays: [string] +- sharedGroupId: string? + + --- ## GroupSummary diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 77201172f5..10d8368857 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -256,7 +256,7 @@ chatTypesDocsData = (sti @FileError, STUnion, "FileErr", [], "", ""), (sti @FileErrorType, STUnion, "", [], "", ""), (sti @FileInvitation, STRecord, "", [], "", ""), - (sti @FileProtocol, (STEnum' $ consLower "FP"), "", [], "", ""), + (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @Format, STUnion, "", ["Unknown"], "", ""), @@ -269,20 +269,23 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), + (sti @GroupKeys, STRecord, "", [], "", ""), + (sti @GroupRootKey, STUnion, "GRK", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), - (sti @GroupMemberCategory, (STEnum' $ dropPfxSfx "GC" "Member"), "", [], "", ""), + (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), - (sti @GroupMemberRole, (STEnum' $ dropPfxSfx "GR" ""), "", ["GRUnknown"], "", ""), + (sti @GroupMemberRole, STEnum' (dropPfxSfx "GR" ""), "", ["GRUnknown"], "", ""), (sti @GroupMemberSettings, STRecord, "", [], "", ""), - (sti @GroupMemberStatus, (STEnum' $ (\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), + (sti @GroupMemberStatus, STEnum' ((\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), (sti @GroupPreference, STRecord, "", [], "", ""), (sti @GroupPreferences, STRecord, "", [], "", ""), (sti @GroupProfile, STRecord, "", [], "", ""), (sti @GroupRelay, STRecord, "", [], "", ""), (sti @GroupShortLinkData, STRecord, "", [], "", ""), + (sti @GroupShortLinkInfo, STRecord, "", [], "", ""), (sti @GroupSummary, STRecord, "", [], "", ""), (sti @GroupSupportChat, STRecord, "", [], "", ""), (sti @HandshakeError, STEnum, "", [], "", ""), @@ -322,7 +325,7 @@ chatTypesDocsData = (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), (sti @RelayStatus, STEnum, "RS", [], "", ""), - (sti @ReportReason, (STEnum' $ dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), + (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), @@ -458,6 +461,8 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo +deriving instance Generic GroupKeys +deriving instance Generic GroupRootKey deriving instance Generic GroupLink deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember @@ -472,6 +477,7 @@ deriving instance Generic GroupPreferences deriving instance Generic GroupProfile deriving instance Generic GroupRelay deriving instance Generic GroupShortLinkData +deriving instance Generic GroupShortLinkInfo deriving instance Generic GroupSummary deriving instance Generic GroupSupportChat deriving instance Generic HandshakeError diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index a70de72d01..61e2595fa9 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -1,5 +1,4 @@ {-# LANGUAGE AllowAmbiguousTypes #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -8,9 +7,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeOperators #-} -{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} @@ -210,6 +207,8 @@ toTypeInfo tr = "MemberId", "Text", "MREmojiChar", + "PrivateKey", + "PublicKey", "ProtocolServer", "SbKey", "SharedMsgId", diff --git a/cabal.project b/cabal.project index a03d492bd6..e1c7ada5c7 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 89b81d151fa0378196d923c5d7fa0aea08462136 + tag: d10e05b7968e7f20313bf6f5b07f2290d9420e0d source-repository-package type: git diff --git a/docs/contributing/CODE.md b/docs/contributing/CODE.md index 6cdd7972f5..ab91b54d04 100644 --- a/docs/contributing/CODE.md +++ b/docs/contributing/CODE.md @@ -41,6 +41,11 @@ Some files that use CPP language extension cannot be formatted as a whole, so in - Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring - Aim to minimize the code changes - do what is minimally required to solve users' problems +**Code analysis and review:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. +- Do not save time on analysis. Read every function in the data flow even when the interface seems clear — wrong assumptions about internals are the main source of missed bugs. + ### Haskell Extensions - `StrictData` enabled by default - Use STM for safe concurrency diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index cce79229d0..c04ff3466e 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2442,6 +2442,13 @@ export interface GroupInfo { groupSummary: GroupSummary membersRequireAttention: number // int viaGroupLinkUri?: string + groupKeys?: GroupKeys +} + +export interface GroupKeys { + sharedGroupId: string + groupRootKey: GroupRootKey + memberPrivKey: string } export interface GroupLink { @@ -2469,7 +2476,7 @@ export namespace GroupLinkPlan { export interface Ok extends Interface { type: "ok" - direct: boolean + groupSLinkInfo_?: GroupShortLinkInfo groupSLinkData_?: GroupShortLinkData } @@ -2514,6 +2521,7 @@ export interface GroupMember { createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat + memberPubKey?: string } export interface GroupMemberAdmission { @@ -2602,10 +2610,36 @@ export interface GroupRelay { relayLink?: string } +export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public + +export namespace GroupRootKey { + export type Tag = "private" | "public" + + interface Interface { + type: Tag + } + + export interface Private extends Interface { + type: "private" + rootPrivKey: string + } + + export interface Public extends Interface { + type: "public" + rootPubKey: string + } +} + export interface GroupShortLinkData { groupProfile: GroupProfile } +export interface GroupShortLinkInfo { + direct: boolean + groupRelays: string[] + sharedGroupId?: string +} + export interface GroupSummary { currentMembers: number // int64 } diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 8e3f039472..767334154d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."89b81d151fa0378196d923c5d7fa0aea08462136" = "033vzd7f62plb9ncf8lbdn894682phxp53ysvry9ch79mlf68yqf"; + "https://github.com/simplex-chat/simplexmq.git"."d10e05b7968e7f20313bf6f5b07f2290d9420e0d" = "1gnsg5xv4m6w0pd34qpb9vg6b458rwyhkpzi3rqm0nxb9xjhpd61"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index de35e4d730..6fc6818730 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -994,7 +994,7 @@ data ContactAddressPlan deriving (Show) data GroupLinkPlan - = GLPOk {direct :: DirectLink, groupSLinkData_ :: Maybe GroupShortLinkData} + = GLPOk {groupSLinkInfo_ :: Maybe GroupShortLinkInfo, groupSLinkData_ :: Maybe GroupShortLinkData} | GLPOwnLink {groupInfo :: GroupInfo} | GLPConnectingConfirmReconnect | GLPConnectingProhibit {groupInfo_ :: Maybe GroupInfo} @@ -1003,6 +1003,13 @@ data GroupLinkPlan type DirectLink = Bool +data GroupShortLinkInfo = GroupShortLinkInfo + { direct :: Bool, + groupRelays :: [ShortLinkContact], + sharedGroupId :: Maybe B64UrlByteString + } + deriving (Show) + connectionPlanProceed :: ConnectionPlan -> Bool connectionPlanProceed = \case CPInvitationLink ilp -> case ilp of @@ -1016,7 +1023,7 @@ connectionPlanProceed = \case CAPContactViaAddress _ -> True _ -> False CPGroupLink glp -> case glp of - GLPOk _direct _ -> True + GLPOk {} -> True GLPOwnLink _ -> True GLPConnectingConfirmReconnect -> True _ -> False @@ -1598,6 +1605,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "ILP") ''InvitationLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CAP") ''ContactAddressPlan) +$(JQ.deriveJSON defaultJSON ''GroupShortLinkInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 0b1cc979c0..3a7069e825 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -97,6 +97,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), textToHostMode) import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.ShortLink as SL import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQEncOff, pattern PQSupportOff, pattern PQSupportOn) @@ -1991,11 +1992,15 @@ processChatCommand vr nm = \case sLnk <- case toShortLinkContact connLinkToConnect of Just sl -> pure sl Nothing -> throwChatError $ CEException "failed to retrieve relays: no short link" - (mainCReq@(CRContactUri crData), ContactLinkData _ UserContactData {relays}) <- getShortLinkConnReq nm user sLnk + (FixedLinkData {linkConnReq = mainCReq@(CRContactUri crData), linkEntityId, rootKey}, ContactLinkData _ UserContactData {relays}) <- getShortLinkConnReq nm user sLnk -- Set group link info and incognito profile once before connecting to relays incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing let cReqHash = contactCReqHash $ CRContactUri crData {crScheme = SSSimplex} gInfo' <- withFastStore $ \db -> setPreparedGroupLinkInfo db vr user gInfo mainCReq cReqHash incognitoProfile + forM_ linkEntityId $ \sharedGroupId -> do + gVar <- asks random + (_, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair gVar + withFastStore' $ \db -> updateGroupMemberKeys db groupId sharedGroupId rootKey memberPrivKey (groupMemberId' $ membership gInfo') rs <- mapConcurrently (connectToRelay gInfo') relays let relayFailed = \case (_, _, Left _) -> True; _ -> False (failed, succeeded) = partition relayFailed rs @@ -2030,8 +2035,9 @@ processChatCommand vr nm = \case -- Save relayLink to re-use relay member record on retry (check by relayLink) relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo' relayLink r <- tryAllErrors $ do - (cReq, _cData) <- getShortLinkConnReq nm user relayLink - let relayLinkToConnect = CCLink cReq (Just relayLink) + (fd, _cData) <- getShortLinkConnReq nm user relayLink + let cReq = linkConnReq fd + relayLinkToConnect = CCLink cReq (Just relayLink) void $ connectViaContact user (Just $ PCEGroup gInfo' relayMember) incognito relayLinkToConnect Nothing Nothing -- Re-read member to get updated activeConn relayMember' <- withFastStore $ \db -> getGroupMember db vr user groupId (groupMemberId' relayMember) @@ -2088,7 +2094,7 @@ processChatCommand vr nm = \case ccLink <- case contactLink of Just (CLFull cReq) -> pure $ CCLink cReq Nothing Just (CLShort sLnk) -> do - (cReq, _cData) <- getShortLinkConnReq nm user sLnk + (FixedLinkData {linkConnReq = cReq}, _cData) <- getShortLinkConnReq nm user sLnk pure $ CCLink cReq $ Just sLnk Nothing -> throwCmdError "no address in contact profile" connectContactViaAddress user incognito ct ccLink `catchAllErrors` \e -> do @@ -2311,50 +2317,48 @@ processChatCommand vr nm = \case chatItemId <- getChatItemIdByText user chatRef msg processChatCommand vr nm $ APIChatItemReaction chatRef chatItemId add reaction APINewGroup userId incognito gProfile -> withUserId userId $ \user -> do - gInfo <- newGroup user incognito gProfile False + g <- asks random + memberId <- liftIO $ MemberId <$> encodedRandomBytes g 12 + gInfo <- newGroup user incognito gProfile False memberId Nothing pure $ CRGroupCreated user gInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand vr nm $ APINewGroup userId incognito gProfile - APINewPublicGroup userId incognito relayIds gProfile -> withUserId userId $ \user -> do - gInfo <- newGroup user incognito gProfile True - (gInfo', gLink, groupRelays) <- setupLink user gInfo `catchAllErrors` \e -> do + APINewPublicGroup userId incognito relayIds groupProfile -> withUserId userId $ \user -> do + (gProfile', memberId, groupKeys, setupLink) <- prepareGroupLink user + gInfo <- newGroup user incognito gProfile' True memberId (Just groupKeys) + (gLink, groupRelays) <- setupLink gInfo `catchAllErrors` \e -> do deleteInProgressGroup user gInfo throwError e - pure $ CRPublicGroupCreated user gInfo' gLink groupRelays + pure $ CRPublicGroupCreated user gInfo gLink groupRelays where - setupLink :: User -> GroupInfo -> CM (GroupInfo, GroupLink, [GroupRelay]) - setupLink user gInfo = do - (gInfo', gLink, sLnk) <- newGroupLink user gInfo - relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) - groupRelays <- addRelays user gInfo' sLnk relays - pure (gInfo', gLink, groupRelays) - newGroupLink :: User -> GroupInfo -> CM (GroupInfo, GroupLink, ShortLinkContact) - newGroupLink user gInfo@GroupInfo {groupProfile} = do + prepareGroupLink :: User -> CM (GroupProfile, MemberId, GroupKeys, GroupInfo -> CM (GroupLink, [GroupRelay])) + prepareGroupLink user = do + gVar <- asks random groupLinkId <- GroupLinkId <$> drgRandomBytes 16 + sharedGroupId <- drgRandomBytes 24 subMode <- chatReadVar subscriptionMode - -- TODO [relays] owner: prepare group link without initially creating on server - -- TODO - add link and owner key to group profile, sign profile - -- TODO - create group link on server with signed profile as data - -- / link creation - let userData = encodeShortLinkData $ GroupShortLinkData groupProfile - userLinkData = UserContactLinkData UserContactData {direct = False, owners = [], relays = [], userData} - crClientData = encodeJSON $ CRDataGroup groupLinkId - (connId, (ccLink, _serviceId)) <- withAgent $ \a -> createConnection a nm (aUserId user) True True SCMContact (Just userLinkData) (Just crClientData) IKPQOff subMode + let crClientData = encodeJSON $ CRDataGroup groupLinkId + -- prepare link with sharedGroupId as linkEntityId (no server request) + ((_, rootPrivKey), ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) (Just sharedGroupId) True (Just crClientData) ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink sLnk <- case toShortLinkContact ccLink' of Just sl -> pure sl Nothing -> throwChatError $ CEException "failed to create relayed group link: no short link" + -- generate owner key, OwnerAuth signed by root key + memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12) + (memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey let groupProfile' = (groupProfile :: GroupProfile) {groupLink = Just sLnk} - userData' = encodeShortLinkData $ GroupShortLinkData groupProfile' - userLinkData' = UserContactLinkData UserContactData {direct = False, owners = [], relays = [], userData = userData'} - void $ withAgent (\a -> setConnShortLink a nm connId SCMContact userLinkData' (Just crClientData)) - -- link creation / - gVar <- asks random - (gInfo', gLink) <- withFastStore $ \db -> do - gLink <- createGroupLink db gVar user gInfo connId ccLink' groupLinkId GRMember subMode - gInfo' <- updateGroupProfile db user gInfo groupProfile' - pure (gInfo', gLink) - pure (gInfo', gLink, sLnk) + userData = encodeShortLinkData $ GroupShortLinkData groupProfile' + userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData} + -- create connection with prepared link (single network call) + connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOff subMode + let groupKeys = GroupKeys {sharedGroupId = B64UrlByteString sharedGroupId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} + setupLink gInfo = do + gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId GRMember subMode + relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) + groupRelays <- addRelays user gInfo sLnk relays + pure (gLink, groupRelays) + pure (groupProfile', memberId, groupKeys, setupLink) NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do @@ -3625,13 +3629,12 @@ processChatCommand vr nm = \case groupId <- getGroupIdByName db user gName groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName pure (groupId, groupMemberId) - newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> CM GroupInfo - newGroup user incognito gProfile@GroupProfile {displayName} useRelays = do + newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> CM GroupInfo + newGroup user incognito gProfile@GroupProfile {displayName} useRelays memberId groupKeys_ = do checkValidName displayName - gVar <- asks random -- [incognito] generate incognito profile for group membership incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing - gInfo <- withFastStore $ \db -> createNewGroup db vr gVar user gProfile incognitoProfile useRelays + gInfo <- withFastStore $ \db -> createNewGroup db vr user gProfile incognitoProfile useRelays memberId groupKeys_ let cd = CDGroupSnd gInfo Nothing createInternalChatItem user cd CIChatBanner (Just epochStart) createInternalChatItem user cd (CISndGroupE2EEInfo E2EInfo {pqEnabled = Just PQEncOff}) Nothing @@ -3667,7 +3670,7 @@ processChatCommand vr nm = \case -- TODO [relays] owner: track and reuse relay profiles -- TODO - single profile linked to relay configuration record (chat_relays) -- TODO - update when fetching link data from relay address - (cReq, _cData) <- getShortLinkConnReq nm user address + (FixedLinkData {linkConnReq = cReq}, _cData) <- getShortLinkConnReq nm user address lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case Nothing -> throwChatError CEInvalidConnReq Just (agentV, _) -> do @@ -3767,7 +3770,7 @@ processChatCommand vr nm = \case knownLinkPlans l' >>= \case Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq nm user l' + (FixedLinkData {linkConnReq = cReq}, cData) <- getShortLinkConnReq nm user l' contactSLinkData_ <- liftIO $ decodeLinkUserData cData invitationReqAndPlan cReq (Just l') contactSLinkData_ where @@ -3793,7 +3796,7 @@ processChatCommand vr nm = \case knownLinkPlans >>= \case Just r -> pure r Nothing -> do - (cReq, cData) <- getShortLinkConnReq nm user l' + (FixedLinkData {linkConnReq = cReq}, cData) <- getShortLinkConnReq nm user l' withFastStore' (\db -> getContactWithoutConnViaShortAddress db vr user l') >>= \case Just ct' | not (contactDeleted ct') -> pure (con cReq, CPContactAddress (CAPContactViaAddress ct')) _ -> do @@ -3812,9 +3815,11 @@ processChatCommand vr nm = \case knownLinkPlans >>= \case Just r -> pure r Nothing -> do - (cReq, cData@(ContactLinkData _ UserContactData {direct})) <- getShortLinkConnReq nm user l' + (fd, cData@(ContactLinkData _ UserContactData {direct, relays})) <- getShortLinkConnReq nm user l' + let FixedLinkData {linkConnReq = cReq, linkEntityId} = fd + linkInfo = GroupShortLinkInfo {direct, groupRelays = relays, sharedGroupId = B64UrlByteString <$> linkEntityId} groupSLinkData_ <- liftIO $ decodeLinkUserData cData - plan <- groupJoinRequestPlan user cReq direct groupSLinkData_ + plan <- groupJoinRequestPlan user cReq (Just linkInfo) groupSLinkData_ pure (con cReq, plan) where knownLinkPlans = withFastStore $ \db -> @@ -3859,7 +3864,7 @@ processChatCommand vr nm = \case groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli case groupLinkId of Nothing -> contactRequestPlan user cReq Nothing - Just _ -> groupJoinRequestPlan user cReq True Nothing + Just _ -> groupJoinRequestPlan user cReq Nothing Nothing contactRequestPlan :: User -> ConnReqContact -> Maybe ContactShortLinkData -> CM ConnectionPlan contactRequestPlan user (CRContactUri crData) contactSLinkData_ = do let cReqSchemas = contactCReqSchemas crData @@ -3880,10 +3885,10 @@ processChatCommand vr nm = \case | contactDeleted ct -> pure $ CPContactAddress (CAPOk contactSLinkData_) | otherwise -> pure $ CPContactAddress (CAPKnown ct) -- TODO [short links] RcvGroupMsgConnection branch is deprecated? (old group link protocol?) - Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo True Nothing + Just (RcvGroupMsgConnection _ gInfo _) -> groupPlan gInfo Nothing Nothing Just _ -> throwCmdError "found connection entity is not RcvDirectMsgConnection or RcvGroupMsgConnection" - groupJoinRequestPlan :: User -> ConnReqContact -> Bool -> Maybe GroupShortLinkData -> CM ConnectionPlan - groupJoinRequestPlan user (CRContactUri crData) direct groupSLinkData_ = do + groupJoinRequestPlan :: User -> ConnReqContact -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> CM ConnectionPlan + groupJoinRequestPlan user (CRContactUri crData) groupSLinkInfo_ groupSLinkData_ = do let cReqSchemas = contactCReqSchemas crData cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db vr user cReqSchemas) >>= \case @@ -3892,21 +3897,21 @@ processChatCommand vr nm = \case connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db vr user cReqHashes gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db vr user cReqHashes case (gInfo_, connEnt_) of - (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk direct groupSLinkData_) + (Nothing, Nothing) -> pure $ CPGroupLink (GLPOk groupSLinkInfo_ groupSLinkData_) -- TODO [short links] RcvDirectMsgConnection branches are deprecated? (old group link protocol?) (Nothing, Just (RcvDirectMsgConnection _conn Nothing)) -> pure $ CPGroupLink GLPConnectingConfirmReconnect (Nothing, Just (RcvDirectMsgConnection _ (Just ct))) | not (contactReady ct) && contactActive ct -> pure $ CPGroupLink (GLPConnectingProhibit gInfo_) - | otherwise -> pure $ CPGroupLink (GLPOk direct groupSLinkData_) + | otherwise -> pure $ CPGroupLink (GLPOk groupSLinkInfo_ groupSLinkData_) (Nothing, Just _) -> throwCmdError "found connection entity is not RcvDirectMsgConnection" - (Just gInfo, _) -> groupPlan gInfo direct groupSLinkData_ - groupPlan :: GroupInfo -> Bool -> Maybe GroupShortLinkData -> CM ConnectionPlan - groupPlan gInfo@GroupInfo {membership} direct groupSLinkData_ + (Just gInfo, _) -> groupPlan gInfo groupSLinkInfo_ groupSLinkData_ + groupPlan :: GroupInfo -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> CM ConnectionPlan + groupPlan gInfo@GroupInfo {membership} groupSLinkInfo_ groupSLinkData_ | memberStatus membership == GSMemRejected = pure $ CPGroupLink (GLPKnown gInfo) | not (memberActive membership) && not (memberRemoved membership) = pure $ CPGroupLink (GLPConnectingProhibit $ Just gInfo) | memberActive membership = pure $ CPGroupLink (GLPKnown gInfo) - | otherwise = pure $ CPGroupLink (GLPOk direct groupSLinkData_) + | otherwise = pure $ CPGroupLink (GLPOk groupSLinkInfo_ groupSLinkData_) contactCReqSchemas :: ConnReqUriData -> (ConnReqContact, ConnReqContact) contactCReqSchemas crData = ( CRContactUri crData {crScheme = SSSimplex}, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 2a3d71ea73..7ea5150583 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1308,7 +1308,7 @@ groupLinkData gInfo@GroupInfo {groupProfile} GroupLink {groupLinkId} groupRelays restoreShortLink' :: ConnShortLink m -> CM (ConnShortLink m) restoreShortLink' l = (`restoreShortLink` l) <$> asks (shortLinkPresetServers . config) -getShortLinkConnReq :: NetworkRequestMode -> User -> ConnShortLink m -> CM (ConnectionRequestUri m, ConnLinkData m) +getShortLinkConnReq :: NetworkRequestMode -> User -> ConnShortLink m -> CM (FixedLinkData m, ConnLinkData m) getShortLinkConnReq nm user@User {userChatRelay} l = do l' <- restoreShortLink' l (fd, cData) <- withAgent $ \a -> getConnShortLink a nm (aUserId user) l' @@ -1318,7 +1318,7 @@ getShortLinkConnReq nm user@User {userChatRelay} l = do where supported = direct || not (null relays) || isTrue userChatRelay _ -> pure () - pure (linkConnReq fd, cData) + pure (fd, cData) encodeShortLinkData :: J.ToJSON a => a -> UserLinkData encodeShortLinkData d = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a5e5c0fcd7..1f1b56598c 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -3438,7 +3438,7 @@ runRelayRequestWorker a Worker {doWork} = do where getLinkDataCreateRelayLink :: RelayRequestData -> GroupInfo -> CM (GroupInfo, ShortLinkContact) getLinkDataCreateRelayLink RelayRequestData {reqGroupLink} gInfo = do - (_cReq, cData) <- getShortLinkConnReq NRMBackground user reqGroupLink + (_fd, cData) <- getShortLinkConnReq NRMBackground user reqGroupLink liftIO (decodeLinkUserData cData) >>= \case Nothing -> throwChatError $ CEException "getLinkDataCreateRelayLink: no group link data" Just (GroupShortLinkData gp) -> do diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 9a007d85fe..0e689267d4 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -142,18 +142,19 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.shared_group_id, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 8564354a97..7abc865ef1 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -101,6 +101,7 @@ module Simplex.Chat.Store.Groups getMemberInvitation, createMemberConnection, createMemberConnectionAsync, + updateGroupMemberKeys, updateGroupMemberStatus, updateGroupMemberStatusById, updateGroupMemberAccepted, @@ -208,11 +209,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -327,13 +328,20 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC pure gLnk {connLinkContact = CCLink connFullLink (Just shortLink), shortLinkDataSet = True, shortLinkLargeDataSet = BoolDef True} -- | creates completely new group with a single member - the current user -createNewGroup :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupProfile -> Maybe Profile -> Bool -> ExceptT StoreError IO GroupInfo -createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile useRelays = ExceptT $ do +createNewGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> ExceptT StoreError IO GroupInfo +createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys = ExceptT $ do let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile withLocalDisplayName db userId displayName $ \ldn -> runExceptT $ do + let (sharedGroupId_, rootPrivKey_, rootPubKey_, memberPrivKey_) = case groupKeys of + Nothing -> (Nothing, Nothing, Nothing, Nothing) + Just GroupKeys {sharedGroupId, groupRootKey, memberPrivKey} -> + let (rpk, rpub) = case groupRootKey of + GRKPrivate pk -> (Just pk, Nothing) + GRKPublic k -> (Nothing, Just k) + in (Just sharedGroupId, rpk, rpub, Just memberPrivKey) groupId <- liftIO $ do DB.execute db @@ -345,13 +353,16 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile useRe [sql| INSERT INTO groups (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, + shared_group_id, root_priv_key, root_pub_key, member_priv_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) + ( (BI useRelays, BI useRelays, ldn, userId, profileId, BI True, currentTs, currentTs, currentTs, currentTs) + :. (sharedGroupId_, rootPrivKey_, rootPubKey_, memberPrivKey_) + ) insertedRowId db - memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser customUserProfileId currentTs vr + let memberPubKey = C.publicKey . memberPrivKey <$> groupKeys + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole memberId GROwner) GCUserMember GSMemCreator IBUser customUserProfileId memberPubKey currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure GroupInfo @@ -376,7 +387,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile useRe groupSummary = GroupSummary 1, customData = Nothing, membersRequireAttention = 0, - viaGroupLinkUri = Nothing + viaGroupLinkUri = Nothing, + groupKeys } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -426,8 +438,10 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ ((profileId, localDisplayName, connRequest, userId, BI True, currentTs, currentTs, currentTs, currentTs) :. businessChatInfoRow business) insertedRowId db let hostVRange = adjustedMemberVRange vr peerChatVRange - GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing currentTs hostVRange - membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId currentTs vr + -- TODO [member keys] inviting host should generate its keys in public groups + GroupMember {groupMemberId} <- createContactMemberInv_ db user groupId Nothing contact fromMember GCHostMember GSMemInvited IBUnknown Nothing Nothing currentTs hostVRange + -- TODO [member keys] relay should pass key received via XMember + membership <- createContactMemberInv_ db user groupId (Just groupMemberId) user invitedMember GCUserMember GSMemInvited (IBContact contactId) incognitoProfileId Nothing currentTs vr let chatSettings = ChatSettings {enableNtfs = MFAll, sendRcpts = Nothing, favorite = False} pure ( GroupInfo @@ -452,7 +466,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ groupSummary = GroupSummary 2, customData = Nothing, membersRequireAttention = 0, - viaGroupLinkUri = Nothing + viaGroupLinkUri = Nothing, + groupKeys = Nothing }, groupMemberId ) @@ -485,8 +500,8 @@ getUpdateNextIndexInGroup_ db groupId = |] (Only groupId) -createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember -createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId createdAt vr = do +createContactMemberInv_ :: IsContact a => DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> a -> MemberIdRole -> GroupMemberCategory -> GroupMemberStatus -> InvitedBy -> Maybe ProfileId -> Maybe C.PublicKeyEd25519 -> UTCTime -> VersionRangeChat -> ExceptT StoreError IO GroupMember +createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMemberId userOrContact MemberIdRole {memberId, memberRole} memberCategory memberStatus invitedBy incognitoProfileId memberPubKey createdAt vr = do incognitoProfile <- forM incognitoProfileId $ \profileId -> getProfileById db userId profileId (indexInGroup, localDisplayName, memberProfile) <- case (incognitoProfile, incognitoProfileId) of (Just profile@LocalProfile {displayName}, Just profileId) -> do @@ -517,7 +532,8 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + memberPubKey } where memberChatVRange@(VersionRange minV maxV) = vr @@ -531,12 +547,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, createdAt, createdAt) + :. (userId, localDisplayName' userOrContact, contactId' userOrContact, localProfileId $ profile' userOrContact, memberPubKey, createdAt, createdAt) :. (minV, maxV) ) pure (indexInGroup, localDisplayName) @@ -550,12 +566,12 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe [sql| INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ( (groupId, indexInGroup, memberId, memberRole, memberCategory, memberStatus, Binary B.empty, fromInvitedBy userContactId invitedBy, invitedByGroupMemberId) - :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, createdAt, createdAt) + :. (userId, incognitoLdn, contactId' userOrContact, localProfileId $ profile' userOrContact, customUserProfileId, memberPubKey, createdAt, createdAt) :. (minV, maxV) ) pure (indexInGroup, incognitoLdn) @@ -577,7 +593,8 @@ createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile bu then liftIO $ MemberId <$> encodedRandomBytes gVar 12 else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" let userMember = MemberIdRole userMemberId GRMember - membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing currentTs vr + -- TODO [member keys] user key must be included here. Should key be added when group is prepared? + membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr hostMember <- getGroupMember db vr user groupId hostMemberId when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember g <- getGroupInfo db vr user groupId @@ -777,7 +794,8 @@ createGroupViaLink' hostMemberId <- insertHost_ currentTs groupId liftIO $ DB.execute db "UPDATE connections SET conn_type = ?, group_member_id = ?, updated_at = ? WHERE connection_id = ?" (ConnMember, hostMemberId, currentTs, connId) -- using IBUnknown since host is created without contact - void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId currentTs vr + -- TODO [member keys] can this be used with public groups? if yes member keys need to be added + void $ createContactMemberInv_ db user groupId (Just hostMemberId) user invitedMember GCUserMember membershipStatus IBUnknown customUserProfileId Nothing currentTs vr liftIO $ setViaGroupLinkUri db groupId connId (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user hostMemberId where @@ -1207,7 +1225,8 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, memberChatVRange = peerChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + memberPubKey = Nothing } where insertMember_ = do @@ -1417,7 +1436,8 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe liftIO $ setRelayRequestData_ groupId ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing currentTs vr + -- TODO [member keys] should relays use member keys? + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing Nothing currentTs vr ownerMember <- getGroupMember db vr user groupId ownerMemberId g <- getGroupInfo db vr user groupId pure (g, ownerMember) @@ -1603,7 +1623,8 @@ createBusinessRequestGroup (groupProfileId, ldn, userId, BI True, currentTs, currentTs, currentTs, currentTs, BCCustomer) groupId <- liftIO $ insertedRowId db memberId <- liftIO $ encodedRandomBytes gVar 12 - membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing currentTs vr + -- TODO [member keys] we could support member keys in business groups to allow binding agreements (though identity keys would be better for it. + membership <- createContactMemberInv_ db user groupId Nothing user (MemberIdRole (MemberId memberId) GROwner) GCUserMember GSMemCreator IBUser Nothing Nothing currentTs vr pure (groupId, membership) VersionRange minV maxV = cReqChatVRange insertClientMember_ currentTs groupId membership = @@ -1665,6 +1686,18 @@ createMemberConnectionAsync db user@User {userId} groupMemberId (cmdId, agentCon Connection {connId} <- createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange Nothing 0 currentTs subMode setCommandConnId db user cmdId connId +updateGroupMemberKeys :: DB.Connection -> GroupId -> ByteString -> C.PublicKeyEd25519 -> C.PrivateKeyEd25519 -> GroupMemberId -> IO () +updateGroupMemberKeys db groupId sharedGroupId rootPubKey memberPrivKey membershipGMId = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE groups SET shared_group_id = ?, root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ?" + (sharedGroupId, rootPubKey, memberPrivKey, currentTs, groupId) + DB.execute + db + "UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ?" + (C.publicKey memberPrivKey, currentTs, membershipGMId) + updateGroupMemberStatus :: DB.Connection -> UserId -> GroupMember -> GroupMemberStatus -> IO () updateGroupMemberStatus db userId GroupMember {groupMemberId} = updateGroupMemberStatusById db userId groupMemberId @@ -1849,7 +1882,9 @@ createNewMember_ memberChatVRange, createdAt, updatedAt = createdAt, - supportChat = Nothing + supportChat = Nothing, + -- TODO [member keys] is it used with relay/public groups? + memberPubKey = Nothing } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 7093c89eec..15b17cd19c 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -679,7 +679,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -2977,7 +2977,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember @@ -2985,13 +2985,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, - rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, - dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs index f86ee049e4..905067bf76 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260222_chat_relays.hs @@ -38,7 +38,11 @@ ALTER TABLE groups ADD COLUMN relay_request_peer_chat_min_version INTEGER, ADD COLUMN relay_request_peer_chat_max_version INTEGER, ADD COLUMN relay_request_failed SMALLINT DEFAULT 0, - ADD COLUMN relay_request_err_reason TEXT; + ADD COLUMN relay_request_err_reason TEXT, + ADD COLUMN shared_group_id BYTEA, + ADD COLUMN root_priv_key BYTEA, + ADD COLUMN root_pub_key BYTEA, + ADD COLUMN member_priv_key BYTEA; ALTER TABLE group_profiles ADD COLUMN group_link BYTEA; @@ -56,7 +60,9 @@ CREATE INDEX idx_group_relays_group_id ON group_relays(group_id); CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays(group_member_id); CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); -ALTER TABLE group_members ADD COLUMN relay_link BYTEA; +ALTER TABLE group_members + ADD COLUMN relay_link BYTEA, + ADD COLUMN member_pub_key BYTEA; |] down_m20260222_chat_relays :: Text @@ -74,7 +80,11 @@ ALTER TABLE groups DROP COLUMN relay_request_peer_chat_min_version, DROP COLUMN relay_request_peer_chat_max_version, DROP COLUMN relay_request_failed, - DROP COLUMN relay_request_err_reason; + DROP COLUMN relay_request_err_reason, + DROP COLUMN shared_group_id, + DROP COLUMN root_priv_key, + DROP COLUMN root_pub_key, + DROP COLUMN member_priv_key; ALTER TABLE group_profiles DROP COLUMN group_link; @@ -88,5 +98,7 @@ DROP INDEX idx_chat_relays_user_id_address; DROP INDEX idx_chat_relays_user_id_name; DROP TABLE chat_relays; -ALTER TABLE group_members DROP COLUMN relay_link; +ALTER TABLE group_members + DROP COLUMN relay_link, + DROP COLUMN member_pub_key; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index bcdcd5c992..3f617ab780 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -811,7 +811,8 @@ CREATE TABLE test_chat_schema.group_members ( member_welcome_shared_msg_id bytea, index_in_group bigint DEFAULT 0 NOT NULL, member_relations_vector bytea, - relay_link bytea + relay_link bytea, + member_pub_key bytea ); @@ -945,7 +946,11 @@ CREATE TABLE test_chat_schema.groups ( relay_request_peer_chat_min_version integer, relay_request_peer_chat_max_version integer, relay_request_failed smallint DEFAULT 0, - relay_request_err_reason text + relay_request_err_reason text, + shared_group_id bytea, + root_priv_key bytea, + root_pub_key bytea, + member_priv_key bytea ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs index eb2fb9f258..594ea37035 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260222_chat_relays.hs @@ -52,6 +52,10 @@ ALTER TABLE groups ADD COLUMN relay_request_peer_chat_min_version INTEGER; ALTER TABLE groups ADD COLUMN relay_request_peer_chat_max_version INTEGER; ALTER TABLE groups ADD COLUMN relay_request_failed INTEGER DEFAULT 0; ALTER TABLE groups ADD COLUMN relay_request_err_reason TEXT; +ALTER TABLE groups ADD COLUMN shared_group_id BLOB; +ALTER TABLE groups ADD COLUMN root_priv_key BLOB; +ALTER TABLE groups ADD COLUMN root_pub_key BLOB; +ALTER TABLE groups ADD COLUMN member_priv_key BLOB; ALTER TABLE group_profiles ADD COLUMN group_link BLOB; @@ -70,6 +74,7 @@ CREATE UNIQUE INDEX idx_group_relays_group_member_id ON group_relays(group_membe CREATE INDEX idx_group_relays_chat_relay_id ON group_relays(chat_relay_id); ALTER TABLE group_members ADD COLUMN relay_link BLOB; +ALTER TABLE group_members ADD COLUMN member_pub_key BLOB; |] down_m20260222_chat_relays :: Query @@ -89,6 +94,10 @@ ALTER TABLE groups DROP COLUMN relay_request_peer_chat_min_version; ALTER TABLE groups DROP COLUMN relay_request_peer_chat_max_version; ALTER TABLE groups DROP COLUMN relay_request_failed; ALTER TABLE groups DROP COLUMN relay_request_err_reason; +ALTER TABLE groups DROP COLUMN shared_group_id; +ALTER TABLE groups DROP COLUMN root_priv_key; +ALTER TABLE groups DROP COLUMN root_pub_key; +ALTER TABLE groups DROP COLUMN member_priv_key; ALTER TABLE group_profiles DROP COLUMN group_link; @@ -103,4 +112,5 @@ DROP INDEX idx_chat_relays_user_id_name; DROP TABLE chat_relays; ALTER TABLE group_members DROP COLUMN relay_link; +ALTER TABLE group_members DROP COLUMN member_pub_key; |] 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 566fe89768..a61e6a34e0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -149,18 +149,19 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.shared_group_id, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -313,9 +314,9 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) @@ -617,9 +618,9 @@ SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (conta Query: INSERT INTO group_members ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, - user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, + user_id, local_display_name, contact_id, contact_profile_id, member_pub_key, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) @@ -1009,7 +1010,7 @@ Query: m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -1214,8 +1215,9 @@ Plan: Query: INSERT INTO groups (use_relays, creating_in_progress, local_display_name, user_id, group_profile_id, enable_ntfs, - created_at, updated_at, chat_ts, user_member_profile_sent_at) - VALUES (?,?,?,?,?,?,?,?,?,?) + created_at, updated_at, chat_ts, user_member_profile_sent_at, + shared_group_id, root_priv_key, root_pub_key, member_priv_key) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1274,7 +1276,7 @@ Query: m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember @@ -1282,13 +1284,13 @@ Query: rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, - rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, - dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id @@ -5103,12 +5105,13 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.shared_group_id, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -5138,12 +5141,13 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.shared_group_id, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -5166,12 +5170,13 @@ Query: g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.shared_group_id, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -5219,7 +5224,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5246,7 +5251,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5265,7 +5270,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5284,7 +5289,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5303,7 +5308,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5322,7 +5327,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5341,7 +5346,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5360,7 +5365,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5379,7 +5384,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5398,7 +5403,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5417,7 +5422,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -6395,7 +6400,7 @@ SEARCH contacts USING INDEX idx_contacts_chat_ts (user_id=?) Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT COUNT(1), COALESCE(SUM(user_mention), 0) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL AND item_status = ? Plan: @@ -6557,7 +6562,7 @@ SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1 Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0 OR chat_item_ttl IS NULL Plan: @@ -6565,7 +6570,7 @@ SCAN groups Query: SELECT group_id FROM groups WHERE user_id = ? AND creating_in_progress = 1 AND created_at <= ? Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_id FROM groups WHERE user_id = ? AND local_display_name = ? Plan: @@ -6577,7 +6582,7 @@ SEARCH user_contact_links USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT group_id, conn_full_link_to_connect FROM groups WHERE user_id = ? AND conn_short_link_to_connect = ? Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 Plan: @@ -6851,6 +6856,10 @@ Query: UPDATE group_members SET member_profile_id = ?, updated_at = ? WHERE grou Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET member_pub_key = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET member_relations_vector = set_member_vector_new_relation(member_relations_vector, ?, ?, ?), updated_at = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6931,6 +6940,10 @@ Query: UPDATE groups SET send_rcpts = NULL Plan: SCAN groups +Query: UPDATE groups SET shared_group_id = ?, root_pub_key = ?, member_priv_key = ?, updated_at = ? WHERE group_id = ? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) @@ -6941,7 +6954,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND unread_chat = ? Plan: -SEARCH groups USING INDEX idx_groups_chat_ts (user_id=?) +SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) Query: UPDATE groups SET user_member_profile_sent_at = ? WHERE user_id = ? AND group_id = ? Plan: diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 4a0348db4b..12951307bb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -167,7 +167,11 @@ CREATE TABLE groups( relay_request_peer_chat_min_version INTEGER, relay_request_peer_chat_max_version INTEGER, relay_request_failed INTEGER DEFAULT 0, - relay_request_err_reason TEXT, -- received + relay_request_err_reason TEXT, + shared_group_id BLOB, + root_priv_key BLOB, + root_pub_key BLOB, + member_priv_key BLOB, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -210,6 +214,7 @@ CREATE TABLE group_members( index_in_group INTEGER NOT NULL DEFAULT 0, member_relations_vector BLOB, relay_link BLOB, + member_pub_key BLOB, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 15c2c4d41c..90ccd4dedc 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -16,6 +16,7 @@ module Simplex.Chat.Store.Shared where +import Control.Applicative ((<|>)) import Control.Exception (Exception) import qualified Control.Exception as E import Control.Monad @@ -662,14 +663,16 @@ type PreparedGroupRow = (Maybe ConnReqContact, Maybe ShortLinkContact, BoolInt, type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupMemberRow +type GroupKeysRow = (Maybe B64UrlByteString, Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime) +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow + +type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupLink) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupLink) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences @@ -677,7 +680,8 @@ toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, businessChat = toBusinessChatInfo businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers} - in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri} + groupKeys = toGroupKeys groupKeysRow + in GroupInfo {groupId, useRelays = BoolDef useRelays, relayOwnStatus, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, preparedGroup, chatTags, chatItemTTL, uiThemes, groupSummary, customData, membersRequireAttention, viaGroupLinkUri, groupKeys} toPreparedGroup :: PreparedGroupRow -> Maybe PreparedGroup toPreparedGroup = \case @@ -685,8 +689,15 @@ toPreparedGroup = \case Just PreparedGroup {connLinkToConnect = CCLink fullLink shortLink_, connLinkPreparedConnection, connLinkStartedConnection, welcomeSharedMsgId, requestSharedMsgId} _ -> Nothing +toGroupKeys :: GroupKeysRow -> Maybe GroupKeys +toGroupKeys = \case + (Just sharedGroupId, rootPrivKey_, rootPubKey_, Just memberPrivKey) -> + (\grk -> GroupKeys {sharedGroupId, groupRootKey = grk, memberPrivKey}) + <$> (GRKPrivate <$> rootPrivKey_ <|> GRKPublic <$> rootPubKey_) + _ -> Nothing + toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs)) = +toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -713,7 +724,7 @@ groupMemberQuery = m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -750,12 +761,13 @@ groupInfoQueryFields = g.business_chat, g.business_member_id, g.customer_member_id, g.use_relays, g.relay_own_status, g.ui_themes, g.summary_current_members_count, g.custom_data, g.chat_item_ttl, g.members_require_attention, g.via_group_link_uri, + g.shared_group_id, g.root_priv_key, g.root_pub_key, g.member_priv_key, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key |] groupInfoQueryFrom :: Query diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 28896329f9..02916d0d82 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -54,6 +54,7 @@ import Simplex.FileTransfer.Description (FileDigest) import Simplex.FileTransfer.Types (RcvFileId, SndFileId) import Simplex.Messaging.Agent.Protocol (ACorrId, ACreatedConnLink, AEventTag (..), AEvtTag (..), ConnId, ConnShortLink, ConnectionLink, ConnectionMode (..), ConnectionRequestUri, CreatedConnLink, InvitationId, SAEntity (..), UserId) import Simplex.Messaging.Agent.Store.DB (Binary (..), blobFieldDecoder, fromTextField_) +import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.File (CryptoFileArgs (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption (..), PQSupport, pattern PQEncOff) import Simplex.Messaging.Encoding.String @@ -447,6 +448,22 @@ data Group = Group {groupInfo :: GroupInfo, members :: [GroupMember]} type GroupId = Int64 +data GroupRootKey + = GRKPrivate {rootPrivKey :: C.PrivateKeyEd25519} + | GRKPublic {rootPubKey :: C.PublicKeyEd25519} + deriving (Eq, Show) + +groupRootPubKey :: GroupRootKey -> C.PublicKeyEd25519 +groupRootPubKey (GRKPrivate pk) = C.publicKey pk +groupRootPubKey (GRKPublic pk) = pk + +data GroupKeys = GroupKeys + { sharedGroupId :: B64UrlByteString, + groupRootKey :: GroupRootKey, + memberPrivKey :: C.PrivateKeyEd25519 + } + deriving (Eq, Show) + data GroupInfo = GroupInfo { groupId :: GroupId, useRelays :: BoolDef, @@ -469,7 +486,8 @@ data GroupInfo = GroupInfo customData :: Maybe CustomData, groupSummary :: GroupSummary, membersRequireAttention :: Int, - viaGroupLinkUri :: Maybe ConnReqContact + viaGroupLinkUri :: Maybe ConnReqContact, + groupKeys :: Maybe GroupKeys } deriving (Eq, Show) @@ -963,7 +981,8 @@ data GroupMember = GroupMember memberChatVRange :: VersionRangeChat, createdAt :: UTCTime, updatedAt :: UTCTime, - supportChat :: Maybe GroupSupportChat + supportChat :: Maybe GroupSupportChat, + memberPubKey :: Maybe C.PublicKeyEd25519 } deriving (Eq, Show) @@ -2039,6 +2058,10 @@ instance FromJSON GroupSummary where parseJSON = $(JQ.mkParseJSON defaultJSON ''GroupSummary) omittedField = Just GroupSummary {currentMembers = 0} +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GRK") ''GroupRootKey) + +$(JQ.deriveJSON defaultJSON ''GroupKeys) + $(JQ.deriveJSON defaultJSON ''GroupInfo) $(JQ.deriveJSON defaultJSON ''Group) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 4192bf5f7f..b1fa59f9ef 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2025,9 +2025,10 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case | business -> ("business address: " <>) _ -> ("contact address: " <>) CPGroupLink glp -> case glp of - GLPOk direct groupSLinkData -> - [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"] - <> [viewJSON groupSLinkData] -- | testView] -- TODO [relays] disable link data output in cli (uncomment testView) + GLPOk groupSLinkInfo_ groupSLinkData -> + let direct = maybe True (\(GroupShortLinkInfo {direct = d}) -> d) groupSLinkInfo_ + in [grpLink $ if direct then "ok to connect directly" else "ok to connect via relays"] + <> [viewJSON groupSLinkData] -- | testView] -- TODO [relays] disable link data output in cli (uncomment testView) GLPOwnLink g -> [grpLink "own link for group " <> ttyGroup' g] GLPConnectingConfirmReconnect -> [grpLink "connecting, allowed to reconnect"] GLPConnectingProhibit Nothing -> [grpLink "connecting"] From 628b00eb08596bdb50246528fa4b11288ee574ac Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 12 Feb 2026 07:11:59 +0000 Subject: [PATCH 008/112] core: channel messages (#6604) * core: channel messages (WIP) * do not include member ID when quoting channel messages * query plans * reduce duplication * refactor * refactor plan * refactor 2 * all tests * remove plan * refactor 3 * refactor 4 * refactor 5 * refactor 6 * plans * plans to imrove test coverage and fix bugs * update plan * update plan * bug fixes (wip) * new plan * fixes wip * more tests * comment, fix lint * restore comment * restore comments * rename param * move type * simplify * comment * fix stale state * refactor * less diff * simplify * less diff * refactor --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../src/Directory/Service.hs | 6 +- bots/api/TYPES.md | 3 + docs/contributing/CODE.md | 13 + .../types/typescript/src/types.ts | 14 +- plans/channel_message_bugs_fix_plan.md | 321 +++++++++ plans/deduplication-channel-messages.md | 256 +++++++ plans/delivery-context-fix.md | 354 ++++++++++ plans/group_channel_feature_coverage.md | 377 ++++++++++ plans/groups_coverage_fill_plan.md | 368 ++++++++++ plans/groups_test_coverage.md | 441 ++++++++++++ src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Delivery.hs | 40 +- src/Simplex/Chat/Library/Commands.hs | 149 ++-- src/Simplex/Chat/Library/Internal.hs | 115 +-- src/Simplex/Chat/Library/Subscriber.hs | 506 ++++++++------ src/Simplex/Chat/Messages.hs | 54 +- src/Simplex/Chat/Messages/Batch.hs | 10 +- src/Simplex/Chat/Protocol.hs | 29 +- src/Simplex/Chat/Store/Delivery.hs | 14 +- src/Simplex/Chat/Store/Files.hs | 19 +- src/Simplex/Chat/Store/Groups.hs | 22 +- src/Simplex/Chat/Store/Messages.hs | 53 +- .../SQLite/Migrations/M20230511_reactions.hs | 2 +- .../Migrations/M20250813_delivery_tasks.hs | 4 +- .../SQLite/Migrations/chat_query_plans.txt | 14 +- .../Store/SQLite/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Terminal/Output.hs | 3 +- src/Simplex/Chat/Types.hs | 6 + src/Simplex/Chat/View.hs | 110 +-- tests/ChatTests/Groups.hs | 657 ++++++++++++++++-- tests/ProtocolTests.hs | 14 +- 31 files changed, 3453 insertions(+), 532 deletions(-) create mode 100644 plans/channel_message_bugs_fix_plan.md create mode 100644 plans/deduplication-channel-messages.md create mode 100644 plans/delivery-context-fix.md create mode 100644 plans/group_channel_feature_coverage.md create mode 100644 plans/groups_coverage_fill_plan.md create mode 100644 plans/groups_test_coverage.md diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 41ea081890..3b2e926189 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -579,7 +579,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName [] -> textMsg "" : _ -> textMsg img : _ -> MCImage "" $ ImageData img - sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(quotedId, MCText noticeText), (Nothing, mc)] + sendCaptcha mc = sendComposedMessages_ cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [(quotedId, MCText noticeText), (Nothing, mc)] gmId = groupMemberId' m approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () @@ -603,7 +603,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName Just PendingCaptcha {captchaText, sentAt, attempts} | ts `diffUTCTime` sentAt > captchaTTL -> sendMemberCaptcha g m (Just ciId) captchaExpired $ attempts - 1 | matchCaptchaStr captchaText msgText -> do - sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just $ groupMemberId' m)) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] + sendComposedMessages_ cc (SRGroup groupId (Just $ GCSMemberSupport (Just $ groupMemberId' m)) False) [(Just ciId, MCText $ "Correct, you joined the group " <> n)] approvePendingMember a g m | attempts >= maxCaptchaAttempts -> rejectPendingMember tooManyAttempts | otherwise -> sendMemberCaptcha g m (Just ciId) (wrongCaptcha attempts) attempts @@ -613,7 +613,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g rejectPendingMember rjctNotice = do let gmId = groupMemberId' m - sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice] sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case Right (CRUserDeletedMembers _ _ (_ : _) _) -> do atomically $ TM.delete gmId $ pendingCaptchas env diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 25a6565f45..328b923f90 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -606,6 +606,9 @@ GroupRcv: - type: "groupRcv" - groupMember: [GroupMember](#groupmember) +ChannelRcv: +- type: "channelRcv" + LocalSnd: - type: "localSnd" diff --git a/docs/contributing/CODE.md b/docs/contributing/CODE.md index 7ae6d176ac..1dcf795c00 100644 --- a/docs/contributing/CODE.md +++ b/docs/contributing/CODE.md @@ -2,6 +2,12 @@ This file provides guidance on coding style and approaches and on building the code. +## Code Security + +When designing code and planning implementations: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. +- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances. + ## Code Style, Formatting and Approaches The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`. @@ -38,9 +44,16 @@ Some files that use CPP language extension cannot be formatted as a whole, so in **Diff and refactoring:** - Avoid unnecessary changes and code movements +- Never rename existing variables, parameters, or functions unless the rename is the point of the change - Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring - Aim to minimize the code changes - do what is minimally required to solve users' problems +**Type-driven development:** +- Types must reflect business semantics, not data shape. E.g., `CIChannelRcv` (channel message) vs `CIGroupRcv GroupMember` (member message) are semantically distinct — do not collapse them into `CIGroupRcv (Maybe GroupMember)` just because the data overlaps. Duplicate pattern match arms across semantic constructors are acceptable. +- Duplicate function bodies are not acceptable. When adding a new variant of existing behavior, parameterize existing functions to handle both variants — do not copy function bodies into parallel code paths. +- Concrete example: if `groupMessageFileDescription` and `channelMessageFileDescription` share 90% of their logic, extract a shared helper and make both into thin wrappers — do not maintain two near-identical function bodies. +- When the return type differs between variants (e.g., one returns `Maybe X`, another returns `()`), use the more general return type and have callers discard what they don't need. + **Document and code structure:** - **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure. - When adding new sections to documents, continue the existing numbering scheme. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index c04ff3466e..0660f0e968 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -554,11 +554,19 @@ export type CIDirection = | CIDirection.DirectRcv | CIDirection.GroupSnd | CIDirection.GroupRcv + | CIDirection.ChannelRcv | CIDirection.LocalSnd | CIDirection.LocalRcv export namespace CIDirection { - export type Tag = "directSnd" | "directRcv" | "groupSnd" | "groupRcv" | "localSnd" | "localRcv" + export type Tag = + | "directSnd" + | "directRcv" + | "groupSnd" + | "groupRcv" + | "channelRcv" + | "localSnd" + | "localRcv" interface Interface { type: Tag @@ -581,6 +589,10 @@ export namespace CIDirection { groupMember: GroupMember } + export interface ChannelRcv extends Interface { + type: "channelRcv" + } + export interface LocalSnd extends Interface { type: "localSnd" } diff --git a/plans/channel_message_bugs_fix_plan.md b/plans/channel_message_bugs_fix_plan.md new file mode 100644 index 0000000000..c50b5ed7ff --- /dev/null +++ b/plans/channel_message_bugs_fix_plan.md @@ -0,0 +1,321 @@ +# Plan: Channel Message Bugs Fix + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Bug 1: Delivery Context Flag](#bug-1-delivery-context-flag) +3. [Bug 2: Reaction Attribution](#bug-2-reaction-attribution) +4. [Bug 3: Update Fallback Default](#bug-3-update-fallback-default) +5. [Bug 4: Forward API Parameter](#bug-4-forward-api-parameter) +6. [Bug 5: CLI Forward Hardcode](#bug-5-cli-forward-hardcode) +7. [Test Plan](#test-plan) +8. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +**5 bugs identified** in channel message handling: + +| # | Location | Bug | Severity | +|---|----------|-----|----------| +| 1 | Subscriber.hs:935-945 | Events use `isChannelOwner` instead of item's `showGroupAsSender` | Critical | +| 2 | Subscriber.hs:1818-1842 | Reactions allow `m_=Nothing` and fall back to membership | High | +| 3 | Subscriber.hs:1950-1969 | Update fallback creates item without correct sendAsGroup flag | Medium | +| 4 | Commands.hs:930,944 | Forward API ignores `_sendAsGroup` parameter | High | +| 5 | Commands.hs:2191,2196,2201,4633 | CLI forward hardcodes False | Medium | + +--- + +## Bug 1: Delivery Context Flag + +### Current Code (Subscriber.hs:935-945) +```haskell +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> isChannelOwner -- BUG: should use item's flag + XMsgDel {} -> isChannelOwner -- BUG + XMsgReact {} -> isChannelOwner -- BUG + XMsgFileDescr {} -> isChannelOwner -- BUG + XFileCancel {} -> isChannelOwner -- BUG + _ -> False +``` + +### Problem +Events referencing existing items (update, delete, react, file) compute `showGroupAsSender'` from **current sender role** (`isChannelOwner`) instead of **item's stored `showGroupAsSender` flag**. + +### Fix +Extract `showGroupAsSender` from the chat item being referenced: + +```haskell +showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> itemShowGroupAsSender ci -- from item lookup + XMsgDel {} -> itemShowGroupAsSender ci + XMsgReact {} -> itemShowGroupAsSender ci + XMsgFileDescr {} -> itemShowGroupAsSender ci + XFileCancel {} -> itemShowGroupAsSender ci + _ -> False +``` + +**Note:** Use `chatDir` from ChatItem and pattern match on `CIChannelRcv` to determine sendAsGroup flag. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 935-945 + +--- + +## Bug 2: Reaction Attribution + +### Current Code (Subscriber.hs:1818-1842) +```haskell +groupMsgReaction :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) +groupMsgReaction g m_ sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + ... + where + GroupInfo {membership} = g + reactor = fromMaybe membership m_ -- BUG (line 1842): uses membership when m_ is Nothing + ciDir = maybe CIChannelRcv CIGroupRcv m_ +``` + +### Problem +When `m_` is `Nothing`, reactor incorrectly falls back to `membership` (user's own member record). However, reactions should always come from an identifiable member - the `m_` parameter should never be `Nothing` for reactions. + +### Fix +Reactions can only come from members (including owners), never from channels. XMsgReact handler must be reworked to require `GroupMember` instead of `Maybe GroupMember`. The `m_` parameter should not be optional for reactions. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1818-1842 + +--- + +## Bug 3: Update Fallback Default + +### Current Code (Subscriber.hs:1950-1969) +```haskell +updateRcvChatItem `catchCINotFound` \_ -> do + (chatDir, mentions', scopeInfo) <- case m_ of + Just m -> ... + Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing) -- BUG: no sendAsGroup info + (ci, cInfo) <- saveRcvChatItem' user chatDir msg ... +``` + +### Problem +When `x.msg.update` arrives for a locally-deleted item in a channel (`m_` is `Nothing`), the fallback creates a new item with `CDChannelRcv gInfo Nothing` but doesn't know the original item's `sendAsGroup` flag. + +### Fix (Option B: Require sender to include flag in the event) +Add `asGroup` field to `XMsgUpdate` message format. + +**Rationale:** We don't know what owner wants otherwise - it may send as channel or it may send as owner, and different members must have the same view (e.g. when multiple relays are used, it would be random). + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1950-1969 +- Protocol message format (XMsgUpdate) + +--- + +## Bug 4: Forward API Parameter + +### Current Code (Commands.hs:930,944) +```haskell +APIForwardChatItems ... _sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope (sendAsGroup' gInfo) False itemTTL cmrs' + -- ^^^^^^^^^^^^^^^^^^^ BUG: ignores _sendAsGroup +``` + +### Problem +The `_sendAsGroup` parameter is received but ignored. The function computes its own `sendAsGroup' gInfo` instead. + +### Fix +```haskell +APIForwardChatItems ... sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Line 930 (rename parameter), Line 944 (use parameter) + +--- + +## Bug 5: CLI Forward Hardcode + +### Current Code (Commands.hs) +```haskell +-- Line 2191 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2196 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2201 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 4633 +"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP <*> pure False), +``` + +### Problem +All CLI forward commands hardcode `False` for `sendAsGroup` instead of computing based on destination. + +### Fix +Compute `sendAsGroup` before calling API based on destination group's channel status: + +```haskell +-- Lines 2191, 2196, 2201: Need to determine sendAsGroup based on toChatRef +-- If toChatRef is a channel and user is owner, sendAsGroup should default to True + +-- Line 4633: Parser should accept optional flag (parser cannot know context) +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Lines 2191, 2196, 2201, 4633 + +--- + +## Test Plan + +### New Tests (8 total) + +Tests 1-4 cover Bug 1 (delivery context flag). Each tests a specific event type where the owner sends as member (sendAsGroup=False). Existing tests already cover the "sends as channel" (sendAsGroup=True) case; these tests verify that the delivery context correctly uses the item's stored sendAsGroup=False flag rather than recomputing from the owner's current role. + +#### Test 1: `testChannelOwnerUpdateAsMember` +**Objective:** Verify x.msg.update uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner updates message +4. Verify update delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 2: `testChannelOwnerDeleteAsMember` +**Objective:** Verify x.msg.del uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner deletes message +4. Verify delete delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 3: `testChannelOwnerFileTransferAsMember` +**Objective:** Verify file delivery (including x.msg.file.descr) uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Verify file delivery uses sendAsGroup=False from the item, not recomputed from owner role + +**Note:** x.msg.file.descr is part of file delivery, not a separate event to test independently. + +**Coverage:** Bug 1 + +--- + +#### Test 4: `testChannelOwnerFileCancelAsMember` +**Objective:** Verify x.file.cancel uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Owner cancels file +4. Verify cancel delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 5: `testChannelReactionAttribution` +**Objective:** Verify reactions require a member sender (not optional). + +**Scenario:** +1. Owner sends channel message +2. Owner adds reaction (as member, not as channel) +3. Verify reaction is attributed to owner's member record +4. Member adds reaction to channel message +5. Verify member reaction is attributed correctly +6. Verify channel cannot send reactions (m_ must be Just) + +**Coverage:** Bug 2 + +--- + +#### Test 6: `testChannelUpdateFallbackSendAsGroup` +**Objective:** Verify update on deleted item creates correct sendAsGroup from protocol field. + +**Scenario:** +1. Owner sends channel message (sendAsGroup=True) +2. Member receives and locally deletes +3. Owner updates message (XMsgUpdate includes asGroup=True) +4. Verify member's recreated item has sendAsGroup=True +5. Owner sends message as member (sendAsGroup=False) +6. Member receives and locally deletes +7. Owner updates message (XMsgUpdate includes asGroup=False) +8. Verify member's recreated item has sendAsGroup=False + +**Coverage:** Bug 3 + +--- + +#### Test 7: `testForwardAPIUsesParameter` +**Objective:** Verify Forward API respects sendAsGroup parameter. + +**Scenario:** +1. Create channel with owner +2. Forward message to channel with sendAsGroup=True +3. Verify message sent as channel +4. Forward message with sendAsGroup=False +5. Verify message sent as member + +**Coverage:** Bug 4 + +--- + +#### Test 8: `testForwardCLISendAsGroup` +**Objective:** Verify CLI forward commands compute sendAsGroup correctly. + +**Scenario:** +1. Create channel with owner +2. Use `/forward` to forward to channel +3. Verify sendAsGroup computed correctly (True for owner in channel) + +**Coverage:** Bug 5 + +--- + +## Implementation Order + +### Phase 1: Critical Fix (Bug 1) +1. Fix delivery context in Subscriber.hs +2. Add Tests 1-4 (`testChannelOwnerUpdateAsMember`, `testChannelOwnerDeleteAsMember`, `testChannelOwnerFileTransferAsMember`, `testChannelOwnerFileCancelAsMember`) + +### Phase 2: API Fixes (Bugs 4, 5) +1. Fix Forward API parameter usage +2. Fix CLI forward hardcodes +3. Add Tests 7 and 8 (`testForwardAPIUsesParameter`, `testForwardCLISendAsGroup`) + +### Phase 3: Behavior Fixes (Bugs 2, 3) +1. Rework XMsgReact handler to require GroupMember (not Maybe GroupMember) +2. Add asGroup field to XMsgUpdate protocol message +3. Add Tests 5 and 6 (`testChannelReactionAttribution`, `testChannelUpdateFallbackSendAsGroup`) + +--- + +## Files Summary + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Library/Subscriber.hs` | Lines 935-945 (Bug 1), 1818-1842 (Bug 2), 1950-1969 (Bug 3) | +| `src/Simplex/Chat/Library/Commands.hs` | Lines 930,944 (Bug 4), 2191,2196,2201,4633 (Bug 5) | +| Protocol message types | Add asGroup field to XMsgUpdate (Bug 3) | +| `tests/ChatTests/Groups.hs` | Add 8 new tests | diff --git a/plans/deduplication-channel-messages.md b/plans/deduplication-channel-messages.md new file mode 100644 index 0000000000..0d09d00528 --- /dev/null +++ b/plans/deduplication-channel-messages.md @@ -0,0 +1,256 @@ +# Deduplication Plan: Channel Message Functions + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Findings by File](#findings-by-file) +3. [Architectural Note: CIChannelRcv Constructor](#architectural-note) +4. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +The PR introduces channel message support by creating parallel channel-specific functions that duplicate 60-80% of existing group functions. The core pattern: channel messages are group messages without a member sender. Most channel functions are the group function with `Just member` → `Nothing`, `CIGroupRcv m` → `CIChannelRcv`, and moderation/blocking guards removed. + +**High-value deduplication targets** (ordered by impact): + +| # | Candidate | Feasibility | Shared code | +|---|-----------|-------------|-------------| +| 1 | `channelMessageUpdate_` → merge into `groupMessageUpdate` | HIGH | ~36 lines | +| 2 | `fwdChannelReaction` → extract shared helper with `groupMsgReaction` | MEDIUM | ~15 lines inner function | +| 3 | `newChannelContentMessage_` → parameterize `newGroupContentMessage` | MEDIUM | ~12 lines happy path | +| 4 | `processForwardedChannelMsg` → merge into `processForwardedMsg` | MEDIUM | depends on 1-3 | +| 5 | `getGroupCIBySharedMsgId'` → parameterize `getGroupChatItemBySharedMsgId` | HIGH | eliminates function | +| 6 | `channelMessageDelete` → parameterize `groupMessageDelete` | LOW | ~5 lines; group has 60+ lines moderation | +| 7 | `saveRcvChatItem'` CDChannelRcv branches | HIGH | ~14 lines across 3 spots | +| 8 | `processContentItem` CIChannelRcv branch | HIGH | ~3 lines | +| 9 | View.hs/Store/Internal pattern match branches | DEFERRED | ~24 branches; requires constructor change | + +--- + +## Findings by File + +### Subscriber.hs + +**D1: `channelMessageUpdate_` vs `groupMessageUpdate`** + +The `updateRcvChatItem` inner function is nearly line-for-line identical between both (~36 shared lines). Differences: +- Lookup: `getGroupChatItemBySharedMsgId` (by member) vs `getGroupCIBySharedMsgId'` (no member) — parameterizable by `Maybe GroupMemberId` (see D5) +- Pattern match: `CIGroupRcv m'` with `sameMemberId` check vs `CIChannelRcv` — branch on `Maybe GroupMember` +- `getGroupCIReactions`: `Just memberId` vs `Nothing` — already parameterized +- Chat direction in fallback: `CDGroupRcv` vs `CDChannelRcv` — branch on `Maybe GroupMember` +- `channelMessageUpdate_` has explicit `forwarded` param; `groupMessageUpdate` always uses `rcvGroupCITimed gInfo ttl_` — the merged function needs to accept `forwarded :: Bool` (or always `False` from the non-forwarded path) +- `groupMessageUpdate` has `prohibitedSimplexLinks` and `blockedMemberCI` guards — skip when member is `Nothing` +- Mentions handling: `groupMessageUpdate` has `mentions' = if memberBlocked m then [] else mentions`; `channelMessageUpdate_` passes `mentions` directly — when member is `Nothing`, use `mentions` directly (no blocking check needed) + +**Solution:** Extend `groupMessageUpdate` to take `Maybe GroupMember`. When `Nothing`: skip prohibited links check, skip blocked member CI, use `CDChannelRcv`, use `getGroupChatItemBySharedMsgId` with `Nothing`, pass mentions directly. Delete `channelMessageUpdate_`. + +--- + +**D2: `fwdChannelReaction` vs `groupMsgReaction`** + +These functions share the `updateChatItemReaction` inner function shape (~15 lines), but are **structurally different** in their outer logic: + +- **Parameter types**: `groupMsgReaction` takes a concrete `GroupMember` + `Maybe MemberId` (item member) + `Maybe MsgScope`; `fwdChannelReaction` takes `Maybe GroupMember` (reactor) and always passes `Nothing` as item member +- **Return type**: `groupMsgReaction` returns `CM (Maybe DeliveryJobScope)` — used by the main dispatch for delivery job routing; `fwdChannelReaction` returns `CM ()` — forwarded context doesn't need delivery jobs +- **CIReaction constructor**: `groupMsgReaction` always uses `CIGroupRcv m`; `fwdChannelReaction` uses `maybe CIChannelRcv CIGroupRcv reactor_` — semantically different when reactor is `Nothing` +- **catchCINotFound fallback**: `groupMsgReaction` has scope-aware delivery job logic; `fwdChannelReaction` does bare `setGroupReaction` +- **Reactor**: `groupMsgReaction` uses `m` directly; `fwdChannelReaction` computes `fromMaybe membership reactor_` + +`fwdChannelReaction` is NOT a rename of `groupMsgReaction`. Calling `void $ groupMsgReaction` from forwarded contexts would be **semantically wrong**: it would attribute channel reactions to the membership member via `CIGroupRcv` instead of showing them as `CIChannelRcv`, and would trigger unnecessary delivery job scope logic. + +**Solution:** Extract the shared `updateChatItemReaction` body (~15 lines) into a helper parameterized by the `CIReaction` constructor and reactor member. Both `groupMsgReaction` and `fwdChannelReaction` call this helper with their respective parameters. This preserves the distinct outer logic while eliminating the inner body duplication. + +--- + +**D3: `newChannelContentMessage_` vs `newGroupContentMessage`** + +The channel version is the "happy path" of the group version with all member-specific guards removed: +- No `blockedByAdmin` check +- No `prohibitedGroupContent` check +- No `getCIModeration` / moderation logic (~40 lines) +- No scope resolution (`mkGetMessageChatScope`) +- No `blockedMemberCI` +- No member-conditional mentions filtering / autoAcceptFile guard + +The shared "save-view-react-accept" core is ~12 lines. + +**Solution:** Extract a shared `saveGroupContentItem` helper containing: process file invitation, save chat item, get reactions, view, auto-accept, return scope. `newGroupContentMessage` calls it after its checks; `newChannelContentMessage_` calls it directly. This keeps `newGroupContentMessage`'s complex flow intact while eliminating the body duplication. + +Alternatively: extend `newGroupContentMessage` to take `Maybe GroupMember`. When `Nothing`: skip all member-specific guards and use `CDChannelRcv`. This is cleaner but changes the function's signature and control flow significantly. + +--- + +**D4: `processForwardedChannelMsg` vs `processForwardedMsg`** + +These are dispatch tables with identical structure. Each event arm calls the group or channel variant: + +``` +processForwardedMsg author: processForwardedChannelMsg: + XMsgNew → newGroupContentMessage XMsgNew → newChannelContentMessage_ + XMsgFileDescr → groupMessageFileDescription XMsgFileDescr → channelMessageFileDescription + XMsgUpdate → groupMessageUpdate XMsgUpdate → channelMessageUpdate_ + ... ... +``` + +If the underlying functions (D1-D3) are parameterized by `Maybe GroupMember`, this dispatch unifies automatically. The extra group-management events (`XInfo`, `XGrpMemNew`, etc.) are guarded by `Just author`. + +**Subtlety: `XMsgReact` handling.** The `XMsgReact` arm has a three-way split: +- `processForwardedMsg` with `Just memId` → `groupMsgReaction` (member reaction with scope/delivery-job logic) +- `processForwardedMsg` with `Nothing` memId → `fwdChannelReaction gInfo (Just author)` (channel reaction from known author) +- `processForwardedChannelMsg` → `fwdChannelReaction gInfo Nothing` (channel reaction, no author) + +This three-way split needs careful handling in the merged function, since `fwdChannelReaction` differs structurally from `groupMsgReaction` (see D2). + +**Solution:** After D1-D3, merge into `processForwardedMsg` taking `Maybe GroupMember`. When `Nothing`, skip group-management events. The `XMsgReact` arm passes the author to `fwdChannelReaction` when in channel mode. Delete `processForwardedChannelMsg`. + +--- + +**D5: `channelMessageDelete` vs `groupMessageDelete`** + +`groupMessageDelete` has ~60 lines of moderation logic (moderate, checkRole, archiveMessageReports, CIModeration creation) that `channelMessageDelete` does not need. The shared portion is only ~5-7 lines (delete/mark-deleted + view). Additionally, the lookup functions differ: `channelMessageDelete` uses `getGroupCIBySharedMsgId'` (no member); `groupMessageDelete` uses `getGroupMemberCIBySharedMsgId` (JOINs group_members by MemberId). The delete condition also differs: `groupFeatureAllowed` vs `groupFeatureMemberAllowed`. + +**Solution:** LOW priority. The functions are architecturally different enough that forced unification would harm readability. If desired, extend `groupMessageDelete` with a `Maybe GroupMember` parameter where `Nothing` takes the simple "channel delete" path early. But the code clarity cost may exceed the deduplication benefit. + +--- + +### Store/Messages.hs + +**D6: `getGroupCIBySharedMsgId'` vs `getGroupChatItemBySharedMsgId`** + +`getGroupChatItemBySharedMsgId` filters by `group_member_id = ?`. +`getGroupCIBySharedMsgId'` omits the `group_member_id` filter entirely (matches any row regardless of member). + +Channel items store `group_member_id = NULL`. Parameterizing with `Maybe GroupMemberId` and `IS NOT DISTINCT FROM` would: +- `Just gmId` → only that member (existing behavior) +- `Nothing` → only NULL rows (channel items) + +This is **stricter** than `getGroupCIBySharedMsgId'`'s current behavior (which matches any member's items too), but this is actually a correctness improvement — all four callers (Subscriber.hs lines 1846, 1962, 1988, 3233) are channel-specific contexts where items have `group_member_id = NULL`. + +**Solution:** Change `getGroupChatItemBySharedMsgId` to take `Maybe GroupMemberId`. SQL becomes: +```sql +WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? +``` +Delete `getGroupCIBySharedMsgId'`. Update all callers to pass `Just gmId` or `Nothing`. + +**Note:** `getGroupMemberCIBySharedMsgId` is a different function (takes `MemberId`, JOINs `group_members` to resolve). It is NOT a duplicate and should be kept. + +**Additional Store/Messages.hs duplications** (minor, collapse with constructor change): +- `createNewRcvChatItem` quoteRow (lines 560-563): `CDGroupRcv` and `CDChannelRcv` branches are verbatim identical +- `getChatItemQuote_` (lines 649-654): `CDChannelRcv` branch is a subset of `CDGroupRcv` (missing sender-specific case) +- `createNewChatItem_` idsRow/groupScope: `CDChannelRcv` branches repeat `CDGroupSnd`-like tuples + +These are inherent to the separate constructor and collapse automatically with the architectural change (see note below). Not worth addressing independently. + +--- + +### Library/Internal.hs + +**D7: `saveRcvChatItem'` CDChannelRcv branches** + +Three duplicate spots within this function, all verbatim copies of CDGroupRcv branches: + +1. **Mentions/userMention computation** (~7 lines): `getRcvCIMentions`, `userReply` via `cmToQuotedMsg`, `userMention'` via membership check. Verbatim identical between CDGroupRcv and CDChannelRcv. + +2. **createGroupCIMentions** (~2 lines): Both branches call `createGroupCIMentions db g ci mentions'` guarded by `not (null mentions')`. Identical. + +3. **memberChatStats / memberAttentionChange** (~3 lines): Only difference is `Just m` vs `Nothing` passed to `memberAttentionChange`. + +Total: ~14 lines of duplication across 3 spots. + +**Solution:** Extract `GroupInfo` and `Maybe GroupMember` from either constructor at the top: +```haskell +case cd of + CDGroupRcv g _s m -> (g, Just m) + CDChannelRcv g _s -> (g, Nothing) +``` +Then use the extracted values for all three spots. The `memberAttentionChange` call already takes `Maybe GroupMember`. + +--- + +**D8: `processContentItem` CIChannelRcv branch** + +Near-duplicate of `CIGroupRcv` branch (lines 1196-1199 vs 1200-1202). Only difference: no `blockedByAdmin` guard, passes `Nothing` instead of `Just sender`. + +**Solution:** Merge the two branches: +```haskell +(CChatItem SMDRcv ci@ChatItem {chatDir, content = CIRcvMsgContent mc, file}) + | maybe True (not . blockedByAdmin) sender_ -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender_ ci mc fInvDescr_ + where sender_ = case chatDir of CIGroupRcv m -> Just m; CIChannelRcv -> Nothing; _ -> Nothing +``` + +**Additional Internal.hs duplication** (minor): +- `quoteData` (lines 228-229): `CIGroupRcv m` returns `(qmc, CIQGroupRcv $ Just m, False, Just m)`, `CIChannelRcv` returns `(qmc, CIQGroupRcv Nothing, False, Nothing)`. Two one-liners differing only in `Just m` vs `Nothing`. Trivial but noted. + +--- + +### View.hs + +**D9: View.hs pattern match duplication** + +The actual count of `CIChannelRcv` pattern match branches: +- **View.hs**: 6 branches (chatDirNtf, viewChatItem new, viewChatItem updated, reaction display, sentByMember', fileFrom) +- **Terminal/Output.hs**: 1 branch +- **Commands.hs**: 2 branches (itemDeletable, itemsMsgMemIds) +- **Internal.hs**: 2 branches (quoteData, processContentItem) +- **Subscriber.hs**: ~6 branches (scattered) +- **Store/Messages.hs**: ~4 branches (toGroupChatItem, createNewRcvChatItem, createNewChatItem_, getChatItemQuote_) + +Total: **~24 pattern match sites** across all files (~17 `CIChannelRcv` + ~7 `CDChannelRcv`). Each mirrors the corresponding `CIGroupRcv m` / `CDGroupRcv` branch passing `Nothing` instead of `Just m`. + +The `ttyFromGroup*` family of functions in View.hs was correctly generalized to take `Maybe GroupMember` — the duplication is at the call sites, not in the helper functions. + +**Solution:** This duplication is **inherent to the separate constructor choice** and can only be eliminated by the architectural change (merging `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)`). Without that change, the branches must remain. Extracting local helpers at each call site would add complexity without reducing total code. + +--- + +### Other Files (no significant deduplication needed) + +- **Commands.hs:** Parameter threading (`ShowGroupAsSender`, `SRGroup`). Clean, no duplication. +- **Protocol.hs:** Wire protocol changes (`ExtMsgContent.asGroup`, `XGrpMsgForward Maybe MemberId`). Necessary. +- **Delivery.hs:** `FwdSender` type replaces separate fields. Could be `Maybe (MemberId, ContactName)` but not a priority. +- **Store/Files.hs:** `createRcvGroupFileTransfer` takes `Maybe GroupMember`. Clean parameterization. +- **Store/Groups.hs:** `createPreparedGroup` returns `Maybe GroupMember`. Necessary for relay groups. +- **Types.hs:** `sendAsGroup'`, `groupId'` utilities. Minor. + +--- + +## Architectural Note: CIChannelRcv Constructor {#architectural-note} + +The deepest source of duplication is the choice to add `CIChannelRcv` / `CDChannelRcv` as separate constructors rather than parameterizing `CIGroupRcv :: Maybe GroupMember -> CIDirection 'CTGroup 'MDRcv` and `CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> ChatDirection 'CTGroup 'MDRcv`. + +This creates ~24 pattern match branches across the codebase, almost all passing `Nothing` where `CIGroupRcv` passes `Just m`. The `chatItemMember` function already returns `Maybe GroupMember`, confirming the abstraction is correct. + +**However**, changing these constructors is a large cross-cutting refactor affecting Messages.hs, View.hs, Commands.hs, Internal.hs, Subscriber.hs, Store/Messages.hs, and tests. It may be better suited as a follow-up PR. + +**Decision needed from user:** Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` in this PR, or defer? + +--- + +## Implementation Order + +### Phase 1: Store layer (D6) +1. Parameterize `getGroupChatItemBySharedMsgId` with `Maybe GroupMemberId` + `IS NOT DISTINCT FROM` +2. Delete `getGroupCIBySharedMsgId'` +3. Update all callers (pass `Just gmId` or `Nothing`) + +### Phase 2: Subscriber.hs function merges (D1, D2, D3) +4. Merge `channelMessageUpdate_` into `groupMessageUpdate` (takes `Maybe GroupMember`) +5. Extract shared `updateChatItemReaction` helper from `groupMsgReaction` and `fwdChannelReaction` +6. Merge `newChannelContentMessage_` into `newGroupContentMessage` (extract shared save-view helper or take `Maybe GroupMember`) + +### Phase 3: Dispatch unification (D4) +7. Merge `processForwardedChannelMsg` into `processForwardedMsg` (takes `Maybe GroupMember`; handle `XMsgReact` three-way split) + +### Phase 4: Internal cleanup (D7, D8) +8. Deduplicate `saveRcvChatItem'` CDChannelRcv branches (3 spots) +9. Merge `processContentItem` CIChannelRcv branch + +### Phase 5 (deferred unless approved): Constructor change (D9) +10. Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` — eliminates ~24 pattern match branches across all files + +### Phase 6 (optional): channelMessageDelete (D5) +11. Only if user wants it — extend `groupMessageDelete` with `Maybe GroupMember` diff --git a/plans/delivery-context-fix.md b/plans/delivery-context-fix.md new file mode 100644 index 0000000000..4b1b13c30a --- /dev/null +++ b/plans/delivery-context-fix.md @@ -0,0 +1,354 @@ +# Plan: Fix Channel Message Delivery Architecture + +## Table of Contents +1. [Context](#context) +2. [Executive Summary](#executive-summary) +3. [Issue 1: Eliminate memberForChannel/memberIdForChannel](#issue-1) +4. [Issue 2: groupMsgReaction required GroupMember](#issue-2) +5. [Issue 3: Fix groupMessageUpdate lookup](#issue-3) +6. [Issue 4: DeliveryTaskContext type](#issue-4) +7. [Issue 5: Fix testChannelReactionAttribution](#issue-5) +8. [Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment](#issue-6) +9. [Other: sendAsGroup parameter ordering](#other-issue) +10. [Verification](#verification) + +## Context + +The current implementation on `ep/channel-messages-2` determines delivery context (whether to forward messages as channel or as member) using `isChannelOwner` — inferring from the sender's role whether they're the channel owner. This is architecturally wrong: the delivery context should be determined **from the item's direction** (`CIChannelRcv` vs `CIGroupRcv`), not from who sent it. The `f/msg-from-channel` branch has the correct approach. + +## Executive Summary + +7 changes across 7 files: +1. **Delivery.hs** — Add `DeliveryTaskContext` type, update `NewMessageDeliveryTask` only (`MessageDeliveryTask` unchanged) +2. **Subscriber.hs** — Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; all processing functions return `Maybe DeliveryTaskContext`; determine `sentAsGroup` from item direction; `groupMsgReaction` takes required `GroupMember`; add `withAuthor` in forwarded handler +3. **Store/Delivery.hs** — Update SQL row mapping for `taskContext` +4. **Commands.hs** — Reorder `sendAsGroup` param in `APIForwardChatItems` +5. **Store/Messages.hs** — Reorder `showGroupAsSender` param in `createNewSndChatItem` +6. **Internal.hs** — Reorder `showGroupAsSender` param in `saveSndChatItems`, `prepareGroupMsg` +7. **Tests** — Fix reaction test comment/expectations, fix update fallback test comment + +--- + +## Issue 1: Eliminate memberForChannel/memberIdForChannel {#issue-1} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 935-937, 939-991 + +**Problem:** `isChannelOwner`, `memberForChannel`, `memberIdForChannel` computed at lines 935-937 and passed to processing functions. This pre-infers delivery context from member role. + +**Fix:** Remove these three bindings entirely. Always pass `(Just m'')` to functions that take `Maybe GroupMember`. Functions determine `sentAsGroup` from item direction internally. + +**Direct handler changes (lines 939-991):** +``` +-- BEFORE: +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + memberForChannel = if isChannelOwner then Nothing else Just m'' + memberIdForChannel = memberId' <$> memberForChannel +(deliveryJobScope_, showGroupAsSender') <- case event of + ... +forM deliveryJobScope_ $ \jobScope -> + pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, showGroupAsSender = showGroupAsSender'} + +-- AFTER: +deliveryTaskContext_ <- case event of + XMsgNew mc -> ... -- returns Maybe DeliveryTaskContext + XMsgFileDescr ... -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr + XMsgUpdate ... -> memberCanSend m'' msgScope Nothing $ groupMessageUpdate gInfo' (Just m'') sharedMsgId ... + XMsgDel ... -> groupMessageDelete gInfo' (Just m'') sharedMsgId ... + XMsgReact ... -> groupMsgReaction gInfo' m'' sharedMsgId ... -- required member + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId + ...other events -> Just <$> memberEventDeliveryContext m'' / Nothing +forM deliveryTaskContext_ $ \taskContext -> + pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} +``` + +**Processing function signature changes:** +- `groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params, pass `Maybe GroupMember`, determine `sentAsGroup` from `chatDir` of found item +- `groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> Maybe Bool -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param +- `groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param; fix `findOwnerCI` dual-lookup (lines 2028-2035) same as Issue 3: when `m_ = Nothing` search with `Nothing`, when `m_ = Just m` use member lookup directly +- `xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params + +**`validSender` simplification:** Remove second `Maybe MemberId` parameter. With `(Just m'')` always passed, validation is just: +```haskell +validSender :: Maybe MemberId -> CIDirection 'CTGroup 'MDRcv -> Bool +validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m +validSender Nothing CIChannelRcv = True +validSender _ _ = False +``` + +**`isChannelDir` helper** remains as-is (line 1870-1872) — used to derive `sentAsGroup` from item's `chatDir`. + +**`memberCanSend`** (line 1436): Generic signature `a -> CM a -> CM a` — no change needed. Default values at call sites change from `(Nothing, False)` to `Nothing`. + +**`memberCanSend'`** (line 1448): Return type changes from `CM (Maybe DeliveryJobScope)` to `CM (Maybe DeliveryTaskContext)`. Used in forwarded handler (lines 3153, 3159). + +--- + +## Issue 2: groupMsgReaction required GroupMember {#issue-2} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` line 1814 + +**Problem:** `groupMsgReaction :: GroupInfo -> Maybe GroupMember -> ...` allows `Nothing`, uses `fromMaybe membership m_` fallback. + +**Fix:** Change to required `GroupMember`: +```haskell +groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) +``` + +- No `reactor` binding needed — use `m` directly (eliminates `fromMaybe membership m_` fallback) +- `ciDir = CIGroupRcv (Just m)` (reactions always attributed to member) +- Always return `sentAsGroup = False` — reactions are never from channel +- Return type: `Maybe DeliveryTaskContext` (not tuple) + +**Direct handler call site (line 958-960):** +```haskell +XMsgReact sharedMsgId memberId scope_ reaction add -> + groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs +``` + +**Forwarded handler call site (line 3162-3163):** +```haskell +XMsgReact sharedMsgId memId_ scope_ reaction add -> + withAuthor XMsgReact_ $ \author -> groupMsgReaction gInfo author sharedMsgId memId_ scope_ reaction add rcvMsg msgTs +``` + +--- + +## Issue 3: Fix groupMessageUpdate lookup {#issue-3} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 1973-1994 + +**Problem:** Dual-lookup with `catchError` tries `Nothing` first, then falls back to `senderGMId_`. This is wrong — the `asGroup_` flag from XMsgUpdate should drive the search. + +**Fix:** Use `asGroup_` (the wire flag) to determine search strategy. No `senderGMId_` parameter needed: +```haskell +updateRcvChatItem = do + (cci, scopeInfo) <- withStore $ \db -> do + cci <- case m_ of + Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId + (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) +``` + +When `m_ = Nothing` (channel owner as channel), search with `Nothing` group_member_id → finds channel items. +When `m_ = Just m` (attributed member message), search with member's `memberId` → finds member items. + +The `isSender` check also simplifies — just check `m_` matches the found item's member. + +**Fallback path** (lines 1948-1968, `catchCINotFound`): When item not found, `showGroupAsSender` is derived from `asGroup_` flag (or defaults based on `m_`), which maps to `sentAsGroup` in the `DeliveryTaskContext`. + +--- + +## Issue 4: DeliveryTaskContext type {#issue-4} + +**File:** `src/Simplex/Chat/Delivery.hs` + +### 4a. Add DeliveryTaskContext type +```haskell +data DeliveryTaskContext = DeliveryTaskContext + { jobScope :: DeliveryJobScope, + sentAsGroup :: ShowGroupAsSender + } + deriving (Show) +``` + +Uses existing `type ShowGroupAsSender = Bool` from Messages.hs. + +### 4b. Modify existing helpers +Rename `infoToDeliveryScope` → `infoToDeliveryContext`, inline the scope logic, add `ShowGroupAsSender` parameter: +```haskell +infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext +infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup} + where + jobScope = case scopeInfo of + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} +``` +Remove `infoToDeliveryScope` entirely. + +Rename `memberEventDeliveryScope` → `memberEventDeliveryContext`, change return type: +```haskell +memberEventDeliveryContext :: GroupMember -> Maybe DeliveryTaskContext +memberEventDeliveryContext m@GroupMember {memberRole, memberStatus} + | memberStatus == GSMemPendingApproval = Nothing + | memberStatus == GSMemPendingReview = Just $ DeliveryTaskContext {jobScope = DJSMemberSupport {supportGMId = groupMemberId' m}, sentAsGroup = False} + | memberRole >= GRModerator = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}, sentAsGroup = False} + | otherwise = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False} +``` + +### 4c. Update NewMessageDeliveryTask +```haskell +data NewMessageDeliveryTask = NewMessageDeliveryTask + { messageId :: MessageId, + taskContext :: DeliveryTaskContext + } + deriving (Show) +``` + +### 4d. MessageDeliveryTask — no change + +`MessageDeliveryTask` stays as-is. It's constructed from DB rows in `getMsgDeliveryTask_` and consumed by relay forwarding code — those consumers need `jobScope` and `fwdSender` directly, not `DeliveryTaskContext`. `DeliveryTaskContext` is only for the path from processing functions → `NewMessageDeliveryTask` creation. + +### 4e. Update Store/Delivery.hs + +**`createMsgDeliveryTask`** (line 71-87): Extract `jobScope` and `sentAsGroup` from `taskContext` instead of separate `jobScope`/`showGroupAsSender` fields. + +**`getMsgDeliveryTask_`** — no change needed (`MessageDeliveryTask` unchanged). + +### 4f. Consumers of MessageDeliveryTask — no change needed + +**Subscriber.hs** lines ~3325-3333 and **Messages/Batch.hs** lines ~77-80 already pattern match on `FwdSender` and use `jobScope` from `MessageDeliveryTask`. Since `MessageDeliveryTask` is unchanged, no updates needed. + +### 4g. Return type changes in processing functions + +All functions currently returning `(Maybe DeliveryJobScope, ShowGroupAsSender)` change to `Maybe DeliveryTaskContext`: +- `groupMessageFileDescription` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageUpdate` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageDelete` → `CM (Maybe DeliveryTaskContext)` +- `xFileCancelGroup` → `CM (Maybe DeliveryTaskContext)` +- `groupMsgReaction` → `CM (Maybe DeliveryTaskContext)` + +Events that return `(Nothing, False)` or `(Just scope, False)` are updated: +- `(Nothing, False)` → `Nothing` +- `(Just scope, False)` → `Just $ DeliveryTaskContext scope False` (or use `memberEventDeliveryContext`) +- `(Just scope, showGroupAsSender)` → `Just $ DeliveryTaskContext scope showGroupAsSender` (or use `infoToDeliveryContext`) + +--- + +## Issue 5: Fix testChannelReactionAttribution {#issue-5} + +**File:** `tests/ChatTests/Groups.hs` lines 9057-9084 + +**Problem:** Comment says "reaction is forwarded as channel (owner is anonymous)" and expects `#team>`. Owner should react **as member** — reactions are always `sentAsGroup = False`. + +**Fix:** Change comment and expectations: +```haskell +-- owner reacts to own member message - reaction is forwarded as member +alice ##> "+1 #team hello" +alice <## "added 👍" +bob <# "#team alice> > alice hello" +bob <## " + 👍" +concurrentlyN_ + [ do cath <# "#team alice> > alice hello" + cath <## " + 👍", + do dan <# "#team alice> > alice hello" + dan <## " + 👍", + do eve <# "#team alice> > alice hello" + eve <## " + 👍" + ] +``` + +--- + +## Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment {#issue-6} + +**File:** `tests/ChatTests/Groups.hs` line 9127 + +**Problem:** Comment says "bob's internally deleted item is still in DB, update finds it with correct member direction". This is wrong — the item was internally deleted, then XMsgUpdate re-creates it via the `catchCINotFound` fallback. + +**Fix:** Change comment to: +```haskell +-- bob's internally deleted item is re-created as from member (sendAsGroup=False) +``` + +--- + +## Other: sendAsGroup parameter ordering {#other-issue} + +**Problem:** `sendAsGroup`/`ShowGroupAsSender` should come right after direction/scope, not at the end. + +### 7a. `APIForwardChatItems` constructor + +**File:** `src/Simplex/Chat/Library/Commands.hs` (ChatCommand type definition + parser) + +Current: `APIForwardChatItems toChat fromChat itemIds itemTTL sendAsGroup` +New: `APIForwardChatItems toChat sendAsGroup fromChat itemIds itemTTL` + +Affects: +- Constructor definition in `src/Simplex/Chat/Controller.hs` line 341 +- Parser at line 4639 +- Call sites at lines 930, 2192, 2198, 2204 + +### 7b. `createNewSndChatItem` + +**File:** `src/Simplex/Chat/Store/Messages.hs` line 528 + +Current: `createNewSndChatItem db user chatDirection msg ciContent quotedItem itemForwarded timed live hasLink showGroupAsSender createdAt` +New: `createNewSndChatItem db user chatDirection showGroupAsSender msg ciContent quotedItem itemForwarded timed live hasLink createdAt` + +Move `showGroupAsSender` right after `chatDirection` (direction context). + +Affects call site in `Internal.hs` line 2276. + +### 7c. `saveSndChatItems` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 2256-2265 + +Current param order: `user -> cd -> itemsData -> itemTimed -> live -> showGroupAsSender` +New: `user -> cd -> showGroupAsSender -> itemsData -> itemTimed -> live` + +Move `showGroupAsSender` right after `cd` (direction context). + +Affects call sites: Internal.hs line 2242, Commands.hs lines 2561, 2608 (and the `saveSndChatItem'` wrapper at line 2240). + +### 7d. `prepareGroupMsg` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 203 + +Current: `prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live showGroupAsSender` +New: `prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live` + +Move `showGroupAsSender` right after `msgScope` (scope context). + +Affects call sites: Internal.hs line 1249, Commands.hs line 4094. + +--- + +## Forwarded handler (xGrpMsgForward) changes + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 3136-3173 + +Add `withAuthor` helper to replace ad-hoc `| Just author <- author_` guards: +```haskell +where + withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () + withAuthor tag action = case author_ of + Just author -> action author + Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" +``` + +Update forwarded event handling: +- `XMsgFileDescr` → pass `author_` (Maybe GroupMember) directly +- `XMsgUpdate` → pass `author_` directly, void result +- `XMsgDel` → pass `author_` directly, void result +- `XMsgReact` → use `withAuthor` (required member) +- `XFileCancel` → pass `author_` directly +- Other events with `| Just author <- author_` → use `withAuthor` + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Delivery.hs` | Add `DeliveryTaskContext`, update `NewMessageDeliveryTask` only | +| `src/Simplex/Chat/Store/Delivery.hs` | Update `createMsgDeliveryTask` to extract from `taskContext` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; change function signatures to return `Maybe DeliveryTaskContext`; add `withAuthor`; simplify `validSender`; `groupMsgReaction` required member; fix lookup | +| `src/Simplex/Chat/Controller.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` constructor | +| `src/Simplex/Chat/Library/Commands.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` parser + call sites | +| `src/Simplex/Chat/Store/Messages.hs` | Reorder `showGroupAsSender` in `createNewSndChatItem` | +| `src/Simplex/Chat/Library/Internal.hs` | Reorder `showGroupAsSender` in `saveSndChatItems`, `prepareGroupMsg` | +| `src/Simplex/Chat/Messages/Batch.hs` | No change needed (`MessageDeliveryTask` unchanged) | +| `tests/ChatTests/Groups.hs` | Fix reaction test expectations + update fallback comment | + +--- + +## Verification + +1. `cabal build --ghc-options=-O0` — must compile clean +2. Run channel test suite: `cabal test simplex-chat-test --test-option='-m "channels"' --ghc-options=-O0` +3. Adversarial self-review loop until 2 consecutive clean passes +4. Verify no `isChannelOwner` references remain in Subscriber.hs direct handler +5. Verify `groupMsgReaction` signature has required `GroupMember` (no Maybe) +6. Verify no dual-lookup with `catchError` in `groupMessageUpdate` diff --git a/plans/group_channel_feature_coverage.md b/plans/group_channel_feature_coverage.md new file mode 100644 index 0000000000..f4b6e49353 --- /dev/null +++ b/plans/group_channel_feature_coverage.md @@ -0,0 +1,377 @@ +# Group & Channel Feature Test Coverage Plan + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Feature Coverage Matrix](#feature-coverage-matrix) +3. [Gap Analysis by Category](#gap-analysis-by-category) +4. [Recommended New Tests](#recommended-new-tests) +5. [Implementation Roadmap](#implementation-roadmap) + +--- + +## Executive Summary + +**Current State:** The test suite in `Groups.hs` provides comprehensive coverage across 120+ scenarios in 14 categories. Core functionality (group CRUD, messaging, member management) is well-tested. + +**Key Gaps Identified:** +- Business/contact card group links (untested invitation flow) +- Legacy group link auto-accept path +- Permission enforcement for `SGFFullDelete` +- Error recovery paths (file transfers, database busy, duplicate forwarding) +- Moderator-only scoped message delivery (`DJSMemberSupport`) +- Edge cases in channel message deletion + +**Risk Assessment:** +| Priority | Gap Count | Impact | +|----------|-----------|--------| +| Critical | 3 | Production failures in business flows | +| High | 5 | Feature regressions possible | +| Medium | 4 | Edge case handling incomplete | + +**Recommendation:** Add 12 new test scenarios in 3 phases over 2 sprints. + +--- + +## Feature Coverage Matrix + +### Legend +- ✅ Tested (comprehensive) +- ⚠️ Partial (some paths covered) +- ❌ Untested + +### Core Group Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Group creation | ✅ | `testGroup` | Basic + edge cases | +| Group deletion | ✅ | `testGroupDelete*` | Multiple scenarios | +| Group naming/description | ✅ | `testUpdateGroupProfile` | | +| Group preferences | ✅ | `testGroupPreferences` | Voice, files, etc. | +| Group link creation | ✅ | `testGroupLink*` | | +| Group link via contact card | ❌ | - | Business links untested | +| Legacy auto-accept | ❌ | - | Deprecated path | + +### Message Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| XMsgNew (send) | ✅ | Multiple | Core flow | +| XMsgUpdate (edit) | ✅ | `testGroupMessageUpdate` | | +| XMsgDel (delete) | ✅ | `testGroupMessageDelete` | | +| XMsgReact | ✅ | `testGroupMsgReaction` | | +| XMsgFileDescr | ✅ | `testGroupFileTransfer` | | +| Batch messages | ✅ | `testBatch*` | | +| Live messages | ✅ | `testGroupLiveMessage` | | +| Quote messages | ✅ | `testGroup*Quote*` | | +| Duplicate forwarding | ❌ | - | De-dup logic untested | + +### Member Management + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Member add | ✅ | `testGroupAddMember*` | | +| Member remove | ✅ | `testGroupRemoveMember*` | | +| Member roles | ✅ | `testGroupMemberRole*` | | +| Member blocking | ✅ | `testGroupBlock*` | | +| Member merging | ✅ | `testMergeMemberContact*` | | +| Member deletion errors | ❌ | - | Error paths missing | +| Contact from member | ✅ | `testCreateMemberContact*` | | + +### Moderation & Full Delete + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Moderate message | ✅ | `testGroupModerate*` | | +| Block for all | ✅ | `testGroupBlockForAll*` | | +| SGFFullDelete enabled | ✅ | `testFullDeleteGroup*` | | +| SGFFullDelete restricted | ❌ | - | Permission checks | + +### Channels & Relays + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| 1-relay delivery | ✅ | `testChannel1Relay*` | | +| 2-relay delivery | ✅ | `testChannel2Relay*` | | +| Owner-only sending | ✅ | `testChannel*Message*` | | +| Identity protection | ✅ | `testChannel*Incognito*` | | +| Channel msg delete errors | ❌ | - | Invalid state handling | + +### Scoped Messages (Support Chats) + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Single moderator | ✅ | `testSupportChat*` | | +| Multi moderator | ✅ | `testSupportChat*Multi*` | | +| Member reports | ✅ | `testReportMessage*` | | +| Forwarding in scope | ✅ | `testSupportChatForward*` | | +| Stats | ✅ | `testSupportChatStats` | | +| DJSMemberSupport delivery | ❌ | - | Moderator-only path | + +### Group Links & Invitations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Create/delete link | ✅ | `testGroupLink*` | | +| Join via link | ✅ | `testGroupLink*` | | +| Link screening | ✅ | `testGroupLink*Screening*` | | +| Connection plans | ✅ | `testPlanGroupLink*` | | +| Short links | ✅ | `testGroupShortLink*` | | +| Business link invitation | ❌ | - | Contact card flow | + +### Error Handling + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| CEGroupNotJoined | ⚠️ | Implicit | Some coverage | +| CEGroupMemberNotFound | ⚠️ | Implicit | Some coverage | +| File transfer errors | ❌ | - | Recovery paths | +| Database busy | ❌ | - | Retry logic | +| Simplex link warnings | ❌ | - | Feature gate | + +### History & Disappearing + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| History on join | ✅ | `testGroupHistory*` | | +| File history | ✅ | `testGroupHistoryFiles` | | +| Disappearing messages | ✅ | `testGroupHistoryDisappear*` | | + +--- + +## Gap Analysis by Category + +### Critical Priority (Production Impact) + +#### 1. Business Group Link via Contact Card +**Location:** `APIAddMember` with `InvitationContact` path +**Risk:** Business users cannot invite via contact cards +**Current State:** Only `InvitationMember` path tested +**Missing Coverage:** +- `processGroupInvitation` with `CTContactRequest` +- Auto-accept flow for business links +- Profile merge on business join + +#### 2. SGFFullDelete Permission Enforcement +**Location:** `canFullDelete`, `checkFullDeleteAllowed` +**Risk:** Non-admins might delete others' messages +**Missing Coverage:** +- `SGFFullDelete` set to `FAAdmins` restriction +- Error `CECommandError` when non-admin attempts full delete +- Role-based permission matrix + +#### 3. DJSMemberSupport Delivery Path +**Location:** `deliverGroupMessages`, `groupMsgDeliveryJobs` +**Risk:** Support messages not reaching moderators correctly +**Missing Coverage:** +- `DJSMemberSupport` job creation +- Moderator-only broadcast logic +- Scope isolation verification + +### High Priority (Feature Regressions) + +#### 4. Channel Message Deletion Errors +**Location:** `apiDeleteMemberChatItem`, `deleteGroupChatItemInternal` +**Missing Coverage:** +- Delete non-existent channel message +- Delete by non-owner in channel +- `CEInvalidChatItemDelete` error path + +#### 5. Member Deletion Error Paths +**Location:** `removeMemberDeleteItem`, `deleteGroupChatItem` +**Missing Coverage:** +- Delete item for already-removed member +- Concurrent deletion race condition +- `CEGroupMemberNotFound` specific handling + +#### 6. File Transfer Error Recovery +**Location:** `rcvFileError`, `sndFileError` +**Missing Coverage:** +- Partial transfer resume +- `CEFileTransferError` handling +- Cleanup on failed transfers + +#### 7. Legacy Group Link Auto-Accept +**Location:** `processGroupInvitation`, `autoAcceptGroupLink` +**Risk:** Breaking change for older clients +**Missing Coverage:** +- V1 protocol compatibility +- Auto-accept timing + +#### 8. Duplicate Message Forwarding +**Location:** `forwardGroupMessage`, `checkDuplicateForward` +**Missing Coverage:** +- Same message forwarded twice +- De-duplication by `sharedMsgId` +- UI state consistency + +### Medium Priority (Edge Cases) + +#### 9. Simplex Links Feature Warnings +**Location:** `simplexLinkWarning`, `SGFSimplexLinks` +**Missing Coverage:** +- Warning when feature disabled +- Link detection in messages +- User preference override + +#### 10. Database Busy Error Handling +**Location:** `withTransaction`, `retryOnBusy` +**Missing Coverage:** +- Concurrent group operations +- Retry exhaustion +- State consistency after retry + +#### 11. Invalid Channel/Member Scope Errors +**Location:** `validateGroupChatScope`, `scopeNotAllowed` +**Missing Coverage:** +- Member sending to wrong scope +- Scope mismatch on receive +- `CECommandError "scope not allowed"` path + +#### 12. Contact Card Profile Merge +**Location:** `mergeMemberContactProfile`, `updateContactProfile` +**Missing Coverage:** +- Profile conflict resolution +- Image merge logic +- Display name precedence + +--- + +## Recommended New Tests + +### Phase 1: Critical (Sprint 1) + +```haskell +-- Test 1: Business Group Link Invitation +testBusinessGroupLinkInvitation :: HasCallStack => TestParams -> IO () +-- Covers: InvitationContact path, CTContactRequest, auto-accept + +-- Test 2: Full Delete Permission Restriction +testFullDeletePermissionRestricted :: HasCallStack => TestParams -> IO () +-- Covers: SGFFullDelete FAAdmins, non-admin rejection, CECommandError + +-- Test 3: Moderator-Only Support Delivery +testSupportChatModeratorOnlyDelivery :: HasCallStack => TestParams -> IO () +-- Covers: DJSMemberSupport, moderator broadcast, scope isolation +``` + +### Phase 2: High (Sprint 1-2) + +```haskell +-- Test 4: Channel Message Delete Errors +testChannelMessageDeleteErrors :: HasCallStack => TestParams -> IO () +-- Covers: non-existent delete, non-owner delete, CEInvalidChatItemDelete + +-- Test 5: Member Deletion Error Paths +testMemberDeletionErrorPaths :: HasCallStack => TestParams -> IO () +-- Covers: removed member delete, concurrent delete, CEGroupMemberNotFound + +-- Test 6: File Transfer Error Recovery +testGroupFileTransferErrorRecovery :: HasCallStack => TestParams -> IO () +-- Covers: partial resume, CEFileTransferError, cleanup + +-- Test 7: Legacy Group Link Compatibility +testLegacyGroupLinkAutoAccept :: HasCallStack => TestParams -> IO () +-- Covers: V1 protocol, auto-accept timing + +-- Test 8: Duplicate Forward Prevention +testDuplicateMessageForwardPrevention :: HasCallStack => TestParams -> IO () +-- Covers: duplicate detection, sharedMsgId, UI consistency +``` + +### Phase 3: Medium (Sprint 2) + +```haskell +-- Test 9: Simplex Links Feature Warning +testSimplexLinksFeatureWarning :: HasCallStack => TestParams -> IO () +-- Covers: disabled feature warning, link detection + +-- Test 10: Database Busy Retry +testGroupOperationsDatabaseBusy :: HasCallStack => TestParams -> IO () +-- Covers: concurrent ops, retry logic, state consistency + +-- Test 11: Scope Validation Errors +testGroupChatScopeValidationErrors :: HasCallStack => TestParams -> IO () +-- Covers: wrong scope send, scope mismatch, CECommandError + +-- Test 12: Contact Card Profile Merge +testMemberContactProfileMerge :: HasCallStack => TestParams -> IO () +-- Covers: conflict resolution, image merge, name precedence +``` + +--- + +## Implementation Roadmap + +### Sprint 1 (Week 1-2) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 1: Business link | - | PR ready | +| 3-4 | Test 2: Full delete perms | - | PR ready | +| 5 | Test 3: Moderator delivery | - | PR ready | +| 6-7 | Test 4: Channel delete errors | - | PR ready | +| 8-9 | Test 5: Member delete errors | - | PR ready | +| 10 | Integration + Review | - | Merged | + +### Sprint 2 (Week 3-4) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 6: File error recovery | - | PR ready | +| 3-4 | Test 7: Legacy link compat | - | PR ready | +| 5-6 | Test 8: Duplicate forward | - | PR ready | +| 7-8 | Tests 9-12: Medium priority | - | PR ready | +| 9-10 | Final integration + CI | - | Release | + +### Dependencies + +``` +Test 1 (Business Link) ─┬─> Test 12 (Profile Merge) + │ +Test 3 (Moderator) ─────┴─> Test 11 (Scope Validation) + +Test 4 (Channel Delete) ──> Test 5 (Member Delete) + +Test 6 (File Error) ──────> (standalone) + +Test 7 (Legacy Link) ─────> Test 1 (Business Link) + +Test 8 (Duplicate) ───────> (standalone) + +Tests 9, 10 ──────────────> (standalone) +``` + +### Success Criteria + +1. **Coverage Target:** 95%+ of identified gaps covered +2. **CI Integration:** All tests in nightly suite +3. **Documentation:** Test rationale in docstrings +4. **No Regressions:** Existing 120+ tests still pass + +### Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Test flakiness | Use explicit waits, avoid timing assumptions | +| Database state leaks | Ensure proper cleanup in each test | +| Protocol version issues | Test both V1 and V2 where applicable | +| CI timeout | Parallelize independent tests | + +--- + +## Appendix: Test File Locations + +| Test Category | Primary File | Secondary | +|---------------|--------------|-----------| +| Group Core | `tests/ChatTests/Groups.hs` | - | +| Channels | `tests/ChatTests/Groups.hs` | `Channels/` if split | +| Support Chats | `tests/ChatTests/Groups.hs` | `ScopedMessages/` if split | +| File Transfers | `tests/ChatTests/Files.hs` | `Groups.hs` | +| Error Handling | Inline with feature tests | - | + +--- + +*Generated: 2026-02-06* +*Branch: ep/channel-messages-2* +*Coverage baseline: 120+ scenarios, 14 categories* diff --git a/plans/groups_coverage_fill_plan.md b/plans/groups_coverage_fill_plan.md new file mode 100644 index 0000000000..ffe0b7a52c --- /dev/null +++ b/plans/groups_coverage_fill_plan.md @@ -0,0 +1,368 @@ +# Plan: Filling Group/Channel Test Coverage Gaps + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Test File Organization](#test-file-organization) +3. [Priority 0: Critical Channel Paths](#priority-0-critical-channel-paths) +4. [Priority 1: Error and Fallback Paths](#priority-1-error-and-fallback-paths) +5. [Priority 2: Scope-Related Features](#priority-2-scope-related-features) +6. [Priority 3: Feature Restrictions](#priority-3-feature-restrictions) + +--- + +## Executive Summary + +This plan addresses the coverage gaps identified in `groups_test_coverage.md`, focusing exclusively on DSL-based scenario tests using the existing test infrastructure. All tests follow patterns established in `tests/ChatTests/Groups.hs`. + +**Excluded from scope:** JSON serialization tests (per user request). + +**Key gap categories:** +- Non-channel-owner members sending in channel groups +- Moderation/delete paths in channels (`memberDelete`) +- Error fallback paths (`catchCINotFound`) +- Member support scope (`GCSIMemberSupport`) +- Full-delete feature, live updates, mentions + +--- + +## Test File Organization + +All new tests go in `tests/ChatTests/Groups.hs` under existing or new `describe` blocks. + +### New `describe` blocks to add: + +```haskell +describe "channel moderation" $ do + -- Tests for memberDelete path, channel moderation errors + +describe "channel error paths" $ do + -- Tests for catchCINotFound, invalid sender, etc. + +describe "channel mentions" $ do + -- Tests for mentions in channel messages + +describe "group full delete feature" $ do + -- Tests for SGFFullDelete enabled +``` + +--- + +## Priority 0: Critical Channel Paths + +### Test 1: `testChannelMemberModerate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel moderation"` + +**Objective:** Cover `memberDelete` path in `groupMessageDelete` (lines 2016-2076) - moderation of channel messages by admin/owner. + +**Scenario:** +1. Create channel with owner (alice) + relay (bob) + members (cath, dan) +2. Owner sends channel message +3. Admin/owner moderates (deletes) the channel message +4. Verify message marked deleted for all members +5. Verify moderation event is forwarded + +**Coverage targets:** +- `memberDelete` function execution +- `moderate` helper with role checks +- `delete` with `delMember_` populated + +--- + +### Test 2: `testChannelMemberDeleteError` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover error path `CIChannelRcv -> messageError "x.msg.del: unexpected channel message in member delete"` (line 2036). + +**Scenario:** +1. Create channel with owner + relay + member +2. Attempt to trigger memberDelete on CIChannelRcv item (malformed delete request) +3. Verify error is logged/handled correctly + +**Coverage targets:** +- Line 2036: `CIChannelRcv` error case in `memberDelete` + +--- + +### Test 3: `testChannelUpdateNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMessageUpdate` (lines 1950-1969) - update arrives for locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner updates the message +5. Verify member creates new item from update (fallback path) + +**Coverage targets:** +- Line 1960: `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` +- Lines 1951-1969: create-from-update fallback + +--- + +### Test 4: `testChannelReactionNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMsgReaction` (lines 1823-1837) - reaction on locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner adds reaction +5. Verify reaction is handled without crash + +**Coverage targets:** +- Lines 1835-1837: channel reaction fallback + +--- + +### Test 5: `testChannelForwardedMessages` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "relay delivery"` (existing) + +**Objective:** Cover `FwdChannel` branch in delivery task (line 3311) and forwarded message parameters. + +**Scenario:** +1. Create channel with owner + 2 relays + members +2. Send various message types (new, update, delete, reaction) +3. Verify all are forwarded through relay chain +4. Check forwarded parameters are correctly passed + +**Coverage targets:** +- Line 3311: `FwdChannel -> (Nothing, Nothing)` +- Lines 3139-3145: forwarded message handlers + +--- + +## Priority 1: Error and Fallback Paths + +### Test 6: `testGroupDeleteNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` or existing moderation tests + +**Objective:** Cover delete error when message not found (line 2039). + +**Scenario:** +1. Create group with alice, bob +2. Bob sends message +3. Alice locally deletes it +4. Bob broadcasts delete for the same message +5. Verify error path is handled + +**Coverage targets:** +- Line 2039: `messageError ("x.msg.del: message not found, " <> tshow e)` + +--- + +### Test 7: `testGroupInvalidSenderUpdate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `validSender _ _ = False` (line 1874) and update from wrong member error (line 1980). + +**Scenario:** +1. Create group with alice, bob, cath +2. Bob sends message +3. Cath (with spoofed member ID) attempts to update bob's message +4. Verify error is thrown + +**Coverage targets:** +- Line 1874: `validSender _ _ = False` +- Line 1980: `messageError "x.msg.update: group member attempted to update..."` + +--- + +### Test 8: `testGroupReactionDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing `describe "group message reactions"` + +**Objective:** Cover reaction disabled path (line 1839). + +**Scenario:** +1. Create group with reactions feature disabled +2. Member attempts to add reaction +3. Verify reaction is rejected + +**Coverage targets:** +- Line 1839: `otherwise = pure Nothing` when reactions not allowed + +--- + +### Test 9: `testChannelItemNotChanged` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel message operations"` (existing) + +**Objective:** Cover `CEvtChatItemNotChanged` path (lines 2001-2002) - update with same content. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message +3. Owner "updates" message with identical content +4. Verify no change event is emitted + +**Coverage targets:** +- Lines 2001-2002: `CEvtChatItemNotChanged` path + +--- + +## Priority 2: Scope-Related Features + +### Test 10: `testScopedSupportMentions` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover mentions in scoped support messages (`getRcvCIMentions` with non-empty mentions). + +**Scenario:** +1. Create group with alice (owner), bob (member), dan (moderator) +2. Bob sends support message mentioning @alice +3. Alice receives with mention highlighted +4. Verify `userMention` flag is set correctly + +**Coverage targets:** +- Line 2316: `getRcvCIMentions` with actual mentions +- Line 2319: `sameMemberId mId membership` in userReply check +- Lines 279-281: `uniqueMsgMentions` path + +--- + +### Test 11: `testMemberChatStats` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `memberChatStats` function (lines 2323-2330) for both `CDGroupRcv` and `CDChannelRcv` with scope. + +**Scenario:** +1. Create group with support enabled +2. Member sends support message +3. Verify unread stats are updated +4. Verify `memberAttentionChange` is computed + +**Coverage targets:** +- Lines 2325-2329: `memberChatStats` branches +- Line 2621: `memberAttentionChange` + +**Note:** Tests `testScopedSupportUnreadStatsOnRead` and `testScopedSupportUnreadStatsOnDelete` exist but may not cover all branches. + +--- + +### Test 12: `testMkGetMessageChatScope` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `mkGetMessageChatScope` branches (lines 1599-1617). + +**Scenario:** +1. Create group with pending member (knocking) +2. Pending member sends message with scope +3. Verify correct scope resolution +4. Test with `isReport mc` content type + +**Coverage targets:** +- Line 1601: `Just _scopeInfo` return +- Line 1604: `isReport mc` branch +- Lines 1610-1617: `sameMemberId` and `otherwise` branches + +--- + +## Priority 3: Feature Restrictions + +### Test 13: `testGroupFullDelete` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** new `describe "group full delete feature"` + +**Objective:** Cover `groupFeatureAllowed SGFFullDelete` = True path (line 2067) - `deleteGroupCIs` instead of `markGroupCIsDeleted`. + +**Scenario:** +1. Create group with full delete enabled: `/set delete #team on` +2. Bob sends message +3. Alice (or bob) deletes message +4. Verify message is fully deleted (not just marked) + +**Coverage targets:** +- Line 2067: `deleteGroupCIs` path +- `groupFeatureAllowed SGFFullDelete` returns True + +--- + +### Test 14: `testGroupLiveMessage` +**File:** `tests/ChatTests/Groups.hs` +**Note:** `testGroupLiveMessage` exists but may not cover update path. + +**Objective:** Cover live message update path (line 830 in View.hs, `itemLive == Just True`). + +**Scenario:** +1. Create group +2. Send live message +3. Update live message content +4. Verify live update is processed + +**Coverage targets:** +- Line 830: `itemLive == Just True && not liveItems -> []` +- Live update in `groupMessageUpdate` + +--- + +### Test 15: `testGroupVoiceDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing tests or new `describe "group feature restrictions"` + +**Objective:** Cover voice message rejection (line 342 in Internal.hs). + +**Scenario:** +1. Create group with voice disabled: `/set voice #team off` +2. Member attempts to send voice message +3. Verify rejection + +**Coverage targets:** +- Line 342: `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` + +--- + +### Test 16: `testGroupReportsDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group member reports"` (existing) + +**Objective:** Cover reports disabled path (line 344 in Internal.hs). + +**Scenario:** +1. Create group with reports disabled +2. Member attempts to send report +3. Verify rejection + +**Coverage targets:** +- Line 344: `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` + +--- + +## Implementation Order + +1. **Phase 1 (P0):** Tests 1-5 - Critical channel paths +2. **Phase 2 (P1):** Tests 6-9 - Error and fallback paths +3. **Phase 3 (P2):** Tests 10-12 - Scope-related features +4. **Phase 4 (P3):** Tests 13-16 - Feature restrictions + +Each test should: +- Use existing DSL operators (`##>`, `<#`, `#$>`, etc.) +- Follow naming convention `test` +- Include `HasCallStack` constraint +- Use appropriate test helpers (`createGroup2`, `createChannel1Relay`, etc.) + +--- + +## Dependencies + +- Existing test infrastructure in `ChatTests.Utils` +- Helper functions: `createChannel1Relay`, `createGroup2`, `createGroup3`, etc. +- DSL operators for assertions + +## Estimated New Tests: 16 + +## Files Modified: 1 +- `tests/ChatTests/Groups.hs` diff --git a/plans/groups_test_coverage.md b/plans/groups_test_coverage.md new file mode 100644 index 0000000000..7ee01f1d6f --- /dev/null +++ b/plans/groups_test_coverage.md @@ -0,0 +1,441 @@ +# Group/Channel Test Coverage Analysis + +Coverage run: `cabal test simplex-chat-test --enable-coverage --ghc-options=-O0 --test-options="-m group"` + +Full 164 group tests executed (151 passed, 13 failed due to unrelated issues). + +## Coverage Summary + +After running all group tests: +- Expressions: 48% +- Alternatives: 33% +- Local declarations: 64% +- Top-level: 34% + +--- + +## What IS Covered (Channel-Specific Paths) + +- `createNewRcvChatItem` with `CDChannelRcv` - channel message creation +- `toGroupChatItem` with `showGroupAsSender = True` - channel message reading +- `validSender Nothing CIChannelRcv = True` - channel sender validation +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId (`IS NOT DISTINCT FROM`) +- `toCIDirection CDChannelRcv -> CIChannelRcv` +- `toChatInfo CDChannelRcv g s -> GroupChat g s` +- `chatItemMember CIChannelRcv -> Nothing` +- `viewChatItem` for both `CIGroupRcv` and `CIChannelRcv` +- `viewItemReaction` dispatch to `groupReaction` for both constructors +- Channel delete happy path (`channelDelete` -> `delete Nothing`) + +--- + +## Uncovered Code Paths + +### 1. Subscriber.hs + +#### `processGroupMessage` dispatch (lines 935-972) + +| Line | Code | Status | +|------|------|--------| +| 956 | `asGroup == Just True && memberRole' m'' < GROwner` | tickonlyfalse - rejecting non-owner sending as group never tested | +| 963 | `ttl` parameter in `groupMessageUpdate` | nottickedoff | +| 965 | `scope_` parameter in `groupMsgReaction` | nottickedoff | +| 967 | `XFile` handler | nottickedoff | +| 970 | `XFileAcptInv` handler | nottickedoff | +| 987 | `XGrpPrefs` handler | nottickedoff | +| 993 | `BFileChunk` handler | nottickedoff | +| 994 | Catch-all `_` for unsupported messages | nottickedoff | + +#### `memberCanSend` / `memberCanSend'` (lines 1446-1454) + +| Line | Code | Status | +|------|------|--------| +| 1449 | `memberPending m` part of condition | tickonlytrue - never false | +| 1450 | `otherwise` branch (error "member is not allowed to send messages") | nottickedoff | + +#### `newGroupContentMessage` (lines 1876-1940) + +| Line | Code | Status | +|------|------|--------| +| 1879 | `vr` parameter in `mkGetMessageChatScope` | nottickedoff | +| 1882 | `ft_` and `False` parameters to `prohibitedGroupContent` | nottickedoff | +| 1883 | `rejected` helper invocation | nottickedoff | +| 1895 | `mentions` parameter for channel messages | nottickedoff | +| 1896 | `pure []` for reactions when `sharedMsgId_` is Nothing | nottickedoff | +| 1901 | `rejected` function body | nottickedoff | +| 1902 | `Just Nothing` for timed_ when forwarded | nottickedoff | +| 1910 | `M.empty` for mentions when blocked | tickonlyfalse | +| 1914 | `gInfo'` and `m'` params to `processFileInv` | nottickedoff | + +#### `groupMessageUpdate` (lines 1943-2002) + +| Line | Code | Status | +|------|------|--------| +| 1960 | `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` | nottickedoff - channel catchCINotFound | +| 1967 | `CDChannelRcv {} -> pure ci'` | nottickedoff | +| 1977 | `mentions' = if memberBlocked m then []` | tickonlyfalse | +| 1980 | `otherwise -> messageError "x.msg.update: group member attempted to update..."` | nottickedoff | +| 1984 | `messageError "x.msg.update: invalid message update"` | nottickedoff | +| 2001-2002 | `CEvtChatItemNotChanged` path | nottickedoff | + +#### `groupMessageDelete` (lines 2004-2076) + +**channelDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2013 | `messageError "x.msg.del: invalid channel message delete"` | nottickedoff | +| 2015 | `messageError ("x.msg.del: channel message not found, " <> tshow e)` | nottickedoff | + +**memberDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2028 | `messageError "x.msg.del: member attempted invalid message delete"` | tickonlyfalse | +| 2036 | `CIChannelRcv -> messageError "x.msg.del: unexpected channel message..."` | nottickedoff | +| 2039 | `messageError ("x.msg.del: message not found, " <> tshow e)` | tickonlyfalse | +| 2041-2042 | `messageError "...message of another member with insufficient..."` | tickonlyfalse | +| 2044-2047 | `createCIModeration` scoped moderation path | nottickedoff | + +**moderate helper:** +| Line | Code | Status | +|------|------|--------| +| 2058 | `messageError "x.msg.del: message of another member with incorrect memberId"` | nottickedoff | +| 2059 | `messageError "x.msg.del: message of another member without memberId"` | nottickedoff | +| 2062 | `messageError "...insufficient member permissions"` | tickonlyfalse | + +#### `groupMsgReaction` (lines 1818-1860) + +| Line | Code | Status | +|------|------|--------| +| 1823-1837 | Entire `catchCINotFound` fallback | nottickedoff | +| 1825-1831 | Scoped reaction path for member with scope | nottickedoff | +| 1832-1834 | Regular group reaction when item not found | nottickedoff | +| 1835-1837 | Channel reaction when item not found | nottickedoff | +| 1839 | `otherwise = pure Nothing` when reactions not allowed | tickonlyfalse | +| 1859 | `Nothing` return for channel (`isJust m_` is False) | nottickedoff | +| 1860 | `pure Nothing` when `ciReactionAllowed` is False | nottickedoff | + +#### `validSender` (lines 1871-1874) + +| Line | Code | Status | +|------|------|--------| +| 1872 | `validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m` | nottickedoff | +| 1873 | `validSender Nothing CIChannelRcv = True` | **covered** | +| 1874 | `validSender _ _ = False` | nottickedoff | + +#### `processForwardedMsg` / `xGrpMsgForward` (lines 3127-3153) + +| Line | Code | Status | +|------|------|--------| +| 3133 | `(const Nothing)` wrapper | nottickedoff | +| 3139 | `mentions`, `msgScope`, `ttl`, `live`, `True` to `groupMessageUpdate` | nottickedoff | +| 3141 | `scope_` and `rcvMsg` to `groupMessageDelete` | nottickedoff | +| 3143 | `scope_` to `groupMsgReaction` | nottickedoff | +| 3145 | `XInfo` handler when `author_` is Just | nottickedoff | +| 3152 | `XGrpPrefs` forwarding | nottickedoff | +| 3153 | Catch-all error for unsupported forwarded event | nottickedoff | +| 3311 | `FwdChannel -> (Nothing, Nothing)` | nottickedoff | + +--- + +### 2. View.hs + +#### `viewChatItem` (line 646) + +| Line | Code | Status | +|------|------|--------| +| 555 | `groupNtf user g mention` - `mention` parameter for channel | nottickedoff | +| 673 | `showSndItemProhibited to` for `CISndGroupInvitation` | nottickedoff | +| 674 | `showSndItem to` fallback for GroupChat | nottickedoff | +| 682 | `CIRcvIntegrityError` in group context | nottickedoff | +| 683 | `CIRcvGroupInvitation` with `isJust m_` guard | nottickedoff | +| 684 | `CIRcvModerated` in group context | nottickedoff | +| 685 | `CIRcvBlocked` in group context | nottickedoff | +| 686 | `showRcvItem from` fallback | nottickedoff | +| 691 | `forwardedFrom` in context computation | nottickedoff | + +#### `viewItemUpdate` (line 798) + +| Line | Code | Status | +|------|------|--------| +| 819 | `CIGroupRcv m -> updGroupItem (Just m)` | nottickedoff | +| 822 | `CIGroupSnd _ -> []` fallback | nottickedoff | +| 825 | `ttyToGroup g scopeInfo` (non-edited send path) | nottickedoff | +| 830 | `itemLive == Just True && not liveItems -> []` | tickonlyfalse | +| 832 | `_ -> []` fallback for non-message content | nottickedoff | +| 834 | `ttyFromGroup g scopeInfo m_` (non-edited receive path) | nottickedoff | +| 837 | `forwardedFrom` in context | nottickedoff | +| 838 | `groupQuote g` in context | nottickedoff | + +#### `viewItemReaction` (line 890) + +| Line | Code | Status | +|------|------|--------| +| 898-899 | `sentByMember' g itemDir` in both CIGroupRcv and CIChannelRcv | nottickedoff | +| 913 | `groupReaction _ -> []` (non-message-content fallback) | nottickedoff | +| 917 | `else sentBy` branch when `showGroupAsSender` is False | nottickedoff | +| 958 | `sentByMember'` function | **entirely nottickedoff** | +| 962 | `CIChannelRcv -> Nothing` in sentByMember' | nottickedoff | + +#### `viewItemDelete` (line 869) + +| Line | Code | Status | +|------|------|--------| +| 880 | `_ -> prohibited` in GroupChat branch | nottickedoff | + +#### `viewGroupChatItemsDeleted` (line 866) + +| Line | Code | Status | +|------|------|--------| +| 158 | `member_` parameter | nottickedoff | +| 866 | `maybe "" (\m -> " " <> ttyMember m) member_` - empty string fallback | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `groupScopeInfoStr` (line 2785) + +| Line | Code | Status | +|------|------|--------| +| - | `Just (GCSIMemberSupport {groupMember_}) -> ...` | nottickedoff | +| - | `Nothing -> "(support)"` sub-branch | nottickedoff | +| - | `Just m -> "(support: " <> viewMemberName m <> ")"` sub-branch | nottickedoff | + +#### Scope info display + +| Line | Code | Status | +|------|------|--------| +| 2768 | `groupScopeInfoStr scopeInfo` in `ttyToGroup` | nottickedoff | +| 2779 | `groupScopeInfoStr scopeInfo` in `ttyToGroupEdited` | nottickedoff | +| 2782 | `groupScopeInfoStr scopeInfo` in `fromGroupAttention_` | nottickedoff | + +#### Other display functions + +| Line | Code | Status | +|------|------|--------| +| 625 | `GroupChat g scopeInfo -> [" " <> ttyToGroup g scopeInfo]` | nottickedoff | +| 766 | `(SMDSnd, GroupChat gInfo _scopeInfo) -> Just $ "you #" <> ...` | nottickedoff | +| 767 | `(SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> ...` | nottickedoff | +| 936 | `viewReactionMembers` | **entirely nottickedoff** | +| 1020 | `viewChatCleared` GroupChat branch | nottickedoff | + +--- + +### 3. Internal.hs + +#### `saveRcvChatItem'` (lines 2294-2340) + +| Line | Code | Status | +|------|------|--------| +| 2288 | `M.empty` for non-group mentions | nottickedoff | +| 2299 | `groupMentions` parameters `db` and `membership` | nottickedoff | +| 2300 | `_ -> pure (M.empty, False)` for non-group | nottickedoff | +| 2303 | `contactChatDeleted cd` | tickonlyfalse | +| 2303 | `vr` parameter in `updateChatTsStats` | nottickedoff | +| 2304 | `else pure $ toChatInfo cd` | nottickedoff | +| 2316 | `getRcvCIMentions` - `db`, `user`, `mentions` parameters | nottickedoff | +| 2319 | `sameMemberId mId membership` in userReply check | nottickedoff | +| 2320 | `\CIMention {memberId} -> sameMemberId memberId membership` | nottickedoff | +| 2311 | `createGroupCIMentions db g ci mentions'` | nottickedoff (mentions always empty) | + +#### `memberChatStats` (line 2323) + +| Line | Code | Status | +|------|------|--------| +| 2325-2327 | `CDGroupRcv _g (Just scope) m -> ...` | nottickedoff | +| 2328-2329 | `CDChannelRcv _g (Just scope) -> ...` | nottickedoff | +| 2330 | `_ -> Nothing` | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `memberAttentionChange` (line 2621) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `getRcvCIMentions` (line 277) + +| Line | Code | Status | +|------|------|--------| +| 279 | `not (null ft) && not (null mentions) -> ...` | nottickedoff | +| 280 | `uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft` | nottickedoff | +| 281 | `mapM (getMentionedMemberByMemberId db user groupId) mentions'` | nottickedoff | + +#### `uniqueMsgMentions` (line 286) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `prepareGroupMsg` / `quoteData` (line 204) + +| Line | Code | Status | +|------|------|--------| +| 209 | `MCForward $ ExtMsgContent ...` forward branch | nottickedoff | +| 227 | `CIGroupSnd` with `showGroupAsSender` False | nottickedoff | +| 228 | `CIGroupRcv m -> pure (qmc, CIQGroupRcv $ Just m, False, Just m)` | nottickedoff | + +#### `mkGetMessageChatScope` (lines 1599-1617) + +| Line | Code | Status | +|------|------|--------| +| 1601 | `groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope` | nottickedoff | +| 1604 | `isReport mc -> ...` | tickonlyfalse | +| 1610 | `sameMemberId mId membership -> ...` | nottickedoff | +| 1614 | `otherwise -> do referredMember <- ...` | nottickedoff | +| 1614 | `vr` parameter in `getGroupMemberByMemberId` | nottickedoff | + +#### `mkGroupSupportChatInfo` (line 1620) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### Feature checks (tickonlyfalse - never true) + +| Line | Code | Status | +|------|------|--------| +| 342 | `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` | tickonlyfalse | +| 344 | `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` | tickonlyfalse | +| 485 | `isACIUserMention deletedChatItem` | tickonlyfalse | +| 1593 | `memberPending m` | tickonlyfalse | + +#### `sendGroupMessages` (line 1986) + +| Line | Code | Status | +|------|------|--------| +| 1989 | `sendProfileUpdate catchAllErrors eToView` | nottickedoff | +| 1995 | `isJust scope = False` branch | nottickedoff | + +--- + +### 4. Messages.hs + +#### JSON direction functions - ALL ENTIRELY UNTESTED + +**`jsonCIDirection` (lines 314-321):** +| Line | Code | Status | +|------|------|--------| +| 315 | `CIDirectSnd -> JCIDirectSnd` | nottickedoff | +| 316 | `CIDirectRcv -> JCIDirectRcv` | nottickedoff | +| 317 | `CIGroupSnd -> JCIGroupSnd` | nottickedoff | +| 318 | `CIGroupRcv m -> JCIGroupRcv m` | nottickedoff | +| 319 | `CIChannelRcv -> JCIChannelRcv` | nottickedoff | +| 320 | `CILocalSnd -> JCILocalSnd` | nottickedoff | +| 321 | `CILocalRcv -> JCILocalRcv` | nottickedoff | + +**`jsonACIDirection` (lines 324-331):** +| Line | Code | Status | +|------|------|--------| +| 325-331 | All branches including `JCIChannelRcv -> ACID SCTGroup SMDRcv CIChannelRcv` | nottickedoff | + +**`jsonCIQDirection` (lines 646-651):** +| Line | Code | Status | +|------|------|--------| +| 647 | `CIQDirectSnd -> JCIDirectSnd` | nottickedoff | +| 648 | `CIQDirectRcv -> JCIDirectRcv` | nottickedoff | +| 649 | `CIQGroupSnd -> JCIGroupSnd` | nottickedoff | +| 650 | `CIQGroupRcv (Just m) -> JCIGroupRcv m` | nottickedoff | +| 651 | `CIQGroupRcv Nothing -> JCIChannelRcv` | nottickedoff | + +**`jsonACIQDirection` (lines 654-661):** +| Line | Code | Status | +|------|------|--------| +| 655-659 | All branches including `JCIChannelRcv -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing` | nottickedoff | +| 660 | `JCILocalSnd -> Left "unquotable"` | nottickedoff | +| 661 | `JCILocalRcv -> Left "unquotable"` | nottickedoff | + +**ToJSON/FromJSON instances:** +| Line | Code | Status | +|------|------|--------| +| 1469-1470 | `CIDirection` ToJSON | nottickedoff | +| 1473 | `CCIDirection` FromJSON | nottickedoff | +| 1476 | `ACIDirection` FromJSON | nottickedoff | +| 1479 | `CIQDirection` FromJSON | nottickedoff | +| 1482-1483 | `CIQDirection` ToJSON | nottickedoff | + +#### Other Messages.hs functions + +| Line | Code | Status | +|------|------|--------| +| 372-375 | `chatItemRcvFromMember` | partially covered - `_ -> Nothing` nottickedoff | +| 403 | `toCIDirection CDLocalRcv _ -> CILocalRcv` | nottickedoff | +| 413 | `toChatInfo CDLocalRcv l -> LocalChat l` | nottickedoff | +| 486 | `aChatItemRcvFromMember` | nottickedoff | +| 665 | `quoteMsgDirection CIQDirectSnd -> MDSnd` | nottickedoff | +| 666 | `quoteMsgDirection CIQDirectRcv -> MDRcv` | nottickedoff | + +--- + +### 5. Store/Messages.hs + +#### Scope-filtered query functions - ALL ENTIRELY UNTESTED + +| Function | Lines | Status | +|----------|-------|--------| +| `findGroupChatPreviews_` | 862-900 | nottickedoff | +| `getChatContentTypes` | 1183-1197 | nottickedoff | +| `getChatItemIDs` | 1476-1505 | nottickedoff | +| `queryUnreadGroupItems` | 1686-1707 | nottickedoff | +| `updateSupportChatItemsRead` | 2038-2077 | nottickedoff | +| `getGroupUnreadTimedItems` | 2080-2102 | nottickedoff | +| `getGroupMemberCIBySharedMsgId` | 2950-2960 | nottickedoff | + +#### `toGroupChatItem` (lines 2327-2337) + +| Line | Code | Status | +|------|------|--------| +| 2329 | `CIChannelRcv` with file | **covered** | +| 2332 | `CIChannelRcv` without file | **covered** | +| 2334 | `CIGroupRcv member` with file | nottickedoff | +| 2336 | `CIGroupRcv member` without file | nottickedoff | +| 2337 | `badItem` fallback | nottickedoff | +| 2321 | `deletedByGroupMember_` parsing | nottickedoff | + +#### `getChatItemQuote_` CDChannelRcv (lines 648-653) + +| Line | Code | Status | +|------|------|--------| +| 651 | `mId == userMemberId` check | nottickedoff | +| 651 | `getUserGroupChatItemId_` call | nottickedoff | +| 652 | `otherwise` fallback | nottickedoff | +| 653 | `_ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing` | **covered** | + +#### Reaction functions + +| Line | Code | Status | +|------|------|--------| +| 3275 | `getGroupCIReactions` | **covered** | +| 3328 | `deleteGroupCIReactions_` | nottickedoff | + +--- + +## Summary + +### Well-tested channel paths: +- Channel message create/read/delete happy paths +- Basic channel reactions +- Channel quote creation (quoting nothing) +- `validSender Nothing CIChannelRcv` +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId + +### Major gaps: + +1. **Non-channel-owner member in channel groups** - `isChannelOwner` always True, `memberForChannel = Just m''` never executed + +2. **All JSON serialization for CI directions** - `jsonCIDirection`, `jsonACIDirection`, `jsonCIQDirection`, `jsonACIQDirection` and all `ToJSON`/`FromJSON` instances entirely untested + +3. **Member support scope (`GCSIMemberSupport`)** - `mkGroupSupportChatInfo`, `groupScopeInfoStr`, `memberChatStats` entirely untested + +4. **Mentions in channel/group messages** - `getRcvCIMentions` with non-empty mentions, `uniqueMsgMentions`, `createGroupCIMentions` never called + +5. **Error/fallback paths** - `catchCINotFound` in update/delete/reaction, invalid sender validation, permission errors + +6. **Full-delete feature** - `groupFeatureAllowed SGFFullDelete` always false, `deleteGroupCIs` never called + +7. **Live message updates** - `itemLive == Just True` always false + +8. **Forwarded message handling** - Most parameters to forwarded handlers untested, `FwdChannel` branch untested + +9. **View functions** - `sentByMember'`, `viewGroupChatItemsDeleted`, `viewReactionMembers` entirely untested + +10. **Scope-filtered store queries** - 7 functions entirely untested + +11. **Feature restriction checks** - Voice messages (`SGFVoice`), reports (`SGFReports`) feature checks never triggered diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6fc6818730..a0a8111160 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -338,7 +338,7 @@ data ChatCommand | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIGetReactionMembers {userId :: UserId, groupId :: GroupId, chatItemId :: ChatItemId, reaction :: MsgReaction} | APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId} - | APIForwardChatItems {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int} + | APIForwardChatItems {toChatRef :: ChatRef, sendAsGroup :: ShowGroupAsSender, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int} | APIUserRead UserId | UserRead | APIChatRead {chatRef :: ChatRef} @@ -934,14 +934,9 @@ logEventToFile = \case data SendRef = SRDirect ContactId - | SRGroup GroupId (Maybe GroupChatScope) + | SRGroup GroupId (Maybe GroupChatScope) ShowGroupAsSender deriving (Eq, Show) -sendToChatRef :: SendRef -> ChatRef -sendToChatRef = \case - SRDirect cId -> ChatRef CTDirect cId Nothing - SRGroup gId scope -> ChatRef CTGroup gId scope - data ChatPagination = CPLast Int | CPAfter ChatItemId Int diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index d0a77514eb..37d6d4ba09 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -10,7 +10,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import Data.Maybe (fromMaybe) import Data.Time.Clock (UTCTime) -import Simplex.Chat.Messages (GroupChatScopeInfo (..), MessageId) +import Simplex.Chat.Messages (GroupChatScopeInfo (..), MessageId, ShowGroupAsSender) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Protocol import Simplex.Chat.Types @@ -41,6 +41,16 @@ instance TextEncoding DeliveryWorkerScope where DWSMemberSupport -> "member_support" -- DWSMemberProfileUpdate -> "member_profile_update" +-- Context for creating a delivery task. Separate from DeliveryJobScope because +-- sentAsGroup is only needed for task persistence and batching into XGrpMsgForward events. +-- Once batched into jobs, sentAsGroup=True and sentAsGroup=False messages can be mixed, +-- so jobs don't need this flag. +data DeliveryTaskContext = DeliveryTaskContext + { jobScope :: DeliveryJobScope, + sentAsGroup :: ShowGroupAsSender + } + deriving (Show) + data DeliveryJobScope = DJSGroup {jobSpec :: DeliveryJobSpec} | DJSMemberSupport {supportGMId :: GroupMemberId} @@ -93,12 +103,14 @@ jobSpecImpliedPending = \case DJDeliveryJob {includePending} -> includePending DJRelayRemoved -> True -infoToDeliveryScope :: GroupInfo -> Maybe GroupChatScopeInfo -> DeliveryJobScope -infoToDeliveryScope GroupInfo {membership} = \case - Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} - Just GCSIMemberSupport {groupMember_} -> - let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ - in DJSMemberSupport {supportGMId} +infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext +infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup} + where + jobScope = case scopeInfo of + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} memberEventDeliveryScope :: GroupMember -> Maybe DeliveryJobScope memberEventDeliveryScope m@GroupMember {memberRole, memberStatus} @@ -109,20 +121,22 @@ memberEventDeliveryScope m@GroupMember {memberRole, memberStatus} data NewMessageDeliveryTask = NewMessageDeliveryTask { messageId :: MessageId, - jobScope :: DeliveryJobScope, - messageFromChannel :: MessageFromChannel + taskContext :: DeliveryTaskContext } deriving (Show) +data FwdSender + = FwdMember MemberId ContactName + | FwdChannel + deriving (Show) + data MessageDeliveryTask = MessageDeliveryTask { taskId :: Int64, jobScope :: DeliveryJobScope, senderGMId :: GroupMemberId, - senderMemberId :: MemberId, - senderMemberName :: ContactName, + fwdSender :: FwdSender, brokerTs :: UTCTime, - chatMessage :: ChatMessage 'Json, - messageFromChannel :: MessageFromChannel + chatMessage :: ChatMessage 'Json } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c2194f7b8f..d7d4fb29bc 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -620,12 +620,12 @@ processChatCommand vr nm = \case mapM_ assertNoMentions cms withContactLock "sendMessage" chatId $ sendContactContentMessages user chatId live itemTTL (L.map composedMessageReq cms) - SRGroup chatId gsScope -> + SRGroup chatId gsScope asGroup -> withGroupLock "sendMessage" chatId $ do (gInfo, cmrs) <- withFastStore $ \db -> do g <- getGroupInfo db vr user chatId (g,) <$> mapM (composedMessageReqMentions db user g) cms - sendGroupContentMessages user gInfo gsScope live itemTTL cmrs + sendGroupContentMessages user gInfo gsScope asGroup live itemTTL cmrs APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do _ <- createChatTag db user emoji text CRChatTags user <$> getUserChatTags db user @@ -654,7 +654,7 @@ processChatCommand vr nm = \case gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId let mc = MCReport reportText reportReason cm = ComposedMessage {fileSource = Nothing, quotedItemId = Just reportedItemId, msgContent = mc, mentions = M.empty} - sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False Nothing [composedMessageReq cm] + sendGroupContentMessages user gInfo (Just $ GCSMemberSupport Nothing) False False Nothing [composedMessageReq cm] ReportMessage {groupName, contactName_, reportReason, reportedMessage} -> withUser $ \user -> do gId <- withFastStore $ \db -> getGroupIdByName db user groupName reportedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user gId contactName_ reportedMessage @@ -672,7 +672,7 @@ processChatCommand vr nm = \case let changed = mc /= oldMC if changed || fromMaybe False itemLive then do - let event = XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) Nothing + let event = XMsgUpdate itemSharedMId mc M.empty (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) Nothing Nothing (SndMessage {msgId}, _) <- sendDirectContactMessage user ct event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -695,7 +695,7 @@ processChatCommand vr nm = \case -- TODO [knocking] check chat item scope? cci <- withFastStore $ \db -> getGroupCIWithReactions db user gInfo itemId case cci of - CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable}, content = ciContent} -> do + CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive, editable, showGroupAsSender}, content = ciContent} -> do case (ciContent, itemSharedMsgId, editable) of (CISndMsgContent oldMC, Just itemSharedMId, True) -> do chatScopeInfo <- mapM (getChatScopeInfo vr user) scope @@ -706,7 +706,7 @@ processChatCommand vr nm = \case ciMentions <- withFastStore $ \db -> getCIMentions db user gInfo ft_ mentions let msgScope = toMsgScope gInfo <$> chatScopeInfo mentions' = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions - event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope + event = XMsgUpdate itemSharedMId mc mentions' (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive) msgScope (Just showGroupAsSender) SndMessage {msgId} <- sendGroupMessage user gInfo scope recipients event ci' <- withFastStore' $ \db -> do currentTs <- liftIO getCurrentTime @@ -852,10 +852,10 @@ processChatCommand vr nm = \case throwCmdError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) unless (ciReactionAllowed ci) $ throwCmdError "reaction not allowed - chat item has no content" - let GroupMember {memberId = itemMemberId} = chatItemMember g ci + let itemMemberId = memberId' <$> chatItemMember g ci rs <- withFastStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs - SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId (Just itemMemberId) (toMsgScope g <$> chatScopeInfo) reaction add) + SndMessage {msgId} <- sendGroupMessage user g scope recipients (XMsgReact itemSharedMId itemMemberId (toMsgScope g <$> chatScopeInfo) reaction add) createdAt <- liftIO getCurrentTime reactions <- withFastStore' $ \db -> do setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt @@ -927,7 +927,7 @@ processChatCommand vr nm = \case MCChat {} -> True MCUnknown {} -> True -- TODO [knocking] forward from / to scope - APIForwardChatItems toChat@(ChatRef toCType toChatId toScope) fromChat@(ChatRef fromCType fromChatId _fromScope) itemIds itemTTL -> withUser $ \user -> case toCType of + APIForwardChatItems toChat@(ChatRef toCType toChatId toScope) sendAsGroup fromChat@(ChatRef fromCType fromChatId _fromScope) itemIds itemTTL -> withUser $ \user -> case toCType of CTDirect -> do cmrs <- prepareForward user case L.nonEmpty cmrs of @@ -941,7 +941,7 @@ processChatCommand vr nm = \case Just cmrs' -> withGroupLock "forwardChatItem, to group" toChatId $ do gInfo <- withFastStore $ \db -> getGroupInfo db vr user toChatId - sendGroupContentMessages user gInfo toScope False itemTTL cmrs' + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' Nothing -> pure $ CRNewChatItems user [] CTLocal -> do cmrs <- prepareForward user @@ -1275,7 +1275,7 @@ processChatCommand vr nm = \case sendWelcomeMsg user ct ucl UserContactRequest {welcomeSharedMsgId} = forM_ (autoReply $ addressSettings ucl) $ \mc -> case welcomeSharedMsgId of Just smId -> - void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing + void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing Nothing -> do (msg, _) <- sendDirectContactMessage user ct $ XMsgNew $ MCSimple $ extMsgContent mc Nothing ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) @@ -1880,7 +1880,8 @@ processChatCommand vr nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False + hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember createItem sharedMsgId content = createChatItem user cd True content sharedMsgId Nothing @@ -1909,13 +1910,9 @@ processChatCommand vr nm = \case welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId let useRelays = not direct gVar <- asks random - (gInfo, hostMember) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) - -- TODO [relays] member: TBC save items as message from channel - -- TODO - hostMember to later be associated with owner profile when relays send it - -- TODO - pick any owner at random from initial introductions, find unknown member in group? - -- TODO - alternatively support not having a member in CDGroupRcv direction? - let cd = CDGroupRcv gInfo Nothing hostMember + let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing void $ createGroupFeatureItems_ user cd True CIRcvGroupFeature gInfo aci <- forM description $ \descr -> createChatItem user cd True (CIRcvMsgContent $ MCText descr) welcomeSharedMsgId Nothing @@ -1933,11 +1930,17 @@ processChatCommand vr nm = \case lift $ createContactChangedFeatureItems user ct ct' pure $ CRContactUserChanged user ct newUser ct' APIChangePreparedGroupUser groupId newUserId -> withUser $ \user -> do - (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId when (isNothing $ preparedGroup gInfo) $ throwCmdError "group doesn't have link to connect" - when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" + hostMember_ <- + if useRelays' gInfo + then pure Nothing + else do + hostMember <- withFastStore $ \db -> getHostMember db vr user groupId + when (isJust $ memberConn hostMember) $ throwCmdError "host member already has connection" + pure $ Just hostMember newUser <- privateGetUser newUserId - gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember newUser + gInfo' <- withFastStore $ \db -> updatePreparedGroupUser db vr user gInfo hostMember_ newUser pure $ CRGroupUserChanged user gInfo newUser gInfo' APIConnectPreparedContact contactId incognito msgContent_ -> withUser $ \user -> do ct@Contact {preparedContact} <- withFastStore $ \db -> getContact db vr user contactId @@ -1985,7 +1988,7 @@ processChatCommand vr nm = \case pure $ CRStartedConnectionToContact user ct' customUserProfile CVRConnectedContact ct' -> pure $ CRContactAlreadyExists user ct' APIConnectPreparedGroup groupId incognito msgContent_ -> withUser $ \user -> do - (gInfo, hostMember) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getHostMember db vr user groupId + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId case gInfo of GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect" GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do @@ -2050,6 +2053,7 @@ processChatCommand vr nm = \case newConnIds <- getAgentConnShortLinkAsync user relayLink withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do + hostMember <- withFastStore $ \db -> getHostMember db vr user groupId msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of Just smId -> pure (smId, mc) Nothing -> do @@ -2184,17 +2188,20 @@ processChatCommand vr nm = \case contactId <- withFastStore $ \db -> getContactIdByName db user fromContactName forwardedItemId <- withFastStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing + asGroup <- getSendAsGroup user toChatRef + processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user fromGroupName forwardedItemId <- withFastStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing + asGroup <- getSendAsGroup user toChatRef + processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) forwardedItemId <- withFastStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg toChatRef <- getChatRef user toChatName - processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing + asGroup <- getSendAsGroup user toChatRef + processChatCommand vr nm $ APIForwardChatItems toChatRef asGroup (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing SendMessage sendName msg -> withUser $ \user -> do let mc = MCText msg case sendName of @@ -2214,13 +2221,14 @@ processChatCommand vr nm = \case _ -> throwChatError $ CEContactNotFound name Nothing SNGroup name scope_ -> do - (gId, cScope_, mentions) <- withFastStore $ \db -> do - gId <- getGroupIdByName db user name + (gInfo, cScope_, mentions) <- withFastStore $ \db -> do + gInfo <- getGroupInfoByName db vr user name + let gId = groupId' gInfo cScope_ <- forM scope_ $ \(GSNMemberSupport mName_) -> GCSMemberSupport <$> mapM (getGroupMemberIdByName db user gId) mName_ - (gId,cScope_,) <$> liftIO (getMessageMentions db user gId msg) - let sendRef = SRGroup gId cScope_ + (gInfo, cScope_,) <$> liftIO (getMessageMentions db user gId msg) + let sendRef = SRGroup (groupId' gInfo) cScope_ (sendAsGroup' gInfo) processChatCommand vr nm $ APISendMessages sendRef False Nothing [ComposedMessage Nothing Nothing mc mentions] SNLocal -> do folderId <- withFastStore (`getUserNoteFolderId` user) @@ -2247,7 +2255,7 @@ processChatCommand vr nm = \case processChatCommand vr nm $ APIAcceptMemberContact contactId SendLiveMessage chatName msg -> withUser $ \user -> do (chatRef, mentions) <- getChatRefAndMentions user chatName msg - withSendRef chatRef $ \sendRef -> do + withSendRef user chatRef $ \sendRef -> do let mc = MCText msg processChatCommand vr nm $ APISendMessages sendRef True Nothing [ComposedMessage Nothing Nothing mc mentions] SendMessageBroadcast mc -> withUser $ \user -> do @@ -2289,7 +2297,7 @@ processChatCommand vr nm = \case combineResults _ _ (Left e) = Left e createCI :: DB.Connection -> User -> Bool -> UTCTime -> (Contact, SndMessage) -> IO () createCI db user hasLink createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) False sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False hasLink createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withFastStore $ \db -> getContactIdByName db user cName quotedItemId <- withFastStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg @@ -2497,7 +2505,7 @@ processChatCommand vr nm = \case pure $ CRMemberSupportChatDeleted user gInfo' m' APIMembersRole groupId memberIds newRole -> withUser $ \user -> withGroupLock "memberRole" groupId $ do - -- TODO [channels fwd] possible optimization is to read only required members + relays + -- TODO [relays] possible optimization is to read only required members + relays g@(Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't change role for self" let (invitedMems, currentMems, unchangedMems, maxRole, anyAdmin, anyPending) = selectMembers members @@ -2550,7 +2558,7 @@ processChatCommand vr nm = \case recipients = filter memberCurrent members (msgs_, _gsr) <- sendGroupMessages user gInfo Nothing recipients events let itemsData = zipWith (fmap . sndItemData) memsToChange (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length memsToChange) $ logError "changeRoleCurrentMems: memsToChange and cis_ length mismatch" (errs, changed) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (updMember db) memsToChange) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ @@ -2566,10 +2574,10 @@ processChatCommand vr nm = \case pure (m :: GroupMember) {memberRole = newRole} APIBlockMembersForAll groupId memberIds blockFlag -> withUser $ \user -> withGroupLock "blockForAll" groupId $ do - -- TODO [channels fwd] possible optimization is to read only required members + relays + -- TODO [relays] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId when (selfSelected gInfo) $ throwCmdError "can't block/unblock self" - -- TODO [channels fwd] consider sending restriction to all members (remove filtering), as we do in delivery jobs + -- TODO [relays] consider sending restriction to all members (remove filtering), as we do in delivery jobs let (blockMems, remainingMems, maxRole, anyAdmin, anyPending) = selectMembers members when (length blockMems /= length memberIds) $ throwChatError CEGroupMemberNotFound when (length memberIds > 1 && anyAdmin) $ throwCmdError "can't block/unblock multiple members when admins selected" @@ -2597,7 +2605,7 @@ processChatCommand vr nm = \case recipients = filter memberCurrent remainingMems (msgs_, _gsr) <- sendGroupMessages_ user gInfo recipients events let itemsData = zipWith (fmap . sndItemData) blockMems (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo Nothing) False itemsData Nothing False when (length cis_ /= length blockMems) $ logError "blockMembers: blockMems and cis_ length mismatch" let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo Nothing)) $ rights cis_ unless (null acis) $ toView $ CEvtNewChatItems user acis @@ -2614,7 +2622,7 @@ processChatCommand vr nm = \case in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user -> withGroupLock "removeMembers" groupId $ do - -- TODO [channels fwd] possible optimization is to read only required members + relays + -- TODO [relays] possible optimization is to read only required members + relays Group gInfo members <- withFastStore $ \db -> getGroup db vr user groupId let (count, invitedMems, pendingApprvMems, pendingRvwMems, currentMems, maxRole, anyAdmin) = selectMembers gmIds members gmIds = S.fromList $ L.toList groupMemberIds @@ -2681,7 +2689,7 @@ processChatCommand vr nm = \case Right (Just a) -> Just $ Right a Left e -> Just $ Left e itemsData = mapMaybe skipUnwantedItem itemsData_ - cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData Nothing False + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) False itemsData Nothing False deleteMembersConnections' user memsToDelete True (errs, deleted) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (delMember db) memsToDelete) let acis = map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo)) $ rights cis_ @@ -2913,13 +2921,14 @@ processChatCommand vr nm = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APIGetGroupLink groupId SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do - (groupId, quotedItemId, mentions) <- + (gInfo, quotedItemId, mentions) <- withFastStore $ \db -> do - gId <- getGroupIdByName db user gName + gInfo <- getGroupInfoByName db vr user gName + let gId = groupId' gInfo qiId <- getGroupChatItemIdByText db user gId cName quotedMsg - (gId, qiId,) <$> liftIO (getMessageMentions db user gId msg) + (gInfo, qiId,) <$> liftIO (getMessageMentions db user gId msg) let mc = MCText msg - processChatCommand vr nm $ APISendMessages (SRGroup groupId Nothing) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] + processChatCommand vr nm $ APISendMessages (SRGroup (groupId' gInfo) Nothing (sendAsGroup' gInfo)) False Nothing [ComposedMessage Nothing (Just quotedItemId) mc mentions] ClearNoteFolder -> withUser $ \user -> do folderId <- withFastStore (`getUserNoteFolderId` user) processChatCommand vr nm $ APIClearChat (ChatRef CTLocal folderId Nothing) @@ -2960,10 +2969,10 @@ processChatCommand vr nm = \case chatRef <- getChatRef user chatName case chatRef of ChatRef CTLocal folderId _ -> processChatCommand vr nm $ APICreateChatItems folderId [composedMessage (Just f) (MCFile "")] - _ -> withSendRef chatRef $ \sendRef -> processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] + _ -> withSendRef user chatRef $ \sendRef -> processChatCommand vr nm $ APISendMessages sendRef False Nothing [composedMessage (Just f) (MCFile "")] SendImage chatName f@(CryptoFile fPath _) -> withUser $ \user -> do chatRef <- getChatRef user chatName - withSendRef chatRef $ \sendRef -> do + withSendRef user chatRef $ \sendRef -> do filePath <- lift $ toFSFilePath fPath unless (any (`isSuffixOf` map toLower fPath) imageExtensions) $ throwChatError CEFileImageType {filePath} fileSize <- getFileSize filePath @@ -3194,6 +3203,9 @@ processChatCommand vr nm = \case | otherwise -> throwCmdError "not supported" _ -> throwCmdError "not supported" pure $ ChatRef cType chatId Nothing + getSendAsGroup :: User -> ChatRef -> CM ShowGroupAsSender + getSendAsGroup user' (ChatRef CTGroup chatId _) = sendAsGroup' <$> withFastStore (\db -> getGroupInfo db vr user' chatId) + getSendAsGroup _ _ = pure False getChatRefAndMentions :: User -> ChatName -> Text -> CM (ChatRef, Map MemberName GroupMemberId) getChatRefAndMentions user cName msg = do chatRef@(ChatRef cType chatId _) <- getChatRef user cName @@ -3539,7 +3551,7 @@ processChatCommand vr nm = \case assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId (Just memId) $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds + events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds mapM_ (sendGroupMessages_ user gInfo ms) events delGroupChatItems user gInfo chatScopeInfo items True where @@ -3552,14 +3564,16 @@ processChatCommand vr nm = \case case chatDir of CIGroupRcv GroupMember {memberRole} -> memberRole' membership >= memberRole && isJust itemSharedMsgId CIGroupSnd -> isJust itemSharedMsgId - itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, MemberId)] + CIChannelRcv -> memberRole' membership == GROwner && isJust itemSharedMsgId + itemsMsgMemIds :: GroupInfo -> [CChatItem 'CTGroup] -> [(SharedMsgId, Maybe MemberId)] itemsMsgMemIds GroupInfo {membership = GroupMember {memberId = membershipMemId}} = mapMaybe itemMsgMemIds where - itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, MemberId) + itemMsgMemIds :: CChatItem 'CTGroup -> Maybe (SharedMsgId, Maybe MemberId) itemMsgMemIds (CChatItem _ ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = join <$> forM itemSharedMsgId $ \msgId -> Just $ case chatDir of - CIGroupRcv GroupMember {memberId} -> (msgId, memberId) - CIGroupSnd -> (msgId, membershipMemId) + CIGroupRcv GroupMember {memberId} -> (msgId, Just memberId) + CIGroupSnd -> (msgId, Just membershipMemId) + CIChannelRcv -> (msgId, Nothing) delGroupChatItems :: User -> GroupInfo -> Maybe GroupChatScopeInfo -> [CChatItem 'CTGroup] -> Bool -> CM [ChatItemDeletion] delGroupChatItems user gInfo@GroupInfo {membership} chatScopeInfo items moderation = do @@ -3977,7 +3991,7 @@ processChatCommand vr nm = \case msgs_ <- sendDirectContactMessages user ct $ L.map XMsgNew msgContainers let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) msgs_ when (length itemsData /= length cmrs) $ logError "sendContactContentMessages: cmrs and itemsData length mismatch" - r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) itemsData timed_ live + r@(_, cis) <- partitionEithers <$> saveSndChatItems user (CDDirectSnd ct) False itemsData timed_ live processSendErrs r forM_ (timed_ >>= timedDeleteAt') $ \deleteAt -> forM_ cis $ \ci -> @@ -3996,8 +4010,8 @@ processChatCommand vr nm = \case prepareMsgs cmsFileInvs timed_ = withFastStore $ \db -> forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do case (quotedItemId, itemForwarded) of - (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) - (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Nothing) + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing Nothing), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing Nothing), Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId @@ -4005,7 +4019,7 @@ processChatCommand vr nm = \case let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just qiId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing), Just quotedItem) + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc M.empty fInv_ (ttl' <$> timed_) (justTrue live) Nothing Nothing), Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where quoteData :: ChatItem c d -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTDirect, Bool) @@ -4013,17 +4027,17 @@ processChatCommand vr nm = \case quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) quoteData _ = throwError SEInvalidQuote - sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages user gInfo scope live itemTTL cmrs = do + sendGroupContentMessages :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages user gInfo scope showGroupAsSender live itemTTL cmrs = do assertMultiSendable live cmrs chatScopeInfo <- mapM (getChatScopeInfo vr user) scope recipients <- getGroupRecipients vr user gInfo chatScopeInfo modsCompatVersion - sendGroupContentMessages_ user gInfo scope chatScopeInfo recipients live itemTTL cmrs + sendGroupContentMessages_ user gInfo scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs where hasReport = any (\(ComposedMessage {msgContent}, _, _, _) -> isReport msgContent) cmrs modsCompatVersion = if hasReport then contentReportsVersion else groupKnockingVersion - sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse - sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope chatScopeInfo recipients live itemTTL cmrs = do + sendGroupContentMessages_ :: User -> GroupInfo -> Maybe GroupChatScope -> ShowGroupAsSender -> Maybe GroupChatScopeInfo -> [GroupMember] -> Bool -> Maybe Int -> NonEmpty ComposedMessageReq -> CM ChatResponse + sendGroupContentMessages_ user gInfo@GroupInfo {groupId, membership} scope showGroupAsSender chatScopeInfo recipients live itemTTL cmrs = do forM_ allowedRole $ assertUserGroupRole gInfo assertGroupContentAllowed processComposedMessages @@ -4048,13 +4062,13 @@ processChatCommand vr nm = \case Nothing processComposedMessages :: CM ChatResponse processComposedMessages = do - -- TODO [channels fwd] single description for all recipients + -- TODO [relays] single description for all recipients (fInvs_, ciFiles_) <- L.unzip <$> setupSndFileTransfers (length recipients) timed_ <- sndGroupCITimed live gInfo itemTTL (chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_ (msgs_, gsr) <- sendGroupMessages user gInfo Nothing recipients chatMsgEvents let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_) - cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) itemsData timed_ live + cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch" createMemberSndStatuses cis_ msgs_ gsr let r@(_, cis) = partitionEithers cis_ @@ -4077,7 +4091,7 @@ processChatCommand vr nm = \case forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, ciMentions), fInv_) -> let msgScope = toMsgScope gInfo <$> chatScopeInfo mentions = M.map (\CIMention {memberId} -> MsgMention {memberId}) ciMentions - in prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId itemForwarded fInv_ timed_ live + in prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId itemForwarded fInv_ timed_ live createMemberSndStatuses :: [Either ChatError (ChatItem 'CTGroup 'MDSnd)] -> NonEmpty (Either ChatError SndMessage) -> @@ -4226,10 +4240,12 @@ processChatCommand vr nm = \case getConnQueueInfo user Connection {connId, agentConnId = AgentConnId acId} = do msgInfo <- withFastStore' (`getLastRcvMsgInfo` connId) CRQueueInfo user msgInfo <$> withAgent (\a -> getConnectionQueueInfo a nm acId) - withSendRef :: ChatRef -> (SendRef -> CM ChatResponse) -> CM ChatResponse - withSendRef chatRef a = case chatRef of + withSendRef :: User -> ChatRef -> (SendRef -> CM ChatResponse) -> CM ChatResponse + withSendRef user chatRef a = case chatRef of ChatRef CTDirect cId _ -> a $ SRDirect cId - ChatRef CTGroup gId scope -> a $ SRGroup gId scope + ChatRef CTGroup gId scope -> do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId + a $ SRGroup gId scope (sendAsGroup' gInfo) _ -> throwCmdError "not supported" getSharedMsgId :: CM SharedMsgId getSharedMsgId = do @@ -4620,7 +4636,7 @@ chatCommandP = "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> (knownReaction <$?> jsonP)), "/_reaction members " *> (APIGetReactionMembers <$> A.decimal <* " #" <*> A.decimal <* A.space <*> A.decimal <* A.space <*> (knownReaction <$?> jsonP)), "/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP), - "/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), + "/_forward " *> (APIForwardChatItems <$> chatRefP <*> (" as_group=" *> onOffP <|> pure False) <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP), "/_read user " *> (APIUserRead <$> A.decimal), "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP), @@ -5047,7 +5063,8 @@ chatCommandP = cType -> (\chatId -> ChatRef cType chatId Nothing) <$> A.decimal sendRefP = (A.char '@' $> SRDirect <*> A.decimal) - <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) + asGroupP = ("(as_group=" *> onOffP <* A.char ')') <|> pure False gcScopeP = "(_support" *> (GCSMemberSupport <$> optional (A.char ':' *> A.decimal)) <* A.char ')' sendNameP = (A.char '@' $> SNDirect <*> displayNameP) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 5095197585..6240ff4a24 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -200,30 +200,33 @@ toggleNtf m ntfOn = forM_ (memberConnId m) $ \connId -> withAgent (\a -> toggleConnectionNtfs a connId ntfOn) `catchAllErrors` eToView -prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg db user g@GroupInfo {membership} msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of +prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> ShowGroupAsSender -> MsgContent -> Map MemberName MsgMention -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> ExceptT StoreError IO (ChatMsgEvent 'Json, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of (Nothing, Nothing) -> - let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope + let mc' = MCSimple $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope (justTrue showGroupAsSender) in pure (XMsgNew mc', Nothing) (Nothing, Just _) -> - let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope + let mc' = MCForward $ ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope (justTrue showGroupAsSender) in pure (XMsgNew mc', Nothing) (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId - (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership - let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + (origQmc, qd, sent, member_) <- quoteData qci membership + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = memberId' <$> member_} qmc = quoteContent mc origQmc file (qmc', ft', _) = updatedMentionNames qmc formattedText quoteMentions quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc', formattedText = ft'} - mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope) + mc' = MCQuote QuotedMsg {msgRef, content = qmc'} (ExtMsgContent mc mentions fInv_ (ttl' <$> timed_) (justTrue live) msgScope (justTrue showGroupAsSender)) pure (XMsgNew mc', Just quotedItem) (Just _, Just _) -> throwError SEInvalidQuote where - quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData :: ChatItem c d -> GroupMember -> ExceptT StoreError IO (MsgContent, CIQDirection 'CTGroup, Bool, Maybe GroupMember) quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwError SEInvalidQuote - quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc} membership' = pure (qmc, CIQGroupSnd, True, membership') - quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, m) + quoteData ChatItem {chatDir = CIGroupSnd, content = CISndMsgContent qmc, meta = CIMeta {showGroupAsSender = sentAsGroup}} membership' + | sentAsGroup = pure (qmc, CIQGroupSnd, True, Nothing) + | otherwise = pure (qmc, CIQGroupSnd, True, Just membership') + quoteData ChatItem {chatDir = CIGroupRcv m, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv $ Just m, False, Just m) + quoteData ChatItem {chatDir = CIChannelRcv, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv Nothing, False, Nothing) quoteData _ _ = throwError SEInvalidQuote updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) @@ -1190,13 +1193,15 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn | otherwise = Nothing itemForwardEvents :: CChatItem 'CTGroup -> CM [ChatMsgEvent 'Json] itemForwardEvents cci = case cci of - (CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv sender, content = CIRcvMsgContent mc, file}) - | not (blockedByAdmin sender) -> do + (CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent mc, file}) + | not (maybe False blockedByAdmin sender_) -> do fInvDescr_ <- join <$> forM file getRcvFileInvDescr - processContentItem sender ci mc fInvDescr_ + processContentItem sender_ ci mc fInvDescr_ + | otherwise -> pure [] + where sender_ = chatItemRcvFromMember ci (CChatItem SMDSnd ci@ChatItem {content = CISndMsgContent mc, file}) -> do fInvDescr_ <- join <$> forM file getSndFileInvDescr - processContentItem membership ci mc fInvDescr_ + processContentItem (Just membership) ci mc fInvDescr_ _ -> pure [] where getRcvFileInvDescr :: CIFile 'MDRcv -> CM (Maybe (FileInvitation, RcvFileDescrText)) @@ -1229,8 +1234,8 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn fInv = xftpFileInvitation fileName fileSize fInvDescr in Just (fInv, fileDescrText) | otherwise = Nothing - processContentItem :: GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] - processContentItem sender ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = + processContentItem :: Maybe GroupMember -> ChatItem 'CTGroup d -> MsgContent -> Maybe (FileInvitation, RcvFileDescrText) -> CM [ChatMsgEvent 'Json] + processContentItem sender_ ChatItem {formattedText, meta, quotedItem, mentions} mc fInvDescr_ = if isNothing fInvDescr_ && not (msgContentHasText mc) then pure [] else do @@ -1239,9 +1244,11 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn fInv_ = fst <$> fInvDescr_ (mc', _, mentions') = updatedMentionNames mc formattedText mentions mentions'' = M.map (\CIMention {memberId} -> MsgMention {memberId}) mentions' + asGroup = isNothing sender_ -- TODO [knocking] send history to other scopes too? - (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False - let senderVRange = memberChatVRange' sender + (chatMsgEvent, _) <- withStore $ \db -> prepareGroupMsg db user gInfo Nothing asGroup mc' mentions'' quotedItemId_ Nothing fInv_ itemTimed False + -- for channel messages default chat version range to membership range + let senderVRange = maybe (memberChatVRange' membership) memberChatVRange' sender_ xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of (Just fileDescrText, Just msgId) -> do @@ -1250,9 +1257,9 @@ sendHistory user gInfo@GroupInfo {groupId, membership} m@GroupMember {activeConn pure . L.toList $ L.map (XMsgFileDescr msgId) parts _ -> pure [] let fileDescrChatMsgs = map (ChatMessage senderVRange Nothing) fileDescrEvents - GroupMember {memberId} = sender - memberName = Just $ memberShortenedName sender - msgForwardEvents = map (\cm -> XGrpMsgForward memberId memberName cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) + memberId_ = memberId' <$> sender_ + memberName_ = memberShortenedName <$> sender_ + msgForwardEvents = map (\cm -> XGrpMsgForward memberId_ memberName_ cm itemTs) (xMsgNewChatMsg : fileDescrChatMsgs) pure msgForwardEvents memberShortenedName :: GroupMember -> ContactName @@ -2105,7 +2112,7 @@ memberSendAction gInfo@GroupInfo {membership} events members m@GroupMember {memb | isRelay membership && not (isRelay m) -> MSASendBatched . snd <$> readyMemberConn m -- if user is not chat relay, send only to chat relays | not (isRelay membership) && isRelay m -> MSASendBatched . snd <$> readyMemberConn m - | otherwise -> Nothing -- TODO [channels fwd] MSAForwarded to create GSSForwarded snd statuses? + | otherwise -> Nothing -- TODO [relays] MSAForwarded to create GSSForwarded snd statuses? | otherwise = case memberConn m of Nothing -> pendingOrForwarded Just conn@Connection {connStatus} @@ -2204,12 +2211,12 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta _ -> throwError e pure (am', conn', msg) -saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage) -saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMember@GroupMember {memberId = refMemberId} msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do +saveGroupFwdRcvMsg :: MsgEncodingI e => User -> GroupInfo -> GroupMember -> Maybe GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage) +saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMember_ msgBody ChatMessage {msgId = sharedMsgId_, chatMsgEvent} brokerTs = do let newMsg = NewRcvMessage {chatMsgEvent, msgBody, brokerTs} fwdMemberId = Just $ groupMemberId' forwardingMember - refAuthorId = Just $ groupMemberId' refAuthorMember - -- TODO [channels fwd] TBC highlighting difference between deduplicated messages (useRelays branch) + refAuthorId = groupMemberId' <$> refAuthorMember_ + -- TODO [relays] TBC highlighting difference between deduplicated messages (useRelays branch) withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case Right msg -> pure $ Just msg Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} @@ -2218,7 +2225,7 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb (Just authorGMId, Nothing) -> do vr <- chatVersionRange am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db vr user groupId authorGMId - if sameMemberId refMemberId am + if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ then forM_ (memberConn forwardingMember) $ \fmConn -> void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" @@ -2233,7 +2240,7 @@ saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothi saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd) saveSndChatItem' user cd msg content ciFile quotedItem itemForwarded itemTimed live = do let itemTexts = ciContentTexts content - saveSndChatItems user cd [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case + saveSndChatItems user cd False [Right NewSndChatItemData {msg, content, itemTexts, itemMentions = M.empty, ciFile, quotedItem, itemForwarded}] itemTimed live >>= \case [Right ci] -> pure ci _ -> throwChatError $ CEInternalError "saveSndChatItem': expected 1 item" @@ -2252,11 +2259,12 @@ saveSndChatItems :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> + ShowGroupAsSender -> [Either ChatError (NewSndChatItemData c)] -> Maybe CITimed -> Bool -> CM [Either ChatError (ChatItem c 'MDSnd)] -saveSndChatItems user cd itemsData itemTimed live = do +saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do createdAt <- liftIO getCurrentTime vr <- chatVersionRange when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ @@ -2266,9 +2274,9 @@ saveSndChatItems user cd itemsData itemTimed live = do createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do let hasLink_ = ciContentHasLink content (snd itemTexts) - ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt + ciId <- createNewSndChatItem db user cd showGroupAsSender msg content quotedItem itemForwarded itemTimed live hasLink_ createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd False ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing createdAt + let ci = mkChatItem_ cd showGroupAsSender ciId content itemTexts ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live False hasLink_ createdAt Nothing createdAt Right <$> case cd of CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci @@ -2288,33 +2296,38 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared createdAt <- liftIO getCurrentTime vr <- chatVersionRange withStore' $ \db -> do - (mentions' :: Map MemberName CIMention, userMention) <- case cd of - CDGroupRcv g@GroupInfo {membership} _scope _m -> do - mentions' <- getRcvCIMentions db user g ft_ mentions - let userReply = case cmToQuotedMsg chatMsgEvent of - Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership - _ -> False - userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' - in pure (mentions', userMention') - CDDirectRcv _ -> pure (M.empty, False) + (mentions' :: Map MemberName CIMention, userMention) <- case toChatInfo cd of + GroupChat g@GroupInfo {membership} _ -> groupMentions db g membership + _ -> pure (M.empty, False) cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) then updateChatTsStats db vr user cd createdAt (memberChatStats userMention) else pure $ toChatInfo cd - let hasLink_ = ciContentHasLink content ft_ + let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False + hasLink_ = ciContentHasLink content ft_ (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - let ci = mkChatItem_ cd False ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember createdAt - ci' <- case cd of - CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' + let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember createdAt + ci' <- case toChatInfo cd of + GroupChat g _ | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci pure (ci', cInfo') where + groupMentions db g membership = do + mentions' <- getRcvCIMentions db user g ft_ mentions + let userReply = case cmToQuotedMsg chatMsgEvent of + Just QuotedMsg {msgRef = MsgRef {memberId = Just mId}} -> sameMemberId mId membership + _ -> False + userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' + in pure (mentions', userMention') memberChatStats :: Bool -> Maybe (Int, MemberAttention, Int) memberChatStats userMention = case cd of - CDGroupRcv _g (Just scope) m -> do + CDGroupRcv _g (Just scope) m -> let unread = fromEnum $ ciCreateStatus content == CISRcvNew - in Just (unread, memberAttentionChange unread (Just brokerTs) m scope, fromEnum userMention) + in Just (unread, memberAttentionChange unread (Just brokerTs) (Just m) scope, fromEnum userMention) + CDChannelRcv _g (Just scope) -> + let unread = fromEnum $ ciCreateStatus content == CISRcvNew + in Just (unread, memberAttentionChange unread (Just brokerTs) Nothing scope, fromEnum userMention) _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing @@ -2594,7 +2607,7 @@ createChatItems user itemTs_ dirsCIContents = do memberChatStats = case cd of CDGroupRcv _g (Just scope) m -> do let unread = length $ filter (ciRequiresAttention . fst) contents - in Just (unread, memberAttentionChange unread itemTs_ m scope, 0) + in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0) _ -> Nothing createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId)]) -> [IO AChatItem] createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents @@ -2605,10 +2618,12 @@ createChatItems user itemTs_ dirsCIContents = do let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -memberAttentionChange :: Int -> (Maybe UTCTime) -> GroupMember -> GroupChatScopeInfo -> MemberAttention -memberAttentionChange unread brokerTs_ rcvMem = \case +-- rcvMem_ Nothing means message from channel - treated same as message from moderator, +-- e.g. it can reset unanswered counter if newer than last unanswered message. +memberAttentionChange :: Int -> (Maybe UTCTime) -> Maybe GroupMember -> GroupChatScopeInfo -> MemberAttention +memberAttentionChange unread brokerTs_ rcvMem_ = \case GCSIMemberSupport (Just suppMem) - | groupMemberId' suppMem == groupMemberId' rcvMem -> MAInc unread brokerTs_ + | maybe False ((groupMemberId' suppMem ==) . groupMemberId') rcvMem_ -> MAInc unread brokerTs_ | msgIsNewerThanLastUnanswered -> MAReset | otherwise -> MAInc 0 Nothing where diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 1f1b56598c..a172b141ec 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -210,7 +210,7 @@ processAgentMsgSndFile _corrId aFileId msg = do Nothing -> eToView $ ChatError $ CEInternalError "SFDONE, sendFileDescriptions: expected at least 1 result" lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) (_, _, SMDSnd, GroupChat g@GroupInfo {groupId} _scope) -> do - -- TODO [channels fwd] single description for all recipients + -- TODO [relays] single description for all recipients ms <- getRecipients let rfdsMemberFTs = zipWith (\rfd (conn, sft) -> (conn, sft, fileDescrText rfd)) rfds (memberFTs ms) extraRFDs = drop (length rfdsMemberFTs) rfds @@ -480,7 +480,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent _ ttl live _msgScope -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live + XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile @@ -667,7 +667,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendAutoReply ct mc = \case Just UserContactRequest {welcomeSharedMsgId = Just smId} -> - void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing + void $ sendDirectContactMessage user ct $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing _ -> do (msg, _) <- sendDirectContactMessage user ct $ XMsgNew $ MCSimple $ extMsgContent mc Nothing ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) @@ -932,48 +932,58 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = logInfo $ "group msg=" <> tshow tag <> " " <> eInfo let body = chatMsgToBody chatMsg (m'', conn', msg@RcvMessage {msgId, chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m' conn msgMeta body chatMsg + let ctx js = DeliveryTaskContext js False + checkSendAsGroup :: Maybe Bool -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + checkSendAsGroup asGroup_ a + | asGroup_ == Just True && memberRole' m'' < GROwner = + messageError "member is not allowed to send as group" $> Nothing + | otherwise = a -- ! see isForwardedGroupMsg: processing functions should return DeliveryJobScope for same events - deliveryJobScope_ <- case event of - XMsgNew mc -> memberCanSend m'' scope $ newGroupContentMessage gInfo' m'' mc msg brokerTs False + deliveryTaskContext_ <- case event of + XMsgNew mc -> + checkSendAsGroup asGroup $ + memberCanSend (Just m'') scope $ newGroupContentMessage gInfo' (Just m'') mc msg brokerTs False where - ExtMsgContent {scope} = mcExtMsgContent mc + ExtMsgContent {scope, asGroup} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' m'' sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> memberCanSend m'' msgScope $ groupMessageUpdate gInfo' m'' sharedMsgId mContent mentions msgScope msg brokerTs ttl live - XMsgDel sharedMsgId memberId scope_ -> groupMessageDelete gInfo' m'' sharedMsgId memberId scope_ msg brokerTs - XMsgReact sharedMsgId (Just memberId) scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs + XMsgFileDescr sharedMsgId fileDescr -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> + checkSendAsGroup asGroup_ $ + memberCanSend (Just m'') msgScope $ + groupMessageUpdate gInfo' (Just m'') sharedMsgId mContent mentions msgScope msg brokerTs ttl live asGroup_ + XMsgDel sharedMsgId memberId_ scope_ -> groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ msg brokerTs + XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs - XFileCancel sharedMsgId -> xFileCancelGroup gInfo' m'' sharedMsgId + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId XFileAcptInv sharedMsgId fileConnReq_ fName -> Nothing <$ xFileAcptInvGroup gInfo' m'' sharedMsgId fileConnReq_ fName - XInfo p -> xInfoMember gInfo' m'' p brokerTs + XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p brokerTs XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs - XGrpMemNew memInfo msgScope -> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs + XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv XGrpMemFwd memInfo introInv -> Nothing <$ xGrpMemFwd gInfo' m'' memInfo introInv - XGrpMemRole memId memRole -> xGrpMemRole gInfo' m'' memId memRole msg brokerTs - XGrpMemRestrict memId memRestrictions -> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs + XGrpMemRole memId memRole -> fmap ctx <$> xGrpMemRole gInfo' m'' memId memRole msg brokerTs + XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId XGrpMemDel memId withMessages -> case encoding @e of - SJson -> xGrpMemDel gInfo' m'' memId withMessages chatMsg msg brokerTs False - SBinary -> pure Nothing -- impossible - XGrpLeave -> xGrpLeave gInfo' m'' msg brokerTs - XGrpDel -> Just (DJSGroup {jobSpec = DJRelayRemoved}) <$ xGrpDel gInfo' m'' msg brokerTs - XGrpInfo p' -> xGrpInfo gInfo' m'' p' msg brokerTs - XGrpPrefs ps' -> xGrpPrefs gInfo' m'' ps' + SJson -> fmap ctx <$> xGrpMemDel gInfo' m'' memId withMessages chatMsg msg brokerTs False + SBinary -> pure Nothing + XGrpLeave -> fmap ctx <$> xGrpLeave gInfo' m'' msg brokerTs + XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs + XGrpInfo p' -> fmap ctx <$> xGrpInfo gInfo' m'' p' msg brokerTs + XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' -- TODO [knocking] why don't we forward these messages? - XGrpDirectInv connReq mContent_ msgScope -> memberCanSend m'' msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs + XGrpDirectInv connReq mContent_ msgScope -> memberCanSend (Just m'') msgScope $ Nothing <$ xGrpDirectInv gInfo' m'' conn' connReq mContent_ msg brokerTs XGrpMsgForward memberId memberName msg' msgTs -> Nothing <$ xGrpMsgForward gInfo' m'' memberId memberName msg' msgTs brokerTs XInfoProbe probe -> Nothing <$ xInfoProbe (COMGroupMember m'') probe XInfoProbeCheck probeHash -> Nothing <$ xInfoProbeCheck (COMGroupMember m'') probeHash XInfoProbeOk probe -> Nothing <$ xInfoProbeOk (COMGroupMember m'') probe BFileChunk sharedMsgId chunk -> Nothing <$ bFileChunkGroup gInfo' sharedMsgId chunk msgMeta _ -> Nothing <$ messageError ("unsupported message: " <> tshow event) - forM deliveryJobScope_ $ \jobScope -> - -- TODO [channels fwd] XMsgNew to return messageFromChannel - pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, messageFromChannel = False} + forM deliveryTaskContext_ $ \taskContext -> + pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo @@ -987,7 +997,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = hasDeliveryReceipt (toCMEventTag chatMsgEvent) createDeliveryTasks :: GroupInfo -> GroupMember -> [NewMessageDeliveryTask] -> CM ShouldDeleteGroupConns createDeliveryTasks gInfo'@GroupInfo {groupId = gId} m' newDeliveryTasks = do - let relayRemovedTask_ = find (\NewMessageDeliveryTask {jobScope} -> isRelayRemoved jobScope) newDeliveryTasks + let relayRemovedTask_ = find (\NewMessageDeliveryTask {taskContext = DeliveryTaskContext {jobScope}} -> isRelayRemoved jobScope) newDeliveryTasks createdDeliveryTasks <- case relayRemovedTask_ of Nothing -> do withStore' $ \db -> @@ -1007,7 +1017,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where uniqueWorkerScopes :: [NewMessageDeliveryTask] -> [DeliveryWorkerScope] uniqueWorkerScopes createdDeliveryTasks = - let workerScopes = map (\NewMessageDeliveryTask {jobScope} -> toWorkerScope jobScope) createdDeliveryTasks + let workerScopes = map (\NewMessageDeliveryTask {taskContext = DeliveryTaskContext {jobScope}} -> toWorkerScope jobScope) createdDeliveryTasks in foldr' addWorkerScope [] workerScopes where addWorkerScope workerScope acc @@ -1128,7 +1138,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure Nothing sendGroupAutoReply mc = \case Just UserContactRequest {welcomeSharedMsgId = Just smId} -> - void $ sendGroupMessage' user gInfo [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing + void $ sendGroupMessage' user gInfo [m] $ XMsgUpdate smId mc M.empty Nothing Nothing Nothing Nothing _ -> do msg <- sendGroupMessage' user gInfo [m] $ XMsgNew $ MCSimple $ extMsgContent mc Nothing ci <- saveSndChatItem user (CDGroupSnd gInfo Nothing) msg (CISndMsgContent mc) @@ -1338,7 +1348,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = upsertBusinessRequestItem cd@(CDGroupRcv gInfo@GroupInfo {groupId} _ clientMember) = upsertRequestItem cd updateRequestItem markRequestItemDeleted where updateRequestItem (sharedMsgId, mc) = - withStore (\db -> getGroupChatItemBySharedMsgId db user gInfo (groupMemberId' clientMember) sharedMsgId) >>= \case + withStore (\db -> getGroupChatItemBySharedMsgId db user gInfo (Just $ groupMemberId' clientMember) sharedMsgId) >>= \case CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', content = CIRcvMsgContent oldMC} | sameMemberId (memberId' clientMember) m' -> if mc /= oldMC @@ -1364,6 +1374,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else markGroupCIsDeleted user gInfo Nothing [cci] Nothing currentTs toView $ CEvtChatItemsDeleted user deletions False False _ -> pure () + upsertBusinessRequestItem (CDChannelRcv _ _) = const $ pure Nothing createRequestItem :: ChatTypeI c => ChatDirection c 'MDRcv -> (SharedMsgId, MsgContent) -> CM AChatItem createRequestItem cd (sharedMsgId, mc) = do aci <- createChatItem user cd False (CIRcvMsgContent mc) (Just sharedMsgId) Nothing @@ -1417,12 +1428,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Nothing -> messageError "memberJoinRequestViaRelay: no group link info for relay link" - memberCanSend :: - GroupMember -> - Maybe MsgScope -> - CM (Maybe DeliveryJobScope) -> - CM (Maybe DeliveryJobScope) - memberCanSend m@GroupMember {memberRole} msgScope a = case msgScope of + memberCanSend :: Maybe GroupMember -> Maybe MsgScope -> CM (Maybe DeliveryTaskContext) -> CM (Maybe DeliveryTaskContext) + memberCanSend Nothing _ a = a -- channel message - was previously checked and allowed by relay + memberCanSend (Just m@GroupMember {memberRole}) msgScope a = case msgScope of Just MSMember {} -> a Nothing | memberRole > GRObserver || memberPending m -> a @@ -1618,7 +1626,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> CM () newContentMessage ct mc msg@RcvMessage {sharedMsgId_} msgMeta = do - let ExtMsgContent content _ fInv_ _ _ _ = mcExtMsgContent mc + let ExtMsgContent content _ fInv_ _ _ _ _ = mcExtMsgContent mc -- Uncomment to test stuck delivery on errors - see test testDirectMessageDelete -- case content of -- MCText "hello 111" -> @@ -1629,7 +1637,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do void $ newChatItem (ciContentNoParse $ CIRcvChatFeatureRejected CFVoice) Nothing Nothing False else do - let ExtMsgContent _ _ _ itemTTL live_ _ = mcExtMsgContent mc + let ExtMsgContent _ _ _ itemTTL live_ _ _ = mcExtMsgContent mc timed_ = rcvContactCITimed ct itemTTL live = fromMaybe False live_ file_ <- processFileInvitation fInv_ content $ \db -> createRcvFileTransfer db userId ct @@ -1656,22 +1664,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure (fileId, aci) processFDMessage fileId aci fileDescr - groupMessageFileDescription :: GroupInfo -> GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryJobScope) - groupMessageFileDescription g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId fileDescr = do + groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext) + groupMessageFileDescription g@GroupInfo {groupId} m_ sharedMsgId fileDescr = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId aci <- getChatItemByFileId db vr user fileId pure (fileId, aci) case aci of - AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir = CIGroupRcv m} -> - if sameMemberId memberId m - then do + AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} + | validSender m_ chatDir -> do -- in processFDMessage some paths are programmed as errors, -- for example failure on not approved relays (CEFileNotApproved). -- we catch error, so that even if processFDMessage fails, message can still be forwarded. processFDMessage fileId aci fileDescr `catchAllErrors` \_ -> pure () - pure $ Just $ infoToDeliveryScope g scopeInfo - else messageError "x.msg.file.descr: file of another member" $> Nothing + pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir) + | otherwise -> messageError "x.msg.file.descr: file/sender mismatch" $> Nothing _ -> messageError "x.msg.file.descr: invalid file description part" $> Nothing processFDMessage :: FileTransferId -> AChatItem -> FileDescr -> CM () @@ -1791,28 +1798,31 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing mapM_ toView cEvt_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - groupMsgReaction g m@GroupMember {memberRole} sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMsgReaction g m sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs | groupFeatureAllowed SGFReactions g = do rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False if reactionAllowed add reaction rs then updateChatItemReaction `catchCINotFound` \_ -> case scope_ of Just (MSMember scopeMemberId) - | memberRole >= GRModerator || scopeMemberId == memberId' m -> - withStore $ \db -> do + | memberRole' m >= GRModerator || scopeMemberId == memberId' m -> do + djScope <- withStore $ \db -> do liftIO $ setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId + pure $ fmap (\js -> DeliveryTaskContext js False) djScope | otherwise -> pure Nothing Nothing -> do withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + pure $ Just $ DeliveryTaskContext (DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}) False else pure Nothing | otherwise = pure Nothing where updateChatItemReaction = do (CChatItem md ci, scopeInfo) <- withStore $ \db -> do - cci <- getGroupMemberCIBySharedMsgId db user g itemMemberId sharedMsgId + cci <- case itemMemberId of + Just itemMemberId' -> getGroupMemberCIBySharedMsgId db user g itemMemberId' sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user g Nothing sharedMsgId scopeInfo <- getGroupChatScopeInfoForItem db vr user g (cChatItemId cci) pure (cci, scopeInfo) if ciReactionAllowed ci @@ -1823,7 +1833,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let ci' = CChatItem md ci {reactions} r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction toView $ CEvtChatItemReaction user add r - pure $ Just $ infoToDeliveryScope g scopeInfo + pure $ Just $ infoToDeliveryContext g scopeInfo False else pure Nothing reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool @@ -1835,70 +1845,92 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId e -> throwError e - newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope) - newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = do - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ - if blockedByAdmin m' - then createBlockedByAdmin gInfo' m' scopeInfo $> Nothing - else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of - Just f -> rejected gInfo' m' scopeInfo f $> Nothing - Nothing -> - withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration gInfo' m' scopeInfo ciModeration - withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ - pure Nothing - Nothing -> do - createContentItem gInfo' m' scopeInfo - pure $ Just $ infoToDeliveryScope gInfo scopeInfo + validSender :: Maybe GroupMember -> CIDirection 'CTGroup 'MDRcv -> Bool + validSender (Just m) (CIGroupRcv mem) = sameMemberId (memberId' m) mem + validSender m_ CIChannelRcv = maybe True (\m -> memberRole' m == GROwner) m_ + validSender _ _ = False + + isChannelDir :: CIDirection 'CTGroup 'MDRcv -> ShowGroupAsSender + isChannelDir CIChannelRcv = True + isChannelDir _ = False + + newGroupContentMessage :: GroupInfo -> Maybe GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryTaskContext) + newGroupContentMessage gInfo m_ mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded = case m_ of + Nothing -> do + createContentItem gInfo Nothing Nothing + -- no delivery task - message already forwarded by relay + pure Nothing + Just m@GroupMember {memberId} -> do + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m content msgScope_ + if blockedByAdmin m' + then createBlockedByAdmin gInfo' (Just m') scopeInfo $> Nothing + else case prohibitedGroupContent gInfo' m' scopeInfo content ft_ fInv_ False of + Just f -> rejected gInfo' (Just m') scopeInfo f $> Nothing + Nothing -> + withStore' (\db -> getCIModeration db vr user gInfo' memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration gInfo' m' scopeInfo ciModeration + withStore' $ \db -> deleteCIModeration db gInfo' memberId sharedMsgId_ + pure Nothing + Nothing -> do + createContentItem gInfo' (Just m') scopeInfo + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo sentAsGroup where rejected gInfo' m' scopeInfo f = newChatItem gInfo' m' scopeInfo (ciContentNoParse $ CIRcvGroupFeatureRejected f) Nothing Nothing False - timed' gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL + timed_ gInfo' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo' itemTTL live' = fromMaybe False live_ - ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ = mcExtMsgContent mc + ExtMsgContent content mentions fInv_ itemTTL live_ msgScope_ asGroup_ = mcExtMsgContent mc + sentAsGroup = asGroup_ == Just True ts@(_, ft_) = msgContentTexts content - saveRcvCI gInfo' m' scopeInfo = saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg sharedMsgId_ brokerTs + -- m' is Maybe GroupMember + saveRcvCI gInfo' m' scopeInfo = + let itemMember_ = if sentAsGroup then Nothing else m' + chatDir = maybe (CDChannelRcv gInfo' scopeInfo) (CDGroupRcv gInfo' scopeInfo) itemMember_ + in saveRcvChatItem' user chatDir msg sharedMsgId_ brokerTs createBlockedByAdmin gInfo' m' scopeInfo | groupFeatureAllowed SGFFullDelete gInfo' = do -- ignores member role when blocked by admin - (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvBlocked) Nothing (timed' gInfo') False M.empty + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvBlocked) Nothing (timed_ gInfo') False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv m' + file_ <- processFileInv gInfo' m' (ci, cInfo) <- createNonLive gInfo' m' scopeInfo file_ ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo' ci groupMsgToView cInfo ci' - applyModeration gInfo' m' scopeInfo CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} + applyModeration gInfo' m'@GroupMember {memberRole} scopeInfo CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = - createContentItem gInfo' m' scopeInfo + createContentItem gInfo' (Just m') scopeInfo | groupFeatureMemberAllowed SGFFullDelete moderator gInfo' = do - (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo (ciContentNoParse CIRcvModerated) Nothing (timed' gInfo') False M.empty + (ci, cInfo) <- saveRcvCI gInfo' (Just m') scopeInfo (ciContentNoParse CIRcvModerated) Nothing (timed_ gInfo') False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt groupMsgToView cInfo ci' | otherwise = do - file_ <- processFileInv m' - (ci, _cInfo) <- createNonLive gInfo' m' scopeInfo file_ + file_ <- processFileInv gInfo' (Just m') + (ci, _cInfo) <- createNonLive gInfo' (Just m') scopeInfo file_ deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt toView $ CEvtChatItemsDeleted user deletions False False + -- m' is Maybe GroupMember createNonLive gInfo' m' scopeInfo file_ = do - saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') False mentions + saveRcvCI gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') False mentions createContentItem gInfo' m' scopeInfo = do - file_ <- processFileInv m' - newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed' gInfo') live' - unless (memberBlocked m') $ autoAcceptFile file_ - processFileInv m' = - processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId m' - newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed_ live = do - let mentions' = if memberBlocked m' then [] else mentions - (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed_ live mentions' - ci' <- blockedMemberCI gInfo' m' ci - reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ + file_ <- processFileInv gInfo' m' + newChatItem gInfo' m' scopeInfo (CIRcvMsgContent content, ts) (snd <$> file_) (timed_ gInfo') live' + unless (maybe False memberBlocked m') $ autoAcceptFile file_ + processFileInv gInfo' m' = + let fileMember_ = if sentAsGroup then Nothing else m' + in processFileInvitation fInv_ content $ \db -> createRcvGroupFileTransfer db userId gInfo' fileMember_ + newChatItem gInfo' m' scopeInfo ciContent ciFile_ timed live = do + let mentions' = if maybe False memberBlocked m' then [] else mentions + (ci, cInfo) <- saveRcvCI gInfo' m' scopeInfo ciContent ciFile_ timed live mentions' + ci' <- maybe (pure ci) (\m -> blockedMemberCI gInfo' m ci) m' + let memberId_ = memberId' <$> m' + reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId_ sharedMsgId) sharedMsgId_ groupMsgToView cInfo ci' {reactions} - groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM (Maybe DeliveryJobScope) - groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ - | prohibitedSimplexLinks gInfo m ft_ = + groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> Maybe Bool -> CM (Maybe DeliveryTaskContext) + groupMessageUpdate gInfo@GroupInfo {groupId} m_ sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ asGroup_ + | Just m <- m_, prohibitedSimplexLinks gInfo m ft_ = messageWarning ("x.msg.update ignored: feature not allowed " <> groupFeatureNameText GFSimplexLinks) $> Nothing | otherwise = do updateRcvChatItem `catchCINotFound` \_ -> do @@ -1906,103 +1938,158 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvGroupCITimed gInfo ttl_ - mentions' = if memberBlocked m then [] else mentions - (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ - (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' - ci' <- withStore' $ \db -> do - createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content True live Nothing - ci'' <- blockedMemberCI gInfo' m' ci' - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') - pure $ Just $ infoToDeliveryScope gInfo scopeInfo + showGroupAsSender = fromMaybe (isNothing m_) asGroup_ + if showGroupAsSender && maybe False (\m -> memberRole' m < GROwner) m_ + then messageError "x.msg.update: member attempted to update as group" $> Nothing + else do + (gInfo', chatDir, mentions', scopeInfo) <- + if showGroupAsSender + then pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) + else case m_ of + Just m -> do + let mentions' = if memberBlocked m then [] else mentions + (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m mc msgScope_ + pure (gInfo', CDGroupRcv gInfo' scopeInfo m', mentions', scopeInfo) + Nothing -> pure (gInfo, CDChannelRcv gInfo Nothing, mentions, Nothing) + (ci, cInfo) <- saveRcvChatItem' user chatDir msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + ci' <- withStore' $ \db -> do + createChatItemVersion db (chatItemId' ci) brokerTs mc + updateGroupChatItem db user groupId ci content True live Nothing + ci'' <- case chatDir of + CDGroupRcv gi' _ m' -> blockedMemberCI gi' m' ci' + CDChannelRcv {} -> pure ci' + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci'') + pure $ Just $ infoToDeliveryContext gInfo' scopeInfo showGroupAsSender where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc live = fromMaybe False live_ updateRcvChatItem = do (cci, scopeInfo) <- withStore $ \db -> do - cci <- getGroupChatItemBySharedMsgId db user gInfo groupMemberId sharedMsgId + cci <- + if asGroup_ == Just True + then getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId + else case m_ of + Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) case cci of - CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> - if sameMemberId memberId m' - then do - let changed = mc /= oldMC - if changed || fromMaybe False itemLive - then do - ci' <- withStore' $ \db -> do - when changed $ - addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - reactions <- getGroupCIReactions db gInfo memberId sharedMsgId - let edited = itemLive /= Just True - ciMentions <- getRcvCIMentions db user gInfo ft_ mentions - ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId - updateGroupCIMentions db gInfo ci' ciMentions - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') - startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' - pure $ Just $ infoToDeliveryScope gInfo scopeInfo - else do - toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) - pure Nothing - else messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing - _ -> messageError "x.msg.update: group member attempted invalid message update" $> Nothing - - groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) - groupMessageDelete gInfo@GroupInfo {membership} m@GroupMember {memberId, memberRole = senderRole} sharedMsgId sndMemberId_ scope_ RcvMessage {msgId} brokerTs = do - let msgMemberId = fromMaybe memberId sndMemberId_ - withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo msgMemberId sharedMsgId) >>= \case - Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case chatDir of - CIGroupRcv mem -> case sndMemberId_ of - -- regular deletion - Nothing - | sameMemberId memberId mem && msgMemberId == memberId && rcvItemDeletable ci brokerTs -> - Just <$> delete cci Nothing - | otherwise -> - messageError "x.msg.del: member attempted invalid message delete" $> Nothing - -- moderation (not limited by time) - Just _ - | sameMemberId memberId mem && msgMemberId == memberId -> - Just <$> delete cci (Just m) - | otherwise -> - moderate mem cci - CIGroupSnd -> moderate membership cci - Left e - | msgMemberId == memberId -> - messageError ("x.msg.del: message not found, " <> tshow e) $> Nothing - | senderRole < GRModerator -> do - messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} + | isSender m' -> updateCI False ci scopeInfo oldMC itemLive (Just $ memberId' m') + | otherwise -> messageError "x.msg.update: group member attempted to update a message of another member" $> Nothing + CChatItem SMDRcv ci@ChatItem {chatDir = CIChannelRcv, meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} + | maybe True (\m -> memberRole' m == GROwner) m_ -> updateCI True ci scopeInfo oldMC itemLive Nothing + | otherwise -> messageError "x.msg.update: member attempted to update channel message" $> Nothing + _ -> messageError "x.msg.update: invalid message update" $> Nothing + where + isSender m' = maybe False (\m -> sameMemberId (memberId' m) m') m_ + updateCI :: ShowGroupAsSender -> ChatItem 'CTGroup 'MDRcv -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe Bool -> Maybe MemberId -> CM (Maybe DeliveryTaskContext) + updateCI showGroupAsSender ci scopeInfo oldMC itemLive memberId = do + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + ci' <- withStore' $ \db -> do + when changed $ + addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) + reactions <- getGroupCIReactions db gInfo memberId sharedMsgId + let edited = itemLive /= Just True + ciMentions <- getRcvCIMentions db user gInfo ft_ mentions + ci' <- updateGroupChatItem db user groupId ci {reactions} content edited live $ Just msgId + updateGroupCIMentions db gInfo ci' ciMentions + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci') + startUpdatedTimedItemThread user (ChatRef CTGroup groupId $ toChatScope <$> scopeInfo) ci ci' + pure $ Just $ infoToDeliveryContext gInfo scopeInfo showGroupAsSender + else do + toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) pure Nothing - | otherwise -> case scope_ of - Just (MSMember scopeMemberId) -> - withStore $ \db -> do - liftIO $ createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user gInfo m scopeMemberId - Nothing -> do - withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs - pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + + groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ rcvMsg brokerTs = + findItem >>= \case + Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case (chatDir, m_) of + (CIGroupRcv mem, Just m@GroupMember {memberId}) -> + let msgMemberId = fromMaybe memberId sndMemberId_ + in case sndMemberId_ of + -- regular deletion + Nothing + | sameMemberId memberId mem && rcvItemDeletable ci brokerTs -> + delete cci False Nothing + | otherwise -> + messageError "x.msg.del: member attempted invalid message delete" $> Nothing + -- moderation (not limited by time) + Just _ + | sameMemberId memberId mem && msgMemberId == memberId -> + delete cci False (Just m) + | otherwise -> moderate m mem cci + (CIChannelRcv, _) + | isNothing sndMemberId_ && isOwner -> delete cci True Nothing + | otherwise -> messageError "x.msg.del: invalid channel message delete" $> Nothing + (CIGroupSnd, Just m) -> moderate m membership cci + _ -> messageError "x.msg.del: invalid message deletion" $> Nothing + Left e -> case m_ of + Just m@GroupMember {memberId, memberRole = senderRole} -> do + let msgMemberId = fromMaybe memberId sndMemberId_ + if + | msgMemberId == memberId -> + messageError ("x.msg.del: message not found, " <> tshow e) $> Nothing + | senderRole < GRModerator -> do + messageError $ "x.msg.del: message not found, message of another member with insufficient member permissions, " <> tshow e + pure Nothing + | otherwise -> case scope_ of + Just (MSMember scopeMemberId) -> + withStore $ \db -> do + liftIO $ createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs + supportGMId <- getScopeMemberIdViaMemberId db user gInfo m scopeMemberId + pure $ Just $ DeliveryTaskContext {jobScope = DJSMemberSupport supportGMId, sentAsGroup = False} + Nothing -> do + withStore' $ \db -> createCIModeration db gInfo m msgMemberId sharedMsgId msgId brokerTs + pure $ Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False} + Nothing -> + messageError ("x.msg.del: channel message not found, " <> tshow e) $> Nothing where - moderate :: GroupMember -> CChatItem 'CTGroup -> CM (Maybe DeliveryJobScope) - moderate mem cci = case sndMemberId_ of + isOwner = maybe True (\m -> memberRole' m == GROwner) m_ + RcvMessage {msgId} = rcvMsg + findItem = do + let tryMemberLookup mId = + withStore' (\db -> runExceptT $ getGroupMemberCIBySharedMsgId db user gInfo mId sharedMsgId) + tryChannelLookup = + withStore' (\db -> runExceptT $ getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId) + case sndMemberId_ of + Just sId -> tryMemberLookup sId + Nothing -> case m_ of + Just GroupMember {memberId} -> + tryMemberLookup memberId >>= \case + Right cci -> pure (Right cci) + Left e -> + tryChannelLookup >>= \case + Right cci -> pure (Right cci) + Left _ -> pure (Left e) + Nothing -> tryChannelLookup + moderate :: GroupMember -> GroupMember -> CChatItem 'CTGroup -> CM (Maybe DeliveryTaskContext) + moderate sender mem cci = case sndMemberId_ of Just sndMemberId - | sameMemberId sndMemberId mem -> checkRole mem $ do - jobScope <- delete cci (Just m) - archiveMessageReports cci m - pure $ Just jobScope + | sameMemberId sndMemberId mem -> checkRole (memberRole' sender) mem $ do + ctx_ <- delete cci False (Just sender) + archiveMessageReports cci sender + pure ctx_ | otherwise -> messageError "x.msg.del: message of another member with incorrect memberId" $> Nothing _ -> messageError "x.msg.del: message of another member without memberId" $> Nothing - checkRole GroupMember {memberRole} a + checkRole senderRole GroupMember {memberRole} a | senderRole < GRModerator || senderRole < memberRole = messageError "x.msg.del: message of another member with insufficient member permissions" $> Nothing | otherwise = a - delete :: CChatItem 'CTGroup -> Maybe GroupMember -> CM DeliveryJobScope - delete cci byGroupMember = do + delete :: CChatItem 'CTGroup -> Bool -> Maybe GroupMember -> CM (Maybe DeliveryTaskContext) + delete cci asGroup byGroupMember = do scopeInfo <- withStore $ \db -> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) + let fullDelete + | asGroup = groupFeatureAllowed SGFFullDelete gInfo + | otherwise = maybe False (\m -> groupFeatureMemberAllowed SGFFullDelete m gInfo) m_ deletions <- - if groupFeatureMemberAllowed SGFFullDelete m gInfo + if fullDelete then deleteGroupCIs user gInfo scopeInfo [cci] byGroupMember brokerTs else markGroupCIsDeleted user gInfo scopeInfo [cci] byGroupMember brokerTs toView $ CEvtChatItemsDeleted user deletions False False - pure $ infoToDeliveryScope gInfo scopeInfo + pure $ if isNothing m_ then Nothing else Just $ infoToDeliveryContext gInfo scopeInfo asGroup archiveMessageReports :: CChatItem 'CTGroup -> GroupMember -> CM () archiveMessageReports (CChatItem _ ci) byMember = do ciIds <- withStore' $ \db -> markMessageReportsDeleted db user gInfo ci byMember brokerTs @@ -2028,7 +2115,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processGroupFileInvitation' gInfo m fInv@FileInvitation {fileName, fileSize} msg@RcvMessage {sharedMsgId_} brokerTs = do ChatConfig {fileChunkSize} <- asks config inline <- receiveInlineMode fInv Nothing fileChunkSize - RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId m fInv inline fileChunkSize + RcvFileTransfer {fileId, xftpRcvFile} <- withStore $ \db -> createRcvGroupFileTransfer db userId gInfo (Just m) fInv inline fileChunkSize let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" @@ -2139,22 +2226,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> pure () receiveFileChunk ft Nothing meta chunk - xFileCancelGroup :: GroupInfo -> GroupMember -> SharedMsgId -> CM (Maybe DeliveryJobScope) - xFileCancelGroup g@GroupInfo {groupId} GroupMember {memberId} sharedMsgId = do + xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext) + xFileCancelGroup g@GroupInfo {groupId} m_ sharedMsgId = do (fileId, aci) <- withStore $ \db -> do fileId <- getGroupFileIdBySharedMsgId db userId groupId sharedMsgId (fileId,) <$> getChatItemByFileId db vr user fileId case aci of - AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir = CIGroupRcv m} -> do - if sameMemberId memberId m - then do + AChatItem SCTGroup SMDRcv (GroupChat _g scopeInfo) ChatItem {chatDir} + | validSender m_ chatDir -> do ft <- withStore $ \db -> getRcvFileTransfer db user fileId unless (rcvFileCompleteOrCancelled ft) $ do cancelRcvFileTransfer user ft toView $ CEvtRcvFileSndCancelled user aci ft - pure $ Just $ infoToDeliveryScope g scopeInfo - else -- shouldn't happen now that query includes group member id - messageError "x.file.cancel: group member attempted to cancel file of another member" $> Nothing + pure $ Just $ infoToDeliveryContext g scopeInfo (isChannelDir chatDir) + | otherwise -> messageError "x.file.cancel: file cancel sender mismatch" $> Nothing _ -> messageError "x.file.cancel: group member attempted invalid file cancel" $> Nothing xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe ConnReqInvitation -> String -> CM () @@ -2840,7 +2925,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if membershipMemId == memId then checkRole membership $ do deleteGroupLinkIfExists user gInfo - -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved let membership' = membership {memberStatus = GSMemRemoved} @@ -2892,7 +2977,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forwardToMember member = do let GroupMember {memberId} = m memberName = Just $ memberShortenedName m - event = XGrpMsgForward memberId memberName chatMsg brokerTs + event = XGrpMsgForward (Just memberId) memberName chatMsg brokerTs sendGroupMemberMessage gInfo member event isUserGrpFwdRelay :: GroupInfo -> Bool @@ -2920,7 +3005,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg brokerTs = do when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemGroupDeleted - -- TODO [channels fwd] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay + -- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) @@ -3048,37 +3133,47 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toViewTE $ TEContactVerificationReset user ct createInternalChatItem user (CDDirectRcv ct) (CIRcvConnEvent RCEVerificationCodeReset) Nothing - xGrpMsgForward :: GroupInfo -> GroupMember -> MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> UTCTime -> CM () - xGrpMsgForward gInfo m@GroupMember {localDisplayName} memberId memberName chatMsg msgTs brokerTs = do + xGrpMsgForward :: GroupInfo -> GroupMember -> Maybe MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> UTCTime -> CM () + xGrpMsgForward gInfo m@GroupMember {localDisplayName} memberId_ memberName_ chatMsg msgTs brokerTs = do unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) - (author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName - when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author - processForwardedMsg author + case memberId_ of + Just memberId -> do + (author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName_ + when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author + processForwardedMsg (Just author) + Nothing -> processForwardedMsg Nothing where -- ! see isForwardedGroupMsg: forwarded group events should include msgId to be deduplicated - processForwardedMsg :: GroupMember -> CM () - processForwardedMsg author = do + processForwardedMsg :: Maybe GroupMember -> CM () + processForwardedMsg author_ = do let body = chatMsgToBody chatMsg - rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author body chatMsg brokerTs + rcvMsg_ <- saveGroupFwdRcvMsg user gInfo m author_ body chatMsg brokerTs forM_ rcvMsg_ $ \rcvMsg@RcvMessage {chatMsgEvent = ACME _ event} -> case event of - XMsgNew mc -> void $ memberCanSend author scope $ (const Nothing) <$> newGroupContentMessage gInfo author mc rcvMsg msgTs True + XMsgNew mc -> + void $ memberCanSend author_ scope $ newGroupContentMessage gInfo author_ mc rcvMsg msgTs True where ExtMsgContent {scope} = mcExtMsgContent mc -- file description is always allowed, to allow sending files to support scope - XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author sharedMsgId fileDescr - XMsgUpdate sharedMsgId mContent mentions ttl live msgScope -> void $ memberCanSend author msgScope $ (const Nothing) <$> groupMessageUpdate gInfo author sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live - XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author sharedMsgId memId scope_ rcvMsg msgTs - XMsgReact sharedMsgId (Just memId) scope_ reaction add -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs - XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author sharedMsgId - XInfo p -> void $ xInfoMember gInfo author p msgTs - XGrpMemNew memInfo msgScope -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs - XGrpMemRole memId memRole -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs - XGrpMemDel memId withMessages -> void $ xGrpMemDel gInfo author memId withMessages chatMsg rcvMsg msgTs True - XGrpLeave -> void $ xGrpLeave gInfo author rcvMsg msgTs - XGrpDel -> void $ xGrpDel gInfo author rcvMsg msgTs - XGrpInfo p' -> void $ xGrpInfo gInfo author p' rcvMsg msgTs - XGrpPrefs ps' -> void $ xGrpPrefs gInfo author ps' + XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr + XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> + void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ + XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ rcvMsg msgTs + XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs + XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId + XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p msgTs + XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs + XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs + XGrpMemDel memId withMessages -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel gInfo author memId withMessages chatMsg rcvMsg msgTs True + XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave gInfo author rcvMsg msgTs + XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs + XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo gInfo author p' rcvMsg msgTs + XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' _ -> messageError $ "x.grp.msg.forward: unsupported forwarded event " <> T.pack (show $ toCMEventTag event) + where + withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () + withAuthor tag action = case author_ of + Just author -> action author + Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" directMsgReceived :: Contact -> Connection -> MsgMeta -> NonEmpty MsgReceipt -> CM () directMsgReceived ct conn@Connection {connId} msgMeta msgRcpts = do @@ -3194,7 +3289,7 @@ runDeliveryTaskWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryTaskWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config vr <- chatVersionRange - -- TODO [channels fwd] in future may be required to read groupInfo and user on each iteration for up to date state + -- TODO [relays] in future may be required to read groupInfo and user on each iteration for up to date state -- TODO - same for delivery jobs (runDeliveryJobWorker) gInfo <- withStore $ \db -> do user <- getUserByGroupId db groupId @@ -3233,8 +3328,11 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do | workerScope /= DWSGroup -> throwChatError $ CEInternalError "delivery task worker: relay removed task in wrong worker scope" | otherwise -> do - let MessageDeliveryTask {senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage} = task - fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs + let MessageDeliveryTask {senderGMId, fwdSender, brokerTs, chatMessage} = task + (memberId_, memberName_) = case fwdSender of + FwdMember mid mname -> (Just mid, Just mname) + FwdChannel -> (Nothing, Nothing) + fwdEvt = XGrpMsgForward memberId_ memberName_ chatMessage brokerTs cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} body = chatMsgToBody cm withStore' $ \db -> do diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 056b857f80..2b9e47bc6a 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -116,8 +116,7 @@ checkChatType x = case testEquality (chatTypeI @c) (chatTypeI @c') of Just Refl -> Right x Nothing -> Left "bad chat type" -data GroupChatScope - = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support +data GroupChatScope = GCSMemberSupport {groupMemberId_ :: Maybe GroupMemberId} -- Nothing means own conversation with support deriving (Eq, Show, Ord) data GroupChatScopeTag @@ -172,8 +171,7 @@ data ChatInfo (c :: ChatType) where deriving instance Show (ChatInfo c) -data GroupChatScopeInfo - = GCSIMemberSupport {groupMember_ :: Maybe GroupMember} +data GroupChatScopeInfo = GCSIMemberSupport {groupMember_ :: Maybe GroupMember} deriving (Show) toChatScope :: GroupChatScopeInfo -> GroupChatScope @@ -292,6 +290,7 @@ data CIDirection (c :: ChatType) (d :: MsgDirection) where CIDirectRcv :: CIDirection 'CTDirect 'MDRcv CIGroupSnd :: CIDirection 'CTGroup 'MDSnd CIGroupRcv :: GroupMember -> CIDirection 'CTGroup 'MDRcv + CIChannelRcv :: CIDirection 'CTGroup 'MDRcv CILocalSnd :: CIDirection 'CTLocal 'MDSnd CILocalRcv :: CIDirection 'CTLocal 'MDRcv @@ -306,6 +305,7 @@ data JSONCIDirection | JCIDirectRcv | JCIGroupSnd | JCIGroupRcv {groupMember :: GroupMember} + | JCIChannelRcv | JCILocalSnd | JCILocalRcv deriving (Show) @@ -316,6 +316,7 @@ jsonCIDirection = \case CIDirectRcv -> JCIDirectRcv CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m + CIChannelRcv -> JCIChannelRcv CILocalSnd -> JCILocalSnd CILocalRcv -> JCILocalRcv @@ -325,6 +326,7 @@ jsonACIDirection = \case JCIDirectRcv -> ACID SCTDirect SMDRcv CIDirectRcv JCIGroupSnd -> ACID SCTGroup SMDSnd CIGroupSnd JCIGroupRcv m -> ACID SCTGroup SMDRcv $ CIGroupRcv m + JCIChannelRcv -> ACID SCTGroup SMDRcv CIChannelRcv JCILocalSnd -> ACID SCTLocal SMDSnd CILocalSnd JCILocalRcv -> ACID SCTLocal SMDRcv CILocalRcv @@ -359,10 +361,13 @@ chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed timedDeleteAt' :: CITimed -> Maybe UTCTime timedDeleteAt' CITimed {deleteAt} = deleteAt -chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> GroupMember -chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of - CIGroupSnd -> membership - CIGroupRcv m -> m +chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> Maybe GroupMember +chatItemMember GroupInfo {membership} ChatItem {chatDir, meta = CIMeta {showGroupAsSender}} = case chatDir of + CIGroupSnd + | showGroupAsSender -> Nothing + | otherwise -> Just membership + CIGroupRcv m -> Just m + CIChannelRcv -> Nothing chatItemRcvFromMember :: ChatItem c d -> Maybe GroupMember chatItemRcvFromMember ChatItem {chatDir} = case chatDir of @@ -383,6 +388,7 @@ data ChatDirection (c :: ChatType) (d :: MsgDirection) where CDDirectRcv :: Contact -> ChatDirection 'CTDirect 'MDRcv CDGroupSnd :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDSnd CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ChatDirection 'CTGroup 'MDRcv + CDChannelRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> ChatDirection 'CTGroup 'MDRcv CDLocalSnd :: NoteFolder -> ChatDirection 'CTLocal 'MDSnd CDLocalRcv :: NoteFolder -> ChatDirection 'CTLocal 'MDRcv @@ -392,6 +398,7 @@ toCIDirection = \case CDDirectRcv _ -> CIDirectRcv CDGroupSnd _ _ -> CIGroupSnd CDGroupRcv _ _ m -> CIGroupRcv m + CDChannelRcv _ _ -> CIChannelRcv CDLocalSnd _ -> CILocalSnd CDLocalRcv _ -> CILocalRcv @@ -401,6 +408,7 @@ toChatInfo = \case CDDirectRcv c -> DirectChat c CDGroupSnd g s -> GroupChat g s CDGroupRcv g s _ -> GroupChat g s + CDChannelRcv g s -> GroupChat g s CDLocalSnd l -> LocalChat l CDLocalRcv l -> LocalChat l @@ -634,23 +642,23 @@ deriving instance Show (CIQDirection c) data ACIQDirection = forall c. (ChatTypeI c, ChatTypeQuotable c) => ACIQDirection (SChatType c) (CIQDirection c) -jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection +jsonCIQDirection :: CIQDirection c -> JSONCIDirection jsonCIQDirection = \case - CIQDirectSnd -> Just JCIDirectSnd - CIQDirectRcv -> Just JCIDirectRcv - CIQGroupSnd -> Just JCIGroupSnd - CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m - CIQGroupRcv Nothing -> Nothing + CIQDirectSnd -> JCIDirectSnd + CIQDirectRcv -> JCIDirectRcv + CIQGroupSnd -> JCIGroupSnd + CIQGroupRcv (Just m) -> JCIGroupRcv m + CIQGroupRcv Nothing -> JCIChannelRcv -jsonACIQDirection :: Maybe JSONCIDirection -> Either String ACIQDirection +jsonACIQDirection :: JSONCIDirection -> Either String ACIQDirection jsonACIQDirection = \case - Just JCIDirectSnd -> Right $ ACIQDirection SCTDirect CIQDirectSnd - Just JCIDirectRcv -> Right $ ACIQDirection SCTDirect CIQDirectRcv - Just JCIGroupSnd -> Right $ ACIQDirection SCTGroup CIQGroupSnd - Just (JCIGroupRcv m) -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv (Just m) - Nothing -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing - Just JCILocalSnd -> Left "unquotable" - Just JCILocalRcv -> Left "unquotable" + JCIDirectSnd -> Right $ ACIQDirection SCTDirect CIQDirectSnd + JCIDirectRcv -> Right $ ACIQDirection SCTDirect CIQDirectRcv + JCIGroupSnd -> Right $ ACIQDirection SCTGroup CIQGroupSnd + JCIGroupRcv m -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv (Just m) + JCIChannelRcv -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing + JCILocalSnd -> Left "unquotable" + JCILocalRcv -> Left "unquotable" quoteMsgDirection :: CIQDirection c -> MsgDirection quoteMsgDirection = \case @@ -1468,7 +1476,7 @@ instance FromJSON ACIDirection where parseJSON v = jsonACIDirection <$> J.parseJSON v instance ChatTypeI c => FromJSON (CIQDirection c) where - parseJSON v = (jsonACIQDirection >=> \(ACIQDirection _ x) -> checkChatType x) <$?> J.parseJSON v + parseJSON v = (jsonACIQDirection . fromMaybe JCIChannelRcv >=> \(ACIQDirection _ x) -> checkChatType x) <$?> J.parseJSON v instance ToJSON (CIQDirection c) where toJSON = J.toJSON . jsonCIQDirection diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 2c3bd2b87d..d8868e1787 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -71,12 +71,14 @@ batchDeliveryTasks1 vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) -- doesn’t fit: stop adding further messages | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) where - MessageDeliveryTask {taskId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel = _messageFromChannel} = task - -- TODO [channels fwd] handle messageFromChannel (null memberId in XGrpMsgForward) + MessageDeliveryTask {taskId, fwdSender, brokerTs, chatMessage} = task msgBody = - let fwdEvt = XGrpMsgForward senderMemberId (Just senderMemberName) chatMessage brokerTs + let (memberId_, memberName_) = case fwdSender of + FwdMember mid mname -> (Just mid, Just mname) + FwdChannel -> (Nothing, Nothing) + fwdEvt = XGrpMsgForward memberId_ memberName_ chatMessage brokerTs cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} - in chatMsgToBody cm + in chatMsgToBody cm msgLen = B.length msgBody len' | n == 0 = msgLen diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index b7cfe7f25c..83880ae4bb 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -238,7 +238,7 @@ data MsgRef = MsgRef { msgId :: Maybe SharedMsgId, sentAt :: UTCTime, sent :: Bool, - memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received + memberId :: Maybe MemberId -- present in group message references, Nothing for channel messages } deriving (Eq, Show) @@ -305,12 +305,10 @@ data ChatMessage e = ChatMessage data AChatMessage = forall e. MsgEncodingI e => ACMsg (SMsgEncoding e) (ChatMessage e) -type MessageFromChannel = Bool - data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json - XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json + XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json @@ -345,7 +343,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json - XGrpMsgForward :: MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json + XGrpMsgForward :: Maybe MemberId -> Maybe ContactName -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json XInfoProbe :: Probe -> ChatMsgEvent 'Json XInfoProbeCheck :: ProbeHash -> ChatMsgEvent 'Json XInfoProbeOk :: Probe -> ChatMsgEvent 'Json @@ -624,7 +622,8 @@ data ExtMsgContent = ExtMsgContent file :: Maybe FileInvitation, ttl :: Maybe Int, live :: Maybe Bool, - scope :: Maybe MsgScope + scope :: Maybe MsgScope, + asGroup :: Maybe Bool } deriving (Eq, Show) @@ -714,10 +713,11 @@ parseMsgContainer v = live <- v .:? "live" mentions <- fromMaybe M.empty <$> (v .:? "mentions") scope <- v .:? "scope" - pure ExtMsgContent {content, mentions, file, ttl, live, scope} + asGroup <- v .:? "asGroup" + pure ExtMsgContent {content, mentions, file, ttl, live, scope, asGroup} extMsgContent :: MsgContent -> Maybe FileInvitation -> ExtMsgContent -extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing Nothing +extMsgContent mc file = ExtMsgContent mc M.empty file Nothing Nothing Nothing Nothing justTrue :: Bool -> Maybe Bool justTrue True = Just True @@ -770,8 +770,8 @@ msgContainerJSON = \case MCSimple mc -> o $ msgContent mc where o = JM.fromList - msgContent ExtMsgContent {content, mentions, file, ttl, live, scope} = - ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) $ ("scope" .=? scope) ["content" .= content] + msgContent ExtMsgContent {content, mentions, file, ttl, live, scope, asGroup} = + ("file" .=? file) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("mentions" .=? nonEmptyMap mentions) $ ("scope" .=? scope) $ ("asGroup" .=? asGroup) ["content" .= content] nonEmptyMap :: Map k v -> Maybe (Map k v) nonEmptyMap m = if M.null m then Nothing else Just m @@ -1089,7 +1089,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do ttl <- opt "ttl" live <- opt "live" scope <- opt "scope" - pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} + asGroup <- opt "asGroup" + pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" <*> opt "scope" XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> opt "scope" <*> p "reaction" <*> p "add" @@ -1131,7 +1132,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do XGrpInfo_ -> XGrpInfo <$> p "groupProfile" XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences" XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope" - XGrpMsgForward_ -> XGrpMsgForward <$> p "memberId" <*> opt "memberName" <*> p "msg" <*> p "msgTs" + XGrpMsgForward_ -> XGrpMsgForward <$> opt "memberId" <*> opt "memberName" <*> p "msg" <*> p "msgTs" XInfoProbe_ -> XInfoProbe <$> p "probe" XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash" XInfoProbeOk_ -> XInfoProbeOk <$> p "probe" @@ -1158,7 +1159,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en params = \case XMsgNew container -> msgContainerJSON container XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] - XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope} -> o $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] + XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId scope -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId scope reaction add -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] @@ -1193,7 +1194,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpInfo p -> o ["groupProfile" .= p] XGrpPrefs p -> o ["groupPreferences" .= p] XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq] - XGrpMsgForward memberId memberName msg msgTs -> o $ ("memberName" .=? memberName) ["memberId" .= memberId, "msg" .= msg, "msgTs" .= msgTs] + XGrpMsgForward memberId memberName msg msgTs -> o $ ("memberId" .=? memberId) $ ("memberName" .=? memberName) ["msg" .= msg, "msgTs" .= msgTs] XInfoProbe probe -> o ["probe" .= probe] XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash] XInfoProbeOk probe -> o ["probe" .= probe] diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index f6e0a0c77c..de12b0deb7 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -81,10 +81,10 @@ createMsgDeliveryTask db gInfo sender newTask = do created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (groupMemberId' sender, messageId, BI messageFromChannel, DTSNew, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (groupMemberId' sender, messageId, BI sentAsGroup, DTSNew, currentTs, currentTs)) where GroupInfo {groupId} = gInfo - NewMessageDeliveryTask {messageId, jobScope, messageFromChannel} = newTask + NewMessageDeliveryTask {messageId, taskContext = DeliveryTaskContext {jobScope, sentAsGroup}} = newTask deleteGroupDeliveryTasks :: DB.Connection -> GroupInfo -> IO () deleteGroupDeliveryTasks db GroupInfo {groupId} = @@ -146,16 +146,18 @@ getMsgDeliveryTask_ db taskId = (Only taskId) where toTask :: MessageDeliveryTaskRow -> Either StoreError MessageDeliveryTask - toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, BI messageFromChannel)) = + toTask ((Only taskId') :. jobScopeRow :. (senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, BI showGroupAsSender)) = case toJobScope_ jobScopeRow of - Just jobScope -> Right $ MessageDeliveryTask {taskId = taskId', jobScope, senderGMId, senderMemberId, senderMemberName, brokerTs, chatMessage, messageFromChannel} + Just jobScope -> + let fwdSender = if showGroupAsSender then FwdChannel else FwdMember senderMemberId senderMemberName + in Right $ MessageDeliveryTask {taskId = taskId', jobScope, senderGMId, fwdSender, brokerTs, chatMessage} Nothing -> Left $ SEInvalidDeliveryTask taskId' markDeliveryTaskFailed_ :: DB.Connection -> Int64 -> IO () markDeliveryTaskFailed_ db taskId = DB.execute db "UPDATE delivery_tasks SET failed = 1 where delivery_task_id = ?" (Only taskId) --- TODO [channels fwd] possible optimization is to read and add tasks to batch iteratively to avoid reading too many tasks +-- TODO [relays] possible optimization is to read and add tasks to batch iteratively to avoid reading too many tasks -- passed MessageDeliveryTask defines the jobScope to search for getNextDeliveryTasks :: DB.Connection -> GroupInfo -> MessageDeliveryTask -> IO (Either StoreError [Either StoreError MessageDeliveryTask]) getNextDeliveryTasks db gInfo task = @@ -316,7 +318,7 @@ updateDeliveryJobStatus_ db jobId status errReason_ = do "UPDATE delivery_jobs SET job_status = ?, job_err_reason = ?, updated_at = ? WHERE delivery_job_id = ?" (status, errReason_, currentTs, jobId) --- TODO [channels fwd] possible improvement is to prioritize owners and "active" members +-- TODO [relays] possible improvement is to prioritize owners and "active" members getGroupMembersByCursor :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMemberId -> Maybe GroupMemberId -> Int -> IO [GroupMember] getGroupMembersByCursor db vr user@User {userContactId} GroupInfo {groupId} cursorGMId_ singleSenderGMId_ count = do gmIds :: [Int64] <- diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index eac046666b..951fce8958 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -380,14 +380,16 @@ createRcvFileTransfer db userId Contact {contactId, localDisplayName = c} f@File (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, rfdId, currentTs, currentTs) pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing, cryptoArgs = Nothing} -createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer -createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do +createRcvGroupFileTransfer :: DB.Connection -> UserId -> GroupInfo -> Maybe GroupMember -> FileInvitation -> Maybe InlineFileMode -> Integer -> ExceptT StoreError IO RcvFileTransfer +createRcvGroupFileTransfer db userId GroupInfo {groupId, localDisplayName = gName} m_ f@FileInvitation {fileName, fileSize, fileConnReq, fileInline, fileDescr} rcvFileInline chunkSize = do currentTs <- liftIO getCurrentTime rfd_ <- mapM (createRcvFD_ db userId currentTs) fileDescr let rfdId = (\RcvFileDescr {fileDescrId} -> fileDescrId) <$> rfd_ -- cryptoArgs = Nothing here, the decision to encrypt is made when receiving it xftpRcvFile = (\rfd -> XFTPRcvFile {rcvFileDescription = rfd, agentRcvFileId = Nothing, agentRcvFileDeleted = False, userApprovedRelays = False}) <$> rfd_ fileProtocol = if isJust rfd_ then FPXFTP else FPSMP + grpMemberId_ = groupMemberId' <$> m_ + senderName = maybe gName (\GroupMember {localDisplayName = c} -> c) m_ fileId <- liftIO $ do DB.execute db @@ -398,8 +400,8 @@ createRcvGroupFileTransfer db userId GroupMember {groupId, groupMemberId, localD DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, file_inline, rcv_file_inline, group_member_id, file_descr_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" - (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, groupMemberId, rfdId, currentTs, currentTs) - pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId, cryptoArgs = Nothing} + (fileId, FSNew, fileConnReq, fileInline, rcvFileInline, grpMemberId_, rfdId, currentTs, currentTs) + pure RcvFileTransfer {fileId, xftpRcvFile, fileInvitation = f, fileStatus = RFSNew, rcvFileInline, senderDisplayName = senderName, chunkSize, cancelled = False, grpMemberId = grpMemberId_, cryptoArgs = Nothing} createRcvStandaloneFileTransfer :: DB.Connection -> UserId -> CryptoFile -> Int64 -> Word32 -> ExceptT StoreError IO Int64 createRcvStandaloneFileTransfer db userId (CryptoFile filePath cfArgs_) fileSize chunkSize = do @@ -528,11 +530,12 @@ getRcvFileTransfer_ db userId fileId = do SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + LEFT JOIN groups g ON g.group_id = f.group_id WHERE f.user_id = ? AND f.file_id = ? |] (userId, fileId) @@ -541,10 +544,10 @@ getRcvFileTransfer_ db userId fileId = do where rcvFileTransfer :: Maybe RcvFileDescr -> - (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) -> + (FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe BoolInt) :. (Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe C.SbKey, Maybe C.CbNonce, Maybe InlineFileMode, Maybe InlineFileMode, Maybe AgentRcvFileId, BoolInt, BoolInt) :. Only (Maybe ContactName) -> ExceptT StoreError IO RcvFileTransfer - rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays)) = - case contactName_ <|> memberName_ <|> standaloneName_ of + rcvFileTransfer rfd_ ((fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_) :. (contactName_, memberName_, filePath_, fileKey, fileNonce, fileInline, rcvFileInline, agentRcvFileId, BI agentRcvFileDeleted, BI userApprovedRelays) :. Only groupName_) = + case contactName_ <|> memberName_ <|> groupName_ <|> standaloneName_ of Nothing -> throwError $ SERcvFileInvalid fileId Just name -> case fileStatus' of diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 7abc865ef1..f603e6d9ba 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -582,23 +582,27 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> ExceptT StoreError IO (GroupInfo, GroupMember) +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing currentTs - hostMemberId <- insertHost_ currentTs groupId groupLDN + hostMemberId_ <- + if useRelays + then pure Nothing + else Just <$> insertHost_ currentTs groupId groupLDN userMemberId <- if useRelays then liftIO $ MemberId <$> encodedRandomBytes gVar 12 else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" let userMember = MemberIdRole userMemberId GRMember -- TODO [member keys] user key must be included here. Should key be added when group is prepared? - membership <- createContactMemberInv_ db user groupId (Just hostMemberId) user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr - hostMember <- getGroupMember db vr user groupId hostMemberId - when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember + membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr + hostMember_ <- forM hostMemberId_ $ getGroupMember db vr user groupId + forM_ hostMember_ $ \hostMember -> + when business $ liftIO $ setGroupBusinessChatInfo groupId membership hostMember g <- getGroupInfo db vr user groupId - pure (g, hostMember) + pure (g, hostMember_) where insertHost_ currentTs groupId groupLDN = do randHostId <- liftIO $ encodedRandomBytes gVar 12 @@ -637,12 +641,12 @@ updateBusinessChatInfo db groupId businessChatInfo = |] (businessChatInfoRow businessChatInfo :. (Only groupId)) -updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> User -> ExceptT StoreError IO GroupInfo -updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember newUser@User {userId = newUserId} = do +updatePreparedGroupUser :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupMember -> User -> ExceptT StoreError IO GroupInfo +updatePreparedGroupUser db vr user gInfo@GroupInfo {groupId, membership} hostMember_ newUser@User {userId = newUserId} = do currentTs <- liftIO getCurrentTime updateGroup gInfo currentTs liftIO $ updateMembership membership currentTs - updateHostMember hostMember currentTs + forM_ hostMember_ $ \hostMember -> updateHostMember hostMember currentTs getGroupInfo db vr newUser groupId where updateGroup GroupInfo {localDisplayName = oldGroupLDN, groupProfile = GroupProfile {displayName = groupDisplayName}} currentTs = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 15b17cd19c..00ab18e939 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -525,9 +525,9 @@ setSupportChatMemberAttention db vr user g m memberAttention = do m_ <- runExceptT $ getGroupMemberById db vr user (groupMemberId' m) pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId -createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live hasLink createdAt = - createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> ShowGroupAsSender -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live hasLink createdAt = + createNewChatItem_ db user chatDirection showGroupAsSender createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False hasLink createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -543,7 +543,8 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection False (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt + let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False + ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg pure (ciId, quotedItem, itemForwarded) where @@ -557,6 +558,8 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw CDDirectRcv _ -> (Just $ not sent, Nothing) CDGroupRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ _ -> (Just $ Just userMemberId == memberId, memberId) + CDChannelRcv GroupInfo {membership = GroupMember {memberId = userMemberId}} _ -> + (Just $ Just userMemberId == memberId, memberId) createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> ShowGroupAsSender -> CIContent d -> Maybe SharedMsgId -> Bool -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection showGroupAsSender ciContent sharedMsgId_ hasLink itemTs = @@ -596,12 +599,14 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share CDDirectSnd Contact {contactId} -> (Just contactId, Nothing, Nothing, Nothing) CDGroupRcv GroupInfo {groupId} _ GroupMember {groupMemberId} -> (Nothing, Just groupId, Just groupMemberId, Nothing) CDGroupSnd GroupInfo {groupId} _ -> (Nothing, Just groupId, Nothing, Nothing) + CDChannelRcv GroupInfo {groupId} _ -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) groupScope :: Maybe (Maybe GroupChatScopeInfo) groupScope = case chatDirection of CDGroupRcv _ scope _ -> Just scope CDGroupSnd _ scope -> Just scope + CDChannelRcv _ scope -> Just scope _ -> Nothing groupScopeRow :: (Maybe GroupChatScopeTag, Maybe GroupMemberId) groupScopeRow = case groupScope of @@ -640,6 +645,12 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe | mId == senderMemberId -> (`ciQuote` CIQGroupRcv (Just sender)) <$> getGroupChatItemId_ groupId senderGMId | otherwise -> getGroupChatItemQuote_ groupId mId _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing + CDChannelRcv GroupInfo {groupId, membership = GroupMember {memberId = userMemberId}} _s -> + case memberId of + Just mId + | mId == userMemberId -> (`ciQuote` CIQGroupSnd) <$> getUserGroupChatItemId_ groupId + | otherwise -> getGroupChatItemQuote_ groupId mId + _ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing where ciQuote :: Maybe ChatItemId -> CIQDirection c -> CIQuote c ciQuote itemId dir = CIQuote dir itemId msgId sentAt content . parseMaybeMarkdownList $ msgContentText content @@ -2313,6 +2324,12 @@ toGroupChatItem Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent (maybeCIFile fileStatus) (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Nothing, Just (AFS SMDRcv fileStatus)) + | showGroupAsSender -> + Right $ cItem SMDRcv CIChannelRcv ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Nothing, Nothing) + | showGroupAsSender -> + Right $ cItem SMDRcv CIChannelRcv ciStatus ciContent Nothing (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent (maybeCIFile fileStatus) (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> @@ -2668,7 +2685,7 @@ groupCIWithReactions db g cci@(CChatItem md ci@ChatItem {meta = CIMeta {itemId, mentions <- getGroupCIMentions db itemId case itemSharedMsgId of Just sharedMsgId -> do - let GroupMember {memberId} = chatItemMember g ci + let memberId = memberId' <$> chatItemMember g ci reactions <- getGroupCIReactions db g memberId sharedMsgId pure $ CChatItem md ci {reactions, mentions} Nothing -> pure $ if null mentions then cci else CChatItem md ci {mentions} @@ -2913,8 +2930,8 @@ markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} |] (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) -getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId sharedMsgId = do +getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> Maybe GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) +getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do itemId <- ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ DB.query @@ -2922,11 +2939,11 @@ getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupM [sql| SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? ORDER BY chat_item_id DESC LIMIT 1 |] - (userId, groupId, groupMemberId, sharedMsgId) + (userId, groupId, groupMemberId_, sharedMsgId) getGroupCIWithReactions db user g itemId getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) @@ -3254,7 +3271,7 @@ getDirectCIReactions db Contact {contactId} itemSharedMsgId = |] (contactId, itemSharedMsgId) -getGroupCIReactions :: DB.Connection -> GroupInfo -> MemberId -> SharedMsgId -> IO [CIReactionCount] +getGroupCIReactions :: DB.Connection -> GroupInfo -> Maybe MemberId -> SharedMsgId -> IO [CIReactionCount] getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = map toCIReaction <$> DB.query @@ -3262,7 +3279,7 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = [sql| SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) FROM chat_item_reactions - WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + WHERE group_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? GROUP BY reaction |] (groupId, itemMemberId, itemSharedMsgId) @@ -3296,7 +3313,7 @@ getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemShar reactions <- getDirectCIReactions db ct itemSharedMId pure $ AChatItem SCTDirect md chat ci {reactions} GroupChat g _s -> do - let GroupMember {memberId} = chatItemMember g ci + let memberId = memberId' <$> chatItemMember g ci reactions <- getGroupCIReactions db g memberId itemSharedMId pure $ AChatItem SCTGroup md chat ci {reactions} _ -> pure aci @@ -3310,10 +3327,10 @@ deleteDirectCIReactions_ db contactId ChatItem {meta = CIMeta {itemSharedMsgId}} deleteGroupCIReactions_ :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> IO () deleteGroupCIReactions_ db g@GroupInfo {groupId} ci@ChatItem {meta = CIMeta {itemSharedMsgId}} = forM_ itemSharedMsgId $ \itemSharedMId -> do - let GroupMember {memberId} = chatItemMember g ci + let memberId = memberId' <$> chatItemMember g ci DB.execute db - "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ?" + "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ?" (groupId, itemSharedMId, memberId) toCIReaction :: (MsgReaction, BoolInt, Int) -> CIReactionCount @@ -3351,7 +3368,7 @@ setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs |] (contactId' ct, itemSharedMId, BI sent, reaction) -getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] +getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> Maybe MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = map fromOnly <$> DB.query @@ -3359,11 +3376,11 @@ getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = [sql| SELECT reaction FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + WHERE group_id = ? AND group_member_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? AND reaction_sent = ? |] (groupId, groupMemberId' m, itemMemberId, itemSharedMId, BI sent) -setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () +setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> Maybe MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs | add = DB.execute @@ -3379,7 +3396,7 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti db [sql| DELETE FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND reaction_sent = ? AND reaction = ? |] (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs index 17ecb97649..18e9c5f6b6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20230511_reactions.hs @@ -10,7 +10,7 @@ m20230511_reactions = [sql| CREATE TABLE chat_item_reactions ( chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT, - item_member_id BLOB, -- member that created item, NULL for items in direct chats + item_member_id BLOB, shared_msg_id BLOB NOT NULL, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs index 0b70fb9dcb..35f2006cef 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250813_delivery_tasks.hs @@ -5,7 +5,7 @@ module Simplex.Chat.Store.SQLite.Migrations.M20250813_delivery_tasks where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- TODO [channels fwd] add later in new migration for MemberProfileUpdate delivery jobs: +-- TODO [relays] add later in new migration for MemberProfileUpdate delivery jobs: -- TODO - ALTER TABLE group_members ADD COLUMN last_profile_delivery_ts TEXT; -- TODO - ALTER TABLE group_members ADD COLUMN join_ts TEXT; @@ -21,7 +21,7 @@ import Database.SQLite.Simple.QQ (sql) -- delivery_tasks table: -- - sender_group_member_id <-> GroupMemberId (sender of the original message that created task), -- - message_id <-> MessageId (reference to the original message that created task), --- - message_from_channel <-> Maybe MessageFromChannel (for MessageDeliveryTask), +-- - message_from_channel <-> ShowGroupAsSender (for MessageDeliveryTask), -- - task_status <-> DeliveryTaskStatus, -- - task_err_reason <-> Maybe Text (set when task status is DTSError, not encoded in status to allow filtering by DTSError in queries). 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 a61e6a34e0..90c44f9734 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1105,7 +1105,7 @@ SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_ Query: DELETE FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND reaction_sent = ? AND reaction = ? Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) @@ -1406,7 +1406,7 @@ SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT chat_item_id FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id = ? AND shared_msg_id = ? + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? ORDER BY chat_item_id DESC LIMIT 1 @@ -1611,11 +1611,12 @@ Query: SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, f.file_crypto_key, f.file_crypto_nonce, r.file_inline, r.rcv_file_inline, - r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays + r.agent_rcv_file_id, r.agent_rcv_file_deleted, r.user_approved_relays, g.local_display_name FROM rcv_files r JOIN files f USING (file_id) LEFT JOIN contacts cs ON cs.contact_id = f.contact_id LEFT JOIN group_members m ON m.group_member_id = r.group_member_id + LEFT JOIN groups g ON g.group_id = f.group_id WHERE f.user_id = ? AND f.file_id = ? Plan: @@ -1623,6 +1624,7 @@ SEARCH f USING INTEGER PRIMARY KEY (rowid=?) SEARCH r USING INTEGER PRIMARY KEY (rowid=?) SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN Query: SELECT r.probe, r.contact_id, g.group_id, r.group_member_id @@ -3700,7 +3702,7 @@ SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_contact (contact_ Query: SELECT reaction FROM chat_item_reactions - WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + WHERE group_id = ? AND group_member_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? AND reaction_sent = ? Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) @@ -3718,7 +3720,7 @@ USE TEMP B-TREE FOR GROUP BY Query: SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) FROM chat_item_reactions - WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + WHERE group_id = ? AND item_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? GROUP BY reaction Plan: @@ -5885,7 +5887,7 @@ Query: DELETE FROM chat_item_reactions WHERE group_id = ? Plan: SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_id (group_id=?) -Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ? +Query: DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id IS NOT DISTINCT FROM ? Plan: SEARCH chat_item_reactions USING INDEX idx_chat_item_reactions_group (group_id=? AND shared_msg_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 12951307bb..84d5a8b001 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -541,7 +541,7 @@ CREATE TABLE chat_item_versions( ) STRICT; CREATE TABLE chat_item_reactions( chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT, - item_member_id BLOB, -- member that created item, NULL for items in direct chats + item_member_id BLOB, shared_msg_id BLOB NOT NULL, contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, group_id INTEGER REFERENCES groups ON DELETE CASCADE, diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index ca591903ff..2c842a609e 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -180,7 +180,8 @@ chatEventNotification t@ChatTerminal {sendNotification} cc = \case whenCurrUser cc u $ setActiveChat t cInfo case (cInfo, chatDir) of (DirectChat ct, _) -> sendNtf (viewContactName ct <> "> ", text) - (GroupChat g scopeInfo, CIGroupRcv m) -> sendNtf (fromGroup_ g scopeInfo m, text) + (GroupChat g scopeInfo, CIGroupRcv m) -> sendNtf (fromGroup_ g scopeInfo (Just m), text) + (GroupChat g scopeInfo, CIChannelRcv) -> sendNtf (fromGroup_ g scopeInfo Nothing, text) _ -> pure () where text = msgText mc formattedText diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 02916d0d82..2329c21e74 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -494,6 +494,12 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays +sendAsGroup' :: GroupInfo -> Bool +sendAsGroup' gInfo@GroupInfo {membership} = useRelays' gInfo && memberRole' membership == GROwner + +groupId' :: GroupInfo -> GroupId +groupId' GroupInfo {groupId} = groupId + data BusinessChatType = BCBusiness -- used on the customer side | BCCustomer -- used on the business side diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b1fa59f9ef..5784e9b7e0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -552,6 +552,7 @@ chatDirNtf :: User -> ChatInfo c -> CIDirection c d -> Bool -> Bool chatDirNtf user cInfo chatDir mention = case (cInfo, chatDir) of (DirectChat ct, CIDirectRcv) -> contactNtf user ct mention (GroupChat g _scopeInfo, CIGroupRcv m) -> groupNtf user g mention && not (memberBlocked m) + (GroupChat g _scopeInfo, CIChannelRcv) -> groupNtf user g mention _ -> True contactNtf :: User -> Contact -> Bool -> Bool @@ -673,16 +674,18 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa _ -> showSndItem to where to = ttyToGroup g scopeInfo - CIGroupRcv m -> case content of - CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc - CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta - CIRcvGroupInvitation {} -> showRcvItemProhibited from - CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False - CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m) context meta [plainContent content] False - _ -> showRcvItem from - where - from = ttyFromGroupAttention g scopeInfo m userMention + CIGroupRcv m -> rcvGroupItem (Just m) + CIChannelRcv -> rcvGroupItem Nothing where + rcvGroupItem m_ = case content of + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc + CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta + CIRcvGroupInvitation {} | isJust m_ -> showRcvItemProhibited from + CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False + CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g scopeInfo m_) context meta [plainContent content] False + _ -> showRcvItem from + where + from = ttyFromGroupAttention g scopeInfo m_ userMention context = maybe (maybe [] forwardedFrom itemForwarded) @@ -813,19 +816,22 @@ viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, itemEd (directQuote chatDir) quotedItem GroupChat g scopeInfo -> case chatDir of - CIGroupRcv m -> case content of - CIRcvMsgContent mc - | itemLive == Just True && not liveItems -> [] - | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta - _ -> [] - where - from = if itemEdited then ttyFromGroupEdited g scopeInfo m else ttyFromGroup g scopeInfo m + CIGroupRcv m -> updGroupItem (Just m) + CIChannelRcv -> updGroupItem Nothing CIGroupSnd -> case content of CISndMsgContent mc -> hideLive meta $ viewSentMessage to context mc ts tz meta _ -> [] where to = if itemEdited then ttyToGroupEdited g scopeInfo else ttyToGroup g scopeInfo where + updGroupItem :: Maybe GroupMember -> [StyledString] + updGroupItem m_ = case content of + CIRcvMsgContent mc + | itemLive == Just True && not liveItems -> [] + | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta + _ -> [] + where + from = if itemEdited then ttyFromGroupEdited g scopeInfo m_ else ttyFromGroup g scopeInfo m_ context = maybe (maybe [] forwardedFrom itemForwarded) @@ -881,7 +887,7 @@ viewItemDelete chat ci@ChatItem {chatDir, meta, content = deletedContent} toItem prohibited = [styled (colored Red) ("[unexpected message deletion, please report to developers]" :: String)] viewItemReaction :: forall c d. Bool -> ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz = +viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content, meta = CIMeta {showGroupAsSender}}, sentAt, reaction} added ts tz = case (chat, chatDir) of (DirectChat c, CIDirectRcv) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc @@ -889,12 +895,8 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md where from = ttyFromContact c reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">" - (GroupChat g scopeInfo, CIGroupRcv m) -> case ciMsgContent content of - Just mc -> view from $ reactionMsg mc - _ -> [] - where - from = ttyFromGroup g scopeInfo m - reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir + (GroupChat g scopeInfo, CIGroupRcv m) -> groupReaction g scopeInfo (Just m) (sentByMember' g itemDir) + (GroupChat g scopeInfo, CIChannelRcv) -> groupReaction g scopeInfo Nothing (sentByMember' g itemDir) (LocalChat _, CILocalRcv) -> case ciMsgContent content of Just mc -> view from $ reactionMsg mc _ -> [] @@ -906,6 +908,13 @@ viewItemReaction showReactions chat CIReaction {chatDir, chatItem = CChatItem md (_, CILocalSnd) -> [sentText] (CInfoInvalidJSON {}, _) -> [] where + groupReaction g scopeInfo m_ sentBy = case ciMsgContent content of + Just mc -> view from $ reactionMsg mc + _ -> [] + where + from = ttyFromGroup g scopeInfo m_ + reactionMsg mc = quoteText mc . ttyQuotedMember $ + if showGroupAsSender then Nothing else sentBy view from msg | showReactions = viewReceivedReaction from msg reactionText ts tz sentAt | otherwise = [] @@ -946,10 +955,11 @@ sentByMember GroupInfo {membership} = \case CIQGroupSnd -> Just membership CIQGroupRcv m -> m -sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> GroupMember +sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> Maybe GroupMember sentByMember' GroupInfo {membership} = \case - CIGroupSnd -> membership - CIGroupRcv m -> m + CIGroupSnd -> Just membership + CIGroupRcv m -> Just m + CIChannelRcv -> Nothing quoteText :: MsgContent -> StyledString -> [StyledString] quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc @@ -2270,6 +2280,7 @@ cryptoFileArgsStr testView cfArgs@(CFArgs key nonce) fileFrom :: ChatInfo c -> CIDirection c d -> StyledString fileFrom (DirectChat ct) CIDirectRcv = " from " <> ttyContact' ct fileFrom _ (CIGroupRcv m) = " from " <> ttyMember m +fileFrom (GroupChat g _) CIChannelRcv = " from " <> ttyGroup' g fileFrom _ _ = "" receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] @@ -2698,7 +2709,7 @@ ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ viewName c <> ">" ttyQuotedMember :: Maybe GroupMember -> StyledString ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom (viewName c) -ttyQuotedMember _ = "> " <> ttyFrom "?" +ttyQuotedMember Nothing = ">" ttyFromContact :: Contact -> StyledString ttyFromContact ct@Contact {localDisplayName = c} = ctIncognito ct <> ttyFrom (viewName c <> "> ") @@ -2734,26 +2745,29 @@ ttyFullGroup :: GroupInfo -> StyledString ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullName, shortDescr}} = ttyGroup g <> optFullName g fullName shortDescr -ttyFromGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString -ttyFromGroup g scopeInfo m = ttyFromGroupAttention g scopeInfo m False +ttyFromGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> StyledString +ttyFromGroup g scopeInfo m_ = ttyFromGroupAttention g scopeInfo m_ False -ttyFromGroupAttention :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> StyledString -ttyFromGroupAttention g scopeInfo m attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g scopeInfo m attention) +ttyFromGroupAttention :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Bool -> StyledString +ttyFromGroupAttention g scopeInfo m_ attention = membershipIncognito g <> ttyFrom (fromGroupAttention_ g scopeInfo m_ attention) -ttyFromGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> StyledString -ttyFromGroupEdited g scopeInfo m = membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> "[edited] ") +ttyFromGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> StyledString +ttyFromGroupEdited g scopeInfo m_ = membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m_ <> "[edited] ") -ttyFromGroupDeleted :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe Text -> StyledString -ttyFromGroupDeleted g scopeInfo m deletedText_ = - membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) +ttyFromGroupDeleted :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Maybe Text -> StyledString +ttyFromGroupDeleted g scopeInfo m_ deletedText_ = + membershipIncognito g <> ttyFrom (fromGroup_ g scopeInfo m_ <> maybe "" (\t -> "[" <> t <> "] ") deletedText_) -fromGroup_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Text -fromGroup_ g scopeInfo m = fromGroupAttention_ g scopeInfo m False +fromGroup_ :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Text +fromGroup_ g scopeInfo m_ = fromGroupAttention_ g scopeInfo m_ False -fromGroupAttention_ :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Bool -> Text -fromGroupAttention_ g scopeInfo m attention = +fromGroupAttention_ :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> Bool -> Text +fromGroupAttention_ g scopeInfo m_ attention = let attn = if attention then "!" else "" - in "#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo <> viewMemberName m <> attn <> "> " + in "#" <> viewGroupName g + <> maybe "" (" " <>) (groupScopeInfoStr scopeInfo) + <> maybe "" ((" " <>) . viewMemberName) m_ + <> attn <> "> " ttyFrom :: Text -> StyledString ttyFrom = styled $ colored Yellow @@ -2762,17 +2776,17 @@ ttyTo :: Text -> StyledString ttyTo = styled $ colored Cyan ttyToGroup :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString -ttyToGroup g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> " " <> groupScopeInfoStr scopeInfo) +ttyToGroup g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> maybe "" (" " <>) (groupScopeInfoStr scopeInfo) <> " ") ttyToGroupEdited :: GroupInfo -> Maybe GroupChatScopeInfo -> StyledString -ttyToGroupEdited g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> groupScopeInfoStr scopeInfo <> " [edited] ") +ttyToGroupEdited g scopeInfo = membershipIncognito g <> ttyTo ("#" <> viewGroupName g <> maybe "" (" " <>) (groupScopeInfoStr scopeInfo) <> " [edited] ") -groupScopeInfoStr :: Maybe GroupChatScopeInfo -> Text +groupScopeInfoStr :: Maybe GroupChatScopeInfo -> Maybe Text groupScopeInfoStr = \case - Nothing -> "" - Just (GCSIMemberSupport {groupMember_}) -> case groupMember_ of - Nothing -> "(support) " - Just m -> "(support: " <> viewMemberName m <> ") " + Nothing -> Nothing + Just (GCSIMemberSupport {groupMember_}) -> Just $ case groupMember_ of + Nothing -> "(support)" + Just m -> "(support: " <> viewMemberName m <> ")" ttyFilePath :: FilePath -> StyledString ttyFilePath = plain diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index ad0d0651b1..da6eee9ec7 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -230,7 +230,7 @@ chatGroupTests = do it "should remove support chat with member when member is removed" testScopedSupportMemberRemoved it "should remove support chat with member when user removes member" testScopedSupportUserRemovesMember it "should remove support chat with member when member leaves" testScopedSupportMemberLeaves - -- TODO [channels fwd] add tests for channels + -- TODO [relays] add tests for channels -- TODO - tests with delivery loop over members restored after restart -- TODO - delivery in support scopes inside channels -- TODO - connect plans for relay groups @@ -249,6 +249,24 @@ chatGroupTests = do describe "multiple relays" $ do it "2 relays: should deliver messages to members" testChannels2RelaysDeliver it "should share same incognito profile with all relays" testChannels2RelaysIncognito + describe "channel message operations" $ do + it "should update channel message" testChannelMessageUpdate + it "should delete channel message" testChannelMessageDelete + it "should send and receive channel message file" testChannelMessageFile + it "should cancel channel message file" testChannelMessageFileCancel + it "should quote channel message" testChannelMessageQuote + it "should not leak owner identity in channel reaction" testChannelOwnerReaction + it "should not leak owner identity in channel quote" testChannelOwnerQuote + it "should update channel message sent as member" testChannelOwnerUpdateAsMember + it "should delete channel message sent as member" testChannelOwnerDeleteAsMember + it "should send and receive file sent as member" testChannelOwnerFileTransferAsMember + it "should cancel file sent as member" testChannelOwnerFileCancelAsMember + it "should attribute reactions to member" testChannelReactionAttribution + it "should recreate deleted item with correct sendAsGroup from update" testChannelUpdateFallbackSendAsGroup + it "should respect sendAsGroup parameter in forward API" testForwardAPIUsesParameter + it "should compute sendAsGroup in CLI forward" testForwardCLISendAsGroup + it "should update member message in channel" testChannelMemberMessageUpdate + it "should delete member message in channel" testChannelMemberMessageDelete testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -8374,21 +8392,21 @@ testChannels1RelayDeliver ps = createChannel1Relay "team" alice bob cath dan eve alice #> "#team hi" - bob <# "#team alice> hi" - [cath, dan, eve] *<# "#team alice> hi [>>]" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" cath ##> "+1 #team hi" cath <## "added 👍" - bob <# "#team cath> > alice hi" + bob <# "#team cath> > hi" bob <## " + 👍" alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" - alice <# "#team cath> > alice hi" + alice <# "#team cath> > hi" alice <## " + 👍" dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" - dan <# "#team cath> > alice hi" + dan <# "#team cath> > hi" dan <## " + 👍" eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" - eve <# "#team cath> > alice hi" + eve <# "#team cath> > hi" eve <## " + 👍" createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () @@ -8539,21 +8557,21 @@ testChannels1RelayDeliverLoop deliveryBucketSize ps = createChannel1Relay "team" alice bob cath dan eve alice #> "#team hi" - bob <# "#team alice> hi" - [cath, dan, eve] *<# "#team alice> hi [>>]" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" cath ##> "+1 #team hi" cath <## "added 👍" - bob <# "#team cath> > alice hi" + bob <# "#team cath> > hi" bob <## " + 👍" alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" - alice <# "#team cath> > alice hi" + alice <# "#team cath> > hi" alice <## " + 👍" dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" - dan <# "#team cath> > alice hi" + dan <# "#team cath> > hi" dan <## " + 👍" eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" - eve <# "#team cath> > alice hi" + eve <# "#team cath> > hi" eve <## " + 👍" where cfg = testCfg {deliveryBucketSize} @@ -8578,9 +8596,9 @@ testChannelsSenderDeduplicateOwn ps = do withTestChatCfgOpts ps cfg relayTestOpts "bob" $ \bob -> do bob <## "subscribed 6 connections on server localhost" bob - <### [ WithTime "#team alice> 1", - WithTime "#team alice> 2", - WithTime "#team alice> 3", + <### [ WithTime "#team> 1", + WithTime "#team> 2", + WithTime "#team> 3", WithTime "#team cath> 4", WithTime "#team cath> 5", WithTime "#team dan> 6" @@ -8594,25 +8612,25 @@ testChannelsSenderDeduplicateOwn ps = do ] cath <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record dan", - WithTime "#team alice> 1 [>>]", - WithTime "#team alice> 2 [>>]", - WithTime "#team alice> 3 [>>]", + WithTime "#team> 1 [>>]", + WithTime "#team> 2 [>>]", + WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", - WithTime "#team alice> 1 [>>]", - WithTime "#team alice> 2 [>>]", - WithTime "#team alice> 3 [>>]", + WithTime "#team> 1 [>>]", + WithTime "#team> 2 [>>]", + WithTime "#team> 3 [>>]", WithTime "#team cath> 4 [>>]", WithTime "#team cath> 5 [>>]" ] eve <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", "#team: bob forwarded a message from an unknown member, creating unknown member record dan", - WithTime "#team alice> 1 [>>]", - WithTime "#team alice> 2 [>>]", - WithTime "#team alice> 3 [>>]", + WithTime "#team> 1 [>>]", + WithTime "#team> 2 [>>]", + WithTime "#team> 3 [>>]", WithTime "#team cath> 4 [>>]", WithTime "#team cath> 5 [>>]", WithTime "#team dan> 6 [>>]" @@ -8631,23 +8649,23 @@ testChannels2RelaysDeliver ps = createChannel2Relays "team" alice bob cath dan eve frank alice #> "#team hi" - [bob, cath] *<# "#team alice> hi" - [dan, eve, frank] *<# "#team alice> hi [>>]" + [bob, cath] *<# "#team> hi" + [dan, eve, frank] *<# "#team> hi [>>]" dan ##> "+1 #team hi" dan <## "added 👍" - bob <# "#team dan> > alice hi" + bob <# "#team dan> > hi" bob <## " + 👍" - cath <# "#team dan> > alice hi" + cath <# "#team dan> > hi" cath <## " + 👍" alice .<## " forwarded a message from an unknown member, creating unknown member record dan" - alice <# "#team dan> > alice hi" + alice <# "#team dan> > hi" alice <## " + 👍" eve .<## " forwarded a message from an unknown member, creating unknown member record dan" - eve <# "#team dan> > alice hi" + eve <# "#team dan> > hi" eve <## " + 👍" frank .<## " forwarded a message from an unknown member, creating unknown member record dan" - frank <# "#team dan> > alice hi" + frank <# "#team dan> > hi" frank <## " + 👍" -- remove below if default role is changed to observer @@ -8669,24 +8687,24 @@ testChannels2RelaysIncognito ps = memberJoinChannel "team" [bob, cath] shortLink fullLink member alice #> "#team hi" - [bob, cath] *<# "#team alice> hi" - dan ?<# "#team alice> hi [>>]" - [eve, frank] *<# "#team alice> hi [>>]" + [bob, cath] *<# "#team> hi" + dan ?<# "#team> hi [>>]" + [eve, frank] *<# "#team> hi [>>]" dan ##> "+1 #team hi" dan <## "added 👍" - bob <# ("#team " <> danIncognito <> "> > alice hi") + bob <# ("#team " <> danIncognito <> "> > hi") bob <## " + 👍" - cath <# ("#team " <> danIncognito <> "> > alice hi") + cath <# ("#team " <> danIncognito <> "> > hi") cath <## " + 👍" alice .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) - alice <# ("#team " <> danIncognito <> "> > alice hi") + alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) - eve <# ("#team " <> danIncognito <> "> > alice hi") + eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" frank .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) - frank <# ("#team " <> danIncognito <> "> > alice hi") + frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" -- remove below if default role is changed to observer @@ -8694,6 +8712,565 @@ testChannels2RelaysIncognito ps = [bob, cath] *<# ("#team " <> danIncognito <> "> hey") [alice, eve, frank] *<# ("#team " <> danIncognito <> "> hey [>>]") +testChannelMessageUpdate :: HasCallStack => TestParams -> IO () +testChannelMessageUpdate ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello" + bob <# "#team> hello" + [cath, dan, eve] *<# "#team> hello [>>]" + + -- owner updates channel message + msgId <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId <> " text hello updated") + alice <# "#team [edited] hello updated" + bob <# "#team> [edited] hello updated" + [cath, dan, eve] *<# "#team> [edited] hello updated" -- TODO show as forwarded + +testChannelMessageDelete :: HasCallStack => TestParams -> IO () +testChannelMessageDelete ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello" + bob <# "#team> hello" + [cath, dan, eve] *<# "#team> hello [>>]" + + -- owner deletes channel message (broadcast) + msgId <- lastItemId alice + alice #$> ("/_delete item #1 " <> msgId <> " broadcast", id, "message marked deleted") + bob <# "#team> [marked deleted] hello" + [cath, dan, eve] *<# "#team> [marked deleted] hello" -- TODO show as forwarded + +testChannelMessageFile :: HasCallStack => TestParams -> IO () +testChannelMessageFile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as channel message + alice #> "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- all members receive the file concurrently + src <- B.readFile "./tests/fixtures/test.jpg" + concurrentlyN_ + [ receiveFile bob "bob" src, + receiveFile cath "cath" src, + receiveFile dan "dan" src, + receiveFile eve "eve" src + ] + where + receiveFile cc name src = do + let path = "./tests/tmp/test_" <> name <> ".jpg" + cc ##> ("/fr 1 " <> path) + cc + <### [ ConsoleString ("saving file 1 from #team to " <> path), + "started receiving file 1 (test.jpg) from #team" + ] + cc <## "completed receiving file 1 (test.jpg) from #team" + B.readFile path >>= (`shouldBe` src) + +testChannelMessageFileCancel :: HasCallStack => TestParams -> IO () +testChannelMessageFileCancel ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as channel message + alice #> "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- owner cancels file + alice ##> "/fc 1" + alice <## "cancelled sending file 1 (test.jpg) to bob" + bob <## "team cancelled sending file 1 (test.jpg)" + concurrentlyN_ + [ cath <## "team cancelled sending file 1 (test.jpg)", + dan <## "team cancelled sending file 1 (test.jpg)", + eve <## "team cancelled sending file 1 (test.jpg)" + ] + +testChannelMessageQuote :: HasCallStack => TestParams -> IO () +testChannelMessageQuote ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello from channel" + bob <# "#team> hello from channel" + [cath, dan, eve] *<# "#team> hello from channel [>>]" + + -- member quotes channel message + cath `send` "> #team (hello from) replying to channel" + cath <# "#team > hello from channel" + cath <## " replying to channel" + bob <# "#team cath> > hello from channel" + bob <## " replying to channel" + concurrentlyN_ + [ do + alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + alice <# "#team cath> > hello from channel [>>]" + alice <## " replying to channel [>>]", + do + dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> > hello from channel [>>]" + dan <## " replying to channel [>>]", + do + eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> > hello from channel [>>]" + eve <## " replying to channel [>>]" + ] + +testChannelOwnerReaction :: HasCallStack => TestParams -> IO () +testChannelOwnerReaction ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello" + bob <# "#team> hello" + [cath, dan, eve] *<# "#team> hello [>>]" + + -- owner reacts to own channel message - reaction is forwarded as member + alice ##> "+1 #team hello" + alice <## "added 👍" + bob <# "#team alice> > hello" + bob <## " + 👍" + concurrentlyN_ + [ do cath <# "#team alice> > hello" + cath <## " + 👍", + do dan <# "#team alice> > hello" + dan <## " + 👍", + do eve <# "#team alice> > hello" + eve <## " + 👍" + ] + +testChannelOwnerQuote :: HasCallStack => TestParams -> IO () +testChannelOwnerQuote ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message + alice #> "#team hello from channel" + bob <# "#team> hello from channel" + [cath, dan, eve] *<# "#team> hello from channel [>>]" + + -- owner quotes own channel message (sender sees own name locally, not a protocol leak) + alice `send` "> #team (hello from) my reply" + alice <# "#team > alice hello from channel" + alice <## " my reply" + bob <# "#team> > hello from channel" + bob <## " my reply" + concurrentlyN_ + [ do cath <# "#team> > hello from channel [>>]" + cath <## " my reply [>>]", + do dan <# "#team> > hello from channel [>>]" + dan <## " my reply [>>]", + do eve <# "#team> > hello from channel [>>]" + eve <## " my reply [>>]" + ] + +testChannelOwnerUpdateAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerUpdateAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends message as member (not as channel) + alice ##> "/_send #1(as_group=off) text hello" + alice <# "#team hello" + bob <# "#team alice> hello" + [cath, dan, eve] *<# "#team alice> hello [>>]" + + -- owner updates message + msgId <- lastItemId alice + alice ##> ("/_update item #1 " <> msgId <> " text hello updated") + alice <# "#team [edited] hello updated" + bob <# "#team alice> [edited] hello updated" + [cath, dan, eve] *<# "#team alice> [edited] hello updated" + +testChannelOwnerDeleteAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerDeleteAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends message as member (not as channel) + alice ##> "/_send #1(as_group=off) text hello" + alice <# "#team hello" + bob <# "#team alice> hello" + [cath, dan, eve] *<# "#team alice> hello [>>]" + + -- owner deletes message (broadcast) + msgId <- lastItemId alice + alice #$> ("/_delete item #1 " <> msgId <> " broadcast", id, "message marked deleted") + bob <# "#team alice> [marked deleted] hello" + [cath, dan, eve] *<# "#team alice> [marked deleted] hello" + +testChannelOwnerFileTransferAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerFileTransferAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as member (not as channel) + alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- all members receive the file + src <- B.readFile "./tests/fixtures/test.jpg" + concurrentlyN_ + [ receiveFile bob "bob" src, + receiveFile cath "cath" src, + receiveFile dan "dan" src, + receiveFile eve "eve" src + ] + where + receiveFile cc name src = do + let path = "./tests/tmp/test_" <> name <> ".jpg" + cc ##> ("/fr 1 " <> path) + cc + <### [ ConsoleString ("saving file 1 from alice to " <> path), + "started receiving file 1 (test.jpg) from alice" + ] + cc <## "completed receiving file 1 (test.jpg) from alice" + B.readFile path >>= (`shouldBe` src) + +testChannelOwnerFileCancelAsMember :: HasCallStack => TestParams -> IO () +testChannelOwnerFileCancelAsMember ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> withXFTPServer $ do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends file as member (not as channel) + alice ##> "/_send #1(as_group=off) json [{\"filePath\": \"./tests/fixtures/test.jpg\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}]" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test.jpg) for #team" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + concurrentlyN_ + [ do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + cath <## "use /fr 1 [/ | ] to receive it [>>]", + do + dan <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + dan <## "use /fr 1 [/ | ] to receive it [>>]", + do + eve <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes) [>>]" + eve <## "use /fr 1 [/ | ] to receive it [>>]" + ] + + -- owner cancels file + alice ##> "/fc 1" + alice <## "cancelled sending file 1 (test.jpg) to bob" + bob <## "alice cancelled sending file 1 (test.jpg)" + concurrentlyN_ + [ cath <## "alice cancelled sending file 1 (test.jpg)", + dan <## "alice cancelled sending file 1 (test.jpg)", + eve <## "alice cancelled sending file 1 (test.jpg)" + ] + +testChannelReactionAttribution :: HasCallStack => TestParams -> IO () +testChannelReactionAttribution ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends message as member + alice ##> "/_send #1(as_group=off) text hello" + alice <# "#team hello" + bob <# "#team alice> hello" + [cath, dan, eve] *<# "#team alice> hello [>>]" + + -- owner reacts to own member message - reaction is forwarded as member + alice ##> "+1 #team hello" + alice <## "added 👍" + bob <# "#team alice> > alice hello" + bob <## " + 👍" + concurrentlyN_ + [ do cath <# "#team alice> > alice hello" + cath <## " + 👍", + do dan <# "#team alice> > alice hello" + dan <## " + 👍", + do eve <# "#team alice> > alice hello" + eve <## " + 👍" + ] + +testChannelUpdateFallbackSendAsGroup :: HasCallStack => TestParams -> IO () +testChannelUpdateFallbackSendAsGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- owner sends channel message (sendAsGroup=True) + alice #> "#team channel msg" + bob <# "#team> channel msg" + [cath, dan, eve] *<# "#team> channel msg [>>]" + + -- bob locally deletes the item + bobMsgId <- lastItemId bob + bob #$> ("/_delete item #1 " <> bobMsgId <> " internal", id, "message deleted") + + -- owner updates message (XMsgUpdate includes asGroup=True) + aliceMsgId <- lastItemId alice + alice ##> ("/_update item #1 " <> aliceMsgId <> " text channel msg updated") + alice <# "#team [edited] channel msg updated" + -- bob's item was locally deleted, fallback recreates it with [edited] marker + bob <# "#team> [edited] channel msg updated" + [cath, dan, eve] *<# "#team> [edited] channel msg updated" + + -- now test sendAsGroup=False case + -- owner sends message as member + alice ##> "/_send #1(as_group=off) text member msg" + alice <# "#team member msg" + bob <# "#team alice> member msg" + [cath, dan, eve] *<# "#team alice> member msg [>>]" + + -- bob locally deletes the item + bobMsgId2 <- lastItemId bob + bob #$> ("/_delete item #1 " <> bobMsgId2 <> " internal", id, "message deleted") + + -- owner updates message (XMsgUpdate includes asGroup=False) + aliceMsgId2 <- lastItemId alice + alice ##> ("/_update item #1 " <> aliceMsgId2 <> " text member msg updated") + alice <# "#team [edited] member msg updated" + -- bob's internally deleted item is re-created as from member (sendAsGroup=False) + bob <# "#team alice> [edited] member msg updated" + -- forwarded members see correct member attribution + [cath, dan, eve] *<# "#team alice> [edited] member msg updated" + +testForwardAPIUsesParameter :: HasCallStack => TestParams -> IO () +testForwardAPIUsesParameter ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + createChannel1Relay "team" alice bob cath dan eve + connectUsers alice frank + + -- frank sends alice a message + frank #> "@alice hi there" + alice <# "frank> hi there" + + -- forward to channel with sendAsGroup=True (as channel) + alice ##> "/last_item_id @frank" + msgId <- getTermLine alice + alice ##> ("/_forward #1 as_group=on @2 " <> msgId) + alice <# "#team <- @frank" + alice <## " hi there" + bob <# "#team> -> forwarded" + bob <## " hi there" + concurrentlyN_ + [ do cath <# "#team> -> forwarded [>>]" + cath <## " hi there [>>]", + do dan <# "#team> -> forwarded [>>]" + dan <## " hi there [>>]", + do eve <# "#team> -> forwarded [>>]" + eve <## " hi there [>>]" + ] + + -- forward to channel with sendAsGroup=False (as member) + alice ##> ("/_forward #1 as_group=off @2 " <> msgId) + alice <# "#team <- @frank" + alice <## " hi there" + bob <# "#team alice> -> forwarded" + bob <## " hi there" + concurrentlyN_ + [ do cath <# "#team alice> -> forwarded [>>]" + cath <## " hi there [>>]", + do dan <# "#team alice> -> forwarded [>>]" + dan <## " hi there [>>]", + do eve <# "#team alice> -> forwarded [>>]" + eve <## " hi there [>>]" + ] + +testForwardCLISendAsGroup :: HasCallStack => TestParams -> IO () +testForwardCLISendAsGroup ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> + withNewTestChat ps "frank" frankProfile $ \frank -> do + createChannel1Relay "team" alice bob cath dan eve + connectUsers alice frank + + -- frank sends alice a message + frank #> "@alice hi" + alice <# "frank> hi" + + -- CLI forward to channel computes sendAsGroup=True (owner in channel) + alice `send` "#team <- @frank hi" + alice <# "#team <- @frank" + alice <## " hi" + bob <# "#team> -> forwarded" + bob <## " hi" + concurrentlyN_ + [ do cath <# "#team> -> forwarded [>>]" + cath <## " hi [>>]", + do dan <# "#team> -> forwarded [>>]" + dan <## " hi [>>]", + do eve <# "#team> -> forwarded [>>]" + eve <## " hi [>>]" + ] + +testChannelMemberMessageUpdate :: HasCallStack => TestParams -> IO () +testChannelMemberMessageUpdate ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- member sends a message + cath #> "#team hello" + bob <# "#team cath> hello" + concurrentlyN_ + [ do alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + alice <# "#team cath> hello [>>]", + do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello [>>]", + do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello [>>]" + ] + + -- member updates their message + cathMsgId <- lastItemId cath + cath ##> ("/_update item #1 " <> cathMsgId <> " text hello updated") + cath <# "#team [edited] hello updated" + bob <# "#team cath> [edited] hello updated" + concurrentlyN_ + [ alice <# "#team cath> [edited] hello updated", + dan <# "#team cath> [edited] hello updated", + eve <# "#team cath> [edited] hello updated" + ] + +testChannelMemberMessageDelete :: HasCallStack => TestParams -> IO () +testChannelMemberMessageDelete ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- member sends a message + cath #> "#team hello" + bob <# "#team cath> hello" + concurrentlyN_ + [ do alice <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + alice <# "#team cath> hello [>>]", + do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <# "#team cath> hello [>>]", + do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <# "#team cath> hello [>>]" + ] + + -- member deletes their message + cathMsgId <- lastItemId cath + cath #$> ("/_delete item #1 " <> cathMsgId <> " broadcast", id, "message marked deleted") + bob <# "#team cath> [marked deleted] hello" + concurrentlyN_ + [ alice <# "#team cath> [marked deleted] hello", + dan <# "#team cath> [marked deleted] hello", + eve <# "#team cath> [marked deleted] hello" + ] + testGroupLinkContentFilter :: HasCallStack => TestParams -> IO () testGroupLinkContentFilter = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index d61f1350d5..c740c7561f 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -116,10 +116,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing)) it "x.msg.new simple text - timed message TTL" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"ttl\":3600}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing Nothing)) it "x.msg.new simple text - live message" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"live\":true}}" - #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing Nothing)) it "x.msg.new simple link" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" #==# XMsgNew (MCSimple (extMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA", content = Nothing}) Nothing)) @@ -146,22 +146,22 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing (Just 3600) Nothing Nothing Nothing))) it "x.msg.new quote - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"live\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") - (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True) Nothing))) + (XMsgNew (MCQuote quotedMsg (ExtMsgContent (MCText "hello to you too") [] Nothing Nothing (Just True) Nothing Nothing))) it "x.msg.new forward" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") Nothing)) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing (Just 3600) Nothing Nothing Nothing)) it "x.msg.new forward - live message" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing)) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") [] Nothing Nothing (Just True) Nothing Nothing)) it "x.msg.new simple text with file" $ "{\"v\":\"1\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew (MCSimple (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) @@ -193,7 +193,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (extMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}))) it "x.msg.update" $ "{\"v\":\"1\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" - #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing Nothing From 764fb27f1cc7d0a0b25b62716d215983bc09a006 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Sat, 14 Feb 2026 09:26:18 +0000 Subject: [PATCH 009/112] core, directory: allow voice messages during member approval phase to allow audio captchas in groups that prohibit voice messages (#6624) * rfcs: add member-support-voice rfc * update based on the feedback * implement RFC * add new tests * fix protocol tests and update plans * restrict voice captcha exemption to host approval phase * update agent_query_plans.txt --- .../src/Directory/Service.hs | 33 ++- docs/rfcs/2026-02-10-member-support-voice.md | 212 ++++++++++++++++++ src/Simplex/Chat/Library/Internal.hs | 13 +- src/Simplex/Chat/Protocol.hs | 7 +- .../SQLite/Migrations/agent_query_plans.txt | 4 - .../SQLite/Migrations/chat_query_plans.txt | 8 - tests/Bots/DirectoryTests.hs | 135 +++++++++++ tests/ProtocolTests.hs | 8 +- 8 files changed, 390 insertions(+), 30 deletions(-) create mode 100644 docs/rfcs/2026-02-10-member-support-voice.md diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a6ddc97e19..6666de7587 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -54,7 +54,7 @@ import Simplex.Chat.Core import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) @@ -569,7 +569,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." - <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" + <> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do @@ -614,6 +614,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName img : _ -> MCImage "" $ ImageData img textMsg = MCText $ T.pack s + canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool + canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) @@ -637,16 +642,20 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName isAudioCmd = T.strip msgText == "/audio" cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case - Nothing -> - let mode = if isAudioCmd then CMAudio else CMText - in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Nothing + | isAudioCmd && canSendVoiceCaptcha g m -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + | isAudioCmd -> sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] + | otherwise -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMText Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} - | isAudioCmd -> case captchaMode of - CMText -> do - atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env - sendVoiceCaptcha sendRef (T.unpack captchaText) - CMAudio -> - sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + | isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] | otherwise -> case cmd of ADC SDRUser (DCSearchGroup _) -> do ts <- getCurrentTime @@ -679,6 +688,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName noCaptcha = "Unexpected message, please try again." audioAlreadyEnabled :: Text audioAlreadyEnabled = "Audio captcha is already enabled." + voiceCaptchaUnavailable :: Text + voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." unknownCommand :: Text unknownCommand = "Unknown command, please enter captcha text." tooManyAttempts :: Text diff --git a/docs/rfcs/2026-02-10-member-support-voice.md b/docs/rfcs/2026-02-10-member-support-voice.md new file mode 100644 index 0000000000..52285d1514 --- /dev/null +++ b/docs/rfcs/2026-02-10-member-support-voice.md @@ -0,0 +1,212 @@ +# Voice messages in member support scope + +## Table of contents + +1. Executive summary +2. Problem +3. High-level design +4. Detailed implementation plan + +## 1. Executive summary + +Allow voice messages from host/admin during the approval phase (member pending) regardless of group voice settings, gated behind chat protocol version 17. This enables the directory bot to send voice captchas in groups that prohibit voice messages. Old clients that don't support this exemption will receive text/image captchas instead. + +## 2. Problem + +The directory bot sends voice captchas to joining members via the member support scope (`GCSMemberSupport`). However, `prohibitedGroupContent` (Internal.hs:338) blocks voice messages when the group disables voice — with no scope exemption: + +```haskell +| isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +Other content types (files, reports, simplex links) already have `isNothing scopeInfo` guards that exempt them in member support scope. Voice does not. + +This means voice captchas fail in the majority of real groups that prohibit voice messages. The check runs on both sender side (Commands.hs:3856) and recipient side (Subscriber.hs:1738), so both the bot and the joining member reject voice in these groups. + +## 3. High-level design + +1. **Protocol version 17** (`memberSupportVoiceVersion`): gates the `prohibitedGroupContent` exemption for host voice during the approval phase. + +2. **Core library change** (Internal.hs): exempt voice in `prohibitedGroupContent` when sender is admin+ (host) AND the member is in the approval phase (pending status). Voice is NOT generally allowed in member support scope — only during approval, only from host. + +3. **Directory bot change** (Service.hs): check member's protocol version and group voice settings before offering or sending voice captcha. Fall back to text/image captcha for old clients in voice-disabled groups. + +## 4. Detailed implementation plan + +### 4.1. Protocol.hs — add version 17 + +**File:** `src/Simplex/Chat/Protocol.hs` + +Add to version history comment (after line 79): + +``` +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +``` + +Update `currentChatVersion` (line 85): + +```haskell +currentChatVersion = VersionChat 17 +``` + +Add version constant (after `shortLinkDataVersion`, line 146): + +```haskell +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 +``` + +### 4.2. Internal.hs — exempt host voice during approval phase + +**File:** `src/Simplex/Chat/Library/Internal.hs` + +Change function header (line 337) to bind sender's role and full membership: + +```haskell +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m@GroupMember {memberRole = senderRole} scopeInfo mc ft file_ sent +``` + +Change line 338 from: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +to: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice +``` + +Add to the `where` clause: + +```haskell + hostApprovalVoice = senderRole >= GRAdmin && inApprovalPhase + inApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberPending scopeMem + Just (GCSIMemberSupport Nothing) -> memberPending mem + Nothing -> False +``` + +Note: `memberPending` returns True for both `GSMemPendingApproval` and `GSMemPendingReview`. The exemption applies to both phases — the member hasn't been fully admitted in either state. + +**Why two cases for `inApprovalPhase`:** + +- **Sender side** (bot sending via Commands.hs:3856): `scopeInfo = GCSIMemberSupport (Just pendingMember)` — the scope contains the pending member being supported. `memberPending pendingMember` checks their status. +- **Receiver side** (member receiving via Subscriber.hs:1738): `scopeInfo = GCSIMemberSupport Nothing` — `Nothing` means the member's own support conversation (constructed by `mkGroupSupportChatInfo` in Internal.hs:1535). `memberPending mem` checks the local user's (receiving member's) status. + +**Behavior matrix:** + +| Scenario | `hostApprovalVoice` | Voice allowed? | +|----------|---------------------|----------------| +| Host → pending member, voice disabled | True | Yes (new) | +| Host → approved member in support, voice disabled | False (`memberPending` = False) | No | +| Pending member → host, voice disabled | False (`senderRole` < GRAdmin) | No | +| Anyone outside support scope, voice disabled | False (`inApprovalPhase` = False) | No | +| Any sender, voice enabled | N/A (`groupFeatureMemberAllowed` = True) | Yes (existing) | + +**Version gating:** Old clients (< v17) don't have this exemption. On the sender side this is handled by the bot (4.3). On the recipient side: + +- Old recipient + voice-disabled group: recipient rejects the voice message (shows "Voice messages: received, prohibited") +- This is why the bot must check the member's version before sending voice + +### 4.3. Service.hs — version-aware voice captcha logic + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +#### 4.3.1. Add import + +Add `memberSupportVoiceVersion` to the `Protocol` import: + +```haskell +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) +``` + +#### 4.3.2. Add helper predicate + +Add a helper in the `directoryService` `where` block (same scope as `sendMemberCaptcha`, `sendVoiceCaptcha`, etc., where `opts` is in scope): + +```haskell +canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool +canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) +``` + +Logic: +- Voice captcha generator must be configured +- AND either the group allows voice for the bot/host (any client version works — old clients accept voice from permitted senders) OR the member's client supports v17 (exemption applies on receive side) + +Note: `groupFeatureUserAllowed` checks if the bot (group owner) is permitted to send voice. This is what the recipient's `prohibitedGroupContent` checks — it validates the *sender's* permission (`m` parameter = sender's GroupMember), not the recipient's. Using `groupFeatureMemberAllowed SGFVoice m gInfo` (joining member) would be wrong: it would incorrectly block voice captcha in groups with role-based voice settings (e.g., "admins only"). + +#### 4.3.3. Update `dePendingMember` hint text (line 572) + +Change from: + +```haskell +<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +to: + +```haskell +<> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" +``` + +This hides the `/audio` hint when voice captcha cannot be delivered. + +#### 4.3.4. Update `dePendingMemberMsg` `/audio` handling (lines 644-649) + +When a member sends `/audio`, check `canSendVoiceCaptcha` before switching mode. If voice captcha is not possible, reply with an upgrade message: + +```haskell +| isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] +``` + +#### 4.3.5. Add message constant + +```haskell +voiceCaptchaUnavailable :: Text +voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." +``` + +#### 4.3.6. Update `dePendingMemberMsg` no-captcha `/audio` path (lines 640-642) + +Same check for the case when no pending captcha exists yet: + +```haskell +Nothing -> + if isAudioCmd && canSendVoiceCaptcha g m + then sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + else if isAudioCmd + then sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(Just ciId, MCText voiceCaptchaUnavailable)] + else let mode = CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode +``` + +### 4.4. Tests + +**File:** `tests/Bots/DirectoryTests.hs` + +Update existing audio captcha tests to cover: +1. Group with voice enabled + any client version: `/audio` works (existing behavior) +2. Group with voice disabled + member version >= 17: `/audio` works +3. Group with voice disabled + member version < 17: `/audio` shows unavailable message, hint is hidden + +### 4.5. Changes summary + +| File | Change | Lines affected | +|------|--------|----------------| +| `Protocol.hs` | Add v17 constant, bump `currentChatVersion` | ~4 lines added | +| `Internal.hs` | Exempt host voice during approval phase | ~6 lines modified/added | +| `Service.hs` | Version-aware voice captcha logic | ~15 lines modified/added | +| `DirectoryTests.hs` | Test coverage for version gating | TBD | diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 607839ed36..896dfbb747 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -334,13 +334,22 @@ quoteContent mc qmc ciFile_ qTextOrFile = if T.null qText then qFileName else qText prohibitedGroupContent :: GroupInfo -> GroupMember -> Maybe GroupChatScopeInfo -> MsgContent -> Maybe MarkdownList -> Maybe f -> Bool -> Maybe GroupFeature -prohibitedGroupContent gInfo@GroupInfo {membership = GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent - | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m scopeInfo mc ft file_ sent + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice | isNothing scopeInfo && not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles | isNothing scopeInfo && isReport mc && (badReportUser || not (groupFeatureAllowed SGFReports gInfo)) = Just GFReports | isNothing scopeInfo && prohibitedSimplexLinks gInfo m ft = Just GFSimplexLinks | otherwise = Nothing where + hostApprovalVoice + | sent = userRole >= GRAdmin && sendApprovalPhase + | otherwise = memberCategory m == GCHostMember && hostApprovalPhase + hostApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport Nothing) -> memberStatus mem == GSMemPendingApproval + _ -> False + sendApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberStatus scopeMem == GSMemPendingApproval + _ -> False -- admins cannot send reports, non-admins cannot receive reports badReportUser | sent = userRole >= GRModerator diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 3272bc0115..e7a52f5153 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -77,12 +77,13 @@ import Simplex.Messaging.Version hiding (version) -- 14 - support sending and receiving group join rejection (2025-02-24) -- 15 - support specifying message scopes for group messages (2025-03-12) -- 16 - support short link data (2025-06-10) +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) -- This should not be used directly in code, instead use `maxVersion chatVRange` from ChatConfig. -- This indirection is needed for backward/forward compatibility testing. -- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code. currentChatVersion :: VersionChat -currentChatVersion = VersionChat 16 +currentChatVersion = VersionChat 17 -- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above) supportedChatVRange :: VersionRangeChat @@ -145,6 +146,10 @@ groupKnockingVersion = VersionChat 15 shortLinkDataVersion :: VersionChat shortLinkDataVersion = VersionChat 16 +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 + agentToChatVersion :: VersionSMPA -> VersionChat agentToChatVersion v | v < pqdrSMPAgentVersion = initialChatVersion diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 1b881bd446..9fb0f40928 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1205,10 +1205,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) 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 6201c8b2e8..c5c110159a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4741,14 +4741,6 @@ Query: Plan: SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE rcv_files - SET to_receive = 1, user_approved_relays = ?, updated_at = ? - WHERE file_id = ? - -Plan: -SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE remote_controllers SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 284a069bab..852e4cf4c4 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -73,6 +73,8 @@ directoryServiceTests = do it "should ask member to pass captcha screen" testCapthaScreening it "should send voice captcha on /audio command" testVoiceCaptchaScreening it "should retry with voice captcha after switching to audio mode" testVoiceCaptchaRetry + it "should send voice captcha when voice disabled but client supports v17" testVoiceCaptchaVoiceDisabled + it "should show unavailable message for old client in voice-disabled group" testVoiceCaptchaOldClient it "should reject member after too many captcha attempts" testCaptchaTooManyAttempts it "should respond to unknown command during captcha" testCaptchaUnknownCommand describe "store log" $ do @@ -1369,6 +1371,139 @@ testVoiceCaptchaRetry ps@TestParams {tmpPath} = do cath <#. "#privacy (support) 'SimpleX Directory'> sends file " cath <##. "use /fr 2" +testVoiceCaptchaVoiceDisabled :: HasCallStack => TestParams -> IO () +testVoiceCaptchaVoiceDisabled ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_vdisabled.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- disable voice messages in the group + bob ##> "/set voice #privacy off" + bob <## "updated group preferences:" + bob <## "Voice messages: off" + -- cath (new client, supports v17 exemption) joins, /audio hint shown + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + cath <## "Send /audio to receive a voice captcha." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- voice captcha works despite voice being disabled (v17 host approval exemption) + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'> voice message (00:05)" + cath <#. "#privacy (support) 'SimpleX Directory'> sends file " + cath <##. "use /fr 1" + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + +testVoiceCaptchaOldClient :: HasCallStack => TestParams -> IO () +testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do + let mockScript = tmpPath "mock_voice_gen_oldclient.py" + writeFile mockScript $ unlines + [ "#!/usr/bin/env python3", + "import os, tempfile", + "out = os.environ.get('VOICE_CAPTCHA_OUT')", + "if not out:", + " fd, out = tempfile.mkstemp(suffix='.m4a')", + " os.close(fd)", + "open(out, 'wb').write(b'\\x00' * 100)", + "print(out)", + "print(5)" + ] + setPermissions mockScript $ setOwnerExecutable True $ setOwnerReadable True $ setOwnerWritable True emptyPermissions + withDirectoryServiceVoiceCaptcha ps mockScript $ \superUser dsLink -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps testCfgVPrev "cath" cathProfile $ \cath -> do + bob `connectVia` dsLink + registerGroup superUser bob "privacy" "Privacy" + bob #> "@'SimpleX Directory' /role 1" + bob <# "'SimpleX Directory'> > /role 1" + bob <## " The initial member role for the group privacy is set to member" + bob <## "Send /'role 1 observer' to change it." + bob <## "" + note <- getTermLine bob + let groupLink = dropStrPrefix "Please note: it applies only to members joining via this link: " note + bob #> "@'SimpleX Directory' /filter 1 captcha" + bob <# "'SimpleX Directory'> > /filter 1 captcha" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: enabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 name captcha' - enable both" + bob <## "/'filter 1 off' - disable filter" + -- disable voice messages in the group + bob ##> "/set voice #privacy off" + bob <## "updated group preferences:" + bob <## "Voice messages: off" + -- cath (old client, max version < v17) joins, /audio hint NOT shown + cath ##> ("/c " <> groupLink) + cath <## "connection request sent!" + cath <## "#privacy: joining the group..." + cath <## "#privacy: you joined the group, pending approval" + cath <# "#privacy (support) 'SimpleX Directory'> Captcha is generated by SimpleX Directory service." + cath <## "" + cath <## "Send captcha text to join the group privacy." + captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath + -- /audio unavailable: old client can't receive voice in voice-disabled group + cath #> "#privacy (support) /audio" + cath <# "#privacy (support) 'SimpleX Directory'!> > cath /audio" + cath <## " Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." + -- text captcha still works + sendCaptcha cath captcha + cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" + cath <## "#privacy: member bob (Bob) is connected" + bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" + bob <## "#privacy: new member cath is connected" + where + sendCaptcha cath captcha = do + cath #> ("#privacy (support) " <> captcha) + cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) + cath <## " Correct, you joined the group privacy" + cath <## "#privacy: you joined the group" + withDirectoryServiceVoiceCaptcha :: HasCallStack => TestParams -> FilePath -> (TestCC -> String -> IO ()) -> IO () withDirectoryServiceVoiceCaptcha ps voiceScript test = do dsLink <- diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2332fa429c..d607d1f208 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -133,7 +133,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new chat message with chat version range" $ - "{\"v\":\"1-16\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"1-17\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (extMsgContent (MCText "hello") Nothing))) it "x.msg.new quote" $ "{\"v\":\"1\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" @@ -249,13 +249,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.new with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} Nothing it "x.grp.mem.intro with member chat version range" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} Nothing it "x.grp.mem.intro with member restrictions" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" @@ -270,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-16\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"1\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"1-17\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"1\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" From 26e15221f6181a156fc4b27b96cbd67dc13f89ff Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 18 Feb 2026 09:28:39 +0000 Subject: [PATCH 010/112] directory-service: fix slow postgresql queries (#6639) * add analysis * implement p1.1 and p1.2 * Update apps/simplex-directory-service/src/Directory/Service.hs Co-authored-by: Evgeny * update plans * remove plans --------- Co-authored-by: Evgeny --- .../src/Directory/Service.hs | 4 ++-- src/Simplex/Chat/Library/Commands.hs | 4 ++-- src/Simplex/Chat/Library/Internal.hs | 8 +++----- src/Simplex/Chat/Library/Subscriber.hs | 8 ++++---- src/Simplex/Chat/Store/Groups.hs | 15 --------------- .../Store/SQLite/Migrations/agent_query_plans.txt | 4 ++++ .../Store/SQLite/Migrations/chat_query_plans.txt | 8 -------- 7 files changed, 15 insertions(+), 36 deletions(-) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index 6666de7587..3b8391ada0 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -708,8 +708,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withAdminUsers $ \cId -> do - sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + let approveCmd = MCText $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + sendComposedMessages cc (SRDirect cId) [msg, approveCmd] deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 0cfcb9ab09..e5e8d60ad7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3478,8 +3478,8 @@ processChatCommand vr nm = \case pure (groupId, groupMemberId) sendGrpInvitation :: User -> Contact -> GroupInfo -> GroupMember -> ConnReqInvitation -> CM () sendGrpInvitation user ct@Contact {contactId, localDisplayName} gInfo@GroupInfo {groupId, groupProfile, membership, businessChat} GroupMember {groupMemberId, memberId, memberRole = memRole} cReq = do - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = GroupInvitation { fromMember = MemberIdRole userMemberId userRole, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 896dfbb747..00fd2f18d9 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -933,11 +933,9 @@ acceptGroupJoinRequestAsync incognitoProfile = do gVar <- asks random let initialStatus = acceptanceToStatus (memberAdmission groupProfile) gAccepted - ((groupMemberId, memberId), currentMemCount) <- withStore $ \db -> - liftM2 - (,) - (createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus) - (liftIO $ getGroupCurrentMembersCount db user gInfo) + (groupMemberId, memberId) <- withStore $ \db -> + createJoiningMember db gVar user gInfo cReqChatVRange cReqProfile cReqXContactId_ welcomeMsgId_ gLinkMemRole initialStatus + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo let Profile {displayName} = userProfileInGroup user gInfo (fromIncognitoProfile <$> incognitoProfile) GroupMember {memberRole = userRole, memberId = userMemberId} = membership msg = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e10bf2a081..7fcc07640d 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -700,8 +700,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where sendGrpInvitation :: Contact -> GroupMember -> Maybe GroupLinkId -> CM () sendGrpInvitation ct GroupMember {memberId, memberRole = memRole} groupLinkId = do - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupMember {memberRole = userRole, memberId = userMemberId} = membership + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupMember {memberRole = userRole, memberId = userMemberId} = membership groupInv = GroupInvitation { fromMember = MemberIdRole userMemberId userRole, @@ -942,8 +942,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, messageFromChannel = False} checkSendRcpt :: [AChatMessage] -> CM Bool checkSendRcpt aMsgs = do - currentMemCount <- withStore' $ \db -> getGroupCurrentMembersCount db user gInfo - let GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo + let currentMemCount = fromIntegral $ currentMembers $ groupSummary gInfo + GroupInfo {chatSettings = ChatSettings {sendRcpts}} = gInfo pure $ fromMaybe (sendRcptsSmallGroups user) sendRcpts && any aChatMsgHasReceipt aMsgs diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index a54a3a6913..7ae47adab5 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -68,7 +68,6 @@ module Simplex.Chat.Store.Groups getGroupModerators, getGroupRelays, getGroupMembersForExpiration, - getGroupCurrentMembersCount, deleteGroupChatItems, deleteGroupMembers, cleanupHostGroupLinkConn, @@ -1100,20 +1099,6 @@ getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo { ) (groupId, userId, userContactId, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown) -getGroupCurrentMembersCount :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupCurrentMembersCount db User {userId} GroupInfo {groupId} = do - statuses :: [GroupMemberStatus] <- - map fromOnly - <$> DB.query - db - [sql| - SELECT member_status - FROM group_members - WHERE group_id = ? AND user_id = ? - |] - (groupId, userId) - pure $ length $ filter memberCurrent' statuses - getGroupInvitation :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO ReceivedGroupInvitation getGroupInvitation db vr user groupId = getConnRec_ user >>= \case diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 9fb0f40928..ebdf7e1f5c 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1197,6 +1197,10 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ? +Plan: +SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index c5c110159a..ac54043194 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1430,14 +1430,6 @@ Plan: SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH i USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT member_status - FROM group_members - WHERE group_id = ? AND user_id = ? - -Plan: -SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) - Query: SELECT r.file_id FROM rcv_files r From 0946f50b6a3b881dfaa4b699cb5a85499c42332d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 19 Feb 2026 10:58:16 +0000 Subject: [PATCH 011/112] ios: product specification (#6633) --- apps/ios/CODE.md | 219 +++++++ apps/ios/Shared/AppDelegate.swift | 1 + apps/ios/Shared/ContentView.swift | 10 + apps/ios/Shared/Model/AppAPITypes.swift | 6 + apps/ios/Shared/Model/BGManager.swift | 6 + apps/ios/Shared/Model/ChatModel.swift | 20 + apps/ios/Shared/Model/NtfManager.swift | 10 + apps/ios/Shared/Model/SimpleXAPI.swift | 19 + apps/ios/Shared/SimpleXApp.swift | 3 + apps/ios/Shared/Theme/Theme.swift | 3 + apps/ios/Shared/Theme/ThemeManager.swift | 13 + .../Shared/Views/Call/ActiveCallView.swift | 5 + .../Shared/Views/Call/CallController.swift | 10 + apps/ios/Shared/Views/Call/WebRTCClient.swift | 12 + apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 + .../Chat/ChatItem/AnimatedImageView.swift | 2 + .../Views/Chat/ChatItem/CICallItemView.swift | 1 + .../Chat/ChatItem/CIChatFeatureView.swift | 2 + .../Views/Chat/ChatItem/CIEventView.swift | 2 + .../ChatItem/CIFeaturePreferenceView.swift | 2 + .../Views/Chat/ChatItem/CIFileView.swift | 2 + .../Chat/ChatItem/CIGroupInvitationView.swift | 2 + .../Views/Chat/ChatItem/CIImageView.swift | 2 + .../Chat/ChatItem/CIInvalidJSONView.swift | 2 + .../Views/Chat/ChatItem/CILinkView.swift | 2 + .../ChatItem/CIMemberCreatedContactView.swift | 2 + .../Views/Chat/ChatItem/CIMetaView.swift | 2 + .../Chat/ChatItem/CIRcvDecryptionError.swift | 2 + .../Views/Chat/ChatItem/CIVideoView.swift | 2 + .../Views/Chat/ChatItem/CIVoiceView.swift | 2 + .../Views/Chat/ChatItem/DeletedItemView.swift | 2 + .../Views/Chat/ChatItem/EmojiItemView.swift | 2 + .../Chat/ChatItem/FramedCIVoiceView.swift | 2 + .../Views/Chat/ChatItem/FramedItemView.swift | 2 + .../Chat/ChatItem/FullScreenMediaView.swift | 2 + .../ChatItem/IntegrityErrorItemView.swift | 2 + .../Chat/ChatItem/MarkedDeletedItemView.swift | 2 + .../Views/Chat/ChatItem/MsgContentView.swift | 2 + .../Shared/Views/Chat/ChatItemInfoView.swift | 1 + apps/ios/Shared/Views/Chat/ChatItemView.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 19 + .../Chat/ComposeMessage/ComposeView.swift | 27 + .../Chat/ComposeMessage/SendMessageView.swift | 1 + .../Chat/Group/AddGroupMembersView.swift | 1 + .../Views/Chat/Group/GroupChatInfoView.swift | 2 + .../Views/Chat/Group/GroupLinkView.swift | 1 + .../Chat/Group/GroupMemberInfoView.swift | 1 + .../Views/ChatList/ChatListNavLink.swift | 10 + .../Shared/Views/ChatList/ChatListView.swift | 12 + .../Views/ChatList/ChatPreviewView.swift | 1 + .../Shared/Views/ChatList/TagListView.swift | 1 + .../Shared/Views/ChatList/UserPicker.swift | 1 + .../Database/DatabaseEncryptionView.swift | 2 + .../Views/Database/DatabaseErrorView.swift | 1 + .../Shared/Views/Database/DatabaseView.swift | 2 + .../Database/MigrateToAppGroupView.swift | 1 + .../Views/LocalAuth/LocalAuthView.swift | 1 + .../Views/LocalAuth/PasscodeEntry.swift | 1 + .../Shared/Views/LocalAuth/PasscodeView.swift | 1 + .../Views/LocalAuth/SetAppPasscodeView.swift | 1 + .../Views/Migration/MigrateFromDevice.swift | 1 + .../Views/Migration/MigrateToDevice.swift | 1 + .../Shared/Views/NewChat/NewChatView.swift | 3 + apps/ios/Shared/Views/NewChat/QRCode.swift | 1 + .../Onboarding/AddressCreationCard.swift | 1 + .../Onboarding/ChooseServerOperators.swift | 1 + .../Views/Onboarding/CreateProfile.swift | 1 + .../Onboarding/CreateSimpleXAddress.swift | 1 + .../Shared/Views/Onboarding/HowItWorks.swift | 1 + .../Views/Onboarding/OnboardingView.swift | 3 + .../Onboarding/SetNotificationsMode.swift | 1 + .../Shared/Views/Onboarding/SimpleXInfo.swift | 1 + .../Views/Onboarding/WhatsNewView.swift | 1 + .../UserSettings/AppearanceSettings.swift | 7 + .../AdvancedNetworkSettings.swift | 1 + .../NetworkAndServers/ConditionsWebView.swift | 1 + .../NetworkAndServers/NetworkAndServers.swift | 1 + .../NetworkAndServers/NewServerView.swift | 1 + .../NetworkAndServers/OperatorView.swift | 1 + .../ProtocolServerView.swift | 1 + .../ProtocolServersView.swift | 1 + .../ScanProtocolServer.swift | 1 + .../Views/UserSettings/SettingsView.swift | 1 + .../Views/UserSettings/UserProfilesView.swift | 1 + .../ios/SimpleX NSE/NotificationService.swift | 12 + apps/ios/SimpleXChat/API.swift | 1 + apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/CallTypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 5 + apps/ios/SimpleXChat/CryptoFile.swift | 5 + apps/ios/SimpleXChat/FileUtils.swift | 19 + apps/ios/SimpleXChat/Notifications.swift | 10 + .../Theme/ChatWallpaperTypes.swift | 2 + apps/ios/SimpleXChat/Theme/ThemeTypes.swift | 9 + apps/ios/product/README.md | 258 ++++++++ apps/ios/product/concepts.md | 83 +++ apps/ios/product/flows/calling.md | 179 ++++++ apps/ios/product/flows/connection.md | 159 +++++ apps/ios/product/flows/file-transfer.md | 209 ++++++ apps/ios/product/flows/group-lifecycle.md | 216 +++++++ apps/ios/product/flows/messaging.md | 178 ++++++ apps/ios/product/flows/onboarding.md | 239 +++++++ apps/ios/product/gaps.md | 61 ++ apps/ios/product/glossary.md | 235 +++++++ apps/ios/product/rules.md | 119 ++++ apps/ios/product/views/call.md | 122 ++++ apps/ios/product/views/chat-list.md | 113 ++++ apps/ios/product/views/chat.md | 165 +++++ apps/ios/product/views/contact-info.md | 154 +++++ apps/ios/product/views/group-info.md | 147 +++++ apps/ios/product/views/new-chat.md | 94 +++ apps/ios/product/views/onboarding.md | 147 +++++ apps/ios/product/views/settings.md | 172 +++++ apps/ios/product/views/user-profiles.md | 137 ++++ apps/ios/spec/README.md | 74 +++ apps/ios/spec/api.md | 600 ++++++++++++++++++ apps/ios/spec/architecture.md | 298 +++++++++ apps/ios/spec/client/chat-list.md | 280 ++++++++ apps/ios/spec/client/chat-view.md | 331 ++++++++++ apps/ios/spec/client/compose.md | 355 +++++++++++ apps/ios/spec/client/navigation.md | 312 +++++++++ apps/ios/spec/database.md | 298 +++++++++ apps/ios/spec/impact.md | 114 ++++ apps/ios/spec/services/calls.md | 383 +++++++++++ apps/ios/spec/services/files.md | 368 +++++++++++ apps/ios/spec/services/notifications.md | 390 ++++++++++++ apps/ios/spec/services/theme.md | 383 +++++++++++ apps/ios/spec/state.md | 463 ++++++++++++++ 128 files changed, 8418 insertions(+) create mode 100644 apps/ios/CODE.md create mode 100644 apps/ios/product/README.md create mode 100644 apps/ios/product/concepts.md create mode 100644 apps/ios/product/flows/calling.md create mode 100644 apps/ios/product/flows/connection.md create mode 100644 apps/ios/product/flows/file-transfer.md create mode 100644 apps/ios/product/flows/group-lifecycle.md create mode 100644 apps/ios/product/flows/messaging.md create mode 100644 apps/ios/product/flows/onboarding.md create mode 100644 apps/ios/product/gaps.md create mode 100644 apps/ios/product/glossary.md create mode 100644 apps/ios/product/rules.md create mode 100644 apps/ios/product/views/call.md create mode 100644 apps/ios/product/views/chat-list.md create mode 100644 apps/ios/product/views/chat.md create mode 100644 apps/ios/product/views/contact-info.md create mode 100644 apps/ios/product/views/group-info.md create mode 100644 apps/ios/product/views/new-chat.md create mode 100644 apps/ios/product/views/onboarding.md create mode 100644 apps/ios/product/views/settings.md create mode 100644 apps/ios/product/views/user-profiles.md create mode 100644 apps/ios/spec/README.md create mode 100644 apps/ios/spec/api.md create mode 100644 apps/ios/spec/architecture.md create mode 100644 apps/ios/spec/client/chat-list.md create mode 100644 apps/ios/spec/client/chat-view.md create mode 100644 apps/ios/spec/client/compose.md create mode 100644 apps/ios/spec/client/navigation.md create mode 100644 apps/ios/spec/database.md create mode 100644 apps/ios/spec/impact.md create mode 100644 apps/ios/spec/services/calls.md create mode 100644 apps/ios/spec/services/files.md create mode 100644 apps/ios/spec/services/notifications.md create mode 100644 apps/ios/spec/services/theme.md create mode 100644 apps/ios/spec/state.md diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md new file mode 100644 index 0000000000..adb5ef8c42 --- /dev/null +++ b/apps/ios/CODE.md @@ -0,0 +1,219 @@ +# Coding and building + +You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context. + +## Three-Layer Documentation Architecture + +### Why this structure exists + +LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results. + +The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves. + +### The layers + +| Layer | Contains | Question it answers | +|-------|----------|-------------------| +| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? | +| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? | +| `Shared/`, `SimpleXChat/`, `SimpleX NSE/` | Executable Swift code (iOS app) | What does it **execute**? | +| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? | + +Each layer links to the next: +- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point +- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents +- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec +- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](Shared/Model/SimpleXAPI.swift#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift. +- Reverse direction: the Document Map (end of this file) maps source → spec → product + +### Navigation workflow + +When the user requests any change, you MUST follow these steps before writing any code: + +1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas. + +2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract. + +3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees. + +4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior. + +5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information. + +For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6. + +6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below. + +### Key navigation documents + +| Document | Purpose | When to read | +|----------|---------|-------------| +| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change | +| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior | +| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms | +| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature | +| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change | +| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation | + +--- + +## Code Security + +When designing code and planning implementations, you MUST: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries. +- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses. + +--- + +## Code Style + +**Follow existing code patterns — you MUST:** +- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise. +- Use Swift structs for value types, classes for reference types, and enums with associated values for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive switch statements over default cases. Why: default cases bypass compiler checks for new enum cases and hide bugs. + +**Comments policy — you MUST:** +- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code. +- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments. +- Assume a competent Swift reader. Why: over-explaining trivial Swift adds noise without value. + +**Diff and refactoring — you MUST:** +- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff. +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit. +- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert. + +**Document and code structure — you MUST:** +- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame. +- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability. +- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult. + +**Code analysis and review — you MUST:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior. +- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs. + +--- + +## Plans + +When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was. + +### Plan requirements + +1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions. + +2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent. + +3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth. + +4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation. + +--- + +## Change Protocol + +### The rule + +Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change. + +### What to update + +1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification. + +2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion. + +3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value. + +4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work. + +5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates. + +6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context. + +7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt. + +8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later. + +9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable. + +### Adversarial self-review + +After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers. + +**Within-layer coherence — you MUST verify:** +- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact +- product/ is internally consistent — flows match views, rules match behavior descriptions + +**Across-layer coherence — you MUST verify:** +- Every new or changed function in source appears in the corresponding spec/ document +- Every user-visible behavior change in source appears in the relevant product/ document +- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines +- All cross-references resolve — product → spec links, spec → source links +- `spec/impact.md` covers all affected product concepts for the changed source files +- `product/concepts.md` rows are current for any affected concepts + +**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent. + +--- + +## Document Map + +### iOS Swift Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| Shared/ContentView.swift | spec/client/navigation.md | product/views/chat-list.md | +| Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | +| Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | +| Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | +| Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ChatInfoView.swift | spec/client/chat-view.md | product/views/contact-info.md | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md | +| Shared/Views/Call/CallController.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/Call/WebRTCClient.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/UserSettings/SettingsView.swift | spec/client/navigation.md | product/views/settings.md | +| Shared/Views/UserSettings/AppearanceSettings.swift | spec/services/theme.md | product/views/settings.md | +| Shared/Views/UserSettings/NetworkAndServers/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/UserSettings/UserProfilesView.swift | spec/client/navigation.md | product/views/user-profiles.md | +| Shared/Views/Onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| Shared/Views/LocalAuth/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/Database/ | spec/database.md | product/views/settings.md | +| Shared/Views/Migration/ | spec/database.md | product/flows/onboarding.md | +| Shared/Model/ChatModel.swift | spec/state.md | product/concepts.md | +| Shared/Model/SimpleXAPI.swift | spec/api.md, spec/architecture.md | product/concepts.md | +| Shared/Model/AppAPITypes.swift | spec/api.md | product/concepts.md | +| Shared/Model/NtfManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Model/BGManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Theme/ThemeManager.swift | spec/services/theme.md | product/views/settings.md | +| SimpleXChat/ChatTypes.swift | spec/state.md, spec/api.md | product/glossary.md | +| SimpleXChat/APITypes.swift | spec/api.md | product/concepts.md | +| SimpleXChat/CallTypes.swift | spec/services/calls.md | product/flows/calling.md | +| SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md | +| SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md | +| SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md | +| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md | +| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md | +| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md | +| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md | +| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md | diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 3f6998c9ec..0a401f9bf3 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 30/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UIKit diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 7adf7a0435..a6896fa51d 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/client/navigation.md import SwiftUI import Intents @@ -19,15 +20,18 @@ private enum NoticesSheet: Identifiable { } } +// Spec: spec/client/navigation.md#ContentView struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared + // Spec: spec/client/navigation.md#AppSheetState @ObservedObject var appSheetState = AppSheetState.shared @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme @EnvironmentObject var sceneDelegate: SceneDelegate + // Spec: spec/client/navigation.md#contentAccessAuthenticationExtended var contentAccessAuthenticationExtended: Bool @Environment(\.scenePhase) var scenePhase @@ -161,6 +165,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#contentView @ViewBuilder private func contentView() -> some View { if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) @@ -176,6 +181,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callView @ViewBuilder private func callView(_ call: Call) -> some View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) @@ -193,6 +199,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callBanner private func activeCallInteractiveArea(_ call: Call) -> some View { HStack { Text(call.contact.displayName).font(.body).foregroundColor(.white) @@ -227,6 +234,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#lockButton private func lockButton() -> some View { Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } @@ -339,6 +347,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#unlockedRecently private func unlockedRecently() -> Bool { if let lastSuccessfulUnlock = lastSuccessfulUnlock { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 @@ -426,6 +435,7 @@ struct ContentView: View { ) } + // Spec: spec/client/navigation.md#connectViaUrl func connectViaUrl() { let m = ChatModel.shared if let url = m.appOpenUrl { diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index e213f1c076..f82a2fd2eb 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -5,11 +5,13 @@ // Created by EP on 01/05/2025. // Copyright © 2025 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import SimpleXChat import SwiftUI // some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised +// Spec: spec/api.md#ChatCommand enum ChatCommand: ChatCmdProtocol { case showActiveUser case createActiveUser(profile: Profile?, pastTimestamp: Bool) @@ -643,6 +645,7 @@ enum ChatCommand: ChatCmdProtocol { } // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. +// Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { case activeUser(user: User) case usersList(users: [UserInfo]) @@ -764,6 +767,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatResponse1 enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) @@ -903,6 +907,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatResponse2 enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) @@ -1046,6 +1051,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatEvent enum ChatEvent: Decodable, ChatAPIResult { case chatSuspended case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 25eab6c69e..aa4dfa24f8 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 08/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import BackgroundTasks @@ -25,6 +26,7 @@ private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes private let maxTimerCount = 9 +// Spec: spec/services/notifications.md#BGManager class BGManager { static let shared = BGManager() var chatReceiver: ChatReceiver? @@ -32,6 +34,7 @@ class BGManager { var completed = true var timerCount = 0 + // Spec: spec/services/notifications.md#register func register() { logger.debug("BGManager.register") BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in @@ -39,6 +42,7 @@ class BGManager { } } + // Spec: spec/services/notifications.md#schedule func schedule(interval: TimeInterval? = nil) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.schedule: disabled") @@ -66,6 +70,7 @@ class BGManager { Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval } + // Spec: spec/services/notifications.md#handleRefresh private func handleRefresh(_ task: BGAppRefreshTask) { if !ChatModel.shared.ntfEnableLocal { logger.debug("BGManager.handleRefresh: disabled") @@ -103,6 +108,7 @@ class BGManager { } } + // Spec: spec/services/notifications.md#receiveMessages-BG func receiveMessages(_ completeReceiving: @escaping (String) -> Void) { if (!self.completed) { logger.debug("BGManager.receiveMessages: in progress, exiting") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f1f4e686bd..46e9df1ef8 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 22/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md import Foundation import Combine @@ -53,6 +54,7 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { } // analogue for SecondaryContextFilter in Kotlin +// Spec: spec/state.md#SecondaryItemsModelFilter enum SecondaryItemsModelFilter { case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) case msgContentTagContext(contentTag: MsgContentTag) @@ -68,6 +70,7 @@ enum SecondaryItemsModelFilter { } // analogue for ChatsContext in Kotlin +// Spec: spec/state.md#ItemsModel class ItemsModel: ObservableObject { static let shared = ItemsModel(secondaryIMFilter: nil) public var secondaryIMFilter: SecondaryItemsModelFilter? @@ -103,12 +106,14 @@ class ItemsModel: ObservableObject { .store(in: &bag) } + // Spec: spec/state.md#loadSecondaryChat static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { let im = ItemsModel(secondaryIMFilter: chatFilter) ChatModel.shared.secondaryIM = im im.loadOpenChat(chatId, willNavigate: willNavigate) } + // Spec: spec/state.md#loadOpenChat func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -134,6 +139,7 @@ class ItemsModel: ObservableObject { } } + // Spec: spec/state.md#loadOpenChatNoWait func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -179,6 +185,7 @@ class PreloadState { } } +// Spec: spec/state.md#ChatTagsModel class ChatTagsModel: ObservableObject { static let shared = ChatTagsModel() @@ -326,6 +333,7 @@ class ConnectProgressManager: ObservableObject { } } +// Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false @@ -383,6 +391,7 @@ final class ChatModel: ObservableObject { @Published var showCallView = false @Published var activeCallViewIsCollapsed = false // remote desktop + // Spec: spec/architecture.md#remoteCtrlSession @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? @@ -423,6 +432,7 @@ final class ChatModel: ObservableObject { userAddress?.shortLinkDataSet ?? true } + // Spec: spec/state.md#getUser func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -433,6 +443,7 @@ final class ChatModel: ObservableObject { users.firstIndex { $0.user.userId == user.userId } } + // Spec: spec/state.md#updateUser func updateUser(_ user: User) { if let i = getUserIndex(user) { users[i].user = user @@ -442,6 +453,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#removeUser func removeUser(_ user: User) { if let i = getUserIndex(user) { users.remove(at: i) @@ -452,6 +464,7 @@ final class ChatModel: ObservableObject { chats.first(where: { $0.id == id }) != nil } + // Spec: spec/state.md#getChat func getChat(_ id: String) -> Chat? { chats.first(where: { $0.id == id }) } @@ -506,6 +519,7 @@ final class ChatModel: ObservableObject { chats.firstIndex(where: { $0.id == id }) } + // Spec: spec/state.md#addChat func addChat(_ chat: Chat) { if chatId == nil { withAnimation { addChat_(chat, at: 0) } @@ -519,6 +533,7 @@ final class ChatModel: ObservableObject { chats.insert(chat, at: position) } + // Spec: spec/state.md#updateChatInfo func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { @@ -570,6 +585,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#replaceChat func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { chats[i] = chat @@ -1054,6 +1070,7 @@ final class ChatModel: ObservableObject { NtfManager.shared.changeNtfBadgeCount(by: by) } + // Spec: spec/state.md#totalUnreadCountForAllUsers func totalUnreadCountForAllUsers() -> Int { var unread: Int = 0 for chat in chats { @@ -1153,6 +1170,7 @@ final class ChatModel: ObservableObject { return (prevMember, memberIds.count) } + // Spec: spec/state.md#popChat func popChat(_ id: String) { if let i = getChatIndex(id) { // no animation here, for it not to look like it just moved when leaving the chat @@ -1176,6 +1194,7 @@ final class ChatModel: ObservableObject { showingInvitation?.connChatUsed = true } + // Spec: spec/state.md#removeChat func removeChat(_ id: String) { withAnimation { if let i = getChatIndex(id) { @@ -1248,6 +1267,7 @@ struct NTFContactRequest { var chatId: String } +// Spec: spec/state.md#Chat final class Chat: ObservableObject, Identifiable, ChatLike { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 79f4ef2f09..c6c6e88d8c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 08/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UserNotifications @@ -22,6 +23,7 @@ enum NtfCallAction { case reject } +// Spec: spec/services/notifications.md#NtfManager class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { static let shared = NtfManager() @@ -48,6 +50,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { handler() } + // Spec: spec/services/notifications.md#processNotificationResponse func processNotificationResponse(_ ntfResponse: UNNotificationResponse) { let chatModel = ChatModel.shared let content = ntfResponse.notification.request.content @@ -149,6 +152,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { return false } + // Spec: spec/services/notifications.md#registerCategories func registerCategories() { logger.debug("NtfManager.registerCategories") UNUserNotificationCenter.current().setNotificationCategories([ @@ -207,6 +211,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { ]) } + // Spec: spec/services/notifications.md#requestAuthorization func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) { logger.debug("NtfManager.requestAuthorization") let center = UNUserNotificationCenter.current() @@ -230,6 +235,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { } } + // Spec: spec/services/notifications.md#notifyContactRequest func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") addNotification(createContactRequestNtf(user, contactRequest, 0)) @@ -240,6 +246,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createContactConnectedNtf(user, contact, 0)) } + // Spec: spec/services/notifications.md#notifyMessageReceived func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") if cInfo.ntfsEnabled(chatItem: cItem) { @@ -247,16 +254,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { } } + // Spec: spec/services/notifications.md#notifyCallInvitation func notifyCallInvitation(_ invitation: RcvCallInvitation) { logger.debug("NtfManager.notifyCallInvitation") addNotification(createCallInvitationNtf(invitation, 0)) } + // Spec: spec/services/notifications.md#setNtfBadgeCount func setNtfBadgeCount(_ count: Int) { UIApplication.shared.applicationIconBadgeNumber = count ntfBadgeCountGroupDefault.set(count) } + // Spec: spec/services/notifications.md#changeNtfBadgeCount func changeNtfBadgeCount(by count: Int = 1) { setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count)) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 46ee753438..7eb2de11ab 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md | spec/architecture.md import Foundation import UIKit @@ -49,6 +50,7 @@ enum TerminalItem: Identifiable { } } +// Spec: spec/architecture.md#beginBGTask func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { var id: UIBackgroundTaskIdentifier! var running = true @@ -86,12 +88,14 @@ private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } +// Spec: spec/api.md#chatSendCmdSync @inline(__always) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R { let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdSync func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult { if log { logger.debug("chatSendCmd \(cmd.cmdType)") @@ -112,12 +116,14 @@ func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = tru return resp } +// Spec: spec/api.md#chatSendCmd @inline(__always) func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R { let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdWithRetry func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) if inProgress == nil || inProgress?.boxedValue == true, @@ -210,6 +216,7 @@ func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String) String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer)) } +// Spec: spec/api.md#chatApiSendCmd @inline(__always) func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult { await withCheckedContinuation { cont in @@ -226,6 +233,7 @@ func apiResult(_ res: APIResult) throws -> R { } } +// Spec: spec/api.md#chatRecvMsg func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in @@ -346,6 +354,7 @@ func apiStopChat() async throws { } } +// Spec: spec/architecture.md#apiActivateChat func apiActivateChat() { chatReopenStore() do { @@ -355,6 +364,7 @@ func apiActivateChat() { } } +// Spec: spec/architecture.md#apiSuspendChat func apiSuspendChat(timeoutMicroseconds: Int) { do { try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) @@ -363,12 +373,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) { } } +// Spec: spec/services/files.md#apiSetAppFilePaths func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl) if case .cmdOk = r { return } throw r.unexpected } +// Spec: spec/services/files.md#apiSetEncryptLocalFiles func apiSetEncryptLocalFiles(_ enable: Bool) throws { try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable)) } @@ -1455,6 +1467,7 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF } } +// Spec: spec/services/files.md#receiveFile func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { await receiveFiles( user: user, @@ -1573,6 +1586,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } } +// Spec: spec/services/files.md#cancelFile func cancelFile(user: User, fileId: Int64) async { if let chatItem = await apiCancelFile(fileId: fileId) { await chatItemSimpleUpdate(user, chatItem) @@ -1595,12 +1609,14 @@ func setLocalDeviceName(_ displayName: String) throws { try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) } +// Spec: spec/architecture.md#connectRemoteCtrl func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } throw r.unexpected } +// Spec: spec/architecture.md#findKnownRemoteCtrl func findKnownRemoteCtrl() async throws { try await sendCommandOkResp(.findKnownRemoteCtrl) } @@ -2078,6 +2094,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws { } } +// Spec: spec/architecture.md#startChat func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { logger.debug("startChat") let m = ChatModel.shared @@ -2199,6 +2216,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws { } } +// Spec: spec/architecture.md#ChatReceiver class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -2244,6 +2262,7 @@ class ChatReceiver { } } +// Spec: spec/api.md#processReceivedMsg func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared logger.debug("processReceivedMsg: \(res.responseType)") diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index e1a6bb61e8..1e9a97c31b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/architecture.md import SwiftUI import OSLog @@ -12,6 +13,7 @@ import SimpleXChat let logger = Logger() @main +// Spec: spec/architecture.md#SimpleXApp struct SimpleXApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var chatModel = ChatModel.shared @@ -60,6 +62,7 @@ struct SimpleXApp: App { } } } +// Spec: spec/architecture.md#scenePhaseHandling .onChange(of: scenePhase) { phase in logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))") AppSheetState.shared.scenePhaseActive = phase == .active diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index 3bd8f00c25..1f98b23a1d 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import SimpleXChat +// Spec: spec/services/theme.md#CurrentColors var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } } @@ -17,6 +18,7 @@ var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 } func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight } +// Spec: spec/services/theme.md#AppTheme class AppTheme: ObservableObject, Equatable { static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper) @@ -89,6 +91,7 @@ struct ThemedBackground: ViewModifier { } } +// Spec: spec/services/theme.md#systemInDarkThemeCurrently var systemInDarkThemeCurrently: Bool { return UITraitCollection.current.userInterfaceStyle == .dark } diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift index 4166619d04..b9a35163cf 100644 --- a/apps/ios/Shared/Theme/ThemeManager.swift +++ b/apps/ios/Shared/Theme/ThemeManager.swift @@ -5,12 +5,15 @@ // Created by Avently on 03.06.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/services/theme.md import Foundation import SwiftUI import SimpleXChat +// Spec: spec/services/theme.md#ThemeManager class ThemeManager { + // Spec: spec/services/theme.md#ActiveTheme struct ActiveTheme: Equatable { let name: String let base: DefaultTheme @@ -41,6 +44,7 @@ class ThemeManager { } } + // Spec: spec/services/theme.md#defaultActiveTheme static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? { let nonSystemThemeName = nonSystemThemeName() let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName] @@ -56,6 +60,7 @@ class ThemeManager { return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil)) } + // Spec: spec/services/theme.md#currentColors static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme { let themeName = currentThemeDefault.get() let nonSystemThemeName = nonSystemThemeName() @@ -96,6 +101,7 @@ class ThemeManager { ) } + // Spec: spec/services/theme.md#currentThemeOverridesForExport static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides { let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get()) let wType = current.wallpaper.type @@ -114,6 +120,7 @@ class ThemeManager { ) } + // Spec: spec/services/theme.md#applyTheme static func applyTheme(_ theme: String) { currentThemeDefault.set(theme) CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get()) @@ -125,6 +132,7 @@ class ThemeManager { // applyNavigationBarColors(CurrentColors.toAppTheme()) } + // Spec: spec/services/theme.md#adjustWindowStyle static func adjustWindowStyle() { let style = switch currentThemeDefault.get() { case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light @@ -161,6 +169,7 @@ class ThemeManager { AppTheme.shared.updateFromCurrentColors() } + // Spec: spec/services/theme.md#saveAndApplyThemeColor static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) { let nonSystemThemeName = baseTheme.themeName let pref = pref ?? themeOverridesDefault @@ -178,6 +187,7 @@ class ThemeManager { pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex()) } + // Spec: spec/services/theme.md#saveAndApplyWallpaper static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) { let nonSystemThemeName = baseTheme.themeName let pref = pref ?? themeOverridesDefault @@ -253,6 +263,7 @@ class ThemeManager { pref.wrappedValue = prevValue } + // Spec: spec/services/theme.md#saveAndApplyThemeOverrides static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) { let wallpaper = theme.wallpaper?.importFromString() let nonSystemThemeName = theme.base.themeName @@ -273,6 +284,7 @@ class ThemeManager { applyTheme(nonSystemThemeName) } + // Spec: spec/services/theme.md#resetAllThemeColors static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) { let nonSystemThemeName = nonSystemThemeName() let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault @@ -295,6 +307,7 @@ class ThemeManager { pref.wrappedValue = prevValue } + // Spec: spec/services/theme.md#removeTheme static func removeTheme(_ themeId: String?) { var themes = themeOverridesDefault.get().map { $0 } themes.removeAll(where: { $0.themeId == themeId }) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index ab7a47b944..754bcb2715 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import SwiftUI import WebKit import SimpleXChat import AVFoundation +// Spec: spec/services/calls.md#ActiveCallView struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @@ -282,6 +284,7 @@ struct ActiveCallView: View { } } +// Spec: spec/services/calls.md#ActiveCallOverlay struct ActiveCallOverlay: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var call: Call @@ -350,6 +353,7 @@ struct ActiveCallOverlay: View { } } + // Spec: spec/services/calls.md#audioCallInfoView private func audioCallInfoView(_ call: Call) -> some View { VStack { Text(call.contact.chatViewName) @@ -399,6 +403,7 @@ struct ActiveCallOverlay: View { } } + // Spec: spec/services/calls.md#endCallButton private func endCallButton() -> some View { let cc = CallController.shared return callButton("phone.down.fill", .red, padding: 10) { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 1f28180e87..9df0c2f0b7 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 21/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import Foundation import CallKit @@ -14,6 +15,7 @@ import AVFoundation import SimpleXChat import WebRTC +// Spec: spec/services/calls.md#CallController class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { static let shared = CallController() static let isInChina = SKStorefront().countryCode == "CHN" @@ -49,6 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse logger.debug("CallController.providerDidReset") } + // Spec: spec/services/calls.md#CXStartCallAction func provider(_ provider: CXProvider, perform action: CXStartCallAction) { logger.debug("CallController.provider CXStartCallAction") if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) { @@ -59,6 +62,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXAnswerCallAction func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { logger.debug("CallController.provider CXAnswerCallAction") Task { @@ -88,6 +92,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXEndCallAction func provider(_ provider: CXProvider, perform action: CXEndCallAction) { logger.debug("CallController.provider CXEndCallAction") // Should be nil here if connection was in connected state @@ -103,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#CXSetMutedCallAction func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) { if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) { action.fulfill() @@ -192,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)") } + // Spec: spec/services/calls.md#pushRegistryDidReceive func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) { logger.debug("CallController: did receive push with type \(type.rawValue)") if type != .voIP { @@ -276,6 +283,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse reportExpiredCall(update: update, completion) } + // Spec: spec/services/calls.md#reportNewIncomingCall func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) { logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))") if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) { @@ -316,6 +324,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse } } + // Spec: spec/services/calls.md#reportOutgoingCall func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) { logger.debug("CallController: reporting outgoing call connected") if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) { @@ -422,6 +431,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse provider.configuration = conf } + // Spec: spec/services/calls.md#hasActiveCalls func hasActiveCalls() -> Bool { controller.callObserver.calls.count > 0 } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index db7910836e..2ce04e4b80 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -2,12 +2,14 @@ // Created by Avently on 09.02.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import WebRTC import LZString import SwiftUI import SimpleXChat +// Spec: spec/services/calls.md#WebRTCClient final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDelegate, RTCFrameDecryptorDelegate { private static let factory: RTCPeerConnectionFactory = { RTCInitializeSSL() @@ -87,6 +89,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), ] + // Spec: spec/services/calls.md#initializeCall func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay) connection.delegate = self @@ -132,6 +135,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg ) } + // Spec: spec/services/calls.md#createPeerConnection func createPeerConnection(_ iceServers: [WebRTC.RTCIceServer], _ relay: Bool?) -> RTCPeerConnection { let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue]) @@ -157,6 +161,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg return config } + // Spec: spec/services/calls.md#addIceCandidates func addIceCandidates(_ connection: RTCPeerConnection, _ remoteIceCandidates: [RTCIceCandidate]) { remoteIceCandidates.forEach { candidate in connection.add(candidate.toWebRTCCandidate()) { error in @@ -167,6 +172,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#sendCallCommand func sendCallCommand(command: WCallCommand) async { var resp: WCallResponse? = nil let pc = activeCall?.connection @@ -295,6 +301,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#sendIceCandidates func sendIceCandidates(_ candidates: [RTCIceCandidate]) async { await self.sendCallResponse(.init( corrId: nil, @@ -353,6 +360,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#enableMedia @MainActor func enableMedia(_ source: CallMediaSource, _ enable: Bool) { logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)") @@ -411,6 +419,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg localRendererAspectRatio.wrappedValue = size.width / size.height } + // Spec: spec/services/calls.md#setupLocalTracks func setupLocalTracks(_ incomingCall: Bool, _ call: Call) { let pc = call.connection let transceivers = call.connection.transceivers @@ -490,6 +499,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } // Should be called after local description set + // Spec: spec/services/calls.md#setupEncryptionForLocalTracks func setupEncryptionForLocalTracks(_ call: Call) { if let encryptor = call.frameEncryptor { call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) } @@ -567,6 +577,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } } + // Spec: spec/services/calls.md#startCaptureLocalVideo func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) { #if targetEnvironment(simulator) guard @@ -630,6 +641,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg return (localCamera, localVideoTrack) } + // Spec: spec/services/calls.md#endCall func endCall() { if #available(iOS 16.0, *) { _endCall() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ad82af05e2..c17d8e23a8 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 05/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI @preconcurrency import SimpleXChat @@ -88,6 +89,7 @@ enum SendReceipts: Identifiable, Hashable { } } +// Spec: spec/client/chat-view.md#ChatInfoView struct ChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift index 30f5e7a589..93ffb9f042 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift @@ -2,10 +2,12 @@ // Created by Avently on 19.12.2022. // Copyright (c) 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import UIKit import SwiftUI +// Spec: spec/client/chat-view.md#AnimatedImageView class AnimatedImageView: UIView { var image: UIImage? = nil var imageView: UIImageView? = nil diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 0283e9c07e..e5f3c05eed 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 20/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index b2b4441646..5521470d07 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 21/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIChatFeatureView struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel @Environment(\.revealed) var revealed: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 1375b87a5a..49a086d45a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 20.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIEventView struct CIEventView: View { var eventText: Text diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index 67f7b69e2c..dcd6ea579c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 21/12/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIFeaturePreferenceView struct CIFeaturePreferenceView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 1b9376b5db..639de1dbc9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 28/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIFileView struct CIFileView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 3fcf578875..ddb58fdfd1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 15.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIGroupInvitationView struct CIGroupInvitationView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index d1f49f635a..8b5172eccf 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 12/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIImageView struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 5e9fa691de..80cccbf907 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 29.12.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIInvalidJSONView struct CIInvalidJSONView: View { @EnvironmentObject var theme: AppTheme var json: Data? diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index f07e90b953..a09518ffdb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -5,10 +5,12 @@ // Created by Ian Davies on 07/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CILinkView struct CILinkView: View { @EnvironmentObject var theme: AppTheme let linkPreview: LinkPreview diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index 2898a318a9..4719c3dcdc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -5,10 +5,12 @@ // Created by spaced4ndy on 19.09.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIMemberCreatedContactView struct CIMemberCreatedContactView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index fc73778239..e3bc654ac9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 11/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIMetaView struct CIMetaView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 3201332c1e..ec23dc15a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 15/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." +// Spec: spec/client/chat-view.md#CIRcvDecryptionError struct CIRcvDecryptionError: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index eacbe9360a..80bea997d3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -5,12 +5,14 @@ // Created by Avently on 30/03/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import AVKit import SimpleXChat import Combine +// Spec: spec/client/chat-view.md#CIVideoView struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 47aee2a586..820074542f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 22.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIVoiceView struct CIVoiceView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index ed2340b6c4..fb5d36ab12 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#DeletedItemView struct DeletedItemView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 250d9d5636..04f36c97a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#EmojiItemView struct EmojiItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 0b6f249b9c..123f7289bb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 22.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#FramedCIVoiceView struct FramedCIVoiceView: View { @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index c9c9952688..ec8bc852c0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny Poberezkin on 04/02/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#FramedItemView struct FramedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index f243a83142..e14683684d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -5,12 +5,14 @@ // Created by Evgeny on 08/10/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat import SwiftyGif import AVKit +// Spec: spec/client/chat-view.md#FullScreenMediaView struct FullScreenMediaView: View { @EnvironmentObject var m: ChatModel @State var chatItem: ChatItem diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 47a30f6cf3..fdf3743aac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 28/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#IntegrityErrorItemView struct IntegrityErrorItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c6a5d0353c..953f4e8c82 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 30.11.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#MarkedDeletedItemView struct MarkedDeletedItemView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 2a1b526893..852c8bbbac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 13/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -23,6 +24,7 @@ private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont. return res } +// Spec: spec/client/chat-view.md#MsgContentView struct MsgContentView: View { @ObservedObject var chat: Chat @Environment(\.showTimestamp) var showTimestamp: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 87c6ba92f8..3858d15252 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#ChatItemInfoView struct ChatItemInfoView: View { @EnvironmentObject var chatModel: ChatModel @Environment(\.dismiss) var dismiss diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5f48c18881..f72bf083f6 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -38,6 +38,7 @@ extension EnvironmentValues { } } +// Spec: spec/client/chat-view.md#ChatItemView struct ChatItemView: View { @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index dc1228fce8..057bf7f75f 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -13,6 +14,7 @@ import Combine private let memberImageSize: CGFloat = 34 +// Spec: spec/client/chat-view.md#ChatView struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -70,6 +72,7 @@ struct ChatView: View { let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + // Spec: spec/client/chat-view.md#body var body: some View { if #available(iOS 16.0, *) { viewBody @@ -668,6 +671,7 @@ struct ChatView: View { .frame(width: 220) } + // Spec: spec/client/chat-view.md#initChatView private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. @@ -727,6 +731,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#scrollToItem private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { @@ -760,6 +765,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchToolbar private func searchToolbar() -> some View { let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search" return HStack(spacing: 12) { @@ -797,6 +803,7 @@ struct ChatView: View { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } + // Spec: spec/client/chat-view.md#filtered private func filtered(_ reversedChatItems: Array) -> Array { reversedChatItems .enumerated() @@ -810,6 +817,7 @@ struct ChatView: View { .map { $0.element } } + // Spec: spec/client/chat-view.md#chatItemsList private func chatItemsList() -> some View { let cInfo = chat.chatInfo return GeometryReader { g in @@ -1083,6 +1091,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchTextChanged private func searchTextChanged(_ s: String) { Task { await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s) @@ -1260,6 +1269,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#callButton private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { CallController.shared.startCall(contact, media) @@ -1397,6 +1407,7 @@ struct ChatView: View { )) } + // Spec: spec/client/chat-view.md#deletedSelectedMessages private func deletedSelectedMessages() async { await MainActor.run { withAnimation { @@ -1405,6 +1416,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#forwardSelectedMessages private func forwardSelectedMessages() { Task { do { @@ -1515,6 +1527,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#loadChatItems private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { if loadingMoreItems { return false } await MainActor.run { @@ -1555,6 +1568,7 @@ struct ChatView: View { VoiceItemState.chatView = [:] } + // Spec: spec/client/chat-view.md#onChatItemsUpdated func onChatItemsUpdated() { if !mergedItems.boxedValue.isActualState() { //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") @@ -1582,6 +1596,7 @@ struct ChatView: View { ) } + // Spec: spec/client/chat-view.md#ChatItemWithMenu private struct ChatItemWithMenu: View { @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @@ -2693,6 +2708,7 @@ struct ChatView: View { } } +// Spec: spec/client/chat-view.md#FloatingButtonModel class FloatingButtonModel: ObservableObject { @ObservedObject var im: ItemsModel @@ -2775,6 +2791,7 @@ private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } +// Spec: spec/client/chat-view.md#deleteMessages private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) { let itemIds = deletingItems if itemIds.count > 0 { @@ -2878,6 +2895,7 @@ private func buildTheme() -> AppTheme { } } +// Spec: spec/client/chat-view.md#ReactionContextMenu struct ReactionContextMenu: View { @EnvironmentObject var m: ChatModel let groupInfo: GroupInfo @@ -3027,6 +3045,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { } } +// Spec: spec/client/chat-view.md#ContentFilter enum ContentFilter: CaseIterable { case images case videos diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3745d0f0b8..2c462df9e4 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1,3 +1,4 @@ +// Spec: spec/client/compose.md import SwiftUI import SimpleXChat @@ -6,6 +7,7 @@ import PhotosUI let MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -14,6 +16,7 @@ enum ComposePreview { case filePreview(fileName: String, file: URL) } +// Spec: spec/client/compose.md#ComposeContextItem enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) @@ -22,12 +25,14 @@ enum ComposeContextItem: Equatable { case reportedItem(chatItem: ChatItem, reason: ReportReason) } +// Spec: spec/client/compose.md#VoiceMessageRecordingState enum VoiceMessageRecordingState { case noRecording case recording case finished } +// Spec: spec/client/compose.md#LiveMessage struct LiveMessage { var chatItem: ChatItem var typedMsg: String @@ -36,6 +41,7 @@ struct LiveMessage { typealias MentionedMembers = [String: CIMention] +// Spec: spec/client/compose.md#ComposeState struct ComposeState { var message: String var parsedMessage: [FormattedText] @@ -256,6 +262,7 @@ struct ComposeState { } } +// Spec: spec/client/compose.md#chatItemPreview func chatItemPreview(chatItem: ChatItem) -> ComposePreview { switch chatItem.content.msgContent { case .text: @@ -276,6 +283,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { } } +// Spec: spec/client/compose.md#UploadContent enum UploadContent: Equatable { case simpleImage(image: UIImage) case animatedImage(image: UIImage) @@ -317,6 +325,7 @@ enum UploadContent: Equatable { } } +// Spec: spec/client/compose.md#ComposeView struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -356,6 +365,7 @@ struct ComposeView: View { @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false @State private var updatingCompose = false + // Spec: spec/client/compose.md#body var body: some View { VStack(spacing: 0) { Divider() @@ -679,6 +689,7 @@ struct ComposeView: View { .padding(.horizontal, 12) } + // Spec: spec/client/compose.md#sendMessageView private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View { ZStack(alignment: .leading) { SendMessageView( @@ -878,6 +889,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#addMediaContent private func addMediaContent(_ content: UploadContent) async { if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { var newMedia: [(String, UploadContent?)] = [] @@ -906,6 +918,7 @@ struct ComposeView: View { getMaxFileSize(.xftp) } + // Spec: spec/client/compose.md#sendLiveMessage private func sendLiveMessage() async { let typedMsg = composeState.message let lm = composeState.liveMessage @@ -923,6 +936,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#updateLiveMessage private func updateLiveMessage() async { let typedMsg = composeState.message if let liveMessage = composeState.liveMessage { @@ -941,6 +955,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#liveMessageToSend private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil @@ -1087,6 +1102,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessage private func sendMessage(ttl: Int?) { logger.debug("ChatView sendMessage") Task { @@ -1095,6 +1111,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessageAsync private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message @@ -1361,6 +1378,7 @@ struct ComposeView: View { await MainActor.run { composeState.inProgress = true } } + // Spec: spec/client/compose.md#startVoiceMessageRecording private func startVoiceMessageRecording() async { startingRecording = true let fileName = generateNewFileName("voice", "m4a") @@ -1401,6 +1419,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#finishVoiceMessageRecording private func finishVoiceMessageRecording() { audioRecorder?.stop() audioRecorder = nil @@ -1411,6 +1430,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#allowVoiceMessagesToContact private func allowVoiceMessagesToContact() { if case let .direct(contact) = chat.chatInfo { allowFeatureToContact(contact, .voice) @@ -1436,12 +1456,14 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#cancelVoiceMessageRecording private func cancelVoiceMessageRecording(_ fileName: String) { stopPlayback.toggle() audioRecorder?.stop() removeFile(fileName) } + // Spec: spec/client/compose.md#clearState private func clearState(live: Bool = false) { if live { composeState.inProgress = false @@ -1455,11 +1477,13 @@ struct ComposeView: View { startingRecording = false } + // Spec: spec/client/compose.md#saveCurrentDraft private func saveCurrentDraft() { chatModel.draft = composeState chatModel.draftChatId = chat.id } + // Spec: spec/client/compose.md#clearCurrentDraft private func clearCurrentDraft() { if chatModel.draftChatId == chat.id { chatModel.draft = nil @@ -1467,6 +1491,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#showLinkPreview private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg) @@ -1486,6 +1511,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#getMessageLinks private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) { guard let parsedMsg else { return (nil, false) } let simplexLink = parsedMsgHasSimplexLink(parsedMsg) @@ -1512,6 +1538,7 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) } + // Spec: spec/client/compose.md#loadLinkPreview private func loadLinkPreview(_ urlStr: String) { if pendingLinkUrl == urlStr, let url = URL(string: urlStr) { composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 07cd61583b..713f462c27 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -11,6 +11,7 @@ import SimpleXChat private let liveMsgInterval: UInt64 = 3000_000000 +// Spec: spec/client/compose.md#SendMessageView struct SendMessageView: View { var placeholder: String? @Binding var composeState: ComposeState diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 3154f16f5b..6b18c0c5ef 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 22.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 96b5e2898a..257d5aac93 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 14.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +// Spec: spec/client/chat-view.md#GroupChatInfoView struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index bc1ac4ab65..43bc26e8f8 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.10.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 207c2170a3..17a05ffca4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 25.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 4937bca20e..381057db5b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -40,6 +40,7 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes { dynamicSizes[font] ?? defaultDynamicSizes } +// Spec: spec/client/chat-list.md#ChatListNavLink struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -90,6 +91,7 @@ struct ChatListNavLink: View { .actionSheet(item: $actionSheet) { $0.actionSheet } } + // Spec: spec/client/chat-list.md#contactNavLink private func contactNavLink(_ contact: Contact) -> some View { Group { if contact.isContactCard { @@ -211,6 +213,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#groupNavLink @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: @@ -295,6 +298,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#noteFolderNavLink private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { NavLinkPlain( chatId: chat.chatInfo.id, @@ -325,6 +329,7 @@ struct ChatListNavLink: View { .tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) } + // Spec: spec/client/chat-list.md#markReadButton @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { @@ -344,6 +349,7 @@ struct ChatListNavLink: View { } + // Spec: spec/client/chat-list.md#toggleFavoriteButton @ViewBuilder private func toggleFavoriteButton() -> some View { if chat.chatInfo.chatSettings?.favorite == true { Button { @@ -362,6 +368,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#toggleNtfsButton @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { if let nextMode = chat.chatInfo.nextNtfMode { Button { @@ -382,6 +389,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#clearChatButton private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) @@ -483,6 +491,7 @@ struct ChatListNavLink: View { .tint(.red) } + // Spec: spec/client/chat-list.md#contactRequestNavLink private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .frameCompat(height: dynamicRowHeight) @@ -517,6 +526,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#contactConnectionNavLink private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) .frameCompat(height: dynamicRowHeight) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index efaba518a9..d84fa29c81 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-list.md import SwiftUI import SimpleXChat @@ -31,6 +32,7 @@ enum UserPickerSheet: Identifiable { } } +// Spec: spec/client/chat-list.md#PresetTag enum PresetTag: Int, Identifiable, CaseIterable, Equatable { case groupReports = 0 case favorites = 1 @@ -46,6 +48,7 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable { } } +// Spec: spec/client/chat-list.md#ActiveFilter enum ActiveFilter: Identifiable, Equatable { case presetTag(PresetTag) case userTag(ChatTag) @@ -135,6 +138,7 @@ struct UserPickerSheetView: View { } } +// Spec: spec/client/chat-list.md#ChatListView struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -160,6 +164,7 @@ struct ChatListView: View { @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + // Spec: spec/client/chat-list.md#body var body: some View { if #available(iOS 16.0, *) { viewBody.scrollDismissesKeyboard(.immediately) @@ -445,6 +450,7 @@ struct ChatListView: View { } + // Spec: spec/client/chat-list.md#unreadBadge private func unreadBadge(size: CGFloat = 18) -> some View { Circle() .frame(width: size, height: size) @@ -464,11 +470,13 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#stopAudioPlayer func stopAudioPlayer() { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } + // Spec: spec/client/chat-list.md#filteredChats private func filteredChats() -> [Chat] { if let linkChatId = searchChatFilteredBySimplexLink { return chatModel.chats.filter { $0.id == linkChatId } @@ -511,6 +519,7 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#searchString func searchString() -> String { searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase } @@ -574,6 +583,7 @@ struct SubsStatusIndicator: View { } } +// Spec: spec/client/chat-list.md#ChatListSearchBar struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -875,6 +885,7 @@ struct TagsView: View { } } + // Spec: spec/client/chat-list.md#setActiveFilter private func setActiveFilter(filter: ActiveFilter) { if filter != chatTagsModel.activeFilter { chatTagsModel.activeFilter = filter @@ -895,6 +906,7 @@ func chatStoppedIcon() -> some View { } } +// Spec: spec/client/chat-list.md#presetTagMatchesChat func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool { switch tag { case .groupReports: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index be2c456802..112e4099c0 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#ChatPreviewView struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 79d122eabf..f484ce8938 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -16,6 +16,7 @@ struct TagEditorNavParams { let tagId: Int64? } +// Spec: spec/client/chat-list.md#TagListView struct TagListView: View { var chat: Chat? = nil @Environment(\.dismiss) var dismiss: DismissAction diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b1cd4015c6..63d28e3624 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -6,6 +6,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#UserPicker struct UserPicker: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 441a164f8a..dbc25e536f 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -33,6 +34,7 @@ enum DatabaseEncryptionAlert: Identifiable { } } +// Spec: spec/database.md#DatabaseEncryptionView struct DatabaseEncryptionView: View { @EnvironmentObject private var m: ChatModel @EnvironmentObject private var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 02a1b87826..9610b4a24d 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index a7e61b3105..d5d70abaea 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 19/06/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -41,6 +42,7 @@ enum DatabaseAlert: Identifiable { } } +// Spec: spec/database.md#DatabaseView struct DatabaseView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 79c0a42ae0..76bdc898d5 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 20/06/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index c21ff9be8b..36608c58d6 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift index 4a6f8e7549..6df31b4d59 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift index ca30fa5ce8..046a3fd1fc 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 11/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift index 7ec3ee1a42..995b9f5b0d 100644 --- a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 0af8fa7ad8..2ff376701c 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 14.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 93fe19cf33..a28acfcba1 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 23.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 3de1fdb972..71a155949b 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.11.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -73,6 +74,7 @@ func showKeepInvitationAlert() { ChatModel.shared.showingInvitation = nil } +// Spec: spec/client/navigation.md#NewChatView struct NewChatView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -1163,6 +1165,7 @@ private func showOpenKnownGroupAlert( ) } +// Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, theme: AppTheme, diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index c9054f30da..2b38065bd9 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 30/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import CoreImage.CIFilterBuiltins diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift index c8d0faafa7..f22d59fcac 100644 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -5,6 +5,7 @@ // Created by Diogo Cunha on 13/11/2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 33ffa04a50..b5598c1f85 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 31.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index f119beec50..7301c0421d 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 03b0fcba1a..ab84bed7df 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.04.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import Contacts diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index 7452d74e91..263b55a42d 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 08/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 8f448dc508..daef95fbc6 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -5,9 +5,11 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI +// Spec: spec/client/navigation.md#OnboardingView struct OnboardingView: View { var onboarding: OnboardingStage @@ -40,6 +42,7 @@ func onboardingButtonPlaceholder() -> some View { Spacer().frame(height: 40) } +// Spec: spec/client/navigation.md#onboardingStage enum OnboardingStage: String, Identifiable { case step1_SimpleXInfo case step2_CreateProfile // deprecated diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 31865e7af9..717405b03b 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 03/07/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 9f41a37b1d..80f35c1190 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 07/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 916e3f9e78..8a7ab465d4 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 24/12/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 02dec5a618..54a60eed19 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 03/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/theme.md import SwiftUI import SimpleXChat @@ -21,6 +22,7 @@ let darkThemesWithoutBlackNames: [String] = [DefaultTheme.DARK.themeName, Defaul let appSettingsURL = URL(string: UIApplication.openSettingsURLString)! +// Spec: spec/services/theme.md#AppearanceSettings struct AppearanceSettings: View { @EnvironmentObject var m: ChatModel @Environment(\.colorScheme) var colorScheme @@ -313,6 +315,7 @@ struct AppearanceSettings: View { } } +// Spec: spec/services/theme.md#ToolbarMaterial enum ToolbarMaterial: String, CaseIterable { case bar case ultraThin @@ -596,6 +599,7 @@ struct CustomizeThemeView: View { } } +// Spec: spec/services/theme.md#ImportExportThemeSection struct ImportExportThemeSection: View { @EnvironmentObject var theme: AppTheme @Binding var showFileImporter: Bool @@ -632,6 +636,7 @@ struct ImportExportThemeSection: View { } } +// Spec: spec/services/theme.md#ThemeImporter struct ThemeImporter: ViewModifier { @Binding var isPresented: Bool var save: (ThemeOverrides) -> Void @@ -1141,6 +1146,7 @@ private func removeUserThemeModeOverrides(_ themeUserDestination: Binding<(Int64 wallpaperFilesToDelete.forEach(removeWallpaperFile) } +// Spec: spec/services/theme.md#decodeYAML private func decodeYAML(_ string: String) -> T? { do { return try YAMLDecoder().decode(T.self, from: string) @@ -1150,6 +1156,7 @@ private func decodeYAML(_ string: String) -> T? { } } +// Spec: spec/services/theme.md#encodeThemeOverrides private func encodeThemeOverrides(_ value: ThemeOverrides) throws -> String { let encoder = YAMLEncoder() encoder.options = YAMLEncoder.Options(sequenceStyle: .block, mappingStyle: .block, newLineScalarStyle: .doubleQuoted) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 3a536c7b17..74d38b050b 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 1e38b7d5ec..6f76e69182 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -5,6 +5,7 @@ // Created by Stanislav Dmitrenko on 26.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import WebKit diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 6f4710396a..64e3d15de0 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index c8cb2349e7..b44271bd89 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 13.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index afbccc109c..abd8be03b9 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 97bfd360cb..97bf9ebc93 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index b9737914ec..49e1ff79ea 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift index b28b1a4d1e..fd29fd906e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 19/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index cb6fdf8597..c091224098 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 31/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import StoreKit diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index ddfe59e719..ad3b5cdf95 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -2,6 +2,7 @@ // Created by Avently on 17.01.2023. // Copyright (c) 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 5d619ac130..25df063f82 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import UserNotifications import OSLog @@ -22,6 +23,7 @@ let nseSuspendSchedule: SuspendSchedule = (2, 4) let fastNSESuspendSchedule: SuspendSchedule = (1, 1) +// Spec: spec/services/notifications.md#NSENotificationData public enum NSENotificationData { case connectionEvent(_ user: User, _ connEntity: ConnectionEntity) case contactConnected(_ user: any UserLike, _ contact: Contact) @@ -76,6 +78,7 @@ public enum NSENotificationData { // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid // background crashes and contention for database with the application (both UI and background fetch triggered either on schedule // or when background notification is received. +// Spec: spec/services/notifications.md#NSEThreads class NSEThreads { static let shared = NSEThreads() private let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock") @@ -238,6 +241,7 @@ class NSEThreads { // NotificationEntities for the same connection across multiple NSE instances (NSEThreads) are processed sequentially, so that the earliest NSE instance receives the earliest messages. // The reason for this complexity is to process all required messages within allotted 30 seconds, // accounting for the possibility that multiple notifications may be delivered concurrently. +// Spec: spec/services/notifications.md#NotificationEntity struct NotificationEntity { var ntfConn: NtfConn var entityId: ChatId @@ -279,6 +283,7 @@ struct NotificationEntity { // Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never // more than one process of notification service extension exists at a time. // Soon after notification service delivers the last notification it is either suspended or terminated. +// Spec: spec/services/notifications.md#NotificationService class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? // served as notification if no message attempts (msgBestAttemptNtf) could be produced @@ -291,6 +296,7 @@ class NotificationService: UNNotificationServiceExtension { var appSubscriber: AppSubscriber? var returnedSuspension = false + // Spec: spec/services/notifications.md#didReceive override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("DEBUGGING: NotificationService.didReceive") let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() } @@ -594,6 +600,7 @@ class NotificationService: UNNotificationServiceExtension { serviceBestAttemptNtf = ntf } + // Spec: spec/services/notifications.md#deliverBestAttemptNtf private func deliverBestAttemptNtf(urgent: Bool = false) { logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)") if let handler = contentHandler, urgent || !expectingMoreMessages { @@ -770,6 +777,7 @@ class NotificationService: UNNotificationServiceExtension { } // nseStateGroupDefault must not be used in NSE directly, only via this singleton +// Spec: spec/services/notifications.md#NSEChatState class NSEChatState { static let shared = NSEChatState() private var value_ = NSEState.created @@ -824,6 +832,7 @@ var networkConfig: NetCfg = getNetCfg() // startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller // Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active + // Spec: spec/services/notifications.md#startChat-NSE func startChat() -> DBMigrationResult? { logger.debug("NotificationService: startChat") // only skip creating if there is chat controller @@ -848,6 +857,7 @@ func startChat() -> DBMigrationResult? { } } + // Spec: spec/services/notifications.md#doStartChat func doStartChat() -> DBMigrationResult? { logger.debug("NotificationService: doStartChat") haskell_init_nse() @@ -940,6 +950,7 @@ func chatSuspended() { // A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state // If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will not be received. + // Spec: spec/services/notifications.md#receiveMessages func receiveMessages() async { logger.debug("NotificationService receiveMessages") while true { @@ -988,6 +999,7 @@ private let isInChina = SKStorefront().countryCode == "CHN" private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } @inline(__always) + // Spec: spec/services/notifications.md#receivedMsgNtf func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? { logger.debug("NotificationService receivedMsgNtf: \(res.responseType)") switch res { diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 40cee93faf..85c84a6f45 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -110,6 +110,7 @@ public func resetChatCtrl() { migrationResult = nil } +// Spec: spec/api.md#sendSimpleXCmd @inline(__always) public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult { if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index fce0f100f2..b31a799e68 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import Foundation import SwiftUI @@ -22,6 +23,7 @@ public func onOff(_ b: Bool) -> String { b ? "on" : "off" } +// Spec: spec/api.md#APIResult public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { case result(R) case error(ChatError) @@ -59,6 +61,7 @@ public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { } } +// Spec: spec/api.md#ChatAPIResult public protocol ChatAPIResult: Decodable { var responseType: String { get } var details: String { get } @@ -79,6 +82,7 @@ extension ChatAPIResult { } } +// Spec: spec/api.md#decodeAPIResult public func decodeAPIResult(_ d: Data) -> APIResult { // print("decodeAPIResult \(String(describing: R.self))") do { @@ -691,6 +695,7 @@ private func encodeCJSON(_ value: T) -> [CChar] { encodeJSON(value).cString(using: .utf8)! } +// Spec: spec/api.md#ChatError public enum ChatError: Decodable, Hashable, Error { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) @@ -713,6 +718,7 @@ public enum ChatError: Decodable, Hashable, Error { } } +// Spec: spec/api.md#ChatErrorType public enum ChatErrorType: Decodable, Hashable { case noActiveUser case noConnectionUser(agentConnId: String) diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index da1720c134..ece65130e6 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -5,10 +5,12 @@ // Created by Evgeny on 05/05/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/calls.md import Foundation import SwiftUI +// Spec: spec/services/calls.md#WebRTCCallOffer public struct WebRTCCallOffer: Encodable { public init(callType: CallType, rtcSession: WebRTCSession) { self.callType = callType @@ -19,6 +21,7 @@ public struct WebRTCCallOffer: Encodable { public var rtcSession: WebRTCSession } +// Spec: spec/services/calls.md#WebRTCSession public struct WebRTCSession: Codable { public init(rtcSession: String, rtcIceCandidates: String) { self.rtcSession = rtcSession @@ -29,6 +32,7 @@ public struct WebRTCSession: Codable { public var rtcIceCandidates: String } +// Spec: spec/services/calls.md#WebRTCExtraInfo public struct WebRTCExtraInfo: Codable { public init(rtcIceCandidates: String) { self.rtcIceCandidates = rtcIceCandidates @@ -37,6 +41,7 @@ public struct WebRTCExtraInfo: Codable { public var rtcIceCandidates: String } +// Spec: spec/services/calls.md#RcvCallInvitation public struct RcvCallInvitation: Decodable { public var user: User public var contact: Contact @@ -65,6 +70,7 @@ public struct RcvCallInvitation: Decodable { ) } +// Spec: spec/services/calls.md#CallType public struct CallType: Codable { public init(media: CallMediaType, capabilities: CallCapabilities) { self.media = media diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e1bf8614e2..c0b15666d2 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 26/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md | spec/api.md import Foundation import SwiftUI @@ -1367,6 +1368,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable { } } +// Spec: spec/state.md#ChatInfo public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { case direct(contact: Contact) case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) @@ -1871,6 +1873,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } } +// Spec: spec/state.md#ChatStats public struct ChatStats: Decodable, Hashable { public init( unreadCount: Int = 0, @@ -4234,6 +4237,7 @@ public struct CIFile: Decodable, Hashable { } } +// Spec: spec/services/files.md#CryptoFile public struct CryptoFile: Codable, Hashable { public var filePath: String // the name of the file, not a full path public var cryptoArgs: CryptoFileArgs? @@ -4281,6 +4285,7 @@ public struct CryptoFile: Codable, Hashable { static var decryptedUrls = Dictionary() } +// Spec: spec/services/files.md#CryptoFileArgs public struct CryptoFileArgs: Codable, Hashable { public var fileKey: String public var fileNonce: String diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index dfe833f832..5a0d48dced 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -4,6 +4,7 @@ // // Created by Evgeny on 05/09/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. +// Spec: spec/services/files.md // import Foundation @@ -13,6 +14,7 @@ enum WriteFileResult: Decodable { case error(writeError: String) } +// Spec: spec/services/files.md#writeCryptoFile public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { let ptr: UnsafeMutableRawPointer = malloc(data.count) memcpy(ptr, (data as NSData).bytes, data.count) @@ -25,6 +27,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs { } } +// Spec: spec/services/files.md#readCryptoFile public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data { var cPath = path.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! @@ -47,6 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D } } +// Spec: spec/services/files.md#encryptCryptoFile public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs { var cFromPath = fromPath.cString(using: .utf8)! var cToPath = toPath.cString(using: .utf8)! @@ -58,6 +62,7 @@ public func encryptCryptoFile(fromPath: String, toPath: String) throws -> Crypto } } +// Spec: spec/services/files.md#decryptCryptoFile public func decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) throws { var cFromPath = fromPath.cString(using: .utf8)! var cKey = cryptoArgs.fileKey.cString(using: .utf8)! diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 2341eb4a4f..3d0dd663c1 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.04.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/files.md import Foundation import OSLog @@ -13,14 +14,19 @@ import UIKit let logger = Logger() // image file size for complession +// Spec: spec/services/files.md#MAX_IMAGE_SIZE public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB +// Spec: spec/services/files.md#MAX_IMAGE_SIZE_AUTO_RCV public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 +// Spec: spec/services/files.md#MAX_VOICE_SIZE_AUTO_RCV public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 +// Spec: spec/services/files.md#MAX_VIDEO_SIZE_AUTO_RCV public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB +// Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max @@ -37,10 +43,12 @@ private let CHAT_DB_BAK: String = "_chat.db.bak" private let AGENT_DB_BAK: String = "_agent.db.bak" +// Spec: spec/database.md#getDocumentsDirectory public func getDocumentsDirectory() -> URL { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! } +// Spec: spec/database.md#getGroupContainerDirectory public func getGroupContainerDirectory() -> URL { FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)! } @@ -51,12 +59,14 @@ func getAppDirectory() -> URL { : getDocumentsDirectory() } +// Spec: spec/database.md#DB_FILE_PREFIX let DB_FILE_PREFIX = "simplex_v1" func getLegacyDatabasePath() -> URL { getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false) } +// Spec: spec/database.md#getAppDatabasePath public func getAppDatabasePath() -> URL { dbContainerGroupDefault.get() == .group ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false) @@ -72,6 +82,7 @@ func fileModificationDate(_ path: String) -> Date? { } } +// Spec: spec/services/files.md#deleteAppDatabaseAndFiles public func deleteAppDatabaseAndFiles() { let fm = FileManager.default let dbPath = getAppDatabasePath().path @@ -93,6 +104,7 @@ public func deleteAppDatabaseAndFiles() { storeDBPassphraseGroupDefault.set(true) } +// Spec: spec/services/files.md#deleteAppFiles public func deleteAppFiles() { let fm = FileManager.default do { @@ -183,6 +195,7 @@ public func removeLegacyDatabaseAndFiles() -> Bool { return r1 && r2 } +// Spec: spec/services/files.md#getTempFilesDirectory public func getTempFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("temp_files", isDirectory: true) } @@ -191,6 +204,7 @@ public func getMigrationTempFilesDirectory() -> URL { getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true) } +// Spec: spec/services/files.md#getAppFilesDirectory public func getAppFilesDirectory() -> URL { getAppDirectory().appendingPathComponent("app_files", isDirectory: true) } @@ -199,6 +213,7 @@ public func getAppFilePath(_ fileName: String) -> URL { getAppFilesDirectory().appendingPathComponent(fileName) } +// Spec: spec/services/files.md#getWallpaperDirectory public func getWallpaperDirectory() -> URL { getAppDirectory().appendingPathComponent("assets", isDirectory: true).appendingPathComponent("wallpapers", isDirectory: true) } @@ -207,6 +222,7 @@ public func getWallpaperFilePath(_ filename: String) -> URL { getWallpaperDirectory().appendingPathComponent(filename) } +// Spec: spec/services/files.md#saveFile public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? { let filePath = getAppFilePath(fileName) do { @@ -223,6 +239,7 @@ public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> Crypt } } +// Spec: spec/services/files.md#removeFile public func removeFile(_ url: URL) { do { try FileManager.default.removeItem(atPath: url.path) @@ -239,12 +256,14 @@ public func removeFile(_ fileName: String) { } } +// Spec: spec/services/files.md#cleanupDirectFile public func cleanupDirectFile(_ aChatItem: AChatItem) { if aChatItem.chatInfo.chatType == .direct { cleanupFile(aChatItem) } } +// Spec: spec/services/files.md#cleanupFile public func cleanupFile(_ aChatItem: AChatItem) { let cItem = aChatItem.chatItem let mc = cItem.content.msgContent diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 31b7ef83ff..24dc58202a 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 28/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/services/notifications.md import Foundation import UserNotifications @@ -22,6 +23,7 @@ public let appNotificationId = "chat.simplex.app.notification" let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") +// Spec: spec/services/notifications.md#createContactRequestNtf public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( @@ -40,6 +42,7 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User ) } +// Spec: spec/services/notifications.md#createContactConnectedNtf public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( @@ -59,6 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, ) } +// Spec: spec/services/notifications.md#createMessageReceivedNtf public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String @@ -78,6 +82,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ ) } +// Spec: spec/services/notifications.md#createCallInvitationNtf public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent { let text = invitation.callType.media == .video ? NSLocalizedString("Incoming video call", comment: "notification") @@ -93,6 +98,7 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCoun ) } +// Spec: spec/services/notifications.md#createConnectionEventNtf public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden var title: String @@ -124,6 +130,7 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit ) } +// Spec: spec/services/notifications.md#createErrorNtf public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent { var title: String switch dbStatus { @@ -149,6 +156,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> ) } +// Spec: spec/services/notifications.md#createAppStoppedNtf public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent { return createNotification( categoryIdentifier: ntfCategoryConnectionEvent, @@ -163,6 +171,7 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember : "#\(groupInfo.displayName) \(groupMember.chatViewName):" } +// Spec: spec/services/notifications.md#createNotification public func createNotification( categoryIdentifier: String, title: String, @@ -187,6 +196,7 @@ public func createNotification( return content } +// Spec: spec/services/notifications.md#hideSecrets func hideSecrets(_ cItem: ChatItem) -> String { if let md = cItem.formattedText { var res = "" diff --git a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift index 662f8b43d1..2b64627dc2 100644 --- a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// Spec: spec/services/theme.md#PresetWallpaper public enum PresetWallpaper: CaseIterable { case cats case flowers @@ -306,6 +307,7 @@ public enum WallpaperScaleType: String, Codable, CaseIterable { } } +// Spec: spec/services/theme.md#WallpaperType public enum WallpaperType: Equatable { public var image: SwiftUI.Image? { if let uiImage { diff --git a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift index 4074382543..a4e8050c6e 100644 --- a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI +// Spec: spec/services/theme.md#DefaultTheme public enum DefaultTheme: String, Codable, Equatable { case LIGHT case DARK @@ -39,6 +40,7 @@ public enum DefaultThemeMode: String, Codable { case dark } +// Spec: spec/services/theme.md#Colors public class Colors: ObservableObject, NSCopying, Equatable { @Published public var primary: Color @Published public var primaryVariant: Color @@ -84,6 +86,7 @@ public class Colors: ObservableObject, NSCopying, Equatable { public func clone() -> Colors { copy() as! Colors } } +// Spec: spec/services/theme.md#AppColors public class AppColors: ObservableObject, NSCopying, Equatable { @Published public var title: Color @Published public var primaryVariant2: Color @@ -135,6 +138,7 @@ public class AppColors: ObservableObject, NSCopying, Equatable { } } +// Spec: spec/services/theme.md#AppWallpaper public class AppWallpaper: ObservableObject, NSCopying, Equatable { public static func == (lhs: AppWallpaper, rhs: AppWallpaper) -> Bool { lhs.background == rhs.background && @@ -222,6 +226,7 @@ public enum ThemeColor { } } +// Spec: spec/services/theme.md#ThemeColors public struct ThemeColors: Codable, Equatable, Hashable { public var primary: String? = nil public var primaryVariant: String? = nil @@ -293,6 +298,7 @@ public struct ThemeColors: Codable, Equatable, Hashable { } } +// Spec: spec/services/theme.md#ThemeWallpaper public struct ThemeWallpaper: Codable, Equatable, Hashable { public var preset: String? public var scale: Float? @@ -375,6 +381,7 @@ public struct ThemeWallpaper: Codable, Equatable, Hashable { /// If you add new properties, make sure they serialized to YAML correctly, see: /// encodeThemeOverrides() +// Spec: spec/services/theme.md#ThemeOverrides public struct ThemeOverrides: Codable, Equatable, Hashable { public var themeId: String = UUID().uuidString public var base: DefaultTheme @@ -559,6 +566,7 @@ extension [ThemeOverrides] { } +// Spec: spec/services/theme.md#ThemeModeOverrides public struct ThemeModeOverrides: Codable, Hashable { public var light: ThemeModeOverride? = nil public var dark: ThemeModeOverride? = nil @@ -573,6 +581,7 @@ public struct ThemeModeOverrides: Codable, Hashable { } } +// Spec: spec/services/theme.md#ThemeModeOverride public struct ThemeModeOverride: Codable, Equatable, Hashable { public var mode: DefaultThemeMode// = CurrentColors.base.mode public var colors: ThemeColors = ThemeColors() diff --git a/apps/ios/product/README.md b/apps/ios/product/README.md new file mode 100644 index 0000000000..107c0e6569 --- /dev/null +++ b/apps/ios/product/README.md @@ -0,0 +1,258 @@ +# SimpleX Chat iOS -- Product Overview + +> SimpleX Chat iOS product specification. Bidirectional code links: product docs reference source files, source files reference product docs. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Vision](#vision) +2. [Target Users](#target-users) +3. [Capability Map](#capability-map) +4. [Navigation Map](#navigation-map) +5. [Related Specifications](#related-specifications) + +## Executive Summary + +SimpleX Chat is the first messaging platform with no user identifiers of any kind -- not even random numbers. It provides end-to-end encrypted messaging (with optional post-quantum cryptography), audio/video calls, file sharing, and group communication through a fully decentralized architecture where users control their own SMP relay servers. The iOS app is a native SwiftUI application backed by a Haskell core library. + +--- + +## Vision + +SimpleX Chat is the first messaging platform that has no user identifiers -- not even random numbers. It uses double-ratchet end-to-end encryption with optional post-quantum cryptography. The system is fully decentralized with user-controlled SMP relay servers. + +The protocol design ensures that no server or network observer can determine who communicates with whom. Each conversation uses separate unidirectional messaging queues on potentially different servers, and there is no shared identifier between the sender and receiver queues. + +--- + +## Target Users + +- **Privacy-conscious individuals** wanting secure messaging without phone-number or email-based identity +- **Groups and communities** needing encrypted group communication with role-based access control +- **Users avoiding identity linkage** who want to communicate without any persistent user identifier +- **Organizations** needing self-hosted messaging infrastructure with full control over relay servers + +--- + +## Capability Map + +### 1. Messaging + +Core message composition, delivery, and interaction features. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Text with markdown | Rich text formatting with SimpleX markdown syntax | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | +| Images | Compressed inline images (up to 255KB) | `Shared/Views/Chat/ChatItem/CIImageView.swift` | +| Video | Video message recording and playback | `Shared/Views/Chat/ChatItem/CIVideoView.swift` | +| Voice messages | Audio recording and playback (5min / 510KB limit) | `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | +| File sharing | Files up to 1GB via XFTP protocol | `Shared/Views/Chat/ChatItem/CIFileView.swift` | +| Link previews | OpenGraph metadata extraction and display | `Shared/Views/Chat/ChatItem/CILinkView.swift` | +| Message reactions | Emoji reactions on sent/received messages | `Shared/Views/Chat/ChatItem/EmojiItemView.swift` | +| Message editing | Edit previously sent messages | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | +| Message deletion | Broadcast delete (for recipient) or internal-only delete | `Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift` | +| Timed messages | Self-destructing messages with configurable TTL | `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` | +| Quoted replies | Reply to specific messages with quote context | `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | +| Forwarding | Forward messages between chats | `Shared/Views/Chat/ChatItemForwardingView.swift` | +| Search | Full-text search within conversations | `Shared/Views/Chat/ChatView.swift` | +| Message reports | Report messages to group moderators | `Shared/Views/Chat/ChatView.swift` | + +### 2. Contacts + +Establishing, managing, and verifying contacts. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Add via SimpleX address | Connect using a SimpleX contact address | `Shared/Views/NewChat/NewChatView.swift` | +| Add via QR code | Scan QR code to establish connection | `Shared/Views/Chat/ScanCodeView.swift` | +| Contact requests | Accept or reject incoming contact requests | `Shared/Views/ChatList/ContactRequestView.swift` | +| Local aliases | Set private display names for contacts | `Shared/Views/Chat/ChatInfoView.swift` | +| Contact verification | Compare security codes out-of-band | `Shared/Views/Chat/VerifyCodeView.swift` | +| Blocking | Block contacts from sending messages | `Shared/Views/Chat/ChatInfoView.swift` | +| Incognito mode | Per-contact random profile generation | `Shared/Views/UserSettings/IncognitoHelp.swift` | +| Bot detection | Identify automated/bot contacts | `SimpleXChat/ChatTypes.swift` | + +### 3. Groups + +Multi-party encrypted conversations with role-based management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Create groups | Create new group with initial members | `Shared/Views/NewChat/AddGroupView.swift` | +| Invite members | Invite by individual contact or link | `Shared/Views/Chat/Group/AddGroupMembersView.swift` | +| Member roles | Owner, admin, moderator, member, observer | `SimpleXChat/ChatTypes.swift` | +| Member admission | Queue-based admission with review workflow | `Shared/Views/Chat/Group/MemberAdmissionView.swift` | +| Group links | Shareable invite links for groups | `Shared/Views/Chat/Group/GroupLinkView.swift` | +| Business chat mode | Structured business communication groups | `Shared/Views/Chat/Group/GroupChatInfoView.swift` | +| Content moderation | Member reports and moderator actions | `Shared/Views/Chat/Group/MemberSupportView.swift` | +| Group preferences | Configure group-level feature settings | `Shared/Views/Chat/Group/GroupPreferencesView.swift` | +| Member direct contacts | Establish direct chats from group membership | `Shared/Views/Chat/Group/GroupMemberInfoView.swift` | + +### 4. Calling + +End-to-end encrypted audio and video communication. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `Shared/Views/Call/WebRTCClient.swift` | +| CallKit integration | Native iOS system call UI (ring, answer, decline) | `Shared/Views/Call/CallController.swift` | +| Audio device switching | Switch between speaker, earpiece, Bluetooth | `Shared/Views/Call/CallAudioDeviceManager.swift` | +| Call history | Call events displayed as chat items | `Shared/Views/Chat/ChatItem/CICallItemView.swift` | +| Incoming call view | Dedicated UI for incoming call notifications | `Shared/Views/Call/IncomingCallView.swift` | + +### 5. Privacy & Security + +Encryption, authentication, and privacy controls. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| E2E encryption | Double-ratchet encryption for all messages | `SimpleXChat/API.swift` | +| Post-quantum encryption | Optional PQ key exchange for direct chats | `SimpleXChat/ChatTypes.swift` | +| Local authentication | Face ID, Touch ID, or app passcode lock | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Hidden profiles | Password-protected profiles invisible in UI | `Shared/Views/UserSettings/HiddenProfileView.swift` | +| Database encryption | AES encryption of local SQLite database | `Shared/Views/Database/DatabaseEncryptionView.swift` | +| Screen privacy | Blur app content when in app switcher | `Shared/Views/UserSettings/PrivacySettings.swift` | +| Encrypted file storage | Local files encrypted at rest | `SimpleXChat/CryptoFile.swift` | +| Delivery receipts control | Toggle delivery/read receipts per contact/group | `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift` | + +### 6. User Management + +Multiple profiles and identity management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Multiple profiles | Multiple user profiles within one app | `Shared/Views/UserSettings/UserProfilesView.swift` | +| Active user switching | Switch between profiles via user picker | `Shared/Views/ChatList/UserPicker.swift` | +| Incognito contacts | Per-contact random identities | `Shared/Views/UserSettings/IncognitoHelp.swift` | +| Profile sharing | Share profile via contact address link | `Shared/Views/UserSettings/UserAddressView.swift` | +| User muting | Mute notifications for specific profiles | `Shared/Views/ChatList/UserPicker.swift` | + +### 7. Network + +Server configuration, proxy support, and connectivity. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Custom SMP servers | Configure personal SMP relay servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` | +| Custom XFTP servers | Configure personal XFTP file servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` | +| Tor/onion support | Route traffic through Tor .onion addresses | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | +| SOCKS5 proxy | Route connections through SOCKS5 proxy | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | +| Custom ICE servers | Configure WebRTC ICE/TURN servers | `Shared/Views/UserSettings/RTCServers.swift` | +| Network timeouts | Configure connection timeout parameters | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` | + +### 8. Customization + +Visual appearance and UI preferences. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Themes | Light, dark, SimpleX, black, and custom themes | `Shared/Theme/ThemeManager.swift` | +| Wallpapers | Preset and custom chat wallpapers | `Shared/Views/Helpers/ChatWallpaper.swift` | +| Chat bubble styling | Customize message bubble appearance | `SimpleXChat/Theme/ThemeTypes.swift` | +| One-handed UI mode | Compact layout for single-hand use | `Shared/Views/ChatList/OneHandUICard.swift` | +| Language selection | In-app language override | `Shared/Views/UserSettings/AppearanceSettings.swift` | + +### 9. Data Management + +Import, export, encryption, and storage management. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Export/import profiles | Full database export and import | `Shared/Views/Database/DatabaseView.swift` | +| Database encryption | Encrypt/decrypt local database with passphrase | `Shared/Views/Database/DatabaseEncryptionView.swift` | +| Local file encryption | Encrypt stored media and attachments | `SimpleXChat/CryptoFile.swift` | +| Storage breakdown | View storage usage by category | `Shared/Views/UserSettings/StorageView.swift` | +| Device-to-device migration | Migrate full profile between iOS devices | `Shared/Views/Migration/MigrateFromDevice.swift` | + +### 10. Desktop Integration + +Remote control of the mobile app from a desktop client. + +| Feature | Description | Key Source (Swift) | +|---------|-------------|--------------------| +| Remote control pairing | Pair with desktop app via QR code | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` | +| Session management | Manage active desktop control sessions | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` | + +--- + +## Navigation Map + +``` +Onboarding + OnboardingView.swift + -> SimpleXInfo -> CreateProfile -> ChooseServerOperators -> SetNotificationsMode -> CreateSimpleXAddress + -> ChatListView (home) + +ChatListView (home) + Shared/Views/ChatList/ChatListView.swift + -> ChatView .................. (tap conversation row) + -> NewChatMenuButton ......... (+ button) + -> SettingsView .............. (gear icon) + -> UserPicker ................ (avatar tap) + -> TagListView ............... (tag filter bar) + -> ServersSummaryView ........ (server status) + +ChatView + Shared/Views/Chat/ChatView.swift + -> ChatInfoView .............. (contact name tap, direct chat) + -> GroupChatInfoView ......... (group name tap, group chat) + -> ActiveCallView ............ (call button) + -> ComposeView ............... (message input area) + -> ChatItemInfoView .......... (long press -> info) + -> ChatItemForwardingView .... (long press -> forward) + -> SecondaryChatView ......... (member support thread) + +ChatInfoView + Shared/Views/Chat/ChatInfoView.swift + -> ContactPreferencesView .... (preferences) + -> VerifyCodeView ............ (verify security code) + +GroupChatInfoView + Shared/Views/Chat/Group/GroupChatInfoView.swift + -> GroupProfileView .......... (edit profile) + -> AddGroupMembersView ....... (invite members) + -> GroupLinkView ............. (manage group link) + -> MemberAdmissionView ....... (admission settings) + -> GroupPreferencesView ...... (group feature settings) + -> GroupMemberInfoView ....... (tap member) + -> GroupWelcomeView .......... (welcome message) + +NewChatMenuButton + Shared/Views/NewChat/NewChatMenuButton.swift + -> NewChatView ............... (QR scanner / paste link) + -> AddGroupView .............. (create group) + -> UserAddressView ........... (create SimpleX address) + +SettingsView + Shared/Views/UserSettings/SettingsView.swift + -> AppearanceSettings ........ (themes, wallpapers, UI) + -> NetworkAndServers ......... (SMP/XFTP/proxy config) + -> PrivacySettings ........... (privacy toggles) + -> NotificationsView ......... (push notification mode) + -> DatabaseView .............. (export/import/encrypt) + -> CallSettings .............. (call preferences) + -> StorageView ............... (storage usage) + -> VersionView ............... (about/version) + -> DeveloperView ............. (developer options) + +UserPicker + Shared/Views/ChatList/UserPicker.swift + -> UserProfilesView .......... (manage all profiles) + -> UserAddressView ........... (SimpleX address) + -> PreferencesView ........... (user preferences) + -> SettingsView .............. (app settings) + -> ConnectDesktopView ........ (pair with desktop) +``` + +--- + +## Related Specifications + +- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links +- [glossary.md](glossary.md) -- Domain term glossary +- [spec/README.md](../spec/README.md) -- Technical specification overview +- [spec/architecture.md](../spec/architecture.md) -- Architecture specification +- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs` +- Swift model: `Shared/Model/ChatModel.swift`, `SimpleXChat/ChatTypes.swift` +- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift` diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md new file mode 100644 index 0000000000..a60fe98cbb --- /dev/null +++ b/apps/ios/product/concepts.md @@ -0,0 +1,83 @@ +# SimpleX Chat iOS -- Concept Index + +> SimpleX Chat iOS concept index. Maps every product concept to its documentation and source code with bidirectional links. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/state.md](../spec/state.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Feature Concepts](#section-1-feature-concepts) +2. [Entity Index](#section-2-entity-index) + +## Executive Summary + +This document provides a structured mapping between product-level concepts, their documentation, and their implementation in both the Swift iOS layer and the Haskell core library. All source paths are relative to `apps/ios/` for Swift and use `../../src/` prefix for Haskell files (relative to `apps/ios/`). + +--- + +## Section 1: Feature Concepts + +| # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) | +|---|---------|-------------|-----------|---------------------|----------------------| +| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) | +| 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` | +| 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) | +| 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) | +| 5 | Message Reactions | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/EmojiItemView.swift` | `Controller.hs` (`APIChatItemReaction`) | +| 6 | Message Editing | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `ChatItemInfoView.swift` | `Controller.hs` (`APIUpdateChatItem`) | +| 7 | Message Deletion | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/MarkedDeletedItemView.swift`, `DeletedItemView.swift` | `Controller.hs` (`APIDeleteChatItem`) | +| 8 | Timed Messages | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/CIChatFeatureView.swift` | `Types/Preferences.hs` (`TimedMessagesPreference`) | +| 9 | Voice Messages | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ChatItem/CIVoiceView.swift`, `ComposeVoiceView.swift` | `Protocol.hs` (`MCVoice`) | +| 10 | File Transfer | [flows/file-transfer.md](flows/file-transfer.md) | [spec/services/files.md](../spec/services/files.md) | `ChatItem/CIFileView.swift`, `SimpleXChat/FileUtils.swift` | `Files.hs`, `Store/Files.hs` | +| 11 | Link Previews | [views/chat.md](views/chat.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `ChatItem/CILinkView.swift`, `ComposeLinkView.swift` | `Protocol.hs` (`MCLink`) | +| 12 | Contact Connection | [flows/connection.md](flows/connection.md), [views/new-chat.md](views/new-chat.md) | [spec/api.md](../spec/api.md) | `NewChat/NewChatView.swift`, `QRCode.swift` | `Controller.hs` (`APIConnect`, `APIAddContact`) | +| 13 | Contact Verification | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `Shared/Views/Chat/VerifyCodeView.swift` | `Controller.hs` (`APIVerifyContact`) | +| 14 | Group Management | [flows/group-lifecycle.md](flows/group-lifecycle.md) | [spec/api.md](../spec/api.md), [spec/database.md](../spec/database.md) | `NewChat/AddGroupView.swift`, `Group/GroupChatInfoView.swift` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` | +| 15 | Group Links | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/GroupLinkView.swift` | `Controller.hs` (`APICreateGroupLink`) | +| 16 | Member Roles | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `SimpleXChat/ChatTypes.swift`, `Group/GroupMemberInfoView.swift` | `Types/Shared.hs` (`GroupMemberRole`) | +| 17 | Audio/Video Calls | [views/call.md](views/call.md), [flows/calling.md](flows/calling.md) | [spec/services/calls.md](../spec/services/calls.md) | `Call/ActiveCallView.swift`, `CallController.swift`, `WebRTCClient.swift` | `Call.hs` (`RcvCallInvitation`, `CallType`) | +| 18 | Push Notifications | [views/settings.md](views/settings.md) | [spec/services/notifications.md](../spec/services/notifications.md) | `Model/NtfManager.swift`, `SimpleX NSE/NotificationService.swift` | `Controller.hs` | +| 19 | User Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/state.md](../spec/state.md), [spec/client/navigation.md](../spec/client/navigation.md) | `UserSettings/UserProfilesView.swift`, `ChatList/UserPicker.swift` | `Types.hs` (`User`), `Store/Profiles.hs` | +| 20 | Incognito Mode | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `UserSettings/IncognitoHelp.swift` | `ProfileGenerator.hs`, `Types.hs` | +| 21 | Hidden Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/api.md](../spec/api.md) | `UserSettings/HiddenProfileView.swift` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) | +| 22 | Local Authentication | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `LocalAuth/LocalAuthView.swift`, `PasscodeView.swift` | N/A (iOS-only) | +| 23 | Database Encryption | [views/settings.md](views/settings.md) | [spec/database.md](../spec/database.md) | `Database/DatabaseEncryptionView.swift`, `DatabaseView.swift` | `Controller.hs` (`APIExportArchive`) | +| 24 | Theme System | [views/settings.md](views/settings.md) | [spec/services/theme.md](../spec/services/theme.md) | `Theme/ThemeManager.swift`, `SimpleXChat/Theme/ThemeTypes.swift` | `Types/UITheme.hs` | +| 25 | Network Configuration | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `NetworkAndServers/NetworkAndServers.swift`, `ProtocolServersView.swift` | `Controller.hs` (`APISetNetworkConfig`) | +| 26 | Device Migration | [flows/onboarding.md](flows/onboarding.md) | [spec/database.md](../spec/database.md) | `Migration/MigrateFromDevice.swift`, `MigrateToDevice.swift` | `Archive.hs` | +| 27 | Remote Desktop | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `RemoteAccess/ConnectDesktopView.swift` | `Remote.hs`, `Remote/Types.hs` | +| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` | +| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) | +| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | + +--- + +## Section 2: Entity Index + +Core data entities, their storage, and the operations that manage their lifecycle. + +| Entity | DB Table (Haskell) | Created By | Read By | Mutated By | Deleted By | +|--------|-------------------|------------|---------|------------|------------| +| **User** | `users` | `CreateActiveUser` in `Controller.hs` | `ListUsers`, `APISetActiveUser` in `Controller.hs` | `APISetActiveUser`, `APIHideUser`, `APIUnhideUser`, `APIMuteUser`, `APIUpdateProfile` in `Controller.hs` | `APIDeleteUser` in `Controller.hs`; `Store/Profiles.hs` | +| **Contact** | `contacts`, `contact_profiles` | `APIAddContact`, `APIConnect` in `Controller.hs` | `APIGetChat` in `Controller.hs`; `Store/Direct.hs` (getContact) | `APISetContactAlias`, `APISetConnectionAlias` in `Controller.hs`; `Store/Direct.hs` | `APIDeleteChat` in `Controller.hs`; `Store/Direct.hs` (deleteContact) | +| **GroupInfo** | `groups`, `group_profiles` | `APINewGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroup) | `APIGetChat`, `APIGroupInfo` in `Controller.hs`; `Store/Groups.hs` | `APIUpdateGroupProfile` in `Controller.hs`; `Store/Groups.hs` (updateGroupProfile) | `APIDeleteChat` in `Controller.hs`; `Store/Groups.hs` (deleteGroup) | +| **GroupMember** | `group_members`, `contact_profiles` | `APIAddMember`, `APIJoinGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroupMember) | `APIListMembers` in `Controller.hs`; `Store/Groups.hs` (getGroupMembers) | `APIMembersRole` in `Controller.hs`; `Store/Groups.hs` (updateGroupMemberRole) | `APIRemoveMembers` in `Controller.hs`; `Store/Groups.hs` (deleteGroupMember) | +| **ChatItem** | `chat_items`, `chat_item_versions` | `APISendMessages` in `Controller.hs`; `Store/Messages.hs` (createNewChatItem) | `APIGetChat`, `APIGetChatItems` in `Controller.hs`; `Store/Messages.hs` (getChatItems) | `APIUpdateChatItem`, `APIChatItemReaction` in `Controller.hs`; `Store/Messages.hs` (updateChatItem) | `APIDeleteChatItem` in `Controller.hs`; `Store/Messages.hs` (deleteChatItem) | +| **Connection** | `connections` | `createConnection` via SMP agent; `Store/Connections.hs` | `Store/Connections.hs` (getConnectionEntity) | `Store/Connections.hs` (updateConnectionStatus) | `Store/Connections.hs` (deleteConnection) | +| **FileTransfer** | `files`, `snd_files`, `rcv_files`, `xftp_file_descriptions` | `APISendMessages` (with file), `ReceiveFile` in `Controller.hs`; `Store/Files.hs` | `Store/Files.hs` (getFileTransfer) | `Store/Files.hs` (updateFileStatus, updateFileProgress) | `Store/Files.hs` (deleteFileTransfer) | +| **GroupLink** | `user_contact_links` | `APICreateGroupLink` in `Controller.hs`; `Store/Groups.hs` | `APIGetGroupLink` in `Controller.hs`; `Store/Groups.hs` | N/A (recreated on change) | `APIDeleteGroupLink` in `Controller.hs`; `Store/Groups.hs` | +| **ChatTag** | `chat_tags`, `chat_tags_chats` | `APICreateChatTag` in `Controller.hs` | `APIGetChats` in `Controller.hs` | `APIUpdateChatTag`, `APISetChatTags` in `Controller.hs` | `APIDeleteChatTag` in `Controller.hs` | +| **RcvCallInvitation** | In-memory (not persisted) | Received via `XCallInv` message in `Library/Subscriber.hs`; stored in `ChatModel.callInvitations` | `CallController.swift`, `IncomingCallView.swift` | Updated on call accept/reject in `CallManager.swift` | Removed on call end/reject; `Controller.hs` | + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Glossary: [glossary.md](glossary.md) +- Haskell core controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell store layer: `../../src/Simplex/Chat/Store/` (Direct.hs, Groups.hs, Messages.hs, Files.hs, Profiles.hs, Connections.hs) +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift` diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md new file mode 100644 index 0000000000..86cb026625 --- /dev/null +++ b/apps/ios/product/flows/calling.md @@ -0,0 +1,179 @@ +# Audio/Video Call Flow + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Overview + +WebRTC-based audio and video calling in SimpleX Chat iOS. Calls are end-to-end encrypted with an additional shared key negotiated over the E2E encrypted SMP channel. The iOS app integrates with CallKit for native call UI (incoming call screen, lock screen integration) with a fallback mode for regions where CallKit is restricted (China). Call signaling (offer/answer/ICE candidates) is exchanged via SMP messages, not through a central signaling server. + +## Prerequisites + +- Established direct contact connection (calls are 1:1 only, not available in groups) +- Microphone permission granted (audio calls) +- Camera permission granted (video calls) +- Network connectivity for WebRTC peer-to-peer or relay + +## Step-by-Step Processes + +### 1. Initiate Call + +1. User opens a direct chat in `ChatView`. +2. Taps the audio or video call button in the navigation bar. +3. `CallController` determines call type: `CallType(media: .audio/.video, capabilities: CallCapabilities(encryption: true))`. +4. If CallKit is enabled (`CallController.useCallKit()`): + - `CXStartCallAction` is requested via `CXCallController`. + - CallKit reports the outgoing call. + - `provider(perform: CXStartCallAction)` fulfills and reports `reportOutgoingCall(startedConnectingAt:)`. +5. Calls `apiSendCallInvitation(contact:callType:)`: + ```swift + func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws + ``` +6. Sends `ChatCommand.apiSendCallInvitation(contact:callType:)`. +7. Core sends the call invitation to the contact via SMP. +8. `ChatModel.shared.activeCall` is set with the call state. + +### 2. Receive Call + +1. `ChatReceiver` receives `ChatEvent.callInvitation(callInvitation: RcvCallInvitation)`. +2. `RcvCallInvitation` contains: `user`, `contact`, `callType`, `sharedKey`, `callUUID`, `callTs`. +3. Processing in `processReceivedMsg`: + - Call invitation is stored in `chatModel.callInvitations`. +4. If CallKit is enabled: + - `CXProvider.reportNewIncomingCall` presents the native iOS incoming call UI. + - Works even on lock screen and in background. +5. If CallKit is disabled (China / user preference): + - `IncomingCallView` is shown as an in-app overlay. + - `SoundPlayer` plays the ringtone. +6. User chooses to accept or reject. + +### 3. Accept Call + +1. **Via CallKit**: User swipes to accept on the native incoming call screen. + - `provider(perform: CXAnswerCallAction)` is triggered. + - Waits for chat to be started if needed (`waitUntilChatStarted(timeoutMs: 30_000)`). + - `callManager.answerIncomingCall(callUUID:)` begins WebRTC setup. + - `fulfillOnConnect` is set -- the action is fulfilled only when WebRTC reaches connected state (required for audio/mic to work on lock screen). +2. **Via in-app UI**: User taps "Accept" in `IncomingCallView`. + - Directly starts WebRTC setup. + +### 4. Reject Call + +1. **Via CallKit**: User taps "Decline" on native UI. + - `provider(perform: CXEndCallAction)` is triggered. + - `callManager.endCall(callUUID:)` cleans up. +2. **Via API**: `apiRejectCall(contact:)` sends rejection to peer. +3. Call invitation is removed from `chatModel.callInvitations`. + +### 5. WebRTC Setup (Signaling) + +All signaling messages are exchanged via E2E encrypted SMP messages (no central signaling server). + +**Caller side:** +1. `WebRTCClient` creates a `RTCPeerConnection`. +2. Creates SDP offer. +3. Calls `apiSendCallOffer(contact:rtcSession:rtcIceCandidates:media:capabilities:)`: + ```swift + func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String, + media: CallMediaType, capabilities: CallCapabilities) async throws + ``` +4. Constructs `WebRTCCallOffer(callType:rtcSession:)` and sends via `ChatCommand.apiSendCallOffer`. +5. Gathers ICE candidates and sends via `apiSendCallExtraInfo(contact:rtcIceCandidates:)`. + +**Callee side:** +1. Receives the offer via SMP. +2. `WebRTCClient` sets remote description from the offer. +3. Creates SDP answer. +4. Calls `apiSendCallAnswer(contact:rtcSession:rtcIceCandidates:)`: + ```swift + func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws + ``` +5. Constructs `WebRTCSession(rtcSession:rtcIceCandidates:)` and sends. +6. Gathers and sends additional ICE candidates via `apiSendCallExtraInfo`. + +### 6. Media Streaming + +1. WebRTC peer connection transitions to connected state. +2. If CallKit is used, `fulfillOnConnect` action is fulfilled (enables audio hardware). +3. Audio/video streams are active. +4. `ActiveCallView` displays: + - Remote video (full screen) + - Local video preview (picture-in-picture corner) + - Call controls: mute, speaker, camera toggle, end call + - Call duration timer +5. `CallViewRenderers` manages WebRTC video rendering surfaces. +6. Call status updates are sent via `apiCallStatus(contact:status:)`. + +### 7. Audio Routing + +1. `CallAudioDeviceManager` handles audio device selection. +2. Options: earpiece (receiver), speaker, Bluetooth devices. +3. `AudioDevicePicker` provides UI for device selection during call. +4. Uses `AVAudioSession` for routing configuration. + +### 8. End Call + +1. Either party taps "End" button. +2. Calls `apiEndCall(contact:)`: + ```swift + func apiEndCall(_ contact: Contact) async throws + ``` +3. Sends `ChatCommand.apiEndCall(contact:)` via SMP to notify peer. +4. `WebRTCClient` closes peer connection, releases media resources. +5. If CallKit: `CXEndCallAction` is requested, `provider(perform: CXEndCallAction)` fulfills. +6. `ChatModel.shared.activeCall` is cleared. +7. A `CICallItemView` event item is added to the chat history (call duration, type). + +### 9. CallKit-Free Mode + +1. `CallController.isInChina` checks `SKStorefront().countryCode == "CHN"`. +2. If in China or user disabled CallKit (`callKitEnabledGroupDefault`): `useCallKit()` returns `false`. +3. Incoming calls use `IncomingCallView` overlay instead of native CallKit UI. +4. `SoundPlayer` handles ringtone playback. +5. No lock-screen call answering; app must be in foreground or notified via push. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CallType` | `SimpleXChat/CallTypes.swift` | `media: CallMediaType` (.audio/.video), `capabilities: CallCapabilities` | +| `CallMediaType` | `SimpleXChat/CallTypes.swift` | `.audio` or `.video` | +| `CallCapabilities` | `SimpleXChat/CallTypes.swift` | `encryption: Bool` for E2E call encryption support | +| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs | +| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data | +| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) | +| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer | +| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting | +| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` | +| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout | +| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry | +| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status | +| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up | +| No camera/mic permission | User denied permissions | Permission request dialog shown | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Call/CallController.swift` | CallKit integration, CXProvider delegate, PKPushRegistry, call lifecycle management | +| `Shared/Views/Call/CallManager.swift` | Call state management, starting/answering/ending calls | +| `Shared/Views/Call/WebRTCClient.swift` | WebRTC peer connection, SDP offer/answer, ICE candidate handling | +| `Shared/Views/Call/ActiveCallView.swift` | Active call UI: video renderers, controls, duration | +| `Shared/Views/Call/CallViewRenderers.swift` | WebRTC video rendering surfaces | +| `Shared/Views/Call/IncomingCallView.swift` | Non-CallKit incoming call overlay | +| `Shared/Views/Call/CallAudioDeviceManager.swift` | Audio routing: speaker, earpiece, Bluetooth | +| `Shared/Views/Call/AudioDevicePicker.swift` | Audio device selection UI | +| `Shared/Views/Call/SoundPlayer.swift` | Ringtone and call sound playback | +| `Shared/Views/Call/WebRTC.swift` | WebRTC configuration and utilities | +| `SimpleXChat/CallTypes.swift` | All call-related type definitions | +| `Shared/Model/SimpleXAPI.swift` | Call API functions: `apiSendCallInvitation`, `apiSendCallOffer`, `apiSendCallAnswer`, `apiSendCallExtraInfo`, `apiEndCall`, `apiRejectCall`, `apiCallStatus` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Calls capability +- `apps/ios/product/flows/connection.md` -- Calls require an established direct connection diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md new file mode 100644 index 0000000000..7b9c8ee304 --- /dev/null +++ b/apps/ios/product/flows/connection.md @@ -0,0 +1,159 @@ +# Connection Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/architecture.md](../../spec/architecture.md) + +## Overview + +Establishing contact between two SimpleX Chat users. SimpleX uses no user identifiers; connections are formed through one-time invitation links or permanent SimpleX addresses. Each connection creates unique unidirectional SMP queues, ensuring no server can correlate sender and receiver. Supports incognito mode for per-contact random profile generation. + +## Prerequisites + +- User profile created and chat engine running +- Network connectivity to SMP relay servers +- For QR code scanning: camera permission granted + +## Step-by-Step Processes + +### 1. Create Invitation Link + +1. User taps "+" button in `ChatListView` -> `NewChatMenuButton` -> "Add contact". +2. `NewChatView` is presented. +3. Calls `apiAddContact(incognito:)`: + ```swift + func apiAddContact(incognito: Bool) async + -> ((CreatedConnLink, PendingContactConnection)?, Alert?) + ``` +4. Internally sends `ChatCommand.apiAddContact(userId:incognito:)` to core. +5. Core creates SMP queues and returns `ChatResponse1.invitation(user, connLinkInv, connection)`. +6. Returns `(CreatedConnLink, PendingContactConnection)`. +7. `CreatedConnLink` contains the invitation URI (both full and short link forms). +8. UI displays: + - QR code rendered by `QRCode` view (scannable by peer) + - Share button to send link via system share sheet + - Copy button for clipboard +9. A `PendingContactConnection` appears in the chat list while awaiting peer. + +### 2. Connect via Link + +1. User receives a SimpleX link (pasted, scanned, or opened via URL scheme). +2. If opened via deep link: `SimpleXApp.onOpenURL` sets `chatModel.appOpenUrl`. +3. For manual entry: User pastes link in `NewChatView`. +4. First, `apiConnectPlan(connLink:inProgress:)` is called to validate: + ```swift + func apiConnectPlan(connLink: String, inProgress: BoxedValue) async + -> ((CreatedConnLink, ConnectionPlan)?, Alert?) + ``` +5. Returns `ConnectionPlan` indicating whether it is an invitation, contact address, or group link, and whether connection is already established. +6. If valid, calls `apiConnect(incognito:connLink:)`: + ```swift + func apiConnect(incognito: Bool, connLink: CreatedConnLink) async + -> (ConnReqType, PendingContactConnection)? + ``` +7. Core creates the connection and returns one of: + - `ChatResponse1.sentConfirmation(user, connection)` -- for invitation links (type: `.invitation`) + - `ChatResponse1.sentInvitation(user, connection)` -- for contact address links (type: `.contact`) + - `ChatResponse1.contactAlreadyExists(user, contact)` -- duplicate +8. `PendingContactConnection` appears in chat list while awaiting peer confirmation. + +### 3. Prepared Contact/Group Flow (Short Links) + +1. For short links with embedded profile data, the app uses a two-phase flow. +2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:groupShortLinkData:)` creates a local prepared chat. +3. Returns `ChatData` with the prepared contact/group shown in UI before connecting. +4. User can switch profiles or set incognito before committing. +5. `apiConnectPreparedContact(contactId:incognito:msg:)` finalizes the connection. +6. Returns `ChatResponse1.startedConnectionToContact(user, contact)`. + +### 4. Accept Contact Request + +1. When a peer connects via the user's SimpleX address, core generates a `ChatEvent.receivedContactRequest`. +2. `processReceivedMsg` handles the event, adding a `UserContactRequest` to `ChatModel`. +3. Contact request appears in `ChatListView` as a special `ContactRequestView` row. +4. User taps "Accept": + ```swift + func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact? + ``` +5. Sends `ChatCommand.apiAcceptContact(incognito:contactReqId:)`. +6. Core returns `ChatResponse1.acceptingContactRequest(user, contact)`. +7. Connection handshake proceeds asynchronously. +8. User can also reject: `apiRejectContactRequest(contactReqId:)` -> `ChatResponse1.contactRequestRejected`. + +### 5. Connection Established + +1. Both sides complete the SMP handshake asynchronously. +2. Core sends `ChatEvent.contactConnected(user, contact, userCustomProfile)`. +3. `processReceivedMsg` updates `ChatModel`: + - Contact status transitions from pending to active. + - Chat becomes available for messaging. +4. `NtfManager` may post a notification: "Contact connected". +5. The `PendingContactConnection` in the chat list is replaced by the full contact chat. + +### 6. Create SimpleX Address + +1. User navigates to Settings or taps "Create SimpleX address" during onboarding. +2. Calls `apiCreateUserAddress()`: + ```swift + func apiCreateUserAddress() async throws -> CreatedConnLink? + ``` +3. Core creates a permanent address (unlike one-time invitations). +4. Address is stored in `ChatModel.shared.userAddress`. +5. Can be shared publicly; multiple contacts can connect via the same address. +6. User must accept each incoming contact request individually. +7. To delete: `apiDeleteUserAddress()` removes the address and associated SMP queues. + +### 7. Incognito Connection + +1. Before connecting, user toggles "Incognito" in the connection UI. +2. `incognito: true` is passed to `apiAddContact`, `apiConnect`, or `apiAcceptContactRequest`. +3. Core generates a random display name for this connection only. +4. The random profile is stored per-connection; the user's real profile is never shared. +5. Incognito status is shown with a mask icon in the chat. +6. Can also be toggled for pending connections via `apiSetConnectionIncognito(connId:incognito:)`. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CreatedConnLink` | `SimpleXChat/APITypes.swift` | Contains `connFullLink` (URI) and optional `connShortLink` | +| `PendingContactConnection` | `SimpleXChat/ChatTypes.swift` | Represents an in-progress connection before contact is established | +| `ConnectionPlan` | `Shared/Model/AppAPITypes.swift` | Enum describing what a link will do: connect contact, join group, already connected, etc. | +| `ConnReqType` | `Shared/Views/NewChat/NewChatView.swift` | `.invitation`, `.contact`, or `.groupLink` -- type of connection request | +| `Contact` | `SimpleXChat/ChatTypes.swift` | Full contact model with profile, connection status, preferences | +| `UserContactRequest` | `SimpleXChat/ChatTypes.swift` | Incoming contact request awaiting acceptance | +| `ChatType` | `SimpleXChat/ChatTypes.swift` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.invalidConnReq` | Malformed or expired link | Alert: "Invalid connection link" | +| `ChatError.unsupportedConnReq` | Link requires newer app version | Alert: "Unsupported connection link" | +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Link already used or deleted | Alert: "Connection error (AUTH)" | +| `ChatError.errorAgent(.SMP(_, .BLOCKED(info)))` | Server operator blocked connection | Alert: "Connection blocked" with reason | +| `ChatError.errorAgent(.SMP(_, .QUOTA))` | Too many undelivered messages | Alert: "Undelivered messages" | +| `ChatError.errorAgent(.INTERNAL("SEUniqueID"))` | Duplicate connection attempt | Alert: "Already connected?" | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable via `chatApiSendCmdWithRetry` | +| `contactAlreadyExists` | Connecting to existing contact | Alert: "Contact already exists" with contact name | +| `errorAgent(.SMP(_, .AUTH))` on accept | Sender deleted request | Alert: "Sender may have deleted the connection request" | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/NewChat/NewChatView.swift` | Main connection UI: create link, paste link, QR scan | +| `Shared/Views/NewChat/NewChatMenuButton.swift` | "+" button menu in chat list | +| `Shared/Views/NewChat/QRCode.swift` | QR code rendering for invitation links | +| `Shared/Views/NewChat/AddContactLearnMore.swift` | Help text explaining connection process | +| `Shared/Views/ChatList/ContactRequestView.swift` | Incoming contact request display | +| `Shared/Views/ChatList/ContactConnectionView.swift` | Pending connection display | +| `Shared/Views/ChatList/ContactConnectionInfo.swift` | Connection details sheet | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiAddContact`, `apiConnect`, `apiConnectPlan`, `apiAcceptContactRequest`, `apiCreateUserAddress` | +| `Shared/Model/AppAPITypes.swift` | `ConnectionPlan` enum, `GroupLink` struct | +| `SimpleXChat/APITypes.swift` | `CreatedConnLink`, `ComposedMessage`, command/response types | +| `SimpleXChat/ChatTypes.swift` | `Contact`, `PendingContactConnection`, `UserContactRequest` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Contacts capability map +- `apps/ios/product/flows/messaging.md` -- Messaging after connection is established diff --git a/apps/ios/product/flows/file-transfer.md b/apps/ios/product/flows/file-transfer.md new file mode 100644 index 0000000000..0b4b0538cc --- /dev/null +++ b/apps/ios/product/flows/file-transfer.md @@ -0,0 +1,209 @@ +# File Transfer Flow + +> **Related spec:** [spec/services/files.md](../../spec/services/files.md) + +## Overview + +File and media sharing in SimpleX Chat iOS. Small files are sent inline within SMP messages; large files use the XFTP (eXtended File Transfer Protocol) for chunked, encrypted uploads up to 1GB. All files are encrypted end-to-end. Optional local encryption protects downloaded files at rest using AES via `CryptoFile`. + +## Prerequisites + +- Established contact or group conversation +- For sending: photo library or file picker access permission +- For receiving: sufficient device storage +- XFTP relay servers configured (default servers or custom) + +## Size Limits + +| Category | Limit | Constant | +|----------|-------|----------| +| Inline image (compressed) | 255 KB | `MAX_IMAGE_SIZE` = 261,120 bytes | +| Auto-receive image | 510 KB | `MAX_IMAGE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 | +| Auto-receive voice | 510 KB | `MAX_VOICE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 | +| Auto-receive video | 1,023 KB | `MAX_VIDEO_SIZE_AUTO_RCV` = 1,047,552 bytes | +| Max file via XFTP | 1 GB | `MAX_FILE_SIZE_XFTP` = 1,073,741,824 bytes | +| Max file via SMP | ~8 MB | `MAX_FILE_SIZE_SMP` = 8,000,000 bytes | +| Max voice message length | 5 min | `MAX_VOICE_MESSAGE_LENGTH` = 300s | + +## Step-by-Step Processes + +### 1. Send Image + +1. User taps the attachment button in `ComposeView` and selects an image. +2. `ComposeImageView` displays the selected image preview. +3. Image is compressed to fit within `MAX_IMAGE_SIZE` (255KB). +4. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: compressedImagePath), + msgContent: .image(text: captionText, image: base64Thumbnail) + ) + ``` +5. `apiSendMessages(type:id:scope:composedMessages:)` is called. +6. For images <=255KB: sent inline within the SMP message. +7. For larger images: XFTP upload is used (see XFTP transfer below). +8. Recipient auto-receives images up to 510KB (`MAX_IMAGE_SIZE_AUTO_RCV`). + +### 2. Send Video + +1. User picks a video from the library. +2. Thumbnail is generated from the first frame. +3. Video duration is calculated. +4. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: videoFilePath), + msgContent: .video(text: captionText, image: base64Thumbnail, duration: durationSeconds) + ) + ``` +5. `apiSendMessages(...)` is called. +6. Video files are typically >255KB, so XFTP upload is used. +7. Recipient auto-receives videos up to 1,023KB (`MAX_VIDEO_SIZE_AUTO_RCV`). +8. `CIVideoView` displays thumbnail with play button; video downloads on tap if not auto-received. + +### 3. Send File + +1. User taps the attachment button and selects a document via the system file picker. +2. `ComposeFileView` shows the file name and size. +3. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: filePath), + msgContent: .file(fileName) + ) + ``` +4. `apiSendMessages(...)` is called. +5. If file <=255KB: sent inline via SMP. +6. If file >255KB and <=1GB: uploaded via XFTP. +7. Files >1GB: rejected (prevented in UI). +8. `CIFileView` displays file icon, name, and size for the recipient. + +### 4. Send Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` records audio to a temporary file. +3. `ComposeVoiceView` shows recording waveform and duration. +4. On release (or tapping stop), recording ends. +5. Duration is checked against `MAX_VOICE_MESSAGE_LENGTH` (5 minutes / 300 seconds). +6. `ComposedMessage` is built: + ```swift + ComposedMessage( + fileSource: CryptoFile(filePath: voiceFilePath), + msgContent: .voice(text: "", duration: durationSeconds) + ) + ``` +7. `apiSendMessages(...)` is called. +8. Voice messages <=510KB are sent inline. +9. Recipient auto-receives voice up to 510KB (`MAX_VOICE_SIZE_AUTO_RCV`). +10. `CIVoiceView` renders waveform with playback controls. + +### 5. Receive File + +1. Core receives a message with a file reference via SMP. +2. `ChatEvent.newChatItems` delivers the chat item with file metadata. +3. Auto-receive logic checks: + - File type and size against auto-receive thresholds. + - User's auto-receive preferences. +4. If auto-received or user taps "Download": + ```swift + func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async + ``` +5. Internally calls `receiveFiles(user:fileIds:userApprovedRelays:auto:)`. +6. Sends `ChatCommand.receiveFile(fileId:userApprovedRelays:encrypted:inline:)`. +7. `encrypted` is determined by `privacyEncryptLocalFilesGroupDefault`. +8. `userApprovedRelays` controls whether unknown XFTP relay servers are trusted. +9. On success: `ChatResponse2.rcvFileAccepted(user, chatItem)` -- file download begins. +10. On sender cancelled: `ChatResponse2.rcvFileAcceptedSndCancelled(user, rcvFileTransfer)`. +11. Download progress is tracked and shown in the UI. +12. Completed files are stored in the app's `Documents/files/` directory. + +### 6. XFTP Transfer (Large Files) + +**Upload (sender side):** +1. File is encrypted locally with a random symmetric key. +2. Encrypted file is split into chunks. +3. Chunks are uploaded to one or more XFTP relay servers. +4. A file description (URI with encryption key and chunk locations) is created. +5. The file description is sent to the recipient via the SMP message. + +**Download (recipient side):** +1. Recipient receives the file description via SMP. +2. Chunks are downloaded from XFTP relay servers. +3. Chunks are reassembled and decrypted locally. +4. File is available at the local path. + +**Standalone file operations** (used for database migration): +- `uploadStandaloneFile(user:file:ctrl:)` -- upload without a chat message +- `downloadStandaloneFile(user:url:file:ctrl:)` -- download from a standalone URL +- `standaloneFileInfo(url:ctrl:)` -- get metadata for a standalone file URL + +### 7. Local File Encryption + +1. If `privacyEncryptLocalFilesGroupDefault` is enabled in privacy settings: + - Downloaded files are encrypted at rest using AES via `CryptoFile`. + - `CryptoFile` wraps a file path with encryption metadata. +2. Encryption key is derived and stored securely. +3. Files are decrypted on-the-fly when accessed for viewing/playback. +4. This protects files even if the device storage is accessed externally. + +### 8. Unknown Relay Server Approval + +1. When receiving a file, XFTP relay servers are checked against known/approved servers. +2. If unknown servers are detected: `ChatError.error(.fileNotApproved(fileId, unknownServers))`. +3. If not auto-receiving, user is shown an alert: + - "Unknown servers! Without Tor or VPN, your IP address will be visible to these XFTP relays: [server list]." + - Option to "Download" (approve) or cancel. +4. On approval: `receiveFiles(user:fileIds:userApprovedRelays: true)` retries with approval. +5. If `privacyAskToApproveRelaysGroupDefault` is disabled, relays are auto-approved. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `CryptoFile` | `SimpleXChat/CryptoFile.swift` | File path with optional encryption key and nonce for local AES encryption | +| `MsgContent.image` | `SimpleXChat/ChatTypes.swift` | `.image(text: String, image: String)` -- text caption + base64 thumbnail | +| `MsgContent.video` | `SimpleXChat/ChatTypes.swift` | `.video(text: String, image: String, duration: Int)` -- caption + thumbnail + duration | +| `MsgContent.voice` | `SimpleXChat/ChatTypes.swift` | `.voice(text: String, duration: Int)` -- empty text + duration in seconds | +| `MsgContent.file` | `SimpleXChat/ChatTypes.swift` | `.file(String)` -- file name | +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message with fileSource, quotedItemId, msgContent, mentions | +| `FileTransferMeta` | `SimpleXChat/ChatTypes.swift` | Metadata for an ongoing file transfer | +| `RcvFileTransfer` | `SimpleXChat/ChatTypes.swift` | State of a file being received | +| `MigrationFileLinkData` | Used for standalone file transfers during database migration | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `fileNotApproved(fileId, unknownServers)` | Unknown XFTP relay servers | Alert with option to approve and retry | +| `fileCancelled` | File transfer was cancelled | Silently ignored in `receiveFiles` | +| `fileAlreadyReceiving` | Duplicate receive request | Silently ignored | +| `rcvFileAcceptedSndCancelled` | Sender cancelled after acceptance | Alert: "Sender cancelled file transfer" | +| File too large | Exceeds 1GB XFTP limit | Prevented in UI picker | +| Network errors | XFTP server unreachable | Standard retry mechanism | +| Storage full | Insufficient device storage | System-level error | + +## Key Files + +| File | Purpose | +|------|---------| +| `SimpleXChat/FileUtils.swift` | File size constants, path utilities, database file management | +| `SimpleXChat/CryptoFile.swift` | Local file encryption/decryption with AES | +| `SimpleXChat/ImageUtils.swift` | Image compression and thumbnail generation | +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | File/media attachment selection and composition | +| `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` | Image preview in compose area | +| `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` | File preview in compose area | +| `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` | Voice recording UI with waveform | +| `Shared/Views/Chat/ChatItem/CIFileView.swift` | File message display: icon, name, size, download action | +| `Shared/Views/Chat/ChatItem/CIImageView.swift` | Image message display: thumbnail, full-screen tap | +| `Shared/Views/Chat/ChatItem/CIVideoView.swift` | Video message display: thumbnail, play button, inline playback | +| `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | Voice message display: waveform, playback controls | +| `Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift` | Voice message inside a framed (quoted/forwarded) context | +| `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` | Full-screen image/video viewer | +| `Shared/Model/SimpleXAPI.swift` | `apiSendMessages`, `receiveFile`, `receiveFiles`, `uploadStandaloneFile`, `downloadStandaloneFile` | +| `Shared/Model/AudioRecPlay.swift` | Audio recording and playback engine for voice messages | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Messaging capability (file sharing) +- `apps/ios/product/flows/messaging.md` -- File transfer is part of the message send flow +- `apps/ios/product/views/chat.md` -- Chat view file/media display diff --git a/apps/ios/product/flows/group-lifecycle.md b/apps/ios/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..78d4f28738 --- /dev/null +++ b/apps/ios/product/flows/group-lifecycle.md @@ -0,0 +1,216 @@ +# Group Lifecycle Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/database.md](../../spec/database.md) + +## Overview + +Complete group management in SimpleX Chat iOS: creating groups, inviting members, joining via links, managing roles and admission, and group deletion. Groups use the same E2E encryption as direct messages -- each member pair has independent encrypted channels. Group metadata (name, image, preferences) is distributed via the group protocol. + +## Prerequisites + +- User profile created and chat engine running +- At least one established contact (to invite to a group) +- For joining via link: a valid group link or invitation + +## Step-by-Step Processes + +### 1. Create Group + +1. User taps "+" in `ChatListView` -> `NewChatMenuButton` -> "Create group". +2. `AddGroupView` is presented for entering group name, optional image, and description. +3. User fills in `GroupProfile(displayName:fullName:image:description:)` and taps "Create". +4. Calls `apiNewGroup(incognito:groupProfile:)`: + ```swift + func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo + ``` +5. Sends `ChatCommand.apiNewGroup(userId:incognito:groupProfile:)` to core (synchronous). +6. Core returns `ChatResponse2.groupCreated(user, groupInfo)`. +7. `GroupInfo` contains the new group's ID, profile, and the creator as owner. +8. User is navigated to `AddGroupMembersView` to optionally invite contacts. +9. User can also create a group link at this stage. + +### 2. Invite Members + +1. From `GroupChatInfoView`, user taps "Add members" -> `AddGroupMembersView`. +2. `filterMembersToAdd` filters contacts already in the group. +3. User selects contacts and assigns roles (default: `.member`). +4. For each selected contact, calls `apiAddMember(groupId:contactId:memberRole:)`: + ```swift + func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember + ``` +5. Core sends group invitation to the contact and returns `ChatResponse2.sentGroupInvitation(user, _, _, member)`. +6. The invited contact receives a `CIGroupInvitationView` in their chat. +7. Invited member's status is `.invited` until they accept. + +### 3. Join via Link + +1. User receives a group link (scanned or pasted). +2. `apiConnectPlan` validates the link and identifies it as a group link. +3. For prepared groups (short links): `apiPrepareGroup(connLink:groupShortLinkData:)` shows group info before joining. +4. `apiConnectPreparedGroup(groupId:incognito:msg:)` or `apiConnect(incognito:connLink:)` initiates joining. +5. Core processes the join request. Depending on group admission settings: + - **Auto-join**: Member is added immediately. + - **Approval required**: Member enters pending admission queue. +6. `apiJoinGroup(groupId:)` is called for invitation-based joins: + ```swift + func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult? + ``` +7. Returns one of: + - `.joined(groupInfo:)` -- successfully joined + - `.invitationRemoved` -- invitation was revoked (SMP AUTH error) + - `.groupNotFound` -- group no longer exists + +### 4. Member Admission + +1. Group has admission settings configured via `MemberAdmissionView`. +2. When a new member joins a group requiring approval, admins see pending members. +3. Admin reviews pending member in the member list. +4. To accept: `apiAcceptMember(groupId:groupMemberId:memberRole:)`: + ```swift + func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) + ``` +5. Core returns `ChatResponse2.memberAccepted(user, groupInfo, member)`. +6. To reject: remove the pending member (same as member removal). +7. Member support chat (`MemberSupportView`, `MemberSupportChatToolbar`) allows admins to communicate with pending members. + +### 5. Change Member Roles + +1. Admin/owner navigates to member info in `GroupChatInfoView`. +2. Selects new role for the member. +3. Calls `apiMembersRole(groupId:memberIds:memberRole:)`: + ```swift + func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember] + ``` +4. Core returns `ChatResponse2.membersRoleUser(user, _, members, _)`. +5. Available roles (in hierarchy order): + - `.owner` -- full control, can delete group + - `.admin` -- can manage members, change roles (below admin) + - `.moderator` -- can delete messages, moderate content + - `.member` -- standard participant, can send messages + - `.observer` -- read-only access +6. Role changes are broadcast to all group members as group events. + +### 6. Remove Member + +1. Admin/owner navigates to member info -> taps "Remove". +2. Calls `apiRemoveMembers(groupId:memberIds:withMessages:)`: + ```swift + func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) + ``` +3. `withMessages: true` also deletes all messages from that member. +4. Core returns `ChatResponse2.userDeletedMembers(user, updatedGroupInfo, members, withMessages)`. +5. Removed member receives notification and loses access. + +### 7. Block Member for All + +1. Admin can block a member's messages from being visible to all group members. +2. Calls `apiBlockMembersForAll(groupId:memberIds:blocked:)`: + ```swift + func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember] + ``` +3. Core returns `ChatResponse2.membersBlockedForAllUser(user, _, members, _)`. + +### 8. Leave Group + +1. User navigates to `GroupChatInfoView` -> taps "Leave group". +2. Confirmation dialog is presented. +3. Calls `leaveGroup(groupId:)` which wraps `apiLeaveGroup(groupId:)`: + ```swift + func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo + ``` +4. Core returns `ChatResponse2.leftMemberUser(user, groupInfo)`. +5. `ChatModel.shared.updateGroup(groupInfo)` updates the UI. +6. User retains local chat history but can no longer send/receive. + +### 9. Delete Group + +1. Owner navigates to `GroupChatInfoView` -> taps "Delete group". +2. Calls `apiDeleteChat(type: .group, id: groupId)`: + ```swift + func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws + ``` +3. Core notifies all members and removes the group. +4. Chat is removed from `ChatModel.shared.chats`. + +### 10. Group Link Management + +**Create group link:** +1. From `GroupLinkView` (accessible via `GroupChatInfoView`). +2. Calls `apiCreateGroupLink(groupId:memberRole:)`: + ```swift + func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink? + ``` +3. Returns `GroupLink` containing the link URI and member role. +4. Optional: `apiAddGroupShortLink(groupId:)` generates an additional short link. + +**Update link role:** +- `apiGroupLinkMemberRole(groupId:memberRole:)` changes the default role for new joiners. + +**Delete group link:** +- `apiDeleteGroupLink(groupId:)` invalidates the link. + +**Get existing link:** +- `apiGetGroupLink(groupId:)` retrieves the current link (returns `nil` if none exists). + +### 11. Group Preferences + +1. `GroupPreferencesView` allows configuring per-feature preferences. +2. Features controlled include: + - Timed/disappearing messages + - Message reactions + - Voice messages + - File sharing + - Direct messages between members + - Full message deletion + - Message history visibility for new members +3. Changes are saved via `apiUpdateGroup(groupId:groupProfile:)` with updated preferences. +4. `GroupWelcomeView` manages the welcome message shown to new joiners. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `GroupInfo` | `SimpleXChat/ChatTypes.swift` | Full group model: ID, profile, membership, preferences, business chat info | +| `GroupProfile` | `SimpleXChat/ChatTypes.swift` | Name, full name, image, description, preferences | +| `GroupMember` | `SimpleXChat/ChatTypes.swift` | Member model: role, status, profile, connection info | +| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer` | +| `GroupMemberStatus` | `SimpleXChat/ChatTypes.swift` | Member lifecycle: `.invited`, `.accepted`, `.connected`, `.complete`, etc. | +| `GroupLink` | `Shared/Model/AppAPITypes.swift` | Group link with URI, member role, and short link data | +| `BusinessChatInfo` | `SimpleXChat/ChatTypes.swift` | Business chat metadata for commercial group chats | +| `JoinGroupResult` | `Shared/Model/SimpleXAPI.swift` | `.joined(groupInfo)`, `.invitationRemoved`, `.groupNotFound` | +| `GMember` | `Shared/Views/Chat/Group/` | View-layer wrapper around `GroupMember` for list display | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `errorStore(.groupNotFound)` | Group deleted or not accessible | `JoinGroupResult.groupNotFound` | +| `errorAgent(.SMP(_, .AUTH))` | Invitation revoked | `JoinGroupResult.invitationRemoved` | +| `errorStore(.groupLinkNotFound)` | No group link exists | `apiGetGroupLink` returns `nil` | +| `duplicateGroupLink` | Link already exists for group | Show alert | +| `errorAgent(.NOTICE(server, preset, expires))` | Server notice during link creation | `showClientNotice` alert | +| Network errors | SMP/XFTP server unreachable | Retryable via `chatApiSendCmdWithRetry` | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/NewChat/AddGroupView.swift` | Group creation UI | +| `Shared/Views/Chat/Group/AddGroupMembersView.swift` | Member invitation UI | +| `Shared/Views/Chat/Group/GroupLinkView.swift` | Group link management UI | +| `Shared/Views/Chat/Group/GroupProfileView.swift` | Group profile editing | +| `Shared/Views/Chat/Group/GroupPreferencesView.swift` | Feature preferences UI | +| `Shared/Views/Chat/Group/GroupWelcomeView.swift` | Welcome message editing | +| `Shared/Views/Chat/Group/MemberAdmissionView.swift` | Admission settings UI | +| `Shared/Views/Chat/Group/MemberSupportView.swift` | Admin-to-pending-member chat | +| `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` | Support chat accept/reject toolbar | +| `Shared/Views/Chat/Group/SecondaryChatView.swift` | Secondary chat view for member support | +| `Shared/Model/SimpleXAPI.swift` | All group API functions | +| `Shared/Model/AppAPITypes.swift` | `GroupLink`, `ConnectionPlan` | +| `SimpleXChat/ChatTypes.swift` | `GroupInfo`, `GroupProfile`, `GroupMember`, `GroupMemberRole` | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: Groups capability map +- `apps/ios/product/flows/connection.md` -- Connection flow (group links use the same connect mechanism) +- `apps/ios/product/flows/messaging.md` -- Messaging within groups diff --git a/apps/ios/product/flows/messaging.md b/apps/ios/product/flows/messaging.md new file mode 100644 index 0000000000..527079995c --- /dev/null +++ b/apps/ios/product/flows/messaging.md @@ -0,0 +1,178 @@ +# Messaging Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Overview + +Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, editing, deleting, reacting to, replying to, and forwarding messages. All messages are end-to-end encrypted via the SMP protocol. The Haskell core handles encryption, routing, and persistence; the Swift UI layer drives composition and display. + +## Prerequisites + +- User profile created and chat engine running (`startChat()` completed) +- At least one established contact or group conversation +- `ChatModel.shared` populated with chat list data + +## Step-by-Step Processes + +### 1. Send Text Message + +1. User navigates to a conversation (direct or group) via `ChatListView` -> `ChatView`. +2. User types text into `ComposeView`'s `SendMessageView` text editor. +3. Link previews are detected and fetched asynchronously (`ComposeLinkView`). +4. User taps the send button. +5. `ComposeView` builds a `ComposedMessage`: + ```swift + ComposedMessage( + fileSource: nil, + quotedItemId: nil, + msgContent: .text("Hello"), + mentions: [:] + ) + ``` +6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:)`. +7. Internally dispatches `ChatCommand.apiSendMessages(...)` to the Haskell core. +8. Core encrypts, queues via SMP, and returns `ChatResponse1.newChatItems(user, aChatItems)`. +9. `processSendMessageCmd` extracts `[ChatItem]` from response. +10. For direct chats, a background task tracks delivery via `chatModel.messageDelivery`. +11. `ChatModel` updates, UI refreshes to show the new message. + +### 2. Send Media (Image/Video/File) + +1. User taps the attachment button in `ComposeView`. +2. **Image**: Picked via `PhotosPicker` or camera. Compressed to <=255KB. Sent inline with `.image(text, base64Image)` content type. +3. **Video**: Picked from library. Thumbnail generated. Video file sent via XFTP for files >255KB. Content type: `.video(text, thumbnail, duration)`. +4. **File**: Picked via document picker. If <=255KB, sent inline. If >255KB, uploaded via XFTP (up to 1GB). Content type: `.file(text)`. +5. `ComposedMessage` includes `fileSource: CryptoFile(filePath:)`. +6. `apiSendMessages(...)` called with the composed message array. +7. Core handles XFTP upload for large files (chunked, encrypted upload to XFTP servers). +8. Recipient receives file reference and can download. + +### 3. Receive Message + +1. `ChatReceiver.shared` runs `receiveMsgLoop()` continuously calling `chatRecvMsg()`. +2. Core delivers events via `APIResult`. +3. On `ChatEvent.newChatItems(user, chatItems)`: + - `processReceivedMsg` is called. + - For the active user, `ChatModel` is updated with new items. + - If the chat is currently open, `ItemsModel` appends to `reversedChatItems`. + - `NtfManager` posts a local notification if the app is in the background. +4. Small files/images attached to incoming messages are auto-received if within size thresholds. + +### 4. Edit Message + +1. User long-presses a sent message -> selects "Edit" from context menu. +2. `ComposeView` enters edit mode with the original text pre-filled. +3. User modifies text and taps send. +4. Calls `apiUpdateChatItem(type:id:scope:itemId:updatedMessage:live:)`. +5. Dispatches `ChatCommand.apiUpdateChatItem(...)`. +6. Core returns `ChatResponse1.chatItemUpdated(user, aChatItem)` or `.chatItemNotChanged(user, aChatItem)`. +7. `ChatModel` updates the item in place. Edit timestamp is shown in the UI. + +### 5. Delete Message + +1. User long-presses a message -> selects "Delete". +2. Presented with options: + - **Delete for me** (`CIDeleteMode.cidmInternal`) -- removes locally only. + - **Delete for everyone** (`CIDeleteMode.cidmBroadcast`) -- sends deletion to recipient(s). +3. Calls `apiDeleteChatItems(type:id:scope:itemIds:mode:)`. +4. Dispatches `ChatCommand.apiDeleteChatItem(type:id:scope:itemIds:mode:)`. +5. Core returns `ChatResponse1.chatItemsDeleted(user, items, _)` containing `[ChatItemDeletion]`. +6. For group messages from other members, admin/owner can call `apiDeleteMemberChatItems(groupId:itemIds:)`. +7. `ChatModel` removes or replaces items with "deleted" placeholders. + +### 6. React to Message + +1. User long-presses a message -> selects "React" -> picks an emoji. +2. Calls `apiChatItemReaction(type:id:scope:itemId:add:reaction:)`. +3. `reaction` is `MsgReaction` (e.g., `.emoji(.heart)`). +4. `add: true` to add, `add: false` to remove. +5. Core returns `ChatResponse1.chatItemReaction(user, _, reaction)`. +6. The reaction is displayed below the message bubble. + +### 7. Reply to Message + +1. User long-presses a message -> selects "Reply". +2. `ComposeView` enters reply mode, showing quoted message in `ContextItemView`. +3. User types reply text and taps send. +4. `ComposedMessage` is created with `quotedItemId: originalItem.id`. +5. `apiSendMessages(...)` sends with the quote reference. +6. Recipient sees the reply with the quoted context rendered above. + +### 8. Forward Message + +1. User long-presses a message -> selects "Forward". +2. `ChatItemForwardingView` is presented for destination chat selection. +3. `apiPlanForwardChatItems(type:id:scope:itemIds:)` validates what can be forwarded, returns `([Int64], ForwardConfirmation?)`. +4. User confirms and selects destination chat. +5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:)`. +6. Core returns `ChatResponse1.newChatItems(...)` with the forwarded items in the destination chat. + +### 9. Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` starts recording to a temporary file. +3. On release, recording stops. Duration is calculated (max 5 minutes / 300 seconds). +4. `ComposedMessage` created with: + - `fileSource: CryptoFile` pointing to the audio file + - `msgContent: .voice(text: "", duration: seconds)` +5. `apiSendMessages(...)` sends the voice message. +6. Voice messages <=510KB sent inline; larger via XFTP. +7. Recipient sees `CIVoiceView` with waveform and playback controls. + +### 10. Delivery Tracking + +1. On send, message status starts as `CIStatus.sndNew`. +2. After SMP delivery: `CIStatus.sndSent(sndProgress)`. +3. When delivered to recipient's agent: status updates to delivered. +4. If delivery receipts are enabled by both parties, read status is reported. +5. Failed delivery results in `CIStatus.sndError*` or `CIStatus.sndWarning*`. +6. Status is displayed via `CIMetaView` (checkmarks/indicators). + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message: fileSource, quotedItemId, msgContent, mentions | +| `MsgContent` | `SimpleXChat/ChatTypes.swift` | Enum: `.text`, `.link`, `.image`, `.video`, `.voice`, `.file` | +| `CIContent` | `SimpleXChat/ChatTypes.swift` | Chat item content wrapper with sent/received variants | +| `CIStatus` | `SimpleXChat/ChatTypes.swift` | Delivery status: sndNew, sndSent, sndError, rcvNew, rcvRead | +| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)` | +| `ChatItem` | `SimpleXChat/ChatTypes.swift` | Full message model: content, meta, status, direction, quotedItem | +| `ChatItemDeletion` | `SimpleXChat/ChatTypes.swift` | Deleted item info with old/new item pairs | +| `CIDeleteMode` | `SimpleXChat/ChatTypes.swift` | `.cidmInternal` (local) or `.cidmBroadcast` (for everyone) | +| `MsgReaction` | `SimpleXChat/ChatTypes.swift` | Reaction type (emoji-based) | +| `UpdatedMessage` | `SimpleXChat/APITypes.swift` | Edited message content for update API | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Recipient queue issue | Show "Connection error (AUTH)" alert | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable: show retry dialog via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable: show retry dialog | +| Send message error | Core processing failure | `sendMessageErrorAlert` shown to user | +| `chatItemNotChanged` | Edit with identical content | No error, item returned unchanged | +| File too large (>1GB) | XFTP limit exceeded | Prevented in UI file picker | +| `fileNotApproved` | Unknown XFTP relay servers | Show "Unknown servers!" alert with approve option | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | Message composition UI and send logic | +| `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` | Text input and send button | +| `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | Reply/edit context display | +| `Shared/Views/Chat/ChatItemView.swift` | Per-message rendering dispatcher | +| `Shared/Views/Chat/ChatItem/MsgContentView.swift` | Text message content with markdown | +| `Shared/Views/Chat/ChatItem/CIMetaView.swift` | Delivery status indicators | +| `Shared/Views/Chat/ChatItemForwardingView.swift` | Forward destination picker | +| `Shared/Views/Chat/ChatItemInfoView.swift` | Message info (delivery details, timestamps) | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiSendMessages`, `apiUpdateChatItem`, `apiDeleteChatItems`, `apiChatItemReaction`, `apiForwardChatItems` | +| `SimpleXChat/APITypes.swift` | `ComposedMessage`, `ChatCommand` enum, response types | +| `SimpleXChat/ChatTypes.swift` | `MsgContent`, `CIContent`, `CIStatus`, `CIDirection`, `ChatItem` | +| `Shared/Model/AudioRecPlay.swift` | Voice message recording/playback engine | + +## Related Specifications + +- `apps/ios/product/views/chat.md` -- Chat view UI specification +- `apps/ios/product/README.md` -- Product overview and capability map diff --git a/apps/ios/product/flows/onboarding.md b/apps/ios/product/flows/onboarding.md new file mode 100644 index 0000000000..5e2e04d42a --- /dev/null +++ b/apps/ios/product/flows/onboarding.md @@ -0,0 +1,239 @@ +# Onboarding Flow + +> **Related spec:** [spec/architecture.md](../../spec/architecture.md) | [spec/database.md](../../spec/database.md) + +## Overview + +First-time setup and migration flows for SimpleX Chat iOS. Covers app initialization, profile creation, server operator selection, notification configuration, and database import/export for device migration. The app uses a Haskell runtime for its core chat engine, with SQLite databases shared between the main app and the Notification Service Extension (NSE). + +## Prerequisites + +- Fresh install of SimpleX Chat from the App Store, or +- Existing install with database archive for import/migration +- iOS 15+ with App Group entitlement configured + +## Step-by-Step Processes + +### 1. App Initialization Sequence + +On every app launch, `SimpleXApp.init()` executes the following in order: + +``` +1. haskell_init() -- Start Haskell runtime system (GHC RTS) +2. UserDefaults.standard.register(defaults:) -- Set default preferences (appDefaults) +3. setGroupDefaults() -- Configure app group shared defaults +4. registerGroupDefaults() -- Register group container defaults +5. setDbContainer() -- Configure database paths in app group container +6. BGManager.shared.register() -- Register background task handlers +7. NtfManager.shared.registerCategories() -- Register notification action categories +``` + +Then in `ContentView.onAppear`: +- If no migration is in progress and authentication is set up, `initChatAndMigrate()` is called. +- This triggers `chatMigrateInit()` to initialize/migrate databases. +- Then `startChat()` is called to start the chat engine. + +### 2. Fresh Install -- Onboarding Steps + +Onboarding is managed by `OnboardingStage` enum and `OnboardingView`: + +**Step 1: SimpleX Info** (`step1_SimpleXInfo`) +1. `SimpleXInfo` view is presented. +2. Explains SimpleX's architecture: no user identifiers, E2E encryption, decentralized servers. +3. User taps "Create your profile" to proceed. + +**Step 2: Create Profile** (`step2_CreateProfile` -- now inline in step 1) +1. `CreateFirstProfile` view (embedded in the onboarding flow). +2. User enters display name (required). Full name is set to empty string. +3. Display name is validated via `mkValidName()` and `canCreateProfile()`. +4. On "Create": + ```swift + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + try startChat() + ``` +5. `apiCreateActiveUser(Profile(displayName:fullName:shortDescr:))` creates the user in the Haskell core. +6. `startChat()` initializes the chat engine. +7. Onboarding advances to `step3_ChooseServerOperators`. + +**Step 3: Choose Server Operators** (`step3_ChooseServerOperators`) +1. `OnboardingConditionsView` is presented (simplified conditions acceptance). +2. User reviews and accepts server operator conditions. +3. This configures which SMP/XFTP server operators to use. +4. Advances to `step4_SetNotificationsMode`. + +**Step 4: Set Notifications** (`step4_SetNotificationsMode`) +1. `SetNotificationsMode` view is presented. +2. Three options: + - **Instant**: Requires Apple Push Notification service. Registers device token via `apiRegisterToken(token:notificationMode:)`. + - **Periodic**: Uses iOS background app refresh. No push token needed. + - **Off**: No notifications. +3. For instant mode: `apiRegisterToken` sends `ChatCommand.apiRegisterToken(token:notificationMode:)` and receives `ChatResponse2.ntfTokenStatus(status)`. +4. On completion: `onboardingStageDefault.set(.onboardingComplete)`. + +**Onboarding Complete** (`onboardingComplete`) +1. `ChatListView` is shown. +2. Empty state displays "Add contact" prompt via `ChatHelp`. +3. If delivery receipts haven't been configured: `chatModel.setDeliveryReceipts = true` triggers a prompt. + +### 3. startChat() -- Chat Engine Startup + +Called after profile creation or on subsequent app launches: + +```swift +func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { + 1. setNetworkConfig(getNetCfg()) -- Apply network configuration + 2. apiCheckChatRunning() -- Check if already running + 3. listUsers() -- Load all user profiles + 4. getUserChatData() -- Load chats, tags, address, TTL + 5. NtfManager.shared.setNtfBadgeCount(...) -- Set badge count + 6. refreshCallInvitations() -- Check pending call invitations + 7. apiGetNtfToken() -- Get notification token status + 8. apiStartChat() -- Start the Haskell chat engine + 9. registerToken(token:) -- Register push token if available + 10. ChatReceiver.shared.start() -- Start message receive loop +} +``` + +### 4. Database Setup + +**Location:** +- App group container (shared with NSE): determined by `dbContainerGroupDefault` +- Path prefix: `simplex_v1` (`DB_FILE_PREFIX`) +- Chat database: `simplex_v1_chat.db` (messages, contacts, groups, settings) +- Agent database: `simplex_v1_agent.db` (SMP connections, encryption keys, queues) + +**Initialization:** +- `chatMigrateInit(useKey:confirmMigrations:backgroundMode:)` in `SimpleXChat/API.swift`. +- Creates databases if they do not exist. +- Runs pending migrations with confirmation mode. +- Handles database encryption: + - If keychain storage enabled: generates random DB key on first run (`randomDatabasePassword()`). + - Stores key in keychain via `kcDatabasePassword`. + - `initialRandomDBPassphraseGroupDefault` tracks whether using auto-generated key. + +**Encryption:** +- Optional database encryption passphrase via `DatabaseEncryptionView`. +- `apiStorageEncryption(currentKey:newKey:)` changes encryption key. +- `testStorageEncryption(key:)` validates a key against the database. + +### 5. Database Export (Source Device) + +1. User navigates to Settings -> Database -> "Export database". +2. Chat must be stopped first for data consistency. +3. Calls `apiExportArchive(config: ArchiveConfig)`: + ```swift + func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core creates a ZIP archive containing both databases and file attachments. +5. Returns any non-fatal `[ArchiveError]` (e.g., file access issues). +6. User transfers the archive to the new device via AirDrop, file share, etc. + +### 6. Database Import (Destination Device) + +1. On new device: during onboarding or Settings -> Database -> "Import database". +2. User selects the archive file. +3. Calls `apiImportArchive(config: ArchiveConfig)`: + ```swift + func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core extracts the archive, replacing local databases. +5. Returns any non-fatal `[ArchiveError]`. +6. Chat engine is restarted with the imported data. +7. All contacts, groups, messages, and settings are restored. + +### 7. In-App Device Migration + +An alternative to manual export/import using direct device-to-device transfer. + +**Source device** (`MigrateFromDevice` view): +1. User navigates to Settings -> Database -> "Migrate to another device". +2. App creates a temporary database and uploads archive via XFTP standalone file. +3. Generates a migration link containing the file URL and encryption key. +4. Displays QR code / share link for the destination device. + +**Destination device** (`MigrateToDevice` view): +1. On new device: onboarding detects migration state or user selects "Migrate". +2. Scans/pastes the migration link. +3. `downloadStandaloneFile(user:url:file:ctrl:)` downloads the archive from XFTP. +4. `standaloneFileInfo(url:ctrl:)` validates the file metadata. +5. Archive is imported, databases are restored. +6. `chatInitTemporaryDatabase(url:key:confirmation:)` may be used for temporary DB operations during migration. +7. Chat engine starts with the migrated data. + +If migration is interrupted: +- `chatModel.migrationState` preserves state across app restarts. +- On next launch, `ContentView.onAppear` detects pending migration and resumes. + +### 8. Additional Profile Creation (Multi-Account) + +1. From `UserPicker` (profile switcher) -> "Add profile". +2. `CreateProfile` view is presented (distinct from `CreateFirstProfile`). +3. User enters display name and optional bio (max 160 bytes JSON-encoded, `MAX_BIO_LENGTH_BYTES`). +4. `apiCreateActiveUser(profile)` creates additional user. +5. `listUsers()` and `getUserChatData()` refresh the model. +6. No onboarding steps -- goes directly to chat list. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `OnboardingStage` | `Shared/Views/Onboarding/OnboardingView.swift` | Enum: `step1_SimpleXInfo`, `step2_CreateProfile`, `step3_ChooseServerOperators`, `step4_SetNotificationsMode`, `onboardingComplete` | +| `Profile` | `SimpleXChat/ChatTypes.swift` | `displayName`, `fullName`, `image`, `shortDescr` | +| `User` | `SimpleXChat/ChatTypes.swift` | Full user model with profile, userId, and settings | +| `ArchiveConfig` | `SimpleXChat/APITypes.swift` | Configuration for database export/import | +| `DBMigrationResult` | `SimpleXChat/API.swift` | Result of database migration: `.ok`, `.errorNotADatabase`, `.errorKeychain`, etc. | +| `MigrationConfirmation` | `SimpleXChat/API.swift` | Migration confirmation mode: `.error`, `.yesUp`, `.yesUpDown` | +| `DeviceToken` | `SimpleXChat/ChatTypes.swift` | Apple push notification device token | +| `NtfTknStatus` | `SimpleXChat/ChatTypes.swift` | Notification token status: registered, active, expired, etc. | +| `NotificationsMode` | `SimpleXChat/ChatTypes.swift` | `.off`, `.periodic`, `.instant` | +| `MigrationFileLinkData` | Used in standalone file transfers for device migration | +| `AppChatState` | `SimpleXChat/` | Shared state: `.active`, `.stopped`, `.suspended` | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `DBMigrationResult.errorNotADatabase` | Wrong encryption key or corrupt DB | Show `DatabaseErrorView` with options | +| `DBMigrationResult.errorKeychain` | Keychain access failed | Show error, offer to re-enter passphrase | +| `DBMigrationResult.errorMigration` | Schema migration failure | Show error with migration details | +| `duplicateUserError` | Display name already in use | `UserProfileAlert.duplicateUserError` | +| `invalidDisplayNameError` | Invalid characters in display name | `UserProfileAlert.invalidDisplayNameError` | +| `createUserError` | Core failed to create user | Alert with error details | +| `invalidNameError(validName)` | Name needs normalization | Alert suggesting the valid name | +| Archive import errors | Missing files, version mismatch | Non-fatal `[ArchiveError]` displayed | +| Migration interrupted | Network failure, app killed | State preserved in `chatModel.migrationState`, resumed on next launch | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/SimpleXApp.swift` | App entry point: `haskell_init`, defaults registration, DB container setup, BG tasks | +| `Shared/AppDelegate.swift` | Push notification registration, URL handling | +| `Shared/ContentView.swift` | Root view: authentication, onboarding routing, chat initialization | +| `Shared/Views/Onboarding/OnboardingView.swift` | Onboarding step router, `OnboardingStage` enum | +| `Shared/Views/Onboarding/SimpleXInfo.swift` | Step 1: Privacy architecture explanation | +| `Shared/Views/Onboarding/CreateProfile.swift` | Profile creation: `CreateProfile` (additional) and `CreateFirstProfile` (onboarding) | +| `Shared/Views/Onboarding/ChooseServerOperators.swift` | Step 3: Server operator conditions | +| `Shared/Views/Onboarding/SetNotificationsMode.swift` | Step 4: Notification mode selection | +| `Shared/Views/Onboarding/CreateSimpleXAddress.swift` | Optional address creation during onboarding | +| `Shared/Views/Onboarding/HowItWorks.swift` | Educational content about SimpleX protocol | +| `Shared/Views/Migration/MigrateFromDevice.swift` | Source device migration UI | +| `Shared/Views/Migration/MigrateToDevice.swift` | Destination device migration UI | +| `Shared/Views/Database/DatabaseView.swift` | Database management: export, import, encryption | +| `Shared/Views/Database/DatabaseEncryptionView.swift` | Database passphrase management | +| `Shared/Views/Database/DatabaseErrorView.swift` | Database error recovery UI | +| `Shared/Views/Database/MigrateToAppGroupView.swift` | Legacy migration from Documents to App Group container | +| `Shared/Model/SimpleXAPI.swift` | `startChat`, `apiCreateActiveUser`, `apiExportArchive`, `apiImportArchive`, `apiRegisterToken` | +| `SimpleXChat/API.swift` | `chatMigrateInit`, `chatInitTemporaryDatabase`, low-level DB initialization | +| `SimpleXChat/FileUtils.swift` | DB file paths, constants (`DB_FILE_PREFIX`, `CHAT_DB`, `AGENT_DB`) | +| `SimpleXChat/AppGroup.swift` | App group container configuration | +| `SimpleXChat/KeyChain.swift` | Keychain access for DB passphrase and app passwords | +| `Shared/Model/BGManager.swift` | Background task registration and scheduling | +| `Shared/Model/NtfManager.swift` | Notification management and badge counts | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: architecture and capabilities +- `apps/ios/product/flows/connection.md` -- After onboarding, user establishes first connections +- `apps/ios/product/flows/messaging.md` -- Messaging starts after profile creation diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md new file mode 100644 index 0000000000..04cf97a6a7 --- /dev/null +++ b/apps/ios/product/gaps.md @@ -0,0 +1,61 @@ +# SimpleX Chat iOS -- Known Gaps & Recommendations + +> Aggregation of `[GAP]` and `[REC]` annotations discovered during specification analysis. Organized by product area. +> +> **Related spec:** [spec/README.md](../spec/README.md) + +--- + +## UI: Error Feedback + +### GAP: No user-visible error on FFI command failure +**Source:** [spec/architecture.md](../spec/architecture.md) +API calls via `chatApiSendCmd` return `APIResult` which can be `.error(ChatError)`. Not all error cases surface user-visible feedback in the UI. + +**REC:** Audit all `chatApiSendCmd` call sites and ensure `.error` cases show appropriate alerts or banners. + +--- + +## UI: Loading States + +### GAP: No loading indicator during initial chat list population +**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md) +When `ChatModel.chatInitialized` transitions to `true`, the chat list appears fully formed. There is no intermediate loading state for users with large numbers of chats. + +**REC:** Add a progress indicator during `apiGetChats` for users with 100+ conversations. + +--- + +## Flows: Group Lifecycle + +### GAP: Bulk member role change — API supports batch but UI uses single-member calls +**Source:** [spec/api.md](../spec/api.md) +`APIMembersRole` accepts `NonEmpty GroupMemberId`, supporting batch role changes at the API level. However, the iOS UI (`GroupMemberInfoView.swift`) currently invokes it with a single member at a time. + +**REC:** Expose batch role change in the UI for group admins managing large groups. + +--- + +## Security + +### GAP: Database passphrase not enforced by default +**Source:** [spec/database.md](../spec/database.md) +Database encryption is optional and requires the user to manually set a passphrase. New installations start with an unencrypted database. + +**REC:** Consider prompting users to set a database passphrase during onboarding, especially on devices without hardware encryption. + +### GAP: No forward secrecy indicator in UI +**Source:** [product/glossary.md](glossary.md) +While the double-ratchet protocol provides forward secrecy, there is no UI indicator showing whether a specific conversation has achieved forward secrecy (i.e., completed initial key exchange ratcheting). + +**REC:** Add a security indicator in contact/group info showing ratchet state. + +--- + +## Documentation + +### GAP: Haskell Store layer not fully specified +**Source:** [spec/database.md](../spec/database.md) +The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages.hs`, etc.) are referenced by function name but not fully specified with parameter types and return types. + +**REC:** Expand database spec with key Store function signatures as the specification matures. diff --git a/apps/ios/product/glossary.md b/apps/ios/product/glossary.md new file mode 100644 index 0000000000..0353c8f606 --- /dev/null +++ b/apps/ios/product/glossary.md @@ -0,0 +1,235 @@ +# SimpleX Chat iOS -- Glossary + +> SimpleX Chat iOS domain glossary. Defines all domain terms used in SimpleX Chat with links to relevant specifications and source code. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Protocols & Cryptography](#protocols--cryptography) +2. [Core Data Types](#core-data-types) +3. [Commands & Events](#commands--events) +4. [Connection & Identity](#connection--identity) +5. [Messaging Features](#messaging-features) +6. [Calling & Media](#calling--media) +7. [Notifications & Background](#notifications--background) +8. [Application Architecture](#application-architecture) +9. [Configuration & Preferences](#configuration--preferences) + +--- + +## Protocols & Cryptography + +### SMP (Simplex Messaging Protocol) +The core messaging protocol used for asynchronous message delivery through relay servers. Each conversation uses separate unidirectional queues, and sender and receiver queues have no shared identifier. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/simplex-messaging.md`, implementation `simplexmq/src/Simplex/Messaging/Protocol.hs`* + +### SMP Server +A relay server that stores and forwards encrypted messages between parties. Users can configure custom SMP servers or use defaults. Servers cannot see message contents or correlate senders with receivers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### XFTP (eXtended File Transfer Protocol) +A protocol for transferring large files (up to 1GB) through relay servers. Files are encrypted, split into chunks, and uploaded to XFTP servers. Recipients download and reassemble chunks independently. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/xftp.md`, implementation `simplexmq/src/Simplex/FileTransfer/Protocol.hs`; chat-level integration `../../src/Simplex/Chat/Files.hs`* + +### XFTP Server +A relay server that stores encrypted file chunks for asynchronous file transfer. Like SMP servers, users can configure custom XFTP servers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### SMP Agent +The lower-level agent library (in [simplexmq](https://github.com/simplex-chat/simplexmq)) that manages SMP connections, queue creation/rotation, duplex connection establishment, message delivery, and the double-ratchet encryption protocol. The chat application layer communicates with the agent via its functional API. *See: protocol spec `simplexmq/protocol/agent-protocol.md`, implementation `simplexmq/src/Simplex/Messaging/Agent.hs`; chat-level integration `../../src/Simplex/Chat/Controller.hs`* + +### Double Ratchet +The key agreement protocol used for E2E encryption. Provides forward secrecy and break-in recovery by deriving new encryption keys for each message. Based on the Signal protocol's double-ratchet algorithm, augmented with post-quantum KEM (PQDR). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Post-Quantum Encryption +Optional quantum-resistant key exchange (PQ) available for direct chats. Uses a hybrid scheme combining classical X25519 with Streamlined NTRU-Prime 761 (sntrup761) KEM. The hybrid secret is SHA3-256(DH_secret || KEM_shared_secret). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs`; Swift types `SimpleXChat/ChatTypes.swift` (PQEncryption, PQSupport)* + +### E2E Encryption +End-to-end encryption ensuring that only the communicating parties can read message contents. Neither SMP relay servers nor any network observer can decrypt messages. All SimpleX Chat messages are E2E encrypted by default using the double-ratchet protocol. *See: `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` (ratchet implementation), `simplexmq/src/Simplex/Messaging/Agent/Protocol.hs` (E2E message envelopes)* + +### Forward Secrecy +A property of the double-ratchet protocol ensuring that compromise of current encryption keys does not compromise past session keys. Each message uses a derived key that is deleted after use. *See: `simplexmq/protocol/pqdr.md`, `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Chat Protocol (x-events) +The chat-level protocol defining message envelopes and content types exchanged between chat participants. Includes x-events (XMsgNew, XMsgUpdate, XMsgDel, XCallInv, XFileCancel, XGrpMemNew, etc.), MsgContent (text, image, video, voice, file, link), and message encoding (Binary/JSON). This is distinct from the lower-level SMP transport protocol. *See: `../../src/Simplex/Chat/Protocol.hs`* + +### Security Code +A hash of the shared encryption session displayed as a numeric code and QR code. Contacts can compare security codes out-of-band to verify they have an uncompromised E2E session. *See: `Shared/Views/Chat/VerifyCodeView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIVerifyContact)* + +--- + +## Core Data Types + +### ChatItem +The fundamental unit of content in a conversation. Represents a single message, event, call record, or system notification within a chat. Each ChatItem has direction (sent/received), content, metadata, and optional quoted context. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatItem), `SimpleXChat/ChatTypes.swift`* + +### ChatInfo +A type-safe wrapper identifying a conversation and its metadata. Variants: DirectChat (1:1 with Contact), GroupChat (with GroupInfo), LocalChat (note folder), ContactRequest, ContactConnection. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatInfo), `SimpleXChat/ChatTypes.swift`* + +### CIContent +The content payload of a ChatItem. Differentiates sent vs. received content types: message content (text/image/file/voice/link), deletion markers, call records, group events, and feature preference changes. *See: `../../src/Simplex/Chat/Messages/CIContent.hs` (data CIContent)* + +### User +A local user profile within the app. Each user has an independent set of contacts, groups, and connections. Multiple users can exist in one app installation. Fields include userId, profile, display name, and optional view password hash for hidden profiles. *See: `../../src/Simplex/Chat/Types.hs` (data User), `Shared/Model/ChatModel.swift`* + +### Contact +A remote party with whom the user has an established E2E encrypted connection. Stores the contact's profile, local alias, connection status, feature preferences, and UI settings. *See: `../../src/Simplex/Chat/Types.hs` (data Contact), `SimpleXChat/ChatTypes.swift`* + +### GroupInfo +Metadata for a group conversation including group profile, member count, preferences, and membership status. Contains the user's own membership record as a GroupMember. *See: `../../src/Simplex/Chat/Types.hs` (data GroupInfo)* + +### GroupMember +A participant in a group conversation. Each member has a role, status, profile, and optionally a direct connection. The user's own membership is also represented as a GroupMember within GroupInfo. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMember)* + +### Connection +A low-level SMP agent connection between two parties. Each connection has a status (new, joined, ready, deleted), an agent connection ID, and is associated with a specific contact or group member. *See: `../../src/Simplex/Chat/Types.hs` (data Connection)* + +### ConnStatus +The lifecycle state of a Connection: ConnNew (created, awaiting join), ConnJoined (joined, handshake in progress), ConnReady (fully established), ConnDeleted (terminated). *See: `../../src/Simplex/Chat/Types.hs` (data ConnStatus)* + +### ContactStatus +The status of a contact record: CSActive (normal), CSDeleted (deleted by contact), CSDeletedByUser (deleted by user). *See: `../../src/Simplex/Chat/Types.hs` (data ContactStatus)* + +### GroupMemberRole +Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver. Roles determine permissions for sending messages, managing members, and moderating content. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole)* + +### GroupMemberStatus +The lifecycle state of a group member: GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown, GSMemInvited, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator, GSMemPendingReview, GSMemPendingApproval. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMemberStatus)* + +### FileTransfer +Represents an in-progress or completed file transfer. Variants: FTSnd (sending, with metadata and per-recipient transfer records) and FTRcv (receiving). Tracks protocol (SMP inline or XFTP), progress, and encryption parameters. *See: `../../src/Simplex/Chat/Types.hs` (data FileTransfer)* + +### ChatTag +A user-defined label for organizing conversations in the chat list. Each tag has a text label and optional emoji. Chats can have multiple tags, and the chat list can be filtered by tag. *See: `../../src/Simplex/Chat/Types.hs` (data ChatTag), `Shared/Views/ChatList/TagListView.swift`* + +--- + +## Commands & Events + +### ChatCommand +A sum type representing all commands the UI can send to the chat controller. Examples: APISendMessages, APIGetChat, APIConnect, APINewGroup, APIDeleteChatItem. Commands are serialized and dispatched through the FFI bridge. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatCommand)* + +### ChatResponse +A sum type representing synchronous responses from the chat controller to the UI after processing a ChatCommand. Examples: CRActiveUser, CRNewChatItems, CRChatItemUpdated. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatResponse)* + +### ChatEvent +A sum type representing asynchronous events pushed from the chat controller to the UI. These are unsolicited notifications about state changes: incoming messages, connection status changes, call invitations, etc. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatEvent)* + +### ChatError +Error types returned by the chat controller. Variants: ChatError (application-level), ChatErrorAgent (SMP agent errors), ChatErrorStore (database errors), ChatErrorRemoteHost (remote desktop errors). *See: `../../src/Simplex/Chat/Controller.hs` (data ChatError)* + +--- + +## Connection & Identity + +### SimpleX Address +A long-lived contact address that others can use to send connection requests. Unlike one-time invitation links, an address can be reused by multiple contacts. The user can accept or reject each incoming request. *See: `Shared/Views/UserSettings/UserAddressView.swift`, `../../src/Simplex/Chat/Controller.hs` (APICreateMyAddress)* + +### Contact Link +A one-time or reusable URI that initiates a contact connection. When scanned or opened, it triggers the SMP handshake to establish an E2E encrypted channel between two parties. *See: `Shared/Views/NewChat/NewChatView.swift`* + +### Group Link +A shareable URI that allows new members to join a group. The link connects to the group host, who then introduces the new member to existing members. Configurable with a default member role. *See: `Shared/Views/Chat/Group/GroupLinkView.swift`, `../../src/Simplex/Chat/Types.hs` (data GroupLink)* + +### Short Link +A compact version of SimpleX contact or group links, using a shorter URI format for easier sharing. Contains encoded connection parameters with reduced character length. *See: `../../src/Simplex/Chat/Controller.hs`* + +### Incognito Mode +A privacy feature that generates a random profile (display name and avatar) for each new contact connection. The real user profile is never shared with incognito contacts. Can be toggled per-connection at invitation time. *See: `Shared/Views/UserSettings/IncognitoHelp.swift`, `../../src/Simplex/Chat/ProfileGenerator.hs`* + +### Hidden Profile +A user profile protected by a separate password. Hidden profiles do not appear in the user picker or profile list. To access a hidden profile, the user enters its password in the search field of the user picker. *See: `Shared/Views/UserSettings/HiddenProfileView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIHideUser)* + +--- + +## Messaging Features + +### Delivery Receipt +A confirmation that a message was successfully delivered to the recipient's device. Displayed as a double-check indicator on sent messages. Can be enabled or disabled per contact or globally. *See: `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift`, `../../src/Simplex/Chat/Controller.hs`* + +### Read Receipt +An indicator that a recipient has viewed a received message. Currently not implemented as a separate feature; delivery receipts serve as the primary delivery confirmation. *See: `Shared/Views/UserSettings/PrivacySettings.swift`* + +### Timed Message +A message with a configurable time-to-live (TTL). After the TTL expires, the message is automatically deleted from both sender and recipient devices. The TTL is set as a chat feature preference. Also referred to as a disappearing message. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Disappearing Message +Synonym for Timed Message. A message that self-destructs after a configured duration. The timer starts when the message is read by the recipient. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Message Integrity +Verification that messages are received in order and without gaps. The system detects skipped messages and decryption failures, displaying integrity error indicators in the chat. *See: `Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +### Decryption Error +An error occurring when a received message cannot be decrypted, typically due to ratchet synchronization issues. The UI displays a specific error view with recovery options. *See: `Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +--- + +## Calling & Media + +### CallKit +Apple's framework for integrating VoIP calls with the native iOS call UI. SimpleX Chat uses CallKit to display incoming calls on the lock screen, support call answering from the system UI, and manage audio sessions. *See: `Shared/Views/Call/CallController.swift`, `Shared/Views/Call/CallManager.swift`* + +### WebRTC +The real-time communication framework used for audio/video calls. SimpleX Chat wraps WebRTC in an E2E encrypted layer, with signaling performed through the existing SMP message channel rather than a central server. *See: `Shared/Views/Call/WebRTC.swift`, `Shared/Views/Call/WebRTCClient.swift`* + +### ICE Server +An Interactive Connectivity Establishment server used by WebRTC to discover network paths between call participants. SimpleX Chat supports configuring custom ICE servers. *See: `Shared/Views/UserSettings/RTCServers.swift`, `SimpleXChat/CallTypes.swift`* + +### TURN Server +A Traversal Using Relays around NAT server that relays WebRTC media when direct peer-to-peer connection is not possible. A specific type of ICE server. SimpleX Chat allows configuring custom TURN servers for call relay. *See: `Shared/Views/UserSettings/RTCServers.swift`* + +### RcvCallInvitation +An in-memory data structure representing an incoming call invitation. Contains the calling contact, call type (audio/video), encryption keys, and shared key for the WebRTC session. Not persisted to database. *See: `../../src/Simplex/Chat/Call.hs` (data RcvCallInvitation)* + +--- + +## Notifications & Background + +### Notification Service Extension (NSE) +An iOS app extension that processes incoming push notifications while the main app is not running. The NSE starts a temporary chat controller, decrypts the incoming message, and displays a notification with the message preview. *See: `SimpleX NSE/NotificationService.swift`, `SimpleX NSE/NSEAPITypes.swift`* + +### Background Task +An iOS background execution context used for periodic message fetching when instant notifications are not enabled. Managed by BGManager to check for new messages at system-determined intervals. *See: `Shared/Model/BGManager.swift`* + +--- + +## Application Architecture + +### chat_ctrl +The opaque C pointer to the Haskell chat controller, obtained via FFI initialization. All chat operations are dispatched through this controller handle. The main app and NSE maintain separate chat_ctrl instances. *See: `SimpleXChat/API.swift` (chatController, getChatCtrl)* + +### ComposeState +A Swift struct holding the current state of the message composition area. Tracks the message text, parsed markdown, preview, attached media, editing context, quote context, and voice recording state. *See: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (struct ComposeState)* + +### ChatModel +The central observable model object for the iOS app. Holds all reactive state: current user, chat list, active chat, call state, app preferences, and navigation state. Published properties drive SwiftUI view updates. *See: `Shared/Model/ChatModel.swift` (class ChatModel)* + +### ItemsModel +An observable model managing the list of ChatItems displayed in a conversation view. Handles item loading, pagination, merging of new items, and secondary chat filtering. *See: `Shared/Model/ChatModel.swift` (class ItemsModel)* + +### AppTheme +An observable object encapsulating the current visual theme: name, base theme, color overrides, app-specific colors, and wallpaper configuration. Shared as an environment object across the SwiftUI view hierarchy. *See: `Shared/Theme/Theme.swift` (class AppTheme)* + +--- + +## Configuration & Preferences + +### FeaturePreference +A type class (Haskell) / protocol pattern representing a user's preference for a specific chat feature (e.g., timed messages, voice messages, calls). Each preference has an allow/enable setting and optional parameters. Feature preferences are negotiated between contacts. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (class FeatureI, type FeaturePreference)* + +### ChatSettings +Per-chat configuration including notification mode (all/mentions/off), send receipts toggle, favorite flag, and tag assignments. Stored per contact and per group. *See: `../../src/Simplex/Chat/Types.hs` (data ChatSettings)* + +### UserDefaults / GroupDefaults +iOS persistent key-value storage for app preferences. GroupDefaults (UserDefaults with the app group suite name) is shared between the main app and the NSE extension. Stores settings like notification mode, appearance preferences, and runtime flags. *See: `SimpleXChat/AppGroup.swift` (groupDefaults)* + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Concept index: [concepts.md](concepts.md) +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell chat protocol (x-events): `../../src/Simplex/Chat/Protocol.hs` +- Haskell messages: `../../src/Simplex/Chat/Messages.hs` +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- simplexmq library (SMP, XFTP, Agent, encryption): [github.com/simplex-chat/simplexmq](https://github.com/simplex-chat/simplexmq) diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md new file mode 100644 index 0000000000..b41792898b --- /dev/null +++ b/apps/ios/product/rules.md @@ -0,0 +1,119 @@ +# SimpleX Chat iOS -- Business Rules + +> Business invariants enforced by the SimpleX Chat iOS app and Haskell core. Each rule states the invariant, where it is enforced, and links to the relevant spec. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) | [spec/state.md](../spec/state.md) + +--- + +## Security & Privacy + +### RULE-01: No user identifiers +**Rule:** The system MUST NOT assign, generate, or expose any persistent user identifier (phone number, email, username, UUID) that could be used to correlate a user across conversations. +**Enforced by:** SMP protocol design in simplexmq library; each connection uses independent unidirectional queues with no shared identifier. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-02: End-to-end encryption on all messages +**Rule:** All message content MUST be encrypted end-to-end using double-ratchet (with optional post-quantum KEM). The SMP server MUST NOT have access to plaintext. +**Enforced by:** simplexmq library (`Simplex.Messaging.Crypto.Ratchet`); encryption happens before `chat_send_cmd_retry` FFI call. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-03: Database encryption at rest +**Rule:** Both SQLite databases (chat and agent) MUST be encrypted with SQLCipher when the user sets a database passphrase. +**Enforced by:** `chat_migrate_init_key` in Haskell core via SQLCipher; `DatabaseEncryptionView.swift` in UI. +**Spec:** [spec/database.md](../spec/database.md) + +### RULE-04: Local authentication before content access +**Rule:** When app lock is enabled, the app MUST authenticate the user (Face ID, Touch ID, or passcode) before displaying any chat content. +**Enforced by:** `LocalAuthView.swift`, `ContentView.swift` (`contentViewAccessAuthenticated` guard on `ChatModel`). +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-05: Incognito profiles are per-connection +**Rule:** When incognito mode is used for a connection, the generated random profile MUST be unique to that connection and MUST NOT be reused across connections. +**Enforced by:** `ProfileGenerator.hs` generates fresh profile per connection; stored on the connection entity. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Message Integrity + +### RULE-06: Message order preservation +**Rule:** Messages within a single connection MUST be displayed in the order determined by the SMP agent's sequence numbers, not by local timestamps. +**Enforced by:** `Store/Messages.hs` (`createNewChatItem` uses agent-assigned ordering); `ItemsModel` in `ChatModel.swift` preserves this order. +**Spec:** [spec/state.md](../spec/state.md) + +### RULE-07: Edited messages retain history +**Rule:** When a message is edited, the previous version MUST be preserved in `chat_item_versions` and accessible via the item info view. +**Enforced by:** `Controller.hs` (`APIUpdateChatItem`); `Store/Messages.hs` (`updateChatItem` creates version record); `ChatItemInfoView.swift` displays history. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-08: Deleted messages respect deletion mode +**Rule:** `CIDeleteMode.cidmBroadcast` sends deletion to recipient; `cidmInternal` only deletes locally. Moderation deletion (`cidmInternalMark`) marks the item but retains a placeholder. +**Enforced by:** `Controller.hs` (`APIDeleteChatItem` checks `CIDeleteMode`); `MarkedDeletedItemView.swift` renders moderation placeholders. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-09: Timed messages auto-delete after TTL +**Rule:** Messages with a TTL MUST be automatically deleted from local storage after the configured time-to-live expires. +**Enforced by:** `Controller.hs` (background task scheduling); `Store/Messages.hs` (TTL-based cleanup). +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Group Integrity + +### RULE-10: Role hierarchy enforcement +**Rule:** A member can only modify members with strictly lower roles. Owner > Admin > Moderator > Member > Observer. +**Enforced by:** `Controller.hs` (`APIMembersRole` validates role hierarchy); `GroupMemberInfoView.swift` restricts available actions in UI. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-11: Group creator is always owner +**Rule:** The user who creates a group MUST be assigned the `GROwner` role and cannot be demoted. +**Enforced by:** `Controller.hs` (`APINewGroup`); `Store/Groups.hs` (`createNewGroup` assigns owner role). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-12: Group link role assignment +**Rule:** Members joining via group link MUST receive the role configured on the link (default: `GRMember`). Only admins and owners can create group links. +**Enforced by:** `Controller.hs` (`APICreateGroupLink` takes `memberRole` parameter); `GroupLinkView.swift` UI restricts to admin+. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## File Transfer + +### RULE-13: File size limits +**Rule:** Files up to 1GB are transferred via XFTP. The system MUST reject files exceeding the configured maximum. +**Enforced by:** Haskell core (`Files.hs` checks file size); XFTP protocol enforces chunk limits. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +### RULE-14: File encryption at rest +**Rule:** When `privacyEncryptLocalFiles` is enabled, downloaded files MUST be encrypted locally using AES with per-file random key/nonce stored in `CryptoFile`. +**Enforced by:** `CryptoFile.swift` (`encryptCryptoFile`, `decryptCryptoFile`); `Library/Commands.hs` uses `CryptoFileArgs` for file encryption. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +--- + +## Notification Delivery + +### RULE-15: Notification preview respects privacy setting +**Rule:** Notification content MUST respect `NotificationPreviewMode`: `.message` shows full content, `.contact` shows sender only, `.hidden` shows generic alert. +**Enforced by:** `Notifications.swift` (notification content creation checks `ntfPreviewModeGroupDefault`); `NotificationService.swift` (NSE content generation). +**Spec:** [spec/services/notifications.md](../spec/services/notifications.md) + +### RULE-16: NSE database coordination +**Rule:** The NSE and main app MUST NOT write to the database simultaneously. File locks coordinate access. +**Enforced by:** `chat_close_store` / `chat_reopen_store` FFI calls; NSE uses short-lived database sessions. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +--- + +## Call Integrity + +### RULE-17: Call encryption key exchange +**Rule:** WebRTC call encryption keys MUST be negotiated over the existing E2E encrypted SMP channel, not through any external signaling server. +**Enforced by:** `ActiveCallView.swift` sends call signaling via `apiSendCallInvitation`/`apiSendCallAnswer` which use SMP; `Call.hs` defines call protocol. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) + +### RULE-18: CallKit region restriction +**Rule:** CallKit MUST be disabled in regions where it is restricted (China). The app uses in-app call UI as fallback. +**Enforced by:** `CallController.swift` checks `useCallKit()` based on region; `ActiveCallView.swift` provides fallback UI. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) diff --git a/apps/ios/product/views/call.md b/apps/ios/product/views/call.md new file mode 100644 index 0000000000..f32f7ec243 --- /dev/null +++ b/apps/ios/product/views/call.md @@ -0,0 +1,122 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. Supports CallKit integration for native iOS call UI, picture-in-picture for video calls, audio device selection, and collapsible call overlay. + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallView` banner appears at top of screen; or native CallKit UI if enabled +- **Presented by**: `ActiveCallView` is overlaid on the main app view when `chatModel.activeCall` is set +- **Collapsible**: Call view can be collapsed via `chatModel.activeCallViewIsCollapsed` to return to chat while call continues +- **Dismiss**: Call ends when user taps end button or remote party disconnects + +## Page Sections + +### Incoming Call Banner (`IncomingCallView`) + +Displayed as an overlay banner when `CallController.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| Profile avatar | User profile image (shown when multiple profiles exist) | +| Call type icon | `video.fill` (green) for video calls, `phone.fill` (green) for audio | +| Call type text | "Audio call" or "Video call" with caller info | +| Caller profile | `ProfilePreview` showing caller name and image | +| Reject button | Red `phone.down.fill` icon -- ends the invitation | +| Ignore button | Neutral `multiply` icon -- dismisses the banner without rejecting | +| Accept button | Green `checkmark` icon -- accepts the call; if another call is active, ends it first | + +Sound: Ringtone plays via `SoundPlayer.startRingtone()` while banner is visible (unless call view is already showing). + +### Active Call View (`ActiveCallView`) + +Full-screen overlay with black background: + +| Element | Description | +|---|---| +| Remote video | Full-screen `CallViewRemote` showing remote party's camera feed; tap toggles between `scaleAspectFill` and `scaleAspectFit` | +| Local video preview | Small floating `CallViewLocal` in top-right corner (30% width); shows local camera with rounded corners | +| Call overlay | `ActiveCallOverlay` with call controls (hidden when PiP is active for video calls) | +| Screen keep-on | `AppDelegate.keepScreenOn(true)` prevents screen dimming during calls | + +### Call Controls (`ActiveCallOverlay`) + +Bottom bar of the active call: + +| Control | Description | +|---|---| +| Mute toggle | Microphone on/off | +| Speaker toggle | Speaker/receiver switch | +| Camera switch | Front/back camera toggle (video calls) | +| Video toggle | Enable/disable video during call | +| End call | Red phone-down button to terminate | +| Audio device picker | `AudioDevicePicker` / `CallAudioDeviceManager` for selecting output (receiver, speaker, Bluetooth, AirPods) | + +### Picture-in-Picture (PiP) + +- When `pipShown == true` and call has video, the call overlay is hidden +- PiP window shows the remote video feed +- User can interact with the app normally while call continues + +### CallKit Integration + +Managed by `CallController`: + +| Feature | Description | +|---|---| +| Native incoming call UI | iOS system call screen for incoming calls (when CallKit is enabled) | +| Call history | Optionally shown in Phone app recents (`DEFAULT_CALL_KIT_CALLS_IN_RECENTS`) | +| System audio routing | CallKit manages audio session configuration | +| Lock screen answering | Call can be answered from lock screen via system UI | + +When CallKit is not used, the app falls back to `IncomingCallView` banner. + +### WebRTC Client + +| Component | Description | +|---|---| +| `WebRTCClient` | Manages peer connection, ICE candidates, media tracks | +| `WebRTC.swift` | Bridge between native code and WebRTC JavaScript via `WKWebView` | +| `CallViewRenderers` | `CallViewLocal` and `CallViewRemote` SwiftUI wrappers for video renderers | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Permissions required | Prompts for microphone (and camera for video) permissions on first call | +| Connecting | Call overlay shows connecting state; `SoundPlayer` plays connecting tone | +| WebRTC client creation | `createWebRTCClient()` called on appear and when `canConnectCall` changes | +| Call ended | `CallSoundsPlayer.vibrate(long: true)` on disconnect if was connected; audio session reset to `.soloAmbient` | +| Call failed | Call dismissed; WebRTC client cleaned up | +| No call invitation | `IncomingCallView` body is empty when no active invitation | + +## Audio Session Management + +- During call: Audio session configured for voice chat +- Camera permissions: `AVFoundation.AVCaptureDevice` authorization checked +- Audio device management: `CallAudioDeviceManager` handles routing changes and device enumeration +- Post-call cleanup: Audio session reverted to `.soloAmbient` + +## Related Specs + +- `spec/services/calls.md` -- Call service specification +- [Chat](chat.md) -- Call buttons in chat navigation bar +- [Contact Info](contact-info.md) -- Call buttons in contact info action row +- [Settings](settings.md) -- Call settings (CallKit, ICE servers, relay policy) + +## Source Files + +- `Shared/Views/Call/ActiveCallView.swift` -- Main active call view with video renderers and overlay +- `Shared/Views/Call/IncomingCallView.swift` -- Incoming call notification banner +- `Shared/Views/Call/CallController.swift` -- CallKit integration and call lifecycle management +- `Shared/Views/Call/CallManager.swift` -- Call state management and CXProvider delegate +- `Shared/Views/Call/CallAudioDeviceManager.swift` -- Audio device enumeration and routing +- `Shared/Views/Call/AudioDevicePicker.swift` -- Audio output device picker UI +- `Shared/Views/Call/WebRTC.swift` -- WebRTC signaling bridge via WKWebView +- `Shared/Views/Call/WebRTCClient.swift` -- WebRTC peer connection management +- `Shared/Views/Call/CallViewRenderers.swift` -- SwiftUI wrappers for local and remote video views +- `Shared/Views/Call/SoundPlayer.swift` -- Ringtone and call sound playback diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md new file mode 100644 index 0000000000..6c2d868d64 --- /dev/null +++ b/apps/ios/product/views/chat-list.md @@ -0,0 +1,113 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat app. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ContentView` as the default view when `chatModel.chatId == nil` +- **Navigation stack**: `NavStackCompat` wrapping `chatListView` with destination `chatView` +- **UserPicker sheet**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet, which links to `UserPickerSheetView` sub-sheets (address, preferences, profiles, current profile, use from desktop, settings) + +## Page Sections + +### Toolbar + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop) | +| Connection status indicator | Center (`SubsStatusIndicator`) | Shows server subscription status; taps navigate to `ServersSummaryView` | +| New chat button (pencil icon) | Trailing | Opens `NewChatSheet` modal | + +The toolbar supports two layout modes: +- **Standard (top)**: Navigation bar with `.topBarLeading`, `.principal`, `.topBarTrailing` placements +- **One-hand UI (bottom)**: Toolbar items placed in `.bottomBar` with the list vertically flipped via `scaleEffect(y: -1)` + +### Search Bar + +- Text field with magnifying glass icon +- When active, `searchMode = true` hides the navigation bar and shows inline search +- Filters chat list in real-time by contact/group name and message content +- Detects pasted SimpleX links (`searchShowingSimplexLink`) and offers to connect + +### Chat Filter Tabs (Tags) + +Managed by `ChatTagsModel` and `TagListView`: + +| Filter | PresetTag | Description | +|---|---|---| +| All | (none) | No filter, shows all chats | +| Unread | `.unread` | Chats with unread messages | +| Favorites | `.favorites` | User-favorited chats | +| Groups | `.groups` | Group conversations only | +| Contacts | `.contacts` | Direct contacts only | +| Business | `.business` | Business chat conversations | +| Notes | `.notes` | Notes to self | +| Group Reports | `.groupReports` | Moderation reports (non-collapsible) | +| Custom tags | `.userTag(ChatTag)` | User-created tags with custom names | + +### Chat Preview Rows + +Each row rendered by `ChatPreviewView` inside `ChatListNavLink`: + +| Element | Description | +|---|---| +| Avatar | Profile image or colored initials circle; online status indicator for contacts | +| Chat name | Display name (contact, group, or note-to-self) | +| Last message preview | Truncated text of most recent message; supports markdown rendering | +| Timestamp | Relative time of last activity (e.g., "2m", "1h", "Yesterday") | +| Unread badge | Numeric count badge for unread messages; distinct styling for mentions | +| Muted indicator | Bell-slash icon when notifications are muted | +| Pinned indicator | Pin icon for pinned chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +### Swipe Actions + +- **Trailing swipe**: Mute/unmute, pin/unpin, tag management +- **Leading swipe**: Mark as read/unread +- **Context menu** (long press): Full set of actions including delete, clear chat, toggle favorite + +### Floating Elements + +- **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode +- **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address + +### Pull-to-Refresh + +Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect servers?"). Uses additional traffic to force message delivery. + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | +| No chats | `ChatHelp` view displayed with onboarding guidance | +| Connection in progress | `ConnectProgressManager` overlay with connecting text | +| Search with no results | Empty list with no special empty-state view | + +## Related Specs + +- `spec/client/chat-list.md` -- Chat list feature specification +- `spec/state.md` -- Application state management +- [User Profiles](user-profiles.md) -- Profile switching from UserPicker +- [Settings](settings.md) -- Settings accessed via UserPicker +- [New Chat](new-chat.md) -- New chat sheet triggered from toolbar +- [Chat](chat.md) -- Navigated to when tapping a chat row + +## Source Files + +- `Shared/Views/ChatList/ChatListView.swift` -- Main view, toolbar, search, filter logic +- `Shared/Views/ChatList/ChatPreviewView.swift` -- Individual chat row rendering +- `Shared/Views/ChatList/ChatListNavLink.swift` -- Navigation link wrapper with swipe actions +- `Shared/Views/ChatList/TagListView.swift` -- Filter tab bar (preset + custom tags) +- `Shared/Views/ChatList/UserPicker.swift` -- User profile picker sheet +- `Shared/Views/ChatList/ChatHelp.swift` -- Empty-state help view +- `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering +- `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering +- `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/product/views/chat.md b/apps/ios/product/views/chat.md new file mode 100644 index 0000000000..57202846eb --- /dev/null +++ b/apps/ios/product/views/chat.md @@ -0,0 +1,165 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` +- **Presented by**: `NavStackCompat` destination from `ChatListView`, bound to `chatModel.chatId` +- **Back navigation**: Dismiss sets `chatModel.chatId = nil`, returning to chat list +- **Sub-navigation**: Info button navigates to `ChatInfoView` (contact) or `GroupChatInfoView` (group); member avatars navigate to `GroupMemberInfoView` + +## Page Sections + +### Navigation Bar + +Custom toolbar overlaying the chat with themed material background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list | +| Contact/Group avatar | Small profile image | +| Chat name | Display name; tappable to open info sheet | +| Encryption badge | Shows PQ (post-quantum) or standard E2E status | +| Call buttons | Audio and video call icons (direct chats only) | +| Search button | Toggles in-chat message search | +| Info button | Opens `ChatInfoView` or `GroupChatInfoView` | + +### Message List + +Rendered by `EndlessScrollView` with lazy loading and pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | Loads more items on scroll to top (`loadingTopItems`) and bottom (`loadingBottomItems`) | +| Merged items | Adjacent messages from the same sender are visually merged via `MergedItems` | +| Floating buttons | Scroll-to-bottom button with unread count; scroll-to-first-unread button | +| Date separators | Sticky date headers between messages from different days | +| Wallpaper | Themed background image with tint and opacity from `theme.wallpaper` | +| Content filter | Filter messages by type: `.images`, `.files`, `.links` | + +### Message Types + +Each type has a dedicated view in `Shared/Views/Chat/ChatItem/`: + +| Type | View | Description | +|---|---|---| +| Text | `MsgContentView` | Rendered with markdown (bold, italic, code, links, mentions) | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `FullScreenMediaView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback | +| Voice | `CIVoiceView` / `FramedCIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions | +| Link preview | `CILinkView` | URL preview card with title, description, image | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message | +| Forward | Opens `forwardedChatItems` sheet to pick destination chat | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode in compose bar (own messages, within edit window) | +| Delete | Delete for self or delete for everyone (with confirmation) | +| React | Opens emoji reaction picker | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward | +| Info | Shows delivery status and timestamps | + +Emoji reactions bar displayed below messages with reaction counts. + +### Compose Bar (`ComposeView`) + +| Element | Description | +|---|---| +| Text input | `NativeTextEditor` with markdown support and auto-growing height | +| Attachment button | Opens picker for images, videos, files, camera | +| Send button | Sends composed message; changes to voice record button when empty | +| Voice record | Hold-to-record with waveform preview; swipe-to-cancel | +| Reply quote | Shows quoted message above input when replying | +| Edit indicator | Shows "editing" label when editing a previous message | +| Link preview | Auto-generated preview card for detected URLs (`ComposeLinkView`) | +| Image/Video preview | Thumbnail strip for selected media (`ComposeImageView`) | +| File preview | File name and size for attached file (`ComposeFileView`) | +| Voice preview | Waveform of recorded voice message (`ComposeVoiceView`) | +| Live message | Real-time typing broadcast (optional, with alert on first use) | +| Context actions | `ContextContactRequestActionsView` for accepting/rejecting contact requests; `ContextPendingMemberActionsView` for pending group member actions | +| Commands menu | `CommandsMenuView` for bot/menu commands in chats with `menuCommands` | +| Group mentions | `GroupMentionsView` autocomplete popup when typing `@` in groups | +| Profile picker | `ContextProfilePickerView` for choosing incognito/main profile | + +### Member Support Chat (Groups) + +For groups with member support enabled: +- `MemberSupportView` and `MemberSupportChatToolbar` shown as secondary chat within group +- `SecondaryChatView` for scoped group chat views (reports, member support) +- User knocking state: `userMemberKnockingTitleBar()` shown when user is pending admission + +## Loading / Error States + +| State | Behavior | +|---|---| +| Initial load | Messages load from `ItemsModel` with merged items; `allowLoadMoreItems` throttles pagination | +| Loading more (top) | `loadingTopItems` spinner at top of scroll view | +| Loading more (bottom) | `loadingBottomItems` spinner at bottom | +| Connection in progress | `ConnectProgressManager` shows connecting text below compose bar | +| Connecting text | "connecting..." label shown below message list when chat not yet ready | +| Send disabled | Compose bar shows `disabledText` reason when `userCantSendReason` is set | +| Empty chat | No messages placeholder (implicit -- empty scroll view) | + +## Related Specs + +- `spec/client/chat-view.md` -- Chat view feature specification +- `spec/client/compose.md` -- Compose bar specification +- [Chat List](chat-list.md) -- Parent navigation +- [Contact Info](contact-info.md) -- Info sheet for direct chats +- [Group Info](group-info.md) -- Info sheet for group chats +- [Call](call.md) -- Audio/video calls initiated from toolbar + +## Source Files + +- `Shared/Views/Chat/ChatView.swift` -- Main chat view, message list, navigation, state management +- `Shared/Views/Chat/ChatItemView.swift` -- Individual message item rendering dispatcher +- `Shared/Views/Chat/ComposeMessage/ComposeView.swift` -- Compose bar container +- `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` -- Send button and voice record +- `Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift` -- Text input with markdown +- `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` -- Image attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` -- File attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` -- Voice recording preview +- `Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift` -- Link preview generation +- `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` -- Reply/edit context display +- `Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift` -- Contact request accept/reject +- `Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift` -- Pending member actions +- `Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift` -- Profile picker for incognito +- `Shared/Views/Chat/ChatItem/FramedItemView.swift` -- Framed message bubble rendering +- `Shared/Views/Chat/ChatItem/MsgContentView.swift` -- Text message content with markdown +- `Shared/Views/Chat/ChatItem/CIImageView.swift` -- Image message view +- `Shared/Views/Chat/ChatItem/CIVideoView.swift` -- Video message view +- `Shared/Views/Chat/ChatItem/CIVoiceView.swift` -- Voice message view +- `Shared/Views/Chat/ChatItem/CIFileView.swift` -- File message view +- `Shared/Views/Chat/ChatItem/CILinkView.swift` -- Link preview view +- `Shared/Views/Chat/ChatItem/EmojiItemView.swift` -- Large emoji view +- `Shared/Views/Chat/ChatItem/CICallItemView.swift` -- Call event view +- `Shared/Views/Chat/ChatItem/CIEventView.swift` -- Group/system event view +- `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` -- Feature change notification +- `Shared/Views/Chat/ChatItem/CIMetaView.swift` -- Timestamp and delivery status +- `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` -- Fullscreen image/video viewer +- `Shared/Views/Chat/ChatItem/AnimatedImageView.swift` -- Animated GIF rendering +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention autocomplete +- `Shared/Views/Chat/Group/MemberSupportView.swift` -- Member support scoped chat +- `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` -- Support chat toolbar +- `Shared/Views/Chat/Group/SecondaryChatView.swift` -- Secondary scoped chat view diff --git a/apps/ios/product/views/contact-info.md b/apps/ios/product/views/contact-info.md new file mode 100644 index 0000000000..5223bfcae4 --- /dev/null +++ b/apps/ios/product/views/contact-info.md @@ -0,0 +1,154 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings, and perform destructive actions like blocking or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` + - Security code verification -> `VerifyCodeView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar; tappable | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field (`aliasTextFieldFocused`) for setting a local-only name visible only on this device. Not shared with the contact. + +### Action Buttons + +Horizontal row of quick-action buttons (width divided by 4): + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` to search messages in chat | +| Audio call | Initiate audio call (`AudioCallButton`) | +| Video call | Initiate video call (`VideoButton`) | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +Call buttons check `connectionStats` and show alerts if connection state prevents calling. + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito): + +| Element | Description | +|---|---| +| "Your random profile" label | Shows the incognito display name used for this contact | + +### Connection Settings Section + +| Element | Condition | Description | +|---|---|---| +| Verify security code | `connectionCode` available | Navigate to `VerifyCodeView` for QR-based code verification | +| Contact preferences | Always | Navigate to `ContactPreferencesView` | +| Send receipts | Always | Toggle: yes / no / default(yes) / default(no) | +| Synchronize connection | `ratchetSyncAllowed` | Fix encryption ratchet desynchronization | +| Chat theme | Always | Navigate to `ChatWallpaperEditorSheet` | + +All items disabled when `!contact.ready || !contact.active`. + +### Chat TTL Section + +| Element | Description | +|---|---| +| Chat TTL option | `ChatTTLOption` -- auto-delete timer for messages on this device | + +Footer: "Delete chat messages from your device." + +### Encryption Info Section + +Shown when `contact.activeConn` exists: + +| Element | Description | +|---|---| +| E2E encryption | "Quantum resistant" (PQ enabled) or "Standard" | + +### Contact Address Section + +Shown when `contact.contactLink` exists: + +| Element | Description | +|---|---| +| QR code | `SimpleXLinkQRCode` displaying the contact's address | +| Share address | Share button for the contact's SimpleX address link | + +Footer: "You can share this address with your contacts to let them connect with **[name]**." + +### Servers Section + +Shown when `contact.ready && contact.active`: + +| Element | Description | +|---|---| +| Subscription status | `SubStatusRow` showing connection health; tappable for details | +| Change receiving address | Button to switch SMP receiving queue (disabled during switch) | +| Abort changing address | Button to cancel in-progress address switch | +| Receiving via | SMP server hostnames for receiving queues | +| Sending via | SMP server hostnames for sending queues | + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Delete all messages locally (confirmation alert) | +| Delete contact | Remove contact entirely (confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal local display name | +| Database ID | API entity ID | +| Debug delivery | Button to fetch queue info via `apiContactQueueInfo` | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading connection info | `apiContactInfo` and `apiGetContactCode` called on appear; stats and code populated asynchronously | +| Progress indicator | `ProgressView` overlay during TTL changes | +| Contact not ready | Settings section disabled with reduced opacity | +| Contact inactive | Settings section disabled | +| Errors | Alert with localized error title and message | + +## Alerts + +| Alert | Trigger | +|---|---| +| `clearChatAlert` | Tap clear chat | +| `subStatusAlert` | Tap subscription status row | +| `switchAddressAlert` | Tap change receiving address | +| `abortSwitchAddressAlert` | Tap abort address change | +| `syncConnectionForceAlert` | Force ratchet sync | +| `queueInfo` | Debug delivery results | +| `someAlert` | Various sub-component alerts | + +## Related Specs + +- `spec/api.md` -- Contact API commands (info, code verification, preferences, delete) +- [Chat](chat.md) -- Parent chat view +- [Group Info](group-info.md) -- Similar pattern for group info + +## Source Files + +- `Shared/Views/Chat/ChatInfoView.swift` -- Main contact info view with all sections +- `Shared/Views/Chat/ContactPreferencesView.swift` -- Per-contact feature preferences (timed messages, reactions, voice, calls, file transfer, full delete) +- `Shared/Views/Chat/VerifyCodeView.swift` -- Security code verification via QR scan or visual comparison diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md new file mode 100644 index 0000000000..9291b3ed2f --- /dev/null +++ b/apps/ios/product/views/group-info.md @@ -0,0 +1,147 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` + - Add members -> `AddGroupMembersView` + - Group link -> `GroupLinkView` + - Group preferences -> `GroupPreferencesView` (via `GroupPreferencesButton`) + - Welcome message -> `GroupWelcomeView` + - Member info -> `GroupMemberInfoView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + - Member support -> `MemberSupportView` + - Group reports -> `GroupReportsChatNavLink` + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners) | +| Member count | "N members" label | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Focused via `aliasTextFieldFocused`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +### Group Management Section + +| Element | Condition | Description | +|---|---|---| +| Group link | `canAddMembers` and not business chat | Navigate to `GroupLinkView` to create/manage invitation link | +| Member support | Not business chat, role >= moderator | Navigate to member support chat view | +| Group reports | `canModerate` | Navigate to group reports chat | +| User support chat | Member active, role < moderator or has support chat | Navigate to own support chat with moderators | + +### Group Profile Section + +| Element | Condition | Description | +|---|---|---| +| Edit group | Owner, not business chat | Navigate to `GroupProfileView` for editing name, image, description | +| Welcome message | Has description or is owner (not business) | Navigate to `GroupWelcomeView` for add/edit | +| Group preferences | Always | Navigate to `GroupPreferencesView` -- timed messages, reactions, voice, files, direct messages, history visibility | + +Footer: "Only group owners can change group preferences." (or "Only chat owners can change preferences." for business chats) + +### Chat Settings Section + +| Element | Description | +|---|---| +| Send receipts | Toggle delivery receipts; disabled for groups > 20 current members with explanation | +| Chat theme | Navigate to `ChatWallpaperEditorSheet` | +| Chat TTL | `ChatTTLOption` -- set auto-deletion timer for messages on device | + +Footer: "Delete chat messages from your device." + +### Member List Section + +Header shows total member count (e.g., "25 members"). + +| Element | Description | +|---|---| +| Invite members button | Shown if `canAddMembers`; disabled with tap alert if incognito | +| Search field | Filter members by name (`searchText`) | +| Member rows | Each shows: avatar, display name, role badge (owner/admin/moderator/observer), online status indicator, connection status | +| Member tap | Navigates to `GroupMemberInfoView` | +| Member swipe actions | Block/unblock member, block/unblock for all (moderators) | + +Member list is sorted by role (owners first) and filtered to exclude `memLeft` and `memRemoved` statuses. + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages locally (with confirmation alert) | +| Leave group | Leave the group (with confirmation alert) | +| Delete group | Delete entire group -- only for owners (with confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal chat local display name | +| Database ID | API entity ID | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading members | Member list populated from `chatModel.groupMembers` | +| Progress indicator | `ProgressView` overlay when `progressIndicator` is true (during TTL changes) | +| Large group receipts | Receipts option disabled with "Disabled for large groups" label and info alert | +| Incognito invite blocked | Alert: "Can't invite contacts when incognito" | +| Errors | Alert with localized title and error description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteGroupAlert` | Tap delete group | +| `clearChatAlert` | Tap clear chat | +| `leaveGroupAlert` | Tap leave group | +| `cantInviteIncognitoAlert` | Tap invite members while incognito | +| `largeGroupReceiptsDisabled` | Tap receipts info on large group | +| `blockMemberAlert` / `unblockMemberAlert` | Block/unblock member actions | +| `blockForAllAlert` / `unblockForAllAlert` | Moderator block/unblock for all members | + +## Related Specs + +- `spec/api.md` -- Group API commands (create, update, add/remove members, roles, links) +- [Chat](chat.md) -- Parent chat view +- [Contact Info](contact-info.md) -- Similar pattern for direct contact info + +## Source Files + +- `Shared/Views/Chat/Group/GroupChatInfoView.swift` -- Main group info view with all sections +- `Shared/Views/Chat/Group/GroupProfileView.swift` -- Edit group name, image, description +- `Shared/Views/Chat/Group/AddGroupMembersView.swift` -- Member invitation view +- `Shared/Views/Chat/Group/GroupLinkView.swift` -- Group link creation and management +- `Shared/Views/Chat/Group/GroupPreferencesView.swift` -- Group feature preferences +- `Shared/Views/Chat/Group/GroupWelcomeView.swift` -- Welcome message editor +- `Shared/Views/Chat/Group/MemberAdmissionView.swift` -- Member admission policy settings +- `Shared/Views/Chat/Group/GroupMemberInfoView.swift` -- Individual member info and actions +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention support in groups diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md new file mode 100644 index 0000000000..e53659e622 --- /dev/null +++ b/apps/ios/product/views/new-chat.md @@ -0,0 +1,94 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary onramp for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar +- **Presented by**: `NewChatSheet` modal from `ChatListView` +- **Internal navigation**: `NewChatMenuButton` provides a dropdown with options: + - "New chat" -- opens `NewChatView` + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: Segmented picker toggles between `.invite` (1-time link) and `.connect` (connect via link) +- **Swipe gesture**: Left/right swipe switches between invite and connect tabs +- **Dismiss behavior**: On dismiss, `showKeepInvitationAlert()` asks whether to keep an unused invitation link or delete it + +## Page Sections + +### Segmented Picker + +| Tab | Icon | Description | +|---|---|---| +| 1-time link | `link` | Generate and share a one-time invitation link | +| Connect via link | `qrcode` | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) + +Displayed when `selection == .invite`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share sheet for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `creatingLinkProgressView` spinner while `creatingConnReq` is true | +| Retry button | Shown if link creation fails | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. + +### Connect Tab (Connect via Link) + +Displayed when `selection == .connect`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based `CodeScanner` view for scanning SimpleX QR codes | +| Paste link field | Text input for pasting a SimpleX link manually | +| Connect button | Initiates connection via the pasted/scanned link | + +Handled by `ConnectView` sub-view with `showQRCodeScanner` state. + +### Info Sheet + +Toolbar trailing button opens `AddContactLearnMore` info sheet explaining how SimpleX connections work. + +### Add Group + +Accessed via `NewChatMenuButton` dropdown: + +| Element | Description | +|---|---| +| Group name | Required text field | +| Group image | Optional profile image picker | +| Incognito option | Create group with random profile | +| Create button | Creates group via API and navigates to group chat | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Creating invitation | `ProgressView` spinner shown; buttons disabled | +| Link creation failure | Retry button displayed | +| Invalid link pasted | Alert shown via `NewChatViewAlert.newChatSomeAlert` | +| Connection in progress | Chat list shows pending connection entry | +| Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options | + +## Related Specs + +- `spec/api.md` -- API commands: `APIAddContact`, `APIConnect`, `APICreateUserAddress` +- [Chat List](chat-list.md) -- Parent view that presents this sheet +- [Chat](chat.md) -- Navigated to after successful connection + +## Source Files + +- `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation +- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group) +- `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display +- `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form +- `Shared/Views/NewChat/AddContactLearnMore.swift` -- Info sheet explaining connection process diff --git a/apps/ios/product/views/onboarding.md b/apps/ios/product/views/onboarding.md new file mode 100644 index 0000000000..a283c25a19 --- /dev/null +++ b/apps/ios/product/views/onboarding.md @@ -0,0 +1,147 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, server operator conditions acceptance, and notification configuration. Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStageDefault` is not `.onboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression; back navigation hidden on later steps (`.navigationBarBackButtonHidden(true)`) +- **Completion**: Sets `onboardingStageDefault` to `.onboardingComplete` and updates `chatModel.onboardingStage` + +## Onboarding Steps + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | SimpleX Chat logo (light/dark variant based on color scheme) | +| "The future of messaging" | Info button opening `HowItWorks` sheet | +| Privacy redefined | "No user identifiers." with privacy icon | +| Immune to spam | "You decide who can connect." with shield icon | +| Decentralized | "Anybody can host servers." with decentralized icon | +| **Create your profile** button | Primary action; navigates to `CreateFirstProfile` | +| **Migrate from another device** button | Secondary action; opens `MigrateToDevice` sheet | + +The "How it works" sheet (`HowItWorks`) explains SimpleX's privacy model with an option to proceed to profile creation. + +### Step 2: Create Profile (`CreateFirstProfile`) + +**Stage**: `step2_CreateProfile` (deprecated -- now part of step 1 flow) + +| Element | Description | +|---|---| +| Display name field | Required; auto-focused after 1 second delay | +| Validation | `mkValidName` check; alerts for invalid/duplicate names | +| Create button | Calls profile creation API; advances to next step | + +Profile is stored locally and only shared with contacts. Footer explains this privacy property. + +### Step 3: Server Operator Conditions (`OnboardingConditionsView`) + +**Stage**: `step3_ChooseServerOperators` (changed to simplified conditions view) + +| Element | Description | +|---|---| +| "Conditions of use" title | Large title header | +| Privacy explanation | "Private chats, groups and your contacts are not accessible to server operators." | +| Operator selection | Toggle operators (with `selectedOperatorIds`) | +| Show conditions | Sheet to view full conditions (`ConditionsWebView`) | +| Configure operators | Sheet to customize operator settings | +| **Accept** button | Accepts conditions and advances to notifications step | + +Previous deprecated step `step3_CreateSimpleXAddress` (`CreateSimpleXAddress`) is no longer in the active flow. + +### Step 4: Set Notification Mode (`SetNotificationsMode`) + +**Stage**: `step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| "Push notifications" title | Large title header | +| Info text | Explanation of notification modes | +| Mode selector | `NtfModeSelector` for each `NotificationsMode.values` | +| **Enable notifications** / **Use chat** button | Sets notification mode and completes onboarding | +| Info sheet | `NotificationsInfoView` accessible for detailed explanation | + +Notification modes: + +| Mode | Description | +|---|---| +| Instant | Background connection maintained; real-time notifications | +| Periodic | Checks every 10 minutes; battery-friendly | +| Off | No push notifications; messages received only when app is open | + +On completion, `onboardingStageDefault.set(.onboardingComplete)` is called. + +### Completion + +**Stage**: `onboardingComplete` + +`OnboardingView` renders `EmptyView()` and the app proceeds to `ChatListView`. + +## Optional Paths + +### Migrate from Another Device + +- Triggered from Step 1 via "Migrate from another device" button +- Sets `chatModel.migrationState = .pasteOrScanLink` +- Opens `MigrateToDevice` in a sheet within `NavigationView` +- User pastes or scans a migration link from the source device +- Imports database and settings from the linked device + +### What's New (`WhatsNewView`) + +- Not part of the linear onboarding flow +- Shown when `DEFAULT_WHATS_NEW_VERSION` differs from current version +- Accessible later from Settings > Help > What's new +- Displays changelog with feature descriptions + +## Onboarding Stage Enum + +``` +enum OnboardingStage: String { + case step1_SimpleXInfo + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // conditions acceptance + case step4_SetNotificationsMode + case onboardingComplete +} +``` + +Persisted via `DEFAULT_ONBOARDING_STAGE` in `UserDefaults`. + +## Loading / Error States + +| State | Behavior | +|---|---| +| No device token | Alert "No device token!" if trying to set notification mode without token | +| Profile creation error | Alert with error description | +| Migration failure | Error handling within `MigrateToDevice` flow | +| Conditions loading | Async fetch of operator conditions | + +## Related Specs + +- `spec/architecture.md` -- App architecture and initialization flow +- [Chat List](chat-list.md) -- Destination after onboarding completes +- [User Profiles](user-profiles.md) -- Profile created during onboarding; additional profiles later +- [Settings](settings.md) -- Notification and server settings revisitable after onboarding + +## Source Files + +- `Shared/Views/Onboarding/OnboardingView.swift` -- Step router and `OnboardingStage` enum definition +- `Shared/Views/Onboarding/SimpleXInfo.swift` -- Step 1: Welcome screen with privacy highlights and migration entry +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared between onboarding and user profiles) +- `Shared/Views/Onboarding/CreateSimpleXAddress.swift` -- Deprecated step 3: SimpleX address creation +- `Shared/Views/Onboarding/ChooseServerOperators.swift` -- Step 3: Server operator conditions and selection +- `Shared/Views/Onboarding/SetNotificationsMode.swift` -- Step 4: Push notification mode selection +- `Shared/Views/Onboarding/HowItWorks.swift` -- "How it works" info sheet from step 1 +- `Shared/Views/Onboarding/WhatsNewView.swift` -- Changelog / what's new display +- `Shared/Views/Onboarding/AddressCreationCard.swift` -- Address creation prompt card diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md new file mode 100644 index 0000000000..58507ce52b --- /dev/null +++ b/apps/ios/product/views/settings.md @@ -0,0 +1,172 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker sheet on the chat list. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option +- **Presented by**: `UserPickerSheetView(sheet: .settings)` wrapping `SettingsView` in a `NavigationView` +- **Navigation title**: "Your settings" +- **Sub-navigation**: Each settings row is a `NavigationLink` to a dedicated settings view + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `bolt` (color varies by token status) | `NotificationsView` | Push notification mode and preview settings | +| Network & servers | `externaldrive.connected.to.line.below` | `NetworkAndServers` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `video` | `CallSettings` | WebRTC relay policy, ICE servers, CallKit options | +| Privacy & security | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | +| Appearance | `sun.max` | `AppearanceSettings` | Theme, language, wallpapers, chat bubbles, toolbar opacity | + +All rows disabled when `chatModel.chatRunning != true`. Appearance row only shown when `UIApplication.shared.supportsAlternateIcons`. + +#### Notifications (`NotificationsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background connection) / Periodic (every 10 min) / Off | +| Notification preview | Hidden / Contact name only / Message preview | +| Token status indicator | Icon color reflects: new, registered, confirmed (yellow), active (green), expired, invalid | + +#### Network & Servers (`NetworkAndServers`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | +| Show sent via proxy | Toggle to show proxy indicator on sent messages | +| Show subscription % | Toggle to show server subscription percentage | + +Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift` + +#### Privacy & Security (`PrivacySettings`) + +| Setting | Description | +|---|---| +| SimpleX Lock | Enable biometric (Face ID / Touch ID) or passcode lock | +| Lock mode | System biometric or custom passcode | +| Lock timeout | Delay before lock activates (0s to 30min) | +| Self-destruct | Optional self-destruct passcode that wipes all data | +| Screen protection | Hide app content in app switcher | +| Encrypt local files | Encrypt media and files stored on device | +| Auto-accept images | Automatically download received images | +| Link previews | Generate link previews for sent URLs | +| SimpleX link mode | Description / Full link / Via browser | +| Chat previews | Show message previews in chat list | +| Save last draft | Remember unsent message drafts | +| Delivery receipts | Enable/disable read receipts globally | +| Media blur radius | Blur level for received media before tapping | + +#### Appearance (`AppearanceSettings`) + +| Setting | Description | +|---|---| +| App icon | Alternative app icon selection | +| Language | Interface language | +| Theme | System / Light / Dark | +| Dark theme variant | Dark / SimpleX / Black | +| Active theme colors | Accent color, chat bubble colors, text colors | +| Wallpapers | Chat background wallpaper selection and customization | +| Profile image corner radius | Adjust avatar roundness | +| Chat bubble roundness | Adjust message bubble corner radius | +| Chat bubble tail | Toggle message bubble tail/pointer | +| Toolbar opacity | `ToolbarMaterial` transparency setting | +| One-hand UI | Bottom toolbar layout for reachability | + +#### Audio & Video Calls (`CallSettings`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / Allow direct | +| ICE servers | Custom STUN/TURN server configuration | +| CallKit integration | Enable/disable native iOS call UI | +| Calls in recents | Show/hide calls in Phone app history | +| Lock screen calls | Show/accept on lock screen options | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `internaldrive` (orange if unencrypted) | `DatabaseView` | Passphrase management, export/import database, file storage stats | +| Migrate to another device | `tray.and.arrow.up` | `MigrateFromDevice` | Export database and generate migration link | + +Database row shows exclamation octagon icon in red when `chatRunning == false`. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use it | `questionmark` | `ChatHelp` | Usage guide with user's display name | +| What's new | `plus` | `WhatsNewView` | Changelog and new features | +| About SimpleX Chat | `info` | `SimpleXInfo` | About page with privacy explanation | +| Send questions and ideas | `number` | Opens SimpleX team chat link | Direct contact with developers | +| Send us email | `envelope` | `mailto:chat@simplex.chat` | Email link | + +### Support SimpleX Chat Section + +| Row | Icon | Action | +|---|---|---| +| Contribute | `keyboard` | Opens GitHub contribution guide | +| Rate the app | `star` | `SKStoreReviewController.requestReview` | +| Star on GitHub | GitHub icon | Opens GitHub repository | + +### Develop Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Developer tools | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | +| App version | (none) | `VersionView` | Shows "v{version} ({build})" | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat not running | Most navigation links disabled; database row shows warning | +| Database not encrypted | Database icon shown in orange | +| Migration in progress | `showProgress` overlays `ProgressView` on entire settings view | +| Terminal cleanup | On disappear: `chatModel.showingTerminal = false`, terminal items cleared | + +## App Defaults + +Key `UserDefaults` / `AppStorage` keys managed by settings: +- `DEFAULT_PERFORM_LA`, `DEFAULT_LA_MODE`, `DEFAULT_LA_LOCK_DELAY`, `DEFAULT_LA_SELF_DESTRUCT` +- `DEFAULT_PRIVACY_ACCEPT_IMAGES`, `DEFAULT_PRIVACY_LINK_PREVIEWS`, `DEFAULT_PRIVACY_PROTECT_SCREEN` +- `DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS`, `DEFAULT_PRIVACY_SAVE_LAST_DRAFT` +- `DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET`, `DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS` +- `DEFAULT_WEBRTC_POLICY_RELAY`, `DEFAULT_WEBRTC_ICE_SERVERS`, `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` +- `DEFAULT_CURRENT_THEME`, `DEFAULT_SYSTEM_DARK_THEME`, `DEFAULT_THEME_OVERRIDES` +- `DEFAULT_PROFILE_IMAGE_CORNER_RADIUS`, `DEFAULT_CHAT_ITEM_ROUNDNESS`, `DEFAULT_CHAT_ITEM_TAIL` +- `DEFAULT_TOOLBAR_MATERIAL`, `DEFAULT_ONE_HAND_UI_CARD_SHOWN` +- `DEFAULT_DEVELOPER_TOOLS`, `DEFAULT_SHOW_SENT_VIA_RPOXY`, `DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE` + +## Related Specs + +- `spec/architecture.md` -- App architecture overview +- `spec/services/theme.md` -- Theme system specification +- [Chat List](chat-list.md) -- Parent view via UserPicker +- [User Profiles](user-profiles.md) -- Profile management (separate UserPicker option) + +## Source Files + +- `Shared/Views/UserSettings/SettingsView.swift` -- Main settings view, section layout, app defaults definitions +- `Shared/Views/UserSettings/NotificationsView.swift` -- Notification mode and preview settings +- `Shared/Views/UserSettings/AppearanceSettings.swift` -- Theme, wallpaper, UI customization +- `Shared/Views/UserSettings/PrivacySettings.swift` -- Privacy and security settings +- `Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift` -- Server and network configuration +- `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` -- TCP/timeout settings +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` -- SMP/XFTP server list +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift` -- Individual server edit +- `Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift` -- Add new server +- `Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift` -- Scan server QR code +- `Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift` -- Server operator configuration +- `Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift` -- Operator conditions display diff --git a/apps/ios/product/views/user-profiles.md b/apps/ios/product/views/user-profiles.md new file mode 100644 index 0000000000..5a38db1816 --- /dev/null +++ b/apps/ios/product/views/user-profiles.md @@ -0,0 +1,137 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/state.md](../../spec/state.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password and support a self-destruct password option. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserPickerSheetView(sheet: .chatProfiles)` wrapping `UserProfilesView` in a `NavigationView` +- **Navigation title**: "Your chat profiles" +- **Sub-navigation**: + - Create profile -> `CreateProfile` + - Edit profile -> profile detail view (via `selectedUser`) + - User address -> `UserAddressView` (via UserPicker `.address` sheet) + +## Page Sections + +### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against profile names and hidden profile passwords + +### Profile List + +Each row rendered by `userView()`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark or highlighted state for the current active profile | +| Profile image | Avatar circle with profile image or colored initials | +| Display name | Profile's display name | +| Unread count | Badge showing unread message count across all chats for this profile | +| Muted indicator | Bell-slash icon if profile notifications are muted | +| Hidden indicator | Lock icon for hidden profiles (only shown when revealed via password) | + +### Profile Actions + +Available via tap on a profile row: + +| Action | Condition | Description | +|---|---|---| +| Switch active | Different from current | Activates the selected profile; all chats switch context | +| Mute / Unmute | Any profile | Toggle notification muting for the profile; shows alert on first mute (`showMuteProfileAlert`) | +| Hide / Unhide | Non-active profile | Hide with password or reveal a hidden profile | +| Delete | Non-active profile | Delete with confirmation; option to delete data from servers | + +### Add Profile Button + +| Element | Description | +|---|---| +| "Add profile" label | `Label("Add profile", systemImage: "plus")` | +| Navigation | `NavigationLink` to `CreateProfile` view | +| Auth required | Requires local authentication before creating | + +Only shown when `trimmedSearchTextOrPassword` is empty (not searching/entering password). + +### Hidden Profile Banner + +Shown when `profileHidden` is true (a profile was just hidden): + +| Element | Description | +|---|---| +| Lock icon | `lock.open` system image | +| Message | "Enter password above to show!" | +| Tap action | Dismisses the banner with animation | + +### Create Profile (`CreateProfile`) + +| Field | Description | +|---|---| +| Display name | Required text field with validation (`mkValidName`) | +| Bio | Optional bio text (max 160 bytes) | +| Create button | Disabled until valid name entered and bio within limit | + +Validation alerts: `duplicateUserError`, `invalidDisplayNameError`, `createUserError`, `invalidNameError`. + +## Profile Visibility + +| Visibility | Description | +|---|---| +| Public | Normal profile, always visible in the list | +| Hidden | Protected by password; not shown unless password entered in search field | +| Muted | Notifications suppressed; visual indicator in profile list | + +### Hidden Profile Password Management + +- Set password when hiding a profile +- Password verified when entering in the search/password field +- `UserProfileAction.unhideUser` requires password entry +- Self-destruct password: Optional secondary password (`DEFAULT_LA_SELF_DESTRUCT`) that wipes all app data when entered + +### Delete Profile + +Two-stage confirmation: + +1. `confirmDeleteUser()` shows initial confirmation +2. `UserProfilesAlert.deleteUser(user:, delSMPQueues:)` with option to delete queues from servers +3. Requires local authentication (`withAuth`) before proceeding + +## Loading / Error States + +| State | Behavior | +|---|---| +| Authentication required | `authorized` state; prompts biometric/passcode before profile operations | +| Profile switch | Async operation; profile switch errors shown via `activateUserError` alert | +| Delete in progress | Profile removed from list; server queue deletion is async | +| Errors | Alert with localized error title and description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteUser` | Confirm profile deletion | +| `hiddenProfilesNotice` | First-time hidden profiles explanation (`showHiddenProfilesNotice`) | +| `muteProfileAlert` | First-time mute explanation (`showMuteProfileAlert`) | +| `activateUserError` | Profile switch failure | +| `error` | General error display | + +## Related Specs + +- `spec/api.md` -- User management API commands (create user, delete user, activate user, hide user) +- `spec/state.md` -- Application state: `chatModel.users`, `chatModel.currentUser` +- [Chat List](chat-list.md) -- Reflects active profile's chats +- [Settings](settings.md) -- Accessed from same UserPicker menu +- [Onboarding](onboarding.md) -- Initial profile creation during first launch + +## Source Files + +- `Shared/Views/UserSettings/UserProfilesView.swift` -- Main profiles list, search/password, profile actions, delete confirmation +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared with onboarding and profiles view) +- `Shared/Views/UserSettings/UserAddressView.swift` -- User's SimpleX address management (create, share, delete) +- `Shared/Views/ChatList/UserPicker.swift` -- Profile switcher sheet that navigates to this view diff --git a/apps/ios/spec/README.md b/apps/ios/spec/README.md new file mode 100644 index 0000000000..eca6103582 --- /dev/null +++ b/apps/ios/spec/README.md @@ -0,0 +1,74 @@ +# SimpleX Chat iOS -- Specification Overview + +> Technical specification suite for the SimpleX Chat iOS application. Each document provides bidirectional links to product documentation and source code. + +## Executive Summary + +The SimpleX Chat iOS app is a native SwiftUI frontend that communicates with a Haskell core library via C FFI. All chat logic, encryption, protocol handling, and database operations happen in the Haskell core (`chat_ctrl`). The iOS layer handles UI rendering, system integration (CallKit, Push Notifications, Background Tasks), local preferences, and theming. The app shares its database with a Notification Service Extension (NSE) for decrypting push payloads while the main app is inactive. + +## Dependency Graph + +``` +SimpleXApp (root entry point) +├── ChatModel (ObservableObject state) <-> SimpleXAPI (FFI bridge) <-> Haskell Core (chat_ctrl) +├── Views (SwiftUI) +│ ├── ChatListView -> ChatView -> ComposeView +│ ├── ChatItemView (renders individual messages) +│ ├── Settings, UserProfiles, Onboarding +│ └── ActiveCallView (WebRTC + CallKit) +├── Models +│ ├── ChatModel (global app state -- singleton) +│ ├── ItemsModel (per-chat message list state -- singleton + secondary instances) +│ ├── ChatTagsModel (tag filtering state) +│ └── Chat (per-conversation observable state) +├── Services +│ ├── NtfManager (push notification coordination) +│ ├── BGManager (background task scheduling) +│ ├── CallController (CallKit + VoIP push) +│ └── ThemeManager (theme resolution engine) +└── Extensions + ├── SimpleX NSE (Notification Service Extension -- decrypts push payloads) + └── SimpleX SE (Share Extension) +``` + +## Specification Documents + +| Document | Description | +|----------|-------------| +| [Architecture](architecture.md) | System architecture, FFI bridge, app lifecycle, extension model | +| [Chat API Reference](api.md) | Complete ChatCommand, ChatResponse, ChatEvent, ChatError type reference | +| [State Management](state.md) | ChatModel, ItemsModel, Chat, ChatInfo, preference storage | +| [Database & Storage](database.md) | SQLite databases, encryption, file storage, export/import | +| [Chat View](client/chat-view.md) | Message rendering, chat item types, context menu actions | +| [Chat List](client/chat-list.md) | Conversation list, filtering, search, swipe actions | +| [Message Composition](client/compose.md) | Compose bar, attachments, reply/edit/forward modes, voice recording | +| [Navigation](client/navigation.md) | Navigation stack, deep linking, sheet presentation, call overlay | +| [Push Notifications](services/notifications.md) | NtfManager, NSE, notification modes, token lifecycle | +| [WebRTC Calling](services/calls.md) | CallController, WebRTCClient, CallKit, signaling via SMP | +| [File Transfer](services/files.md) | Inline/XFTP transfer, auto-receive, CryptoFile, file constants | +| [Theme Engine](services/theme.md) | ThemeManager, default themes, customization layers, wallpapers | +| [Impact Graph](impact.md) | Source file → product concept mapping, risk levels | + +## Related Product Documentation + +- [Product Overview](../product/README.md) +- [Concept Index](../product/concepts.md) +- [Business Rules](../product/rules.md) +- [Known Gaps](../product/gaps.md) +- [Glossary](../product/glossary.md) +- [Chat List View](../product/views/chat-list.md) +- [Chat View](../product/views/chat.md) + +## Source Code Entry Points + +| File | Role | +|------|------| +| `Shared/SimpleXApp.swift` | App entry point, Haskell init, lifecycle management | +| `Shared/AppDelegate.swift` | UIApplicationDelegate for push token registration | +| `Shared/ContentView.swift` | Root view -- authentication gate, call overlay, navigation | +| `Shared/Model/ChatModel.swift` | Primary observable state (ChatModel, ItemsModel, Chat) | +| `Shared/Model/SimpleXAPI.swift` | FFI bridge -- chatSendCmd, chatApiSendCmd, sendSimpleXCmd | +| `Shared/Model/AppAPITypes.swift` | ChatCommand, ChatResponse, ChatEvent enums (iOS app layer) | +| `SimpleXChat/APITypes.swift` | APIResult, ChatError, ChatCmdProtocol (shared framework) | +| `SimpleXChat/ChatTypes.swift` | User, ChatInfo, Contact, GroupInfo, ChatItem data types | +| `SimpleXChat/SimpleX.h` | C header for Haskell FFI functions | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md new file mode 100644 index 0000000000..fe40a4c4ec --- /dev/null +++ b/apps/ios/spec/api.md @@ -0,0 +1,600 @@ +# SimpleX Chat iOS -- Chat API Reference + +> Complete specification of the ChatCommand, ChatResponse, ChatEvent, and ChatError types that form the API between the Swift UI layer and the Haskell core. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +**Source:** [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | [`APITypes.swift`](../SimpleXChat/APITypes.swift) | [`API.swift`](../SimpleXChat/API.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories (ChatCommand)](#2-command-categories) +3. [Response Types (ChatResponse)](#3-response-types) +4. [Event Types (ChatEvent)](#4-event-types) +5. [Error Types (ChatError)](#5-error-types) +6. [FFI Bridge Functions](#6-ffi-bridge-functions) +7. [Result Type (APIResult)](#7-result-type) + +--- + +## 1. Overview + +The iOS app communicates with the Haskell core exclusively through a command/response protocol: + +1. Swift constructs a `ChatCommand` enum value +2. The command's `cmdString` property serializes it to a text command +3. The FFI bridge sends the string to Haskell via `chat_send_cmd_retry` +4. Haskell returns a JSON response, decoded as `APIResult` +5. Async events arrive separately via `chat_recv_msg_wait`, decoded as `ChatEvent` + +**Source files**: +- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L14](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L647](../Shared/Model/AppAPITypes.swift#L649)), `ChatResponse1` ([L768](../Shared/Model/AppAPITypes.swift#L771)), `ChatResponse2` ([L907](../Shared/Model/AppAPITypes.swift#L911)), `ChatEvent` ([L1050](../Shared/Model/AppAPITypes.swift#L1055)) enums +- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult` ([L26](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L63](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L695](../SimpleXChat/APITypes.swift#L699)) +- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L117](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L230](../Shared/Model/SimpleXAPI.swift#L237)) +- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L114](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L136](../SimpleXChat/API.swift#L137)) +- `SimpleXChat/ChatTypes.swift` -- Data types used in commands/responses (User, Contact, GroupInfo, ChatItem, etc.) +- `../../src/Simplex/Chat/Controller.hs` -- Haskell controller (function `chat_send_cmd_retry`, `chat_recv_msg_wait`) + +--- + +## 2. Command Categories + +The `ChatCommand` enum ([`AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area. + +### 2.1 User Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showActiveUser` | -- | Get current active user | [L15](../Shared/Model/AppAPITypes.swift#L16) | +| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L16](../Shared/Model/AppAPITypes.swift#L17) | +| `listUsers` | -- | List all user profiles | [L17](../Shared/Model/AppAPITypes.swift#L18) | +| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L18](../Shared/Model/AppAPITypes.swift#L19) | +| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L23](../Shared/Model/AppAPITypes.swift#L24) | +| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L24](../Shared/Model/AppAPITypes.swift#L25) | +| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L25](../Shared/Model/AppAPITypes.swift#L26) | +| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L27) | +| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L27](../Shared/Model/AppAPITypes.swift#L28) | +| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L138](../Shared/Model/AppAPITypes.swift#L139) | +| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L19](../Shared/Model/AppAPITypes.swift#L20) | +| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L20](../Shared/Model/AppAPITypes.swift#L21) | +| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L22) | +| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L22](../Shared/Model/AppAPITypes.swift#L23) | + +### 2.2 Chat Lifecycle Control + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L28](../Shared/Model/AppAPITypes.swift#L29) | +| `checkChatRunning` | -- | Check if chat is running | [L29](../Shared/Model/AppAPITypes.swift#L30) | +| `apiStopChat` | -- | Stop chat engine | [L30](../Shared/Model/AppAPITypes.swift#L31) | +| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L31](../Shared/Model/AppAPITypes.swift#L32) | +| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L32](../Shared/Model/AppAPITypes.swift#L33) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L33](../Shared/Model/AppAPITypes.swift#L34) | +| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L34](../Shared/Model/AppAPITypes.swift#L35) | + +### 2.3 Chat & Message Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L43](../Shared/Model/AppAPITypes.swift#L44) | +| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L44](../Shared/Model/AppAPITypes.swift#L45) | +| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L45](../Shared/Model/AppAPITypes.swift#L46) | +| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L46](../Shared/Model/AppAPITypes.swift#L47) | +| `apiSendMessages` | `type, id, scope, live, ttl, composedMessages` | Send one or more messages | [L47](../Shared/Model/AppAPITypes.swift#L48) | +| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L53](../Shared/Model/AppAPITypes.swift#L54) | +| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L55](../Shared/Model/AppAPITypes.swift#L56) | +| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L56](../Shared/Model/AppAPITypes.swift#L57) | +| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L57](../Shared/Model/AppAPITypes.swift#L58) | +| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L60](../Shared/Model/AppAPITypes.swift#L61) | +| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L61](../Shared/Model/AppAPITypes.swift#L62) | +| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L62](../Shared/Model/AppAPITypes.swift#L63) | +| `apiForwardChatItems` | `toChatType, toChatId, toScope, from..., itemIds, ttl` | Forward messages | [L63](../Shared/Model/AppAPITypes.swift#L64) | +| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L54](../Shared/Model/AppAPITypes.swift#L55) | +| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L163](../Shared/Model/AppAPITypes.swift#L164) | +| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L164](../Shared/Model/AppAPITypes.swift#L165) | +| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L165](../Shared/Model/AppAPITypes.swift#L166) | + +### 2.4 Contact Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiAddContact` | `userId, incognito` | Create invitation link | [L123](../Shared/Model/AppAPITypes.swift#L124) | +| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L133](../Shared/Model/AppAPITypes.swift#L134) | +| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L126](../Shared/Model/AppAPITypes.swift#L127) | +| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L127](../Shared/Model/AppAPITypes.swift#L128) | +| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L131](../Shared/Model/AppAPITypes.swift#L132) | +| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L134](../Shared/Model/AppAPITypes.swift#L135) | +| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L151](../Shared/Model/AppAPITypes.swift#L152) | +| `apiRejectContact` | `contactReqId` | Reject contact request | [L152](../Shared/Model/AppAPITypes.swift#L153) | +| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L135](../Shared/Model/AppAPITypes.swift#L136) | +| `apiClearChat` | `type, id` | Clear conversation history | [L136](../Shared/Model/AppAPITypes.swift#L137) | +| `apiListContacts` | `userId` | List all contacts | [L137](../Shared/Model/AppAPITypes.swift#L138) | +| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L139](../Shared/Model/AppAPITypes.swift#L140) | +| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L140](../Shared/Model/AppAPITypes.swift#L141) | +| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L142](../Shared/Model/AppAPITypes.swift#L143) | +| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L109](../Shared/Model/AppAPITypes.swift#L110) | +| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L124](../Shared/Model/AppAPITypes.swift#L125) | + +### 2.5 Group Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L71](../Shared/Model/AppAPITypes.swift#L72) | +| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L72](../Shared/Model/AppAPITypes.swift#L73) | +| `apiJoinGroup` | `groupId` | Accept group invitation | [L73](../Shared/Model/AppAPITypes.swift#L74) | +| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L74](../Shared/Model/AppAPITypes.swift#L75) | +| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L78](../Shared/Model/AppAPITypes.swift#L79) | +| `apiLeaveGroup` | `groupId` | Leave group | [L79](../Shared/Model/AppAPITypes.swift#L80) | +| `apiListMembers` | `groupId` | List group members | [L80](../Shared/Model/AppAPITypes.swift#L81) | +| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L81](../Shared/Model/AppAPITypes.swift#L82) | +| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L76](../Shared/Model/AppAPITypes.swift#L77) | +| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L77](../Shared/Model/AppAPITypes.swift#L78) | +| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L82](../Shared/Model/AppAPITypes.swift#L83) | +| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L83](../Shared/Model/AppAPITypes.swift#L84) | +| `apiDeleteGroupLink` | `groupId` | Delete group link | [L84](../Shared/Model/AppAPITypes.swift#L85) | +| `apiGetGroupLink` | `groupId` | Get existing group link | [L85](../Shared/Model/AppAPITypes.swift#L86) | +| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L86](../Shared/Model/AppAPITypes.swift#L87) | +| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L87](../Shared/Model/AppAPITypes.swift#L88) | +| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L88](../Shared/Model/AppAPITypes.swift#L89) | +| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L110](../Shared/Model/AppAPITypes.swift#L111) | +| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L75](../Shared/Model/AppAPITypes.swift#L76) | +| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L108](../Shared/Model/AppAPITypes.swift#L109) | +| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L141](../Shared/Model/AppAPITypes.swift#L142) | + +### 2.6 Chat Tags + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChatTags` | `userId` | Get all user tags | [L42](../Shared/Model/AppAPITypes.swift#L43) | +| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L48](../Shared/Model/AppAPITypes.swift#L49) | +| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L49](../Shared/Model/AppAPITypes.swift#L50) | +| `apiDeleteChatTag` | `tagId` | Delete a tag | [L50](../Shared/Model/AppAPITypes.swift#L51) | +| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L51](../Shared/Model/AppAPITypes.swift#L52) | +| `apiReorderChatTags` | `tagIds` | Reorder tags | [L52](../Shared/Model/AppAPITypes.swift#L53) | + +### 2.7 File Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L166](../Shared/Model/AppAPITypes.swift#L167) | +| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L167](../Shared/Model/AppAPITypes.swift#L168) | +| `cancelFile` | `fileId` | Cancel file transfer | [L168](../Shared/Model/AppAPITypes.swift#L169) | +| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L178](../Shared/Model/AppAPITypes.swift#L179) | +| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L179](../Shared/Model/AppAPITypes.swift#L180) | +| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L180](../Shared/Model/AppAPITypes.swift#L181) | + +### 2.8 WebRTC Call Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L154](../Shared/Model/AppAPITypes.swift#L155) | +| `apiRejectCall` | `contact` | Reject incoming call | [L155](../Shared/Model/AppAPITypes.swift#L156) | +| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L156](../Shared/Model/AppAPITypes.swift#L157) | +| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L157](../Shared/Model/AppAPITypes.swift#L158) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L158](../Shared/Model/AppAPITypes.swift#L159) | +| `apiEndCall` | `contact` | End active call | [L159](../Shared/Model/AppAPITypes.swift#L160) | +| `apiGetCallInvitations` | -- | Get pending call invitations | [L160](../Shared/Model/AppAPITypes.swift#L161) | +| `apiCallStatus` | `contact, callStatus` | Report call status change | [L161](../Shared/Model/AppAPITypes.swift#L162) | + +### 2.9 Push Notifications + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetNtfToken` | -- | Get current notification token | [L64](../Shared/Model/AppAPITypes.swift#L65) | +| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L65](../Shared/Model/AppAPITypes.swift#L66) | +| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L66](../Shared/Model/AppAPITypes.swift#L67) | +| `apiCheckToken` | `token` | Check token status | [L67](../Shared/Model/AppAPITypes.swift#L68) | +| `apiDeleteToken` | `token` | Unregister token | [L68](../Shared/Model/AppAPITypes.swift#L69) | +| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L69](../Shared/Model/AppAPITypes.swift#L70) | +| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L71) | + +### 2.10 Settings & Configuration + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L40](../Shared/Model/AppAPITypes.swift#L41) | +| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L41](../Shared/Model/AppAPITypes.swift#L42) | +| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L107](../Shared/Model/AppAPITypes.swift#L108) | +| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L99](../Shared/Model/AppAPITypes.swift#L100) | +| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L100](../Shared/Model/AppAPITypes.swift#L101) | +| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L101](../Shared/Model/AppAPITypes.swift#L102) | +| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L102](../Shared/Model/AppAPITypes.swift#L103) | +| `apiGetNetworkConfig` | -- | Get network configuration | [L103](../Shared/Model/AppAPITypes.swift#L104) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L104](../Shared/Model/AppAPITypes.swift#L105) | +| `reconnectAllServers` | -- | Force reconnect all servers | [L105](../Shared/Model/AppAPITypes.swift#L106) | +| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L106](../Shared/Model/AppAPITypes.swift#L107) | + +### 2.11 Database & Storage + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L38](../Shared/Model/AppAPITypes.swift#L39) | +| `testStorageEncryption` | `key: String` | Test encryption key | [L39](../Shared/Model/AppAPITypes.swift#L40) | +| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L35](../Shared/Model/AppAPITypes.swift#L36) | +| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L36](../Shared/Model/AppAPITypes.swift#L37) | +| `apiDeleteStorage` | -- | Delete all storage | [L37](../Shared/Model/AppAPITypes.swift#L38) | + +### 2.12 Server Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetServerOperators` | -- | Get server operators | [L91](../Shared/Model/AppAPITypes.swift#L92) | +| `apiSetServerOperators` | `operators` | Set server operators | [L92](../Shared/Model/AppAPITypes.swift#L93) | +| `apiGetUserServers` | `userId` | Get user's configured servers | [L93](../Shared/Model/AppAPITypes.swift#L94) | +| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L94](../Shared/Model/AppAPITypes.swift#L95) | +| `apiValidateServers` | `userId, userServers` | Validate server configuration | [L95](../Shared/Model/AppAPITypes.swift#L96) | +| `apiGetUsageConditions` | -- | Get usage conditions | [L96](../Shared/Model/AppAPITypes.swift#L97) | +| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L98](../Shared/Model/AppAPITypes.swift#L99) | +| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L90](../Shared/Model/AppAPITypes.swift#L91) | + +### 2.13 Theme & UI + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L143](../Shared/Model/AppAPITypes.swift#L144) | +| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L144](../Shared/Model/AppAPITypes.swift#L145) | + +### 2.14 Remote Desktop + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L170](../Shared/Model/AppAPITypes.swift#L171) | +| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L171](../Shared/Model/AppAPITypes.swift#L172) | +| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L172](../Shared/Model/AppAPITypes.swift#L173) | +| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L173](../Shared/Model/AppAPITypes.swift#L174) | +| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L174](../Shared/Model/AppAPITypes.swift#L175) | +| `listRemoteCtrls` | -- | List known remote controllers | [L175](../Shared/Model/AppAPITypes.swift#L176) | +| `stopRemoteCtrl` | -- | Stop remote session | [L176](../Shared/Model/AppAPITypes.swift#L177) | +| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L177](../Shared/Model/AppAPITypes.swift#L178) | + +### 2.15 Diagnostics + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showVersion` | -- | Get core version info | [L182](../Shared/Model/AppAPITypes.swift#L183) | +| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L183](../Shared/Model/AppAPITypes.swift#L184) | +| `getAgentServersSummary` | `userId` | Get server summary stats | [L184](../Shared/Model/AppAPITypes.swift#L185) | +| `resetAgentServersStats` | -- | Reset server statistics | [L185](../Shared/Model/AppAPITypes.swift#L186) | + +### 2.16 Address Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L145](../Shared/Model/AppAPITypes.swift#L146) | +| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L146](../Shared/Model/AppAPITypes.swift#L147) | +| `apiShowMyAddress` | `userId` | Show current address | [L147](../Shared/Model/AppAPITypes.swift#L148) | +| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L148](../Shared/Model/AppAPITypes.swift#L149) | +| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L149](../Shared/Model/AppAPITypes.swift#L150) | +| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L150](../Shared/Model/AppAPITypes.swift#L151) | + +### 2.17 Connection Security + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetContactCode` | `contactId` | Get verification code | [L119](../Shared/Model/AppAPITypes.swift#L120) | +| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L120](../Shared/Model/AppAPITypes.swift#L121) | +| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L121](../Shared/Model/AppAPITypes.swift#L122) | +| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L122](../Shared/Model/AppAPITypes.swift#L123) | +| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L113](../Shared/Model/AppAPITypes.swift#L114) | +| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L114](../Shared/Model/AppAPITypes.swift#L115) | +| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L115](../Shared/Model/AppAPITypes.swift#L116) | +| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L116](../Shared/Model/AppAPITypes.swift#L117) | +| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L117](../Shared/Model/AppAPITypes.swift#L118) | +| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L118](../Shared/Model/AppAPITypes.swift#L119) | + +--- + +## 3. Response Types + +Responses are split across three enums due to Swift enum size limitations: + +### ChatResponse0 + +Synchronous query responses ([`AppAPITypes.swift` L647](../Shared/Model/AppAPITypes.swift#L649)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `activeUser` | `user: User` | Current active user | [L648](../Shared/Model/AppAPITypes.swift#L650) | +| `usersList` | `users: [UserInfo]` | All user profiles | [L649](../Shared/Model/AppAPITypes.swift#L651) | +| `chatStarted` | -- | Chat engine started | [L650](../Shared/Model/AppAPITypes.swift#L652) | +| `chatRunning` | -- | Chat is already running | [L651](../Shared/Model/AppAPITypes.swift#L653) | +| `chatStopped` | -- | Chat engine stopped | [L652](../Shared/Model/AppAPITypes.swift#L654) | +| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L653](../Shared/Model/AppAPITypes.swift#L655) | +| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L654](../Shared/Model/AppAPITypes.swift#L656) | +| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L656](../Shared/Model/AppAPITypes.swift#L658) | +| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L657](../Shared/Model/AppAPITypes.swift#L659) | +| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L658](../Shared/Model/AppAPITypes.swift#L660) | +| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L664](../Shared/Model/AppAPITypes.swift#L666) | +| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L665](../Shared/Model/AppAPITypes.swift#L667) | +| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L666](../Shared/Model/AppAPITypes.swift#L668) | +| `connectionVerified` | `verified, expectedCode` | Verification result | [L676](../Shared/Model/AppAPITypes.swift#L678) | +| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L677](../Shared/Model/AppAPITypes.swift#L679) | + +### ChatResponse1 + +Contact, message, and profile responses ([`AppAPITypes.swift` L768](../Shared/Model/AppAPITypes.swift#L771)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L769](../Shared/Model/AppAPITypes.swift#L772) | +| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L772](../Shared/Model/AppAPITypes.swift#L775) | +| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L773](../Shared/Model/AppAPITypes.swift#L776) | +| `contactDeleted` | `user, contact` | Contact deleted | [L782](../Shared/Model/AppAPITypes.swift#L785) | +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L800](../Shared/Model/AppAPITypes.swift#L803) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L803](../Shared/Model/AppAPITypes.swift#L806) | +| `chatItemReaction` | `user, added, reaction` | Reaction change | [L805](../Shared/Model/AppAPITypes.swift#L808) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L807](../Shared/Model/AppAPITypes.swift#L810) | +| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L808](../Shared/Model/AppAPITypes.swift#L811) | +| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L788](../Shared/Model/AppAPITypes.swift#L791) | +| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L796](../Shared/Model/AppAPITypes.swift#L799) | +| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L802](../Shared/Model/AppAPITypes.swift#L805) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L801](../Shared/Model/AppAPITypes.swift#L804) | + +### ChatResponse2 + +Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L907](../Shared/Model/AppAPITypes.swift#L911)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `groupCreated` | `user, groupInfo` | New group created | [L909](../Shared/Model/AppAPITypes.swift#L913) | +| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L910](../Shared/Model/AppAPITypes.swift#L914) | +| `groupMembers` | `user, group: Group` | Group member list | [L914](../Shared/Model/AppAPITypes.swift#L918) | +| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L918](../Shared/Model/AppAPITypes.swift#L922) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L920](../Shared/Model/AppAPITypes.swift#L924) | +| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L921](../Shared/Model/AppAPITypes.swift#L925) | +| `rcvFileAccepted` | `user, chatItem` | File download started | [L928](../Shared/Model/AppAPITypes.swift#L932) | +| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L937](../Shared/Model/AppAPITypes.swift#L941) | +| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L940](../Shared/Model/AppAPITypes.swift#L944) | +| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L948](../Shared/Model/AppAPITypes.swift#L952) | +| `cmdOk` | `user_` | Generic success | [L949](../Shared/Model/AppAPITypes.swift#L953) | +| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L953](../Shared/Model/AppAPITypes.swift#L957) | +| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L954](../Shared/Model/AppAPITypes.swift#L958) | +| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L955](../Shared/Model/AppAPITypes.swift#L959) | + +--- + +## 4. Event Types + +The `ChatEvent` enum ([`AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands. + +Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266) in `SimpleXAPI.swift`. + +### Connection Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1057](../Shared/Model/AppAPITypes.swift#L1062) | +| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1058](../Shared/Model/AppAPITypes.swift#L1063) | +| `contactSndReady` | `user, contact` | Ready to send to contact | [L1059](../Shared/Model/AppAPITypes.swift#L1064) | +| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1056](../Shared/Model/AppAPITypes.swift#L1061) | +| `contactUpdated` | `user, toContact` | Contact profile updated | [L1061](../Shared/Model/AppAPITypes.swift#L1066) | +| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1060](../Shared/Model/AppAPITypes.swift#L1065) | +| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1063](../Shared/Model/AppAPITypes.swift#L1068) | + +### Message Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1065](../Shared/Model/AppAPITypes.swift#L1070) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1067](../Shared/Model/AppAPITypes.swift#L1072) | +| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1068](../Shared/Model/AppAPITypes.swift#L1073) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1069](../Shared/Model/AppAPITypes.swift#L1074) | +| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1066](../Shared/Model/AppAPITypes.swift#L1071) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1071](../Shared/Model/AppAPITypes.swift#L1076) | +| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1064](../Shared/Model/AppAPITypes.swift#L1069) | + +### Group Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1072](../Shared/Model/AppAPITypes.swift#L1077) | +| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1073](../Shared/Model/AppAPITypes.swift#L1078) | +| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1074](../Shared/Model/AppAPITypes.swift#L1079) | +| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1076](../Shared/Model/AppAPITypes.swift#L1081) | +| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1078](../Shared/Model/AppAPITypes.swift#L1083) | +| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1079](../Shared/Model/AppAPITypes.swift#L1084) | +| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1080](../Shared/Model/AppAPITypes.swift#L1085) | +| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1081](../Shared/Model/AppAPITypes.swift#L1086) | +| `leftMember` | `user, groupInfo, member` | Member left | [L1082](../Shared/Model/AppAPITypes.swift#L1087) | +| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1083](../Shared/Model/AppAPITypes.swift#L1088) | +| `userJoinedGroup` | `user, groupInfo` | Successfully joined | [L1084](../Shared/Model/AppAPITypes.swift#L1089) | +| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1085](../Shared/Model/AppAPITypes.swift#L1090) | +| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1086](../Shared/Model/AppAPITypes.swift#L1091) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1087](../Shared/Model/AppAPITypes.swift#L1092) | +| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1062](../Shared/Model/AppAPITypes.swift#L1067) | + +### File Transfer Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `rcvFileStart` | `user, chatItem` | Download started | [L1092](../Shared/Model/AppAPITypes.swift#L1097) | +| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1093](../Shared/Model/AppAPITypes.swift#L1098) | +| `rcvFileComplete` | `user, chatItem` | Download complete | [L1094](../Shared/Model/AppAPITypes.swift#L1099) | +| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1096](../Shared/Model/AppAPITypes.swift#L1101) | +| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1097](../Shared/Model/AppAPITypes.swift#L1102) | +| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1100](../Shared/Model/AppAPITypes.swift#L1105) | +| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1101](../Shared/Model/AppAPITypes.swift#L1106) | +| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1103](../Shared/Model/AppAPITypes.swift#L1108) | +| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1105](../Shared/Model/AppAPITypes.swift#L1110) | +| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1107](../Shared/Model/AppAPITypes.swift#L1112) | + +### Call Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1110](../Shared/Model/AppAPITypes.swift#L1115) | +| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1111](../Shared/Model/AppAPITypes.swift#L1116) | +| `callAnswer` | `user, contact, answer` | SDP answer received | [L1112](../Shared/Model/AppAPITypes.swift#L1117) | +| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1113](../Shared/Model/AppAPITypes.swift#L1118) | +| `callEnded` | `user, contact` | Call ended by remote | [L1114](../Shared/Model/AppAPITypes.swift#L1119) | + +### Connection Security Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1052](../Shared/Model/AppAPITypes.swift#L1057) | +| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1053](../Shared/Model/AppAPITypes.swift#L1058) | +| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1054](../Shared/Model/AppAPITypes.swift#L1059) | +| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1055](../Shared/Model/AppAPITypes.swift#L1060) | + +### System Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `chatSuspended` | -- | Core suspended | [L1051](../Shared/Model/AppAPITypes.swift#L1056) | + +--- + +## 5. Error Types + +Defined in [`SimpleXChat/APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699): + +```swift +public enum ChatError: Decodable, Hashable { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) + case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) + case invalidJSON(json: String) + case unexpectedResult(type: String) +} +``` + +### Error Categories + +| Category | Enum | Description | Source | +|----------|------|-------------|--------| +| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied) | [`APITypes.swift` L717](../SimpleXChat/APITypes.swift#L722) | +| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L873](../SimpleXChat/APITypes.swift#L878) | +| Database store | `StoreError` | SQLite query/constraint errors | [`APITypes.swift` L796](../SimpleXChat/APITypes.swift#L801) | +| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L860](../SimpleXChat/APITypes.swift#L865) | +| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1043](../SimpleXChat/APITypes.swift#L1048) | +| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) | +| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) | + +--- + +## 6. FFI Bridge Functions + +Defined in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift): + +### Synchronous (blocking current thread) + +```swift +// Throws on error, returns typed result +func chatSendCmdSync( // SimpleXAPI.swift L91 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) throws -> R + +// Returns APIResult (caller handles error) +func chatApiSendCmdSync( // SimpleXAPI.swift L96 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + retryNum: Int32 = 0, + log: Bool = true +) -> APIResult +``` + +### Asynchronous (Swift concurrency) + +```swift +// Throws on error, returns typed result +func chatSendCmd( // SimpleXAPI.swift L117 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) async throws -> R + +// Returns APIResult with optional retry on network errors +func chatApiSendCmdWithRetry( // SimpleXAPI.swift L122 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + inProgress: BoxedValue? = nil, + retryNum: Int32 = 0 +) async -> APIResult? +``` + +### Low-Level FFI + +```swift +// Direct C FFI call -- serializes cmd.cmdString, calls chat_send_cmd_retry, decodes response +public func sendSimpleXCmd( // API.swift L115 + _ cmd: ChatCmdProtocol, + _ ctrl: chat_ctrl?, + retryNum: Int32 = 0 +) -> APIResult +``` + +### Event Receiver + +```swift +// Polls for async events from the Haskell core +func chatRecvMsg( // SimpleXAPI.swift L230 + _ ctrl: chat_ctrl? = nil +) async -> APIResult? + +// Processes a received event and updates app state +func processReceivedMsg( // SimpleXAPI.swift L2248 + _ res: ChatEvent +) async +``` + +--- + +## 7. Result Type + +Defined in [`SimpleXChat/APITypes.swift` L26](../SimpleXChat/APITypes.swift#L27): + +```swift +public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { + case result(R) // Successful response + case error(ChatError) // Error response from core + case invalid(type: String, json: Data) // Undecodable response + + public var responseType: String { ... } + public var unexpected: ChatError { ... } +} + +public protocol ChatAPIResult: Decodable { // APITypes.swift L63 + var responseType: String { get } + var details: String { get } + static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? +} +``` + +The `decodeAPIResult` function ([`APITypes.swift` L83](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: +1. Try standard `JSONDecoder.decode(APIResult.self, from: data)` +2. If that fails, try manual JSON parsing via `JSONSerialization` +3. Check for `"error"` key -- return `.error` +4. Check for `"result"` key -- try `R.fallbackResult` or return `.invalid` +5. Last resort: return `.invalid(type: "invalid", json: ...)` + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15) | +| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L647, L768, L907](../Shared/Model/AppAPITypes.swift#L649) | +| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055) | +| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L26, L695](../SimpleXChat/APITypes.swift#L27) | +| FFI bridge functions | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) | +| Data types | `SimpleXChat/ChatTypes.swift` | +| C header | `SimpleXChat/SimpleX.h` | +| Haskell controller | `../../src/Simplex/Chat/Controller.hs` | diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md new file mode 100644 index 0000000000..84d9d3269d --- /dev/null +++ b/apps/ios/spec/architecture.md @@ -0,0 +1,298 @@ +# SimpleX Chat iOS -- System Architecture + +> Technical specification for the iOS app's layered architecture, FFI bridge, event system, and extension model. +> +> Related specs: [README](README.md) | [API Reference](api.md) | [State Management](state.md) | [Database](database.md) +> Related product: [Product Overview](../product/README.md) + +**Source:** [`SimpleXApp.swift`](../Shared/SimpleXApp.swift#L1-L183) | [`AppDelegate.swift`](../Shared/AppDelegate.swift#L1-L209) | [`ContentView.swift`](../Shared/ContentView.swift#L1-L513) | [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1373) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L1-L2915) | [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L1-L2357) | [`APITypes.swift`](../SimpleXChat/APITypes.swift#L1-L1071) | [`API.swift`](../SimpleXChat/API.swift#L1-L388) + +--- + +## Table of Contents + +1. [Layered Architecture](#1-layered-architecture) +2. [FFI Bridge](#2-ffi-bridge) +3. [Event Streaming](#3-event-streaming) +4. [Database Architecture](#4-database-architecture) +5. [App Lifecycle](#5-app-lifecycle) +6. [Extension Architecture](#6-extension-architecture) +7. [Remote Desktop Control](#7-remote-desktop-control) + +--- + +## [1. Layered Architecture](../Shared/SimpleXApp.swift#L17-L184) + +The app follows a strict layered model where each layer communicates only with its immediate neighbor: + +``` +┌─────────────────────────────────────────┐ +│ SwiftUI Views │ Rendering, user interaction +│ (ChatListView, ChatView, ComposeView) │ +├─────────────────────────────────────────┤ +│ ChatModel (ObservableObject) │ App state, @Published properties +│ ItemsModel, Chat, ChatTagsModel │ Per-chat state, tag filtering +├─────────────────────────────────────────┤ +│ SimpleXAPI (FFI Bridge) │ chatSendCmd/chatApiSendCmd +│ AppAPITypes (ChatCommand/Response) │ JSON serialization/deserialization +├─────────────────────────────────────────┤ +│ C FFI Layer │ chat_send_cmd_retry, chat_recv_msg_wait +│ (SimpleX.h, libsimplex.a) │ Compiled Haskell via GHC cross-compiler +├─────────────────────────────────────────┤ +│ Haskell Core (chat_ctrl) │ Chat logic, chat protocol (x-events), +│ (Simplex.Chat.Controller) │ database operations, file management +├─────────────────────────────────────────┤ +│ simplexmq library (external) │ SMP/XFTP protocols, SMP Agent, +│ (github.com/simplex-chat/simplexmq) │ double-ratchet (PQDR), transport (TLS) +└─────────────────────────────────────────┘ +``` + +**Key invariant**: No SwiftUI view directly calls FFI functions. All communication flows through `ChatModel` or dedicated API functions in `SimpleXAPI.swift`. + +### Source Files + +| Layer | File | Role | Line | +|-------|------|------|------| +| Views | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift) | Chat list rendering | | +| Views | [`Shared/Views/Chat/ChatView.swift`](../Shared/Views/Chat/ChatView.swift) | Conversation rendering | | +| State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 | +| API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 | +| API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 | +| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | | +| FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 | +| Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | | + +--- + +## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49) + +### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49) + +The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`: + +```c +typedef void* chat_ctrl; + +// Initialize database, apply migrations, return controller +char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, + int backgroundMode, chat_ctrl *ctrl); + +// Send command string, return JSON response string +char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum); + +// Block until next async event arrives (or timeout) +char *chat_recv_msg_wait(chat_ctrl ctl, int wait); + +// Close/reopen database store +char *chat_close_store(chat_ctrl ctl); +char *chat_reopen_store(chat_ctrl ctl); + +// Utility: markdown parsing, server validation, password hashing +char *chat_parse_markdown(char *str); +char *chat_parse_server(char *str); +char *chat_password_hash(char *pwd, char *salt); + +// File encryption/decryption +char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); +char *chat_read_file(char *path, char *key, char *nonce); +char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); +char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); +``` + +### [Swift Bridge Functions (SimpleXAPI.swift)](../Shared/Model/SimpleXAPI.swift#L93-L221) + +```swift +// Synchronous send -- blocks calling thread +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) throws -> R // L91 + +// Async send -- dispatches to background +func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) async -> APIResult // L215 + +// Low-level FFI call -- serializes command to string, calls chat_send_cmd_retry, decodes JSON +func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl?, + retryNum: Int32 = 0) -> APIResult // SimpleXChat/API.swift L114 +``` + +### Data Flow + +1. Swift constructs a `ChatCommand` enum value (e.g., `.apiSendMessages(type:id:scope:live:ttl:composedMessages:)`) +2. [`ChatCommand.cmdString`](../Shared/Model/AppAPITypes.swift#L15) serializes it to a command string (e.g., `"/_send @1 json {...}"`) +3. [`sendSimpleXCmd`](../SimpleXChat/API.swift#L115) passes the string to `chat_send_cmd_retry` via C FFI +4. Haskell core processes the command, returns JSON response string +5. Swift decodes JSON into [`APIResult`](../SimpleXChat/APITypes.swift#L27) where `R: ChatAPIResult` +6. Result is either `.result(R)`, `.error(ChatError)`, or `.invalid(type, json)` + +### [Background Task Protection](../Shared/Model/SimpleXAPI.swift#L54-L79) + +All FFI calls are wrapped in [`beginBGTask()`](../Shared/Model/SimpleXAPI.swift#L54) / `endBackgroundTask()` to prevent iOS from killing the app mid-operation. The `maxTaskDuration` is 15 seconds. + +--- + +## [3. Event Streaming](../Shared/Model/SimpleXAPI.swift#L2220-L2916) + +The Haskell core emits async events (new messages, connection status changes, file progress, etc.) that are not direct responses to commands. These are received via polling: + +``` +Haskell Core --[chat_recv_msg_wait]--> Swift event loop --> ChatModel update --> SwiftUI re-render +``` + +The event loop is implemented in [`ChatReceiver`](../Shared/Model/SimpleXAPI.swift#L2220-L2263), and events are dispatched by [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266). + +### [Event Types (ChatEvent enum)](../Shared/Model/AppAPITypes.swift#L1055-L1129) + +Key async events delivered from core to UI: + +| Event | Description | Line | +|-------|-------------|------| +| `newChatItems` | New messages received | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | +| `chatItemUpdated` | Message edited by sender | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `chatItemsDeleted` | Messages deleted | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | +| `chatItemReaction` | Reaction added/removed | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `contactConnected` | New contact connected | [L1062](../Shared/Model/AppAPITypes.swift#L1062) | +| `contactUpdated` | Contact profile changed | [L1066](../Shared/Model/AppAPITypes.swift#L1066) | +| `receivedGroupInvitation` | Group invitation received | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `groupMemberUpdated` | Group member info changed | [L1067](../Shared/Model/AppAPITypes.swift#L1067) | +| `callInvitation` | Incoming call | [L1115](../Shared/Model/AppAPITypes.swift#L1115) | +| `chatSuspended` | Core suspended (background) | [L1056](../Shared/Model/AppAPITypes.swift#L1056) | +| `rcvFileComplete` | File download finished | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `sndFileCompleteXFTP` | File upload finished | [L1110](../Shared/Model/AppAPITypes.swift#L1110) | + +Events are decoded as [`ChatEvent`](../Shared/Model/AppAPITypes.swift#L1055) enum in `Shared/Model/AppAPITypes.swift` and dispatched to update `ChatModel` / `ItemsModel` properties, triggering SwiftUI view re-renders via `@Published` property observation. + +--- + +## [4. Database Architecture](../SimpleXChat/FileUtils.swift#L70-L294) + +Two SQLite databases in the app group container (shared with NSE): + +| Database | File | Contents | +|----------|------|----------| +| Chat DB | `simplex_v1_chat.db` | Messages, contacts, groups, profiles, files, tags, preferences | +| Agent DB | `simplex_v1_agent.db` | SMP connections, keys, queues, server info | + +Both databases use the `DB_FILE_PREFIX = "simplex_v1"` prefix. The database path is resolved via [`getAppDatabasePath()`](../SimpleXChat/FileUtils.swift#L70) in `SimpleXChat/FileUtils.swift`, which checks `dbContainerGroupDefault` to determine whether to use the app group container or legacy documents directory. + +See [Database & Storage specification](database.md) for full details. + +--- + +## [5. App Lifecycle](../Shared/SimpleXApp.swift#L17-L184) + +### [Initialization Sequence (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L17-L38) + +```swift +// SimpleXApp.init() +1. haskell_init() // Initialize Haskell RTS (background queue, sync) +2. UserDefaults.register(defaults:) // Register app preference defaults +3. setGroupDefaults() // Sync preferences to app group container +4. setDbContainer() // Set database path L122 +5. BGManager.shared.register() // Register background task handlers +6. NtfManager.shared.registerCategories() // Register notification action categories +``` + +### State Transitions + +``` + ┌──────────┐ + │ Launched │ + └─────┬─────┘ + │ initChatAndMigrate() + v + ┌──────────┐ + │ DB Setup │ chat_migrate_init_key() + └─────┬─────┘ + │ startChat() SimpleXAPI.swift L2098 + v + ┌──────────┐ + │ Active │ apiActivateChat() SimpleXAPI.swift L358 + └─────┬─────┘ + │ scenePhase == .background + v + ┌──────────┐ + │Background │ apiSuspendChat(timeoutMicroseconds:) SimpleXAPI.swift L368 + └─────┬─────┘ + │ scenePhase == .active + v + ┌──────────┐ + │ Active │ startChatAndActivate() + └──────────┘ +``` + +### [Scene Phase Handling (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L38-L123) + +- **`.active`**: Calls `startChatAndActivate()`, processes pending notification responses, refreshes chat list and call invitations +- **`.background`**: Records authentication timestamp, calls `suspendChat()` (unless CallKit call active), schedules `BGManager` background refresh, updates badge count +- **`.inactive`**: No explicit handling (transitional state) + +### CallKit Exception + +When a CallKit call is active during backgrounding, chat suspension is deferred (`CallController.shared.shouldSuspendChat = true`) until the call ends, to maintain the WebRTC session. + +--- + +## [6. Extension Architecture](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +### [Notification Service Extension (NSE)](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +The NSE ([`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228)) is a separate process that: + +1. Receives encrypted push notification payload from APNs +2. Initializes its own Haskell core instance (`chat_ctrl`) with shared database access +3. Decrypts the push payload using stored keys +4. Generates a visible `UNMutableNotificationContent` with the decrypted message preview +5. Delivers the notification to the user + +**Database sharing**: Both main app and NSE access the same database files in the app group container (`APP_GROUP_NAME`). Coordination uses file locks to prevent concurrent write conflicts. + +**Lifecycle**: The NSE has a ~30-second execution window per notification. It must initialize Haskell RTS, open the database, decrypt, and deliver within this window. + +### Share Extension (SE) + +The Share Extension (`SimpleX SE/`) allows sharing content (text, images, files) from other apps into SimpleX conversations. + +--- + +## [7. Remote Desktop Control](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545) + +Optional desktop pairing allows controlling the mobile app from a desktop client: + +- **Pairing**: Encrypted QR code scanned by desktop client establishes a session +- **Commands**: [`connectRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1613), [`findKnownRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1620), [`confirmRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1624), [`verifyRemoteCtrlSession`](../Shared/Model/SimpleXAPI.swift#L1630), [`listRemoteCtrls`](../Shared/Model/SimpleXAPI.swift#L1636), [`stopRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1642), [`deleteRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1646) +- **State**: [`ChatModel.remoteCtrlSession`](../Shared/Model/ChatModel.swift#L395)`: RemoteCtrlSession?` tracks the active session +- **Transport**: Encrypted reverse HTTP transport between mobile and desktop +- **Source**: [`Shared/Views/RemoteAccess/ConnectDesktopView.swift`](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545), see `Remote.hs` in `../../src/Simplex/Chat/` + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| App entry point | [`Shared/SimpleXApp.swift`](../Shared/SimpleXApp.swift#L17) | L17 | +| App delegate | [`Shared/AppDelegate.swift`](../Shared/AppDelegate.swift#L15) | L15 | +| Root view | [`Shared/ContentView.swift`](../Shared/ContentView.swift#L24) | L24 | +| FFI bridge | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | L93 | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift#L115) | L115 | +| App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 | +| API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 | +| Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 | +| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | | +| NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | | +| Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | | +| Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | | + +### External: simplexmq Library + +The lower-level protocol and encryption layers are in the separate [simplexmq](https://github.com/simplex-chat/simplexmq) library: + +| Component | Spec | Implementation | +|-----------|------|----------------| +| SMP protocol | `simplexmq/protocol/simplex-messaging.md` | `simplexmq/src/Simplex/Messaging/Protocol.hs` | +| XFTP protocol | `simplexmq/protocol/xftp.md` | `simplexmq/src/Simplex/FileTransfer/Protocol.hs` | +| SMP Agent (duplex connections) | `simplexmq/protocol/agent-protocol.md` | `simplexmq/src/Simplex/Messaging/Agent.hs` | +| Double ratchet (PQDR) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` | +| Post-quantum KEM (sntrup761) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs` | +| TLS transport | — | `simplexmq/src/Simplex/Messaging/Transport.hs` | +| File encryption | — | `simplexmq/src/Simplex/Messaging/Crypto/File.hs` | diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md new file mode 100644 index 0000000000..0eb3cd75f7 --- /dev/null +++ b/apps/ios/spec/client/chat-list.md @@ -0,0 +1,280 @@ +# SimpleX Chat iOS -- Chat List Module + +> Technical specification for the conversation list, filtering, search, swipe actions, and user picker. +> +> Related specs: [Chat View](chat-view.md) | [Navigation](navigation.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Chat List View](../../product/views/chat-list.md) + +**Source:** [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView](#2-chatlistview) +3. [ChatPreviewView](#3-chatpreviewview) +4. [ChatListNavLink](#4-chatlistnavlink) +5. [Filtering & Tags](#5-filtering--tags) +6. [Search](#6-search) +7. [Swipe Actions](#7-swipe-actions) +8. [UserPicker](#8-userpicker) +9. [Floating Action Button](#9-floating-action-button) + +--- + +## 1. Overview + +The chat list is the main screen of the app, displaying all conversations for the current user. It provides: + +- Conversation previews with unread badges +- Filter tabs (All, Unread, Favorites, Groups, Contacts, Business, user-defined tags) +- Search across chat names and message content +- Swipe actions for quick operations +- User profile switcher +- Floating action button for new conversations + +``` +ChatListView +├── Navigation Bar +│ ├── User avatar (tap → UserPicker) +│ └── Filter tabs (TagListView) +├── Search bar (on pull-down or tap) +├── Chat List (List/LazyVStack) +│ └── ChatListNavLink (per conversation) +│ └── ChatPreviewView +│ ├── Avatar +│ ├── Chat name + last message preview +│ ├── Timestamp +│ └── Unread badge +├── FAB (New Chat button) +└── Pending connection cards +``` + +--- + +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} + +**File**: `Shared/Views/ChatList/ChatListView.swift` + +The root list view. Key responsibilities: + +### Data Source +- Reads `ChatModel.shared.chats` (all conversations) +- Applies active filter from `ChatTagsModel.shared.activeFilter` +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Sorts by last activity (most recent first), with pinned chats at top + +### Layout +- Uses SwiftUI `List` with `ForEach` over filtered chats +- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView` +- Pull-to-refresh triggers `updateChats()` API call +- Empty state: `ChatHelp` view with getting-started guidance + +### Connection Cards +- Pending contact connections (`ChatInfo.contactConnection`) shown as cards +- Contact requests (`ChatInfo.contactRequest`) shown with accept/reject UI via `ContactRequestView` + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | + +--- + +## 3. [`ChatPreviewView`](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) {#3-chatpreviewview} + +**File**: `Shared/Views/ChatList/ChatPreviewView.swift` + +Renders a single row in the chat list. Shows: + +| Element | Source | Description | +|---------|--------|-------------| +| Avatar | `chatInfo.image` | Profile image or default icon | +| Chat name | `chatInfo.displayName` | Contact name, group name, or connection label | +| Last message | `chat.chatItems.last` | Preview text of most recent message | +| Timestamp | `chat.chatItems.last?.timestampText` | Relative time of last message | +| Unread badge | `chat.chatStats.unreadCount` | Circular badge with unread count | +| Mute icon | `chatInfo.chatSettings?.enableNtfs` | Bell-slash icon if notifications muted | +| Pin icon | -- | Pin indicator for pinned chats | +| Incognito icon | Contact.contactConnIncognito | Incognito mode indicator | +| Delivery status | Last sent item's `meta.itemStatus` | Check marks for delivery confirmation | + +### Preview Text Rendering +- Text messages: first line of message content +- Images: camera icon + caption (if any) +- Files: paperclip icon + filename +- Voice: microphone icon + duration +- Calls: phone icon + call status +- Group events: system event description +- Encrypted/deleted: placeholder text + +--- + +## 4. [`ChatListNavLink`](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) {#4-chatlistnavlink} + +**File**: `Shared/Views/ChatList/ChatListNavLink.swift` + +Wraps `ChatPreviewView` in a navigation link with tap and swipe behavior: + +### Tap Behavior +- Direct chat: navigates to `ChatView` via `ItemsModel.loadOpenChat(chatId)` -- [`contactNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L95) L93 +- Group chat: navigates to `ChatView` -- [`groupNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L217) L214 +- Contact request: shows `ContactRequestView` with accept/reject -- [`contactRequestNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L495) L486 +- Contact connection: shows `ContactConnectionInfo` -- [`contactConnectionNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L530) L520 +- Notes folder: navigates to `ChatView` -- [`noteFolderNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L302) L298 + +### Navigation +- Uses `NavigationLink` (iOS 15) or programmatic navigation (iOS 16+) +- Sets `ChatModel.chatId` to trigger navigation +- `ItemsModel.loadOpenChat()` loads messages with a 250ms navigation delay for smooth animation + +--- + +## 5. Filtering & Tags + +### Filter Tabs ([`TagListView`](../../Shared/Views/ChatList/TagListView.swift#L20)) + +**File**: `Shared/Views/ChatList/TagListView.swift` + +Horizontal scrolling tab bar below the navigation bar. Tabs: + +| Tab | Filter | Shows | +|-----|--------|-------| +| All | `nil` | All conversations | +| Unread | `.unread` | Conversations with unread messages | +| Favorites | `.presetTag(.favorites)` | Favorited conversations | +| Groups | `.presetTag(.groups)` | Group conversations | +| Contacts | `.presetTag(.contacts)` | Direct conversations | +| Business | `.presetTag(.business)` | Business conversations | +| Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | +| User tags | `.userTag(ChatTag)` | User-defined custom tags | + +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). + +### ChatTagsModel State + +Filtering state is managed by [`ChatTagsModel`](../../Shared/Model/ChatModel.swift#L189) (`ChatModel.swift` L183): + +```swift +class ChatTagsModel: ObservableObject { + @Published var userTags: [ChatTag] = [] + @Published var activeFilter: ActiveFilter? = nil + @Published var presetTags: [PresetTag: Int] = [:] // count per preset tag + @Published var unreadTags: [Int64: Int] = [:] // unread count per user tag +} +``` + +- `presetTags` counts are updated whenever `chats` changes via [`updateChatTags()`](../../Shared/Model/ChatModel.swift#L197) (L197) +- Tags with zero matching chats are auto-hidden +- Active filter is auto-cleared when its tag has no matching chats + +### Supporting Types + +| Type | File | Line | Description | +|------|------|------|-------------| +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | + +### Tag Management Commands +- `apiCreateChatTag(tag: ChatTagData)` -- create tag +- `apiSetChatTags(type:, id:, tagIds:)` -- assign tags to a chat +- `apiDeleteChatTag(tagId:)` -- delete tag +- `apiUpdateChatTag(tagId:, tagData:)` -- rename tag +- `apiReorderChatTags(tagIds:)` -- reorder tags + +--- + +## 6. Search + +Search is available via pull-down gesture or search button in the navigation bar. + +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) + +### Filtering Logic +- Filters `ChatModel.chats` by matching search text against: + - `chatInfo.displayName` (contact/group name) + - `chatInfo.localAlias` (local alias) + - `chatInfo.fullName` (full name) +- For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) + +### Search Results +- Matching chats are displayed in the same list format +- Results update as the user types (debounced) +- Clearing search restores the full filtered list + +--- + +## 7. Swipe Actions + +`ChatListNavLink` provides swipe actions on each row: + +### Leading Swipe (left-to-right) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Pin / Unpin | pin | [`toggleFavoriteButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L353) | 347 | `apiSetChatSettings` (favorite) | Always | +| Read / Unread | envelope | [`markReadButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L333) | 328 | `apiChatRead` / `apiChatUnread` | Always | + +### Trailing Swipe (right-to-left) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Mute / Unmute | bell.slash | [`toggleNtfsButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L372) | 365 | `apiSetChatSettings` (enableNtfs) | Always | +| Clear | trash | [`clearChatButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L393) | 385 | `apiClearChat` | Has messages | +| Delete | trash.fill | -- | -- | `apiDeleteChat` | Not active chat | +| Tag | tag | -- | -- | `apiSetChatTags` | Always | + +--- + +## 8. [`UserPicker`](../../Shared/Views/ChatList/UserPicker.swift#L10) {#8-userpicker} + +**File**: `Shared/Views/ChatList/UserPicker.swift` + +Triggered by tapping the user avatar in the navigation bar. Presented as a sheet with: + +| Section | Contents | +|---------|----------| +| User list | All non-hidden users with unread counts | +| Active user | Highlighted with checkmark | +| Actions | Settings, Your SimpleX address, User profiles | + +### User Switching +- Tapping a different user calls `apiSetActiveUser(userId:)` +- Triggers `apiGetChats` for the new user +- `ChatModel.currentUser` updates, causing full UI refresh +- Hidden users are not shown (require password entry via settings) + +--- + +## 9. Floating Action Button + +The FAB (floating action button) in the bottom-right corner opens the new chat flow: + +- Tap: opens `NewChatView` sheet for creating a new contact connection or group +- Shows options: Create link, Scan QR code, Paste link, Create group + +--- + +## Source Files + +| File | Path | Key struct | Line | +|------|------|------------|------| +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | +| Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | +| Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | +| User picker sheet | [`UserPicker.swift`](../../Shared/Views/ChatList/UserPicker.swift) | `UserPicker` | [9](../../Shared/Views/ChatList/UserPicker.swift#L10) | +| Getting started help | [`ChatHelp.swift`](../../Shared/Views/ChatList/ChatHelp.swift) | | | +| Contact request view | [`ContactRequestView.swift`](../../Shared/Views/ChatList/ContactRequestView.swift) | | | +| Contact connection info | [`ContactConnectionInfo.swift`](../../Shared/Views/ChatList/ContactConnectionInfo.swift) | | | +| Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | +| Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | +| One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md new file mode 100644 index 0000000000..b913287746 --- /dev/null +++ b/apps/ios/spec/client/chat-view.md @@ -0,0 +1,331 @@ +# SimpleX Chat iOS -- Chat View Module + +> Technical specification for the message rendering, chat item types, and context menu actions in the conversation view. +> +> Related specs: [Compose Module](compose.md) | [State Management](../state.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView](#2-chatview) +3. [ChatItemView -- Message Routing](#3-chatitemview) +4. [Message Renderers](#4-message-renderers) +5. [Media Views](#5-media-views) +6. [Metadata & Info](#6-metadata--info) +7. [Context Menu Actions](#7-context-menu-actions) +8. [Selection Mode](#8-selection-mode) + +--- + +## 1. Overview + +The chat view module renders individual conversations. It consists of: + +- **ChatView** -- The main conversation screen with message list, compose bar, and navigation +- **ChatItemView** -- Router that dispatches each chat item to the appropriate renderer +- **Specialized renderers** -- FramedItemView (standard messages), EmojiItemView (emoji-only), CICallItemView (calls), event views, etc. +- **Media views** -- CIImageView, CIVideoView, CIVoiceView, CIFileView for attachments + +``` +ChatView +├── Message List (ScrollView / LazyVStack) +│ ├── ChatItemView (per message) +│ │ ├── FramedItemView (text/media bubbles) +│ │ │ ├── MsgContentView (text with markdown) +│ │ │ ├── CIImageView / CIVideoView / CIVoiceView +│ │ │ └── CIMetaView (timestamp, status) +│ │ ├── EmojiItemView (emoji-only messages) +│ │ ├── CICallItemView (call events) +│ │ ├── CIEventView (system events) +│ │ ├── CIGroupInvitationView (group invitations) +│ │ ├── DeletedItemView / MarkedDeletedItemView +│ │ └── CIInvalidJSONView (decode errors) +│ └── ... (more items) +├── ComposeView (message input) +└── Navigation bar (contact/group info) +``` + +--- + +## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3135) + +**File**: [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) + +The main conversation view. Key responsibilities: + +### State +- Uses `ItemsModel.shared.reversedChatItems` for the primary message list +- `ChatModel.shared.chatId` identifies the active conversation +- Manages compose state, scroll position, keyboard visibility +- Tracks selection mode for multi-message actions + +### Message List +- Renders messages in a `ScrollViewReader` with `LazyVStack` +- Items are in reverse chronological order (newest at bottom) +- Supports infinite scroll: preloads older messages when scrolling up via `ItemsModel.preloadState` +- Handles pagination splits (`chatState.splits`) for non-contiguous loaded ranges + +### Navigation Bar +- Title: contact name / group name with connection status indicator +- Trailing button: navigates to [`ChatInfoView`](../../Shared/Views/Chat/ChatInfoView.swift#L93) (direct) or [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) (group) +- Search button: toggles in-chat message search + +### Scroll Behavior +- Auto-scrolls to bottom on new sent/received messages (if already near bottom) +- "Scroll to bottom" floating button when scrolled up +- `openAroundItemId` support: scrolls to a specific message (e.g., from search or notification) + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ChatView.swift#L76) | L74 | Main view body | +| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L675) | L672 | Initializes chat view state on appear | +| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L821) | L814 | Builds the scrollable message list | +| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L735) | L731 | Scrolls to a specific message by ID | +| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L769) | L764 | In-chat search toolbar UI | +| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1087 | Handles search query changes | +| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1519 | Loads chat items with pagination | +| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L807) | L801 | Filters items by content type | +| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1264 | Audio/video call toolbar button | +| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1284 | Search toggle toolbar button | +| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1352 | Group add-members toolbar button | +| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1409 | Forwards batch-selected messages | +| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1401 | Deletes batch-selected messages | +| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1559 | Reacts to chat items model changes | +| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1292 | Content filter dropdown menu | + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1586 | Wraps each chat item with context menu | +| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2712) | L2697 | Manages scroll-to-bottom button state | +| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2899) | L2882 | Reaction picker context menu | +| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L2997) | L2980 | Mute/unmute notifications button | +| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3049) | L3031 | Enum for message content filter types | +| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2795) | L2779 | Deletes messages with confirmation | +| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2842) | L2826 | Archives report messages | + +--- + +## [3. ChatItemView](../../Shared/Views/Chat/ChatItemView.swift#L42) + +**File**: [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) + +Routes each `ChatItem` to the appropriate renderer based on its `CIContent` type: + +### Content Types (CIContent enum) + +| Content Type | Renderer | Line | Description | +|-------------|----------|------|-------------| +| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L13 | Standard sent/received text+media message | +| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L13 | Locally deleted message placeholder | +| `sndCall` / `rcvCall` | [`CICallItemView`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | L13 | Call event (missed, ended, duration) | +| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L13 | Message integrity error | +| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L15 | Decryption failure | +| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L13 | Group invite | +| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Group system event | +| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Connection event | +| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L13 | Feature toggle event | +| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L13 | Preference change | +| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L13 | Failed to decode | + +### Bubble Direction +- Sent messages: aligned right, sender-colored bubble +- Received messages: aligned left, receiver-colored bubble +- Events/system messages: centered, no bubble + +### Appearance Dependencies +Each [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) may depend on the previous and next items for visual decisions: +- Whether to show the sender name (group messages, different sender than previous) +- Whether to show the tail on the bubble (last consecutive message from same sender) +- Date separator between messages on different days + +`ChatItemDummyModel.shared.sendUpdate()` forces a re-render of all items when global appearance changes. + +--- + +## 4. Message Renderers + +### [FramedItemView](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) + +The standard message bubble. Renders: +- Quote/reply preview (if replying to another message) +- Forwarded indicator +- Sender name (in groups) +- Message content (`MsgContentView` with markdown) +- Attached media (image, video, voice, file, link preview) +- Reaction summary bar +- Metadata line (`CIMetaView`) + +### [EmojiItemView](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) + +Renders emoji-only messages (messages containing only emoji characters) in a larger font without a bubble background. + +### [MsgContentView](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) + +**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) + +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). + +### DeletedItemView / MarkedDeletedItemView + +**Files**: [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) + +- [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14): Placeholder for locally deleted messages +- [`MarkedDeletedItemView`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14): Shows "message deleted" with optional moderation info (who deleted, when) + +### [CIEventView](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) + +Centered system event text for group events (member joined, left, role changed) and connection events. + +### [CIGroupInvitationView](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) + +Renders group invitation with accept/reject buttons. + +--- + +## 5. Media Views + +### [CIImageView](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) + +Renders inline images. Tapping opens `FullScreenMediaView` for zooming/panning. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [CIVideoView](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) + +Renders video thumbnails with play button. Tapping opens video player. Videos above auto-receive threshold require manual download. + +### CIVoiceView / FramedCIVoiceView + +**Files**: [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) + +Renders voice messages with waveform visualization, play/pause control, and duration. [`FramedCIVoiceView`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) is the version inside a message bubble with additional context. + +### [CIFileView](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) + +Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. + +### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) + +Renders link preview cards with OpenGraph metadata (title, description, image). + +### [AnimatedImageView](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) + +**File**: [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) + +Renders animated GIF images. + +### [FullScreenMediaView](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) + +Full-screen media viewer with zoom, pan, and share actions. Supports images and videos. + +--- + +## 6. Metadata & Info + +### [CIMetaView](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) + +Displays message metadata inline at the bottom of the bubble: +- Timestamp (sent time) +- Delivery status icon (sending, sent, delivered, read, error) +- Edit indicator (pencil icon if message was edited) +- Disappearing message timer (if timed message) + +### [ChatItemInfoView](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) + +**File**: [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) + +Detailed message information sheet (accessed via long-press menu "Info"): +- Full delivery history (per-member delivery status in groups) +- Edit history (all previous versions of edited messages) +- Forward chain info +- Message timestamps (created, updated, deleted) + +--- + +## 7. Context Menu Actions + +Long-pressing a message shows a context menu with actions based on message type and ownership: + +| Action | Available For | API Command | +|--------|--------------|-------------| +| Reply | All messages | Sets compose state to `.replying` | +| Forward | Sent/received content messages | `apiForwardChatItems` | +| Copy | Text messages | Copies to clipboard | +| Edit | Own sent messages (within edit window) | `apiUpdateChatItem` | +| Delete for me | All messages | `apiDeleteChatItem(mode: .cidmInternal)` | +| Delete for everyone | Own sent messages | `apiDeleteChatItem(mode: .cidmBroadcast)` | +| Moderate | Group admin/owner for others' messages | `apiDeleteMemberChatItem` | +| React | Content messages (if reactions enabled) | `apiChatItemReaction` | +| Select | All messages | Enters multi-select mode | +| Info | All messages | Opens [`ChatItemInfoView`](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| Save | Media messages | Saves to photo library / files | +| Share | Content messages | iOS share sheet | + +--- + +## 8. Selection Mode + +Multi-selection mode allows batch operations on messages: + +- Enter via long-press "Select" action +- Toggle individual messages with tap +- Toolbar appears with batch actions: Delete, Forward +- Exit via cancel button or completing batch action + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L17](../../Shared/Views/Chat/ChatView.swift#L18) | +| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L41](../../Shared/Views/Chat/ChatItemView.swift#L42) | +| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | +| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) | +| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) | +| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) | +| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) | +| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) | +| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) | +| Call event | [`Shared/Views/Chat/ChatItem/CICallItemView.swift`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | +| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) | +| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L12](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | +| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | +| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) | +| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L27](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) | +| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | +| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | +| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | +| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | +| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) | +| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L10](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) | +| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) | +| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) | diff --git a/apps/ios/spec/client/compose.md b/apps/ios/spec/client/compose.md new file mode 100644 index 0000000000..03116ddf6b --- /dev/null +++ b/apps/ios/spec/client/compose.md @@ -0,0 +1,355 @@ +# SimpleX Chat iOS -- Message Composition Module + +> Technical specification for the compose bar, attachment types, reply/edit/forward modes, voice recording, and mentions. +> +> Related specs: [Chat View](chat-view.md) | [File Transfer](../services/files.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeView](#2-composeview) +3. [ComposeState Machine](#3-composestate-machine) +4. [Attachment Types](#4-attachment-types) +5. [Reply Mode](#5-reply-mode) +6. [Edit Mode](#6-edit-mode) +7. [Forward Mode](#7-forward-mode) +8. [Live Messages](#8-live-messages) +9. [Voice Recording](#9-voice-recording) +10. [Link Previews](#10-link-previews) +11. [Mentions](#11-mentions) + +--- + +## 1. Overview + +The compose module handles all message creation, editing, and forwarding. It sits at the bottom of `ChatView` and adapts its UI based on the current compose state. + +``` +ComposeView +├── Context banner (reply quote / edit indicator / forward indicator) +├── Attachment preview (image / video / file / voice waveform) +├── Text input (NativeTextEditor with markdown support) +├── Action buttons +│ ├── Attachment menu (camera, photo library, file picker) +│ ├── Voice record button (hold or toggle) +│ └── Send button (or live message indicator) +└── Link preview (auto-generated when URL detected) +``` + +--- + +## 2. [ComposeView](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) (`struct ComposeView: View`) + +**File**: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` + +### Layout +- Fixed at the bottom of ChatView +- Expands vertically as text input grows (up to a maximum height) +- Context banner appears above the text field when in reply/edit/forward mode +- Attachment preview appears between context banner and text field + +### Key Properties +- Reads `ChatModel.shared.draft` / `draftChatId` for persisted drafts +- Manages its own internal compose state +- Coordinates with `ChatView` for scroll-to-bottom behavior on send + +### Send Flow +1. User taps send button +2. ComposeView constructs `[ComposedMessage]` from current state +3. Calls `apiSendMessages(type:, id:, scope:, live:, ttl:, composedMessages:)` +4. On success: clears compose state, scrolls to bottom +5. On failure: shows error alert, preserves compose state + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L369) | L360 | Main view body | +| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L693) | L683 | Builds the send-message UI | +| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1106) | L1091 | Entry point: initiates send | +| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1115) | L1099 | Async send implementation | +| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1467) | L1446 | Resets compose state after send | +| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L893) | L882 | Adds media attachment | +| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L866) | L856 | Checks link preview before connect | +| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L754) | L744 | Builds commands menu button | + +### Draft Persistence + +| Function | Line | Description | +|----------|------|-------------| +| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1481) | L1459 | Saves compose state to `ChatModel.draft` | +| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1487) | L1464 | Clears persisted draft | + +- When navigating away from a chat, compose state is saved to `ChatModel.draft` / `ChatModel.draftChatId` +- When returning to the same chat, draft is restored +- Drafts are not persisted across app restarts + +--- + +## 3. [ComposeState](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) Machine (`struct ComposeState`) + +The compose bar operates as a state machine with these primary states: + +``` + ┌──────────┐ + │ .empty │ ← initial / after send + └─────┬────┘ + │ user types / attaches / quotes + v + ┌─────────────────────────────────────┐ + │ │ + ┌────▼────┐ ┌──────────────┐ ┌──────────▼───┐ + │ .text │ │ .mediaPending │ │ .voiceRecording │ + └─────────┘ └──────────────┘ └───────────────┘ + │ │ + │ long-press reply│ tap edit + v v + ┌──────────┐ ┌──────────┐ ┌───────────┐ + │ .replying │ │ .editing │ │ .forwarding│ + └──────────┘ └──────────┘ └───────────┘ +``` + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L10 | Preview variants (image, voice, file, etc.) | +| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L18 | Context item for reply/quote | +| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L26 | Recording state enum | +| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L40 | Full compose state struct | +| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L93 | Copy compose state with overrides | +| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L113 | Format mention display name | +| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L260 | Build preview from chat item | +| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L280 | Upload content variants | + +### States + +| State | Description | UI | +|-------|-------------|-----| +| `.empty` | No input, no attachments | Placeholder text, attachment button | +| `.text` | Text entered, no attachments | Send button visible | +| `.mediaPending` | Media/file selected, optionally with text | Preview visible, send button | +| `.voiceRecording` | Voice recording in progress | Waveform animation, stop/send | +| `.replying` | Replying to a specific message | Quote banner above input | +| `.editing` | Editing a previously sent message | Edit banner, pre-filled text | +| `.forwarding` | Forwarding selected messages | Forward banner, item previews | + +### Transitions + +| From | Trigger | To | +|------|---------|-----| +| `.empty` | User types text | `.text` | +| `.empty` | User selects media | `.mediaPending` | +| `.empty` | User holds voice button | `.voiceRecording` | +| `.empty` | User long-presses message "Reply" | `.replying` | +| `.empty` | User long-presses message "Edit" | `.editing` | +| `.empty` | User selects "Forward" | `.forwarding` | +| Any | User taps send | `.empty` | +| Any | User taps cancel (X) | `.empty` | + +--- + +## 4. Attachment Types + +### [ComposeImageView](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) + +**File**: [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) (struct at L12) + +Preview of selected image(s) before sending. Shows thumbnail with remove button. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [ComposeFileView](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) + +**File**: [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) (struct at L11) + +Preview of selected file or video. Shows filename, size, and remove button. Videos show a thumbnail frame. + +### [ComposeVoiceView](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) + +**File**: [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) (struct at L26) + +Voice message recording/playback preview. Shows waveform visualization, duration, and play/delete buttons. + +### Attachment Menu Options + +| Option | Picker | Max Size | Transfer Method | +|--------|--------|----------|-----------------| +| Camera photo | UIImagePickerController | Compressed to 255KB | Inline in SMP message | +| Photo library | PHPickerViewController | Compressed to 255KB | Inline or XFTP | +| Video | PHPickerViewController | Up to 1GB | XFTP | +| File | UIDocumentPickerViewController | Up to 1GB | XFTP | + +--- + +## 5. Reply Mode + +Activated via long-press context menu "Reply" on any message. + +### UI +- Quote banner above text input showing original message preview +- X button to cancel reply +- Original message reference stored in compose state + +### API +- Reply is sent as part of `ComposedMessage` with `quotedItemId` parameter +- `apiSendMessages(composedMessages: [ComposedMessage(quotedItemId: originalItem.id, ...)])` + +--- + +## 6. Edit Mode + +Activated via long-press context menu "Edit" on own sent messages (within the edit window). + +### UI +- Edit banner above text input with pencil icon +- Text field pre-filled with original message content +- Send button changes to "Save" / checkmark + +### API +- `apiUpdateChatItem(type:, id:, scope:, itemId:, updatedMessage:, live:)` +- Response: `ChatResponse1.chatItemUpdated(user:, chatItem:)` + +### Constraints +- Only own sent messages can be edited +- Edit is available within a server-defined time window +- Edited messages show a pencil indicator in `CIMetaView` +- Edit history is visible in `ChatItemInfoView` + +--- + +## 7. Forward Mode + +Activated via long-press context menu "Forward" or via multi-select toolbar. + +### Flow +1. User selects "Forward" on message(s) +2. `apiPlanForwardChatItems(fromChatType:, fromChatId:, fromScope:, itemIds:)` is called to plan +3. Response: `ChatResponse1.forwardPlan(user:, chatItemIds:, forwardConfirmation:)` +4. User selects destination chat +5. `apiForwardChatItems(toChatType:, toChatId:, toScope:, fromChatType:, fromChatId:, fromScope:, itemIds:, ttl:)` executes the forward +6. Forwarded messages appear with a forwarded indicator + +### ForwardConfirmation +The plan response may include a `forwardConfirmation` requiring user confirmation (e.g., forwarding to a less secure chat). + +--- + +## 8. [Live Messages](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L36) (`struct LiveMessage`) + +Optional feature where the recipient sees typing in real-time. + +### How It Works +- User enables live message mode (lightning icon) +- As user types, `apiSendMessages(live: true)` is called repeatedly +- Each call sends the current text as an update to the same message +- Recipient sees the message being composed in real-time +- Final send marks the message as complete + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L922) | L910 | Initiates a live message | +| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L940) | L927 | Sends incremental live update | +| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L959) | L945 | Determines text diff to send | +| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L964) | L950 | Truncates text at word boundary | + +### API +- Initial: `apiSendMessages(live: true, composedMessages: [...])` -- creates live message +- Updates: `apiUpdateChatItem(live: true)` -- updates content as user types +- Final: `apiUpdateChatItem(live: false)` -- marks as complete + +--- + +## 9. Voice Recording + +### Recording Flow +1. User taps (or holds) the microphone button +2. `AVAudioRecorder` starts recording in compressed format +3. Waveform visualization shows real-time audio levels +4. User taps stop (or releases hold) to finish recording +5. Preview with playback shown in compose area +6. User taps send to deliver + +### Voice Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1382) | L1365 | Begins audio recording | +| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1423) | L1405 | Stops recording, shows preview | +| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1434) | L1415 | Enables voice messages for contact | +| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1441) | L1422 | Updates state after recording finishes | +| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1453) | L1434 | Cancels in-progress recording | +| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1460) | L1440 | Cancels and cleans up recording file | + +### Constraints +- Maximum duration: `MAX_VOICE_MESSAGE_LENGTH = 300` seconds (5 minutes) +- Auto-receive threshold: `MAX_VOICE_SIZE_AUTO_RCV = 522,240` bytes (510KB) +- Compressed audio format for small file sizes + +### Audio Management +- [`AudioRecorder`](../../Shared/Model/AudioRecPlay.swift#L14) (`Shared/Model/AudioRecPlay.swift` L14) manages recording and playback +- `ChatModel.stopPreviousRecPlay` coordinates exclusive audio playback (only one audio source plays at a time) + +--- + +## 10. [Link Previews](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) (`ComposeLinkView`) + +**File**: [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) (struct at L13) + +### Auto-Detection +- As user types, URLs in the text are detected +- When a URL is found, `ComposeLinkView` fetches OpenGraph metadata +- Preview card shows title, description, and thumbnail image + +### Link Preview Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1495) | L1471 | Triggers link preview loading | +| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1515) | L1490 | Extracts URLs from formatted text | +| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) | L1501 | Checks if URL is a SimpleX link | +| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1530) | L1505 | Cancels pending preview | +| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1542) | L1516 | Fetches OpenGraph metadata | +| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1559) | L1533 | Resets preview state | + +### Behavior +- Only the first URL in the message generates a preview +- Preview can be dismissed by the user +- Link preview data is included in the `ComposedMessage` sent to the core +- Toggle in privacy settings to disable auto-preview generation + +--- + +## 11. Mentions + +In group chats, typing `@` triggers member name autocomplete: + +### Flow +1. User types `@` in the text field +2. Autocomplete dropdown appears with matching group members +3. User selects a member +4. `@displayName` is inserted into the text +5. Mention is rendered with special formatting in the sent message + +### Data +- Group members loaded from `ChatModel.groupMembers` +- Mention metadata included in `ComposedMessage` + +--- + +## Source Files + +| File | Path | Struct/Class | Line | +|------|------|--------------|------| +| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L321](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) | +| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L14](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) | +| Image preview | [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | `ComposeImageView` | [L12](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) | +| File preview | [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | `ComposeFileView` | [L11](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) | +| Voice preview | [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | `ComposeVoiceView` | [L26](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) | +| Link preview | [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) | `ComposeLinkView` | [L13](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) | +| Audio recording | [`AudioRecPlay.swift`](../../Shared/Model/AudioRecPlay.swift) | `AudioRecorder` | [L14](../../Shared/Model/AudioRecPlay.swift#L14) | diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md new file mode 100644 index 0000000000..e755115827 --- /dev/null +++ b/apps/ios/spec/client/navigation.md @@ -0,0 +1,312 @@ +# SimpleX Chat iOS -- Navigation Architecture + +> Technical specification for the navigation stack, deep linking, sheet presentation, and call overlay. +> +> Related specs: [Chat List](chat-list.md) | [Chat View](chat-view.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ContentView.swift`](../../Shared/ContentView.swift) | [`NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | [`SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | [`OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | [`UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Root View -- ContentView](#2-root-view) +3. [Navigation Stack](#3-navigation-stack) +4. [Sheet Presentation](#4-sheet-presentation) +5. [Deep Linking](#5-deep-linking) +6. [Call Overlay](#6-call-overlay) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) + +--- + +## 1. Overview + +The app's navigation follows a hierarchical model with a single navigation stack rooted in `ContentView`. Modal sheets and full-screen overlays augment the primary navigation path. + +``` +SimpleXApp +└── ContentView (root) + ├── Authentication gate (LocalAuthView / SetAppPasscodeView) + ├── Onboarding flow (if first launch / migration) + ├── Main content + │ └── NavigationStack / NavigationView + │ ├── ChatListView (root of stack) + │ │ ├── ChatView (pushed) + │ │ │ ├── ChatInfoView / GroupChatInfoView (pushed) + │ │ │ └── ChatItemInfoView (pushed) + │ │ └── ContactConnectionInfo (pushed) + │ └── Settings views (pushed) + ├── Sheets (modal) + │ ├── UserPicker + │ ├── NewChatView + │ ├── WhatsNew / Notices + │ └── Settings sub-views + └── Overlays (always on top) + ├── Active call banner (when call active) + └── ActiveCallView (full-screen call) +``` + +--- + +## 2. Root View -- [`ContentView`](../../Shared/ContentView.swift#L24) + +**File**: [`Shared/ContentView.swift`](../../Shared/ContentView.swift) + +`ContentView` is the root view injected by `SimpleXApp`. It manages: + +### [Environment](../../Shared/ContentView.swift#L25-L37) +- `@EnvironmentObject var chatModel: ChatModel` +- `@EnvironmentObject var theme: AppTheme` +- `@Environment(\.scenePhase) var scenePhase` + +### [Key State](../../Shared/ContentView.swift#L35-L52) +| Property | Type | Purpose | +|----------|------|---------| +| [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) | `Bool` | Passed at init to avoid re-render timing issues | +| [`automaticAuthenticationAttempted`](../../Shared/ContentView.swift#L38) | `Bool` | Whether biometric auth was auto-attempted | +| [`waitingForOrPassedAuth`](../../Shared/ContentView.swift#L51) | `Bool` | Whether auth gate should show | +| [`chatListUserPickerSheet`](../../Shared/ContentView.swift#L52) | `UserPickerSheet?` | Active user picker sheet | + +### [View Selection Logic](../../Shared/ContentView.swift#L60-L80) + +```swift +// Simplified decision tree in ContentView.body: +if !prefPerformLA || accessAuthenticated { + contentView() // Main app content +} else { + lockButton() // Authentication required +} +``` + +The [`contentView()`](../../Shared/ContentView.swift#L169) function further decides: +- If `chatModel.onboardingStage != .onboardingComplete`: show [onboarding](../../Shared/ContentView.swift#L174) +- If `chatModel.migrationState != nil`: show migration UI +- Otherwise: show `ChatListView` in a navigation container + +--- + +## 3. Navigation Stack + +### iOS Version Compatibility + +**File**: [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) + +The app supports iOS 15+ and uses a compatibility wrapper ([`NavStackCompat`](../../Shared/Views/Helpers/NavStackCompat.swift#L11)): + +```swift +// NavStackCompat provides: +// - NavigationStack (iOS 16+): programmatic navigation via NavigationPath +// - NavigationView (iOS 15): classic NavigationLink-based navigation +``` + +### Primary Navigation Path + +``` +ChatListView + │ + ├─[tap chat]─→ ChatView + │ │ + │ ├─[tap info]─→ ChatInfoView (direct) + │ │ └─→ VerifyCodeView, etc. + │ │ + │ ├─[tap info]─→ GroupChatInfoView (group) + │ │ ├─→ GroupMemberInfoView + │ │ ├─→ GroupProfileView + │ │ └─→ GroupLinkView + │ │ + │ └─[tap message info]─→ ChatItemInfoView + │ + ├─[tap connection]─→ ContactConnectionInfo + │ + └─[settings]─→ SettingsView + ├─→ NotificationsView + ├─→ NetworkAndServers + ├─→ AppearanceSettings + ├─→ PrivacySettings + ├─→ DatabaseView + └─→ UserProfilesView +``` + +### Navigation Trigger + +Chat navigation is triggered by setting `ChatModel.chatId`: + +```swift +// In ChatListNavLink: +ItemsModel.shared.loadOpenChat(chatId) { + // This sets ChatModel.chatId = chatId after a 250ms delay + // allowing navigation animation to start smoothly +} +``` + +--- + +## 4. Sheet Presentation + +Sheets are presented modally on top of the navigation stack: + +| Sheet | Trigger | Content | +|-------|---------|---------| +| UserPicker | Tap user avatar in nav bar | User list, settings shortcuts | +| [`NewChatView`](../../Shared/Views/NewChat/NewChatView.swift#L78) | Tap FAB / "+" button | Create link, scan QR, paste link, new group | +| WhatsNew | App update detected | Release notes | +| AddGroupView | "New Group" action | Group creation wizard | +| ConnectDesktopView | Settings > Desktop | Remote desktop pairing | +| MigrateFromDevice | Settings > Migration | Device export | +| MigrateToDevice | Onboarding migration | Device import | +| [LocalAuthView](../../Shared/ContentView.swift#L95) | App foreground after background | Biometric/passcode auth | + +### Sheet Management + +Sheets use SwiftUI `.sheet(item:)` or `.sheet(isPresented:)` modifiers on `ContentView` and `ChatListView`. Some sheets use the centralized [`AppSheetState.shared`](../../Shared/ContentView.swift#L29) observable for coordination: + +```swift +class AppSheetState: ObservableObject { + static let shared = AppSheetState() + var scenePhaseActive: Bool = false + // ... sheet state coordination +} +``` + +--- + +## 5. Deep Linking + +### Notification Deep Link + +When the user taps a notification: + +1. `NtfManager.processNotificationResponse()` extracts the `chatId` from notification payload +2. If a different user: calls `changeActiveUser(userId:)` +3. Sets `ChatModel.chatId = chatId` to navigate to the conversation +4. If the app was in background: the notification response is stored in `ChatModel.notificationResponse` and processed when the app becomes active + +### [URL Deep Link](../../Shared/ContentView.swift#L281) + +SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Shared/ContentView.swift#L439): + +```swift +.onOpenURL { url in + if AppChatState.shared.value == .active { + chatModel.appOpenUrl = url // Process immediately + } else { + chatModel.appOpenUrlLater = url // Process when active + } +} +``` + +URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1169). + +### Call Deep Link + +Call invitations from notifications: +1. `NtfManager` detects `ntfActionAcceptCall` action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept)` +3. `ContentView` picks up the pending action and initiates the call + +--- + +## 6. Call Overlay + +The call UI overlays the entire app when a call is active: + +### [Call Banner](../../Shared/ContentView.swift#L203) + +When `ChatModel.activeCall != nil` and call is in connecting/active state: +- A banner appears at the top of ContentView (height: [`callTopPadding = 40`](../../Shared/ContentView.swift#L54)) +- Shows contact name, call duration, tap to return to full-screen call +- Main content is padded down to accommodate the banner + +### [Full-Screen Call View](../../Shared/ContentView.swift#L185) + +When `ChatModel.showCallView == true`: +- `ActiveCallView` covers the entire screen as a ZStack overlay +- Contains local/remote video, controls (mute, camera, speaker, end) +- PiP mode: `ChatModel.activeCallViewIsCollapsed` collapses to mini view +- Call view is always rendered on top of navigation and sheets + +```swift +// In ContentView.allViews(): +ZStack { + contentView() + .padding(.top, showCallArea ? callTopPadding : 0) + + if showCallArea, let call = chatModel.activeCall { + VStack { + activeCallInteractiveArea(call) + Spacer() + } + } + + if chatModel.showCallView, let call = chatModel.activeCall { + callView(call) // Full screen overlay + } +} +``` + +--- + +## 7. Authentication Gate + +### [Local Authentication](../../Shared/ContentView.swift#L359) + +When [`DEFAULT_PERFORM_LA`](../../Shared/ContentView.swift#L44) is enabled: + +1. App enters background: `chatModel.contentViewAccessAuthenticated = false` +2. App returns to foreground: `ContentView` shows [`lockButton()`](../../Shared/ContentView.swift#L238) instead of content +3. User taps lock button: [`LocalAuthView`](../../Shared/ContentView.swift#L95) presented +4. On successful auth: `chatModel.contentViewAccessAuthenticated = true`, content revealed + +### Authentication Methods +- Face ID / Touch ID (via `LocalAuthentication` framework) +- Custom numeric passcode +- Custom alphanumeric passcode + +### [Extended Authentication](../../Shared/ContentView.swift#L351) +- After successful auth, a grace period prevents re-auth for brief background/foreground cycles ([`unlockedRecently()`](../../Shared/ContentView.swift#L351)) +- [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) is computed at `ContentView.init` to avoid render-time race conditions +- The `enteredBackgroundAuthenticated` timestamp tracks when the app was last authenticated in background + +--- + +## 8. [Onboarding Flow](../../Shared/Views/Onboarding/OnboardingView.swift#L13) + +First-launch experience controlled by [`ChatModel.onboardingStage`](../../Shared/Views/Onboarding/OnboardingView.swift#L46): + +```swift +enum OnboardingStage: String, Identifiable { + case step1_SimpleXInfo // Welcome screen + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // Choose server operators + case step4_SetNotificationsMode // Set notification preferences + case onboardingComplete // Normal operation +} +``` + +Each stage is a dedicated view presented in place of `ChatListView` within [`ContentView`](../../Shared/ContentView.swift#L174). + +Migration state (`ChatModel.migrationState != nil`) takes precedence over onboarding. + +--- + +## Source Files + +| File | Path | +|------|------| +| Root view | [`Shared/ContentView.swift`](../../Shared/ContentView.swift) | +| App entry point | `Shared/SimpleXApp.swift` | +| Navigation compat | [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) | +| Chat list (nav root) | `Shared/Views/ChatList/ChatListView.swift` | +| Nav link wrapper | `Shared/Views/ChatList/ChatListNavLink.swift` | +| User picker | `Shared/Views/ChatList/UserPicker.swift` | +| New chat view | [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | +| Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | +| User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) | +| Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | +| Active call view | `Shared/Views/Call/ActiveCallView.swift` | +| Local auth view | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Notification manager | `Shared/Model/NtfManager.swift` | diff --git a/apps/ios/spec/database.md b/apps/ios/spec/database.md new file mode 100644 index 0000000000..9e5adfcb64 --- /dev/null +++ b/apps/ios/spec/database.md @@ -0,0 +1,298 @@ +# SimpleX Chat iOS -- Database & Storage + +**Source:** [`FileUtils.swift`](../SimpleXChat/FileUtils.swift) + +> Technical specification for the database architecture, encryption, file storage, and export/import functionality. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Product Overview](../product/README.md) + +--- + +## Table of Contents + +1. [Database Overview](#1-database-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [App Group Sharing](#8-app-group-sharing) + +--- + +## 1. Database Overview + +SimpleX Chat uses two SQLite databases managed entirely by the Haskell core. The iOS Swift layer never reads or writes directly to the databases -- all data access goes through the FFI command/response API. + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat DB | `_chat.db` | Messages, contacts, groups, user profiles, files, tags, preferences, call history | +| Agent DB | `_agent.db` | SMP agent connections, cryptographic keys, message queues, server state, XFTP chunks | + +Both databases are initialized and migrated via the C FFI function `chat_migrate_init_key()`, which applies pending migrations and returns a `chat_ctrl` pointer. + +--- + +## 2. Database Files & Paths + +### [Path Resolution](../SimpleXChat/FileUtils.swift#L63-L73) (FileUtils.swift) + +```swift +let DB_FILE_PREFIX = "simplex_v1" + +// Database path depends on container preference +func getAppDatabasePath() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX) + : getLegacyDatabasePath() +} + +// Full database file paths: +// Chat: {container}/simplex_v1_chat.db +// Agent: {container}/simplex_v1_agent.db +``` + +### [File Constants](../SimpleXChat/FileUtils.swift#L38-L44) + +```swift +let CHAT_DB: String = "_chat.db" +let AGENT_DB: String = "_agent.db" +private let CHAT_DB_BAK: String = "_chat.db.bak" +private let AGENT_DB_BAK: String = "_agent.db.bak" +``` + +### Container Locations + +See [`getDocumentsDirectory()`](../SimpleXChat/FileUtils.swift#L47) and [`getGroupContainerDirectory()`](../SimpleXChat/FileUtils.swift#L52). + +| Container | Path | Used When | +|-----------|------|-----------| +| App Group | `FileManager.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)` | Default (shared with NSE) | +| Documents | `FileManager.urls(for: .documentDirectory)` | Legacy installations | + +The container choice is stored in `dbContainerGroupDefault` (`GroupDefaults`). + +--- + +## 3. Haskell Store Modules + +All database operations are implemented in Haskell. Key store modules (paths relative to repo root): + +| Module | Path | Size | Description | +|--------|------|------|-------------| +| Messages | `src/Simplex/Chat/Store/Messages.hs` | ~178KB | Message CRUD, pagination, search, reactions, delivery receipts | +| Groups | `src/Simplex/Chat/Store/Groups.hs` | ~126KB | Group CRUD, member management, roles, links, invitations | +| Direct | `src/Simplex/Chat/Store/Direct.hs` | ~52KB | Direct contact connections, contact requests. See `createDirectChat` in `Store/Direct.hs` | +| Files | `src/Simplex/Chat/Store/Files.hs` | ~43KB | File transfer state, XFTP chunks, inline files | +| Profiles | `src/Simplex/Chat/Store/Profiles.hs` | ~42KB | User profiles, contact profiles, incognito profiles | +| Connections | `src/Simplex/Chat/Store/Connections.hs` | ~17KB | Connection lifecycle, queue management | + +### Data Model (key tables) + +``` +users -- User profiles (userId, displayName, fullName, image, ...) +contacts -- Contact records (contactId, userId, localDisplayName, ...) +groups -- Group records (groupId, userId, groupProfile, ...) +group_members -- Group membership (groupMemberId, groupId, memberId, role, ...) +messages -- Message records (messageId, chatItemId, msgBody, ...) +chat_items -- Chat items (chatItemId, chatType, chatId, content, ...) +files -- File transfer records (fileId, chatItemId, fileName, fileSize, ...) +connections -- SMP connections (connId, agentConnId, ...) +chat_tags -- User-defined chat tags +chat_tags_chats -- Tag-to-chat assignments +``` + +--- + +## 4. Migrations + +Database migrations are managed by the Haskell core. Migration files are located in: + +``` +src/Simplex/Chat/Store/SQLite/Migrations/ +``` + +Migrations are numbered sequentially starting from `M20220101` through `M20260122` (200+ migrations). Each migration is a Haskell module containing SQL statements for schema changes. + +The migration process: +1. `chat_migrate_init_key()` is called with the database path +2. Haskell reads the current schema version from the database +3. Pending migrations are applied in order +4. If migration fails, the function returns an error string (not a `chat_ctrl`) +5. On success, a `chat_ctrl` pointer is returned + +Migration results are decoded in Swift as `DBMigrationResult`: +- `.ok` -- migrations applied successfully +- `.invalidConfirmation` -- migration requires user confirmation +- `.errorNotADatabase(dbFile:)` -- file is not a valid SQLite database +- `.errorMigration(dbFile:, migrationError:)` -- migration failed +- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration +- `.errorKeychain` -- keychain access failed +- `.unknown(json:)` -- unrecognized response + +--- + +## 5. Database Encryption + +### Encryption Configuration + +Database encryption uses SQLCipher (AES-256) and is managed through the API: + +```swift +// Set or change encryption +ChatCommand.apiStorageEncryption(config: DBEncryptionConfig) + +// Test if a key is correct +ChatCommand.testStorageEncryption(key: String) +``` + +`DBEncryptionConfig` contains: +- `currentKey: String` -- current encryption key (empty if unencrypted) +- `newKey: String` -- new encryption key (empty to decrypt) + +### Key Storage + +The encryption key is stored in the iOS Keychain via `kcDatabasePassword`: +- On first launch with encryption, the key is generated and stored +- The `storeDBPassphraseGroupDefault` flag controls whether the key is auto-stored +- If the user opts out of auto-storage, they must enter the key on each launch + +### UI + +- [`DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) -- Encryption settings UI +- [`DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) -- Database management UI (size, export, import, encryption) + +--- + +## 6. File Storage + +### Directory Structure + +``` +{App Container}/ +├── Documents/ +│ ├── app_files/ -- Downloaded and sent files +│ ├── temp_files/ -- Temporary files during transfer +│ └── assets/wallpapers/ -- Custom wallpaper images +├── {App Group Container}/ +│ ├── simplex_v1_chat.db -- Chat database +│ ├── simplex_v1_agent.db -- Agent database +│ └── ... +``` + +### [File Size Constants](../SimpleXChat/FileUtils.swift#L18-L36) (FileUtils.swift) + +```swift +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB -- inline image compression target +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive images +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive voice +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB -- auto-receive video +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB -- max XFTP transfer +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB -- max SMP inline +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit for local files +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes +``` + +### CryptoFile (Encrypted File Storage) + +When `apiSetEncryptLocalFiles(enable: true)` is set, files stored on device are AES-encrypted: + +- Encryption/decryption uses `chat_encrypt_file` / `chat_decrypt_file` C FFI functions +- Each file gets a unique key and nonce stored alongside the file reference +- The `CryptoFile` type wraps `(filePath: String, cryptoArgs: CryptoFileArgs?)` where `CryptoFileArgs` contains `(fileKey: String, fileNonce: String)` + +### [File Path Helpers](../SimpleXChat/FileUtils.swift#L219-L221) + +```swift +public func getDocumentsDirectory() -> URL // Standard documents dir +public func getGroupContainerDirectory() -> URL // App group container +func getAppFilesDirectory() -> URL // {appDir}/app_files/ +func getTempFilesDirectory() -> URL // {appDir}/temp_files/ +func getWallpaperDirectory() -> URL // {appDir}/assets/wallpapers/ +``` + +See also [`saveFile()`](../SimpleXChat/FileUtils.swift#L226), [`removeFile()`](../SimpleXChat/FileUtils.swift#L243), and [`getMaxFileSize()`](../SimpleXChat/FileUtils.swift#L276). + +### [Cleanup](../SimpleXChat/FileUtils.swift#L86-L116) + +- Files are deleted when their associated `ChatItem` is deleted. See [`cleanupFile()`](../SimpleXChat/FileUtils.swift#L267) and [`cleanupDirectFile()`](../SimpleXChat/FileUtils.swift#L260). +- Timed message expiry triggers file deletion +- [`deleteAppDatabaseAndFiles()`](../SimpleXChat/FileUtils.swift#L86) removes all databases, files, temp files, and wallpapers +- [`deleteAppFiles()`](../SimpleXChat/FileUtils.swift#L108) removes only the files directory (preserving databases) + +--- + +## 7. Export & Import + +### Export + +```swift +ChatCommand.apiExportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveExported(archiveErrors: [ArchiveError]) +``` + +`ArchiveConfig` specifies: +- `archivePath: String` -- destination path for the archive +- `disableCompression: Bool?` -- optional flag to skip compression + +The archive contains both databases and optionally files. The Haskell core handles the actual export, creating a ZIP archive. + +### Import + +```swift +ChatCommand.apiImportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveImported(archiveErrors: [ArchiveError]) +``` + +Import replaces the current databases with the archive contents. The app must be restarted after import. + +### Archive Errors + +`ArchiveError` is an array returned with both export and import results, listing any non-fatal issues encountered (e.g., missing files, corrupt entries). + +--- + +## 8. App Group Sharing + +### Shared Access Model + +The main app and NSE share database access through the iOS App Group container: + +``` +Main App ──┐ + ├── {App Group}/simplex_v1_chat.db + ├── {App Group}/simplex_v1_agent.db +NSE ────────┘ +``` + +### Coordination + +- Both processes can initialize their own `chat_ctrl` instance pointing to the same database files +- SQLite WAL mode allows concurrent reads +- Write coordination uses `chat_close_store` / `chat_reopen_store` to manage database locks +- The main app suspends its chat controller when entering background, allowing NSE to access the database +- NSE is short-lived (~30 seconds per notification) and releases its lock quickly + +### App State Communication + +The `appStateGroupDefault` in `GroupDefaults` communicates app state between main app and NSE: +- `.active` -- main app is in foreground +- `.suspended` -- main app is in background +- `.stopped` -- main app is terminated + +The NSE checks this flag to determine whether to process notifications (it avoids processing if the main app is active). + +--- + +## Source Files + +| File | Path | +|------|------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | +| Database management UI | [`Shared/Views/Database/DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) | +| Encryption settings UI | [`Shared/Views/Database/DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) | +| C FFI (migration, file ops) | `SimpleXChat/SimpleX.h` | +| Haskell store root | `../../src/Simplex/Chat/Store/` | +| Haskell migrations | `../../src/Simplex/Chat/Store/SQLite/Migrations/` | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md new file mode 100644 index 0000000000..9593419b87 --- /dev/null +++ b/apps/ios/spec/impact.md @@ -0,0 +1,114 @@ +# SimpleX Chat iOS -- Impact Graph + +> Source file → product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Derived from [CODE.md](../CODE.md) Document Map and [product/concepts.md](../product/concepts.md). + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Push Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | + +--- + +## 1. Swift Source Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access | +| Shared/SimpleXApp.swift | PC1 through PC30 | High | App entry point — initialization affects everything | +| Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | +| Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11 | High | Message composition — send path for all messages | +| Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | +| Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | +| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| Shared/Views/NewChat/NewChatView.swift | PC12 | High | New connection creation — onramp for all contacts | +| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | +| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | +| Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle | +| Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management | +| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI | +| Shared/Views/UserSettings/NetworkAndServers/ | PC25 | High | Server configuration — affects connectivity | +| Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management | +| Shared/Views/Onboarding/ | PC1 | Medium | First-time setup — affects initial state | +| Shared/Views/LocalAuth/ | PC22 | Medium | App lock functionality | +| Shared/Views/Database/ | PC23, PC26 | High | Database encryption and export | +| Shared/Views/Migration/ | PC26 | High | Device migration — data portability | +| Shared/Model/ChatModel.swift | PC1 through PC30 | High | Central state — all features depend on it | +| Shared/Model/SimpleXAPI.swift | PC1 through PC30 | High | FFI bridge — all commands flow through here | +| Shared/Model/AppAPITypes.swift | PC1 through PC30 | High | Command/response types — all API communication | +| Shared/Model/NtfManager.swift | PC18 | High | Notification delivery | +| Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling | +| Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine | +| SimpleXChat/ChatTypes.swift | PC1 through PC30 | High | Core data types — all features use them | +| SimpleXChat/APITypes.swift | PC1 through PC30 | High | API result types and error handling | +| SimpleXChat/CallTypes.swift | PC17 | Medium | Call-specific data types | +| SimpleXChat/FileUtils.swift | PC10, PC23, PC26 | Medium | File paths and encryption utilities | +| SimpleXChat/Notifications.swift | PC18 | Medium | Notification type definitions | +| SimpleX NSE/NotificationService.swift | PC18 | High | Push notification decryption and display | + +--- + +## 2. Haskell Core Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| src/Simplex/Chat/Controller.hs | PC1 through PC30 | High | Command processor — all API commands | +| src/Simplex/Chat/Types.hs | PC1 through PC30 | High | Core data types shared across all features | +| src/Simplex/Chat/Core.hs | PC1 through PC30 | High | Chat engine lifecycle | +| src/Simplex/Chat/Protocol.hs | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| src/Simplex/Chat/Messages.hs | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| src/Simplex/Chat/Messages/CIContent.hs | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| src/Simplex/Chat/Call.hs | PC17 | Medium | Call signaling types | +| src/Simplex/Chat/Files.hs | PC10 | Medium | File transfer orchestration | +| src/Simplex/Chat/Store/Messages.hs | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| src/Simplex/Chat/Store/Groups.hs | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| src/Simplex/Chat/Store/Direct.hs | PC2, PC12, PC13 | High | Contact persistence | +| src/Simplex/Chat/Store/Files.hs | PC10 | Medium | File transfer persistence | +| src/Simplex/Chat/Store/Profiles.hs | PC19, PC21 | Medium | User profile persistence | +| src/Simplex/Chat/Store/Connections.hs | PC2, PC12 | High | Connection persistence and entity resolution | +| src/Simplex/Chat/Archive.hs | PC26 | Medium | Database export/import for migration | +| src/Simplex/Chat/ProfileGenerator.hs | PC20 | Low | Random profile generation for incognito | +| src/Simplex/Chat/Remote.hs | PC27 | Medium | Remote desktop protocol handler | +| src/Simplex/Chat/Remote/Types.hs | PC27 | Low | Remote desktop data types | +| src/Simplex/Chat/Types/UITheme.hs | PC24 | Low | Theme data types for UI customization | +| src/Simplex/Chat/Types/Preferences.hs | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| src/Simplex/Chat/Types/Shared.hs | PC3, PC16 | Medium | Shared types including GroupMemberRole | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md new file mode 100644 index 0000000000..6a1d89f6a3 --- /dev/null +++ b/apps/ios/spec/services/calls.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- WebRTC Calling Service + +> Technical specification for the calling system: CallController, WebRTCClient, CallKit integration, and signaling via SMP. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Notifications](notifications.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`CallController.swift`](../../Shared/Views/Call/CallController.swift) | [`WebRTCClient.swift`](../../Shared/Views/Call/WebRTCClient.swift) | [`ActiveCallView.swift`](../../Shared/Views/Call/ActiveCallView.swift) | [`CallTypes.swift`](../../SimpleXChat/CallTypes.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [CallController](#2-callcontroller) +3. [WebRTCClient](#3-webrtcclient) +4. [Call Flow via SMP](#4-call-flow-via-smp) +5. [CallKit Integration](#5-callkit-integration) +6. [CallKit-Free Mode](#6-callkit-free-mode) +7. [Audio Routing](#7-audio-routing) +8. [Key Types](#8-key-types) +9. [ActiveCallView](#9-activecallview) + +--- + +## 1. Overview + +SimpleX Chat provides end-to-end encrypted audio and video calls using WebRTC. The unique aspect is that all call signaling (SDP offers/answers, ICE candidates) is transmitted through the same encrypted SMP messaging channels used for chat, eliminating the need for a separate signaling server. + +``` +Caller SMP Relay Callee + │ │ │ + ├─ apiSendCallInvitation ──────→│──── push/event ──────→│ + │ │ │ + │ │←── apiSendCallOffer ──┤ + │←── ChatEvent.callOffer ───────│ │ + │ │ │ + ├─ apiSendCallAnswer ──────────→│──── callAnswer ──────→│ + │ │ │ + │←── callExtraInfo (ICE) ───────│←── apiSendCallExtraInfo│ + ├─ apiSendCallExtraInfo ───────→│──── callExtraInfo ───→│ + │ │ │ + │◄══════════ WebRTC P2P Media Stream ═══════════════════►│ + │ │ │ + ├─ apiEndCall ─────────────────→│──── callEnded ───────→│ +``` + +--- + +## [2. CallController](../../Shared/Views/Call/CallController.swift#L19-L449) + +**File**: `Shared/Views/Call/CallController.swift` + +Central call coordinator that bridges SimpleX call protocol with iOS CallKit (or non-CallKit fallback). + +### [Class Definition](../../Shared/Views/Call/CallController.swift#L19-L48) + +```swift +class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { + static let shared = CallController() + static let isInChina = SKStorefront().countryCode == "CHN" + static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } + + private let provider: CXProvider // CallKit provider + private let controller: CXCallController // CallKit controller + private let callManager: CallManager // Internal call state + private let registry: PKPushRegistry // VoIP push registration + + @Published var activeCallInvitation: RcvCallInvitation? + var shouldSuspendChat: Bool = false + var fulfillOnConnect: CXAnswerCallAction? = nil +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`reportNewIncomingCall()`](../../Shared/Views/Call/CallController.swift#L287) | Reports incoming call to CallKit for native UI | L287 | +| [`reportOutgoingCall()`](../../Shared/Views/Call/CallController.swift#L328) | Reports outgoing call to CallKit | L328 | +| [`provider(_:perform: CXAnswerCallAction)`](../../Shared/Views/Call/CallController.swift#L66) | Handles user answering via CallKit UI | L66 | +| [`provider(_:perform: CXEndCallAction)`](../../Shared/Views/Call/CallController.swift#L96) | Handles user ending via CallKit UI | L96 | +| [`provider(_:perform: CXStartCallAction)`](../../Shared/Views/Call/CallController.swift#L55) | Handles outgoing call start | L55 | +| [`pushRegistry(_:didReceiveIncomingPushWith:)`](../../Shared/Views/Call/CallController.swift#L202) | Handles VoIP push tokens | L202 | +| [`hasActiveCalls()`](../../Shared/Views/Call/CallController.swift#L435) | Checks if any calls are active | L435 | + +### Call Manager (internal) + +`CallManager` tracks call state internally: +- Maps call UUIDs to `Call` objects +- Handles call state transitions +- Coordinates between CallKit actions and SimpleX API calls + +--- + +## [3. WebRTCClient](../../Shared/Views/Call/WebRTCClient.swift#L13-L676) + +**File**: `Shared/Views/Call/WebRTCClient.swift` (~49KB) + +Manages the WebRTC peer connection, media streams, and data channels. + +### Responsibilities + +- Creates and configures `RTCPeerConnection` +- Manages local audio/video capture (`RTCCameraVideoCapturer`, `RTCAudioTrack`) +- Handles SDP offer/answer creation and application +- Processes ICE candidates +- Manages media stream encryption + +### Key Operations + +| Operation | Description | Line | +|-----------|-------------|------| +| [`initializeCall`](../../Shared/Views/Call/WebRTCClient.swift#L93) | Sets up peer connection, tracks, encryption | L93 | +| [`createPeerConnection`](../../Shared/Views/Call/WebRTCClient.swift#L139) | Creates and configures RTCPeerConnection | L139 | +| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | +| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | +| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | +| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | +| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | +| [`endCall`](../../Shared/Views/Call/WebRTCClient.swift#L645) | Tears down connection and tracks | L645 | +| [`setupEncryptionForLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L503) | Sets up frame encryption for local media tracks | L503 | + +### [Additional Encryption](../../Shared/Views/Call/WebRTCClient.swift#L513-L546) + +Beyond WebRTC's built-in SRTP encryption, SimpleX adds an extra encryption layer: +- A shared key from the E2E SMP channel is used +- Applied via `chat_encrypt_media` / `chat_decrypt_media` C FFI functions +- Each media frame is encrypted/decrypted with this additional key +- Provides defense-in-depth even if SRTP is compromised + +--- + +## 4. Call Flow via SMP + +All call signaling travels through the same encrypted SMP message channels used for chat. No separate signaling server is needed. + +### Outgoing Call (Caller Side) + +``` +1. User initiates call + └── apiSendCallInvitation(contact:, callType:) + └── Sends CallInvitation via SMP to contact + +2. Callee accepts, sends SDP offer + └── ChatEvent.callOffer received + └── WebRTCClient creates answer + └── apiSendCallAnswer(contact:, answer:) + +3. ICE candidates exchanged + └── ChatEvent.callExtraInfo received → WebRTCClient.addIceCandidate() + └── WebRTCClient generates candidates → apiSendCallExtraInfo(contact:, extraInfo:) + +4. P2P connection established + └── Media streams flowing + +5. End call + └── apiEndCall(contact:) +``` + +### Incoming Call (Callee Side) + +``` +1. ChatEvent.callInvitation received (or push notification) + └── CallController reports to CallKit (or shows in-app notification) + +2. User accepts + └── WebRTCClient creates SDP offer (callee creates offer in SimpleX protocol) + └── apiSendCallOffer(contact:, callOffer:) + +3. Caller sends answer + └── ChatEvent.callAnswer received + └── WebRTCClient.setRemoteDescription(answer) + +4. ICE candidates exchanged (same as above) + +5. P2P connection established +``` + +### API Commands + +| Command | Direction | Purpose | +|---------|-----------|---------| +| `apiSendCallInvitation(contact:, callType:)` | Caller -> Callee | Initiate call | +| `apiRejectCall(contact:)` | Callee -> Caller | Reject call | +| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | +| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiEndCall(contact:)` | Either | End call | +| `apiGetCallInvitations` | -- | Get pending invitations | +| `apiCallStatus(contact:, callStatus:)` | -- | Report status change | + +--- + +## [5. CallKit Integration](../../Shared/Views/Call/CallController.swift#L24-L155) + +CallKit provides the native iOS incoming call experience (lock screen UI, call history, system call handling). + +### [CXProvider Configuration](../../Shared/Views/Call/CallController.swift#L24-L37) + +```swift +let configuration = CXProviderConfiguration() +configuration.supportsVideo = true +configuration.supportedHandleTypes = [.generic] +configuration.includesCallsInRecents = UserDefaults.standard.bool( + forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS +) +configuration.maximumCallGroups = 1 +configuration.maximumCallsPerCallGroup = 1 +configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() +``` + +### [VoIP Push (PKPushRegistry)](../../Shared/Views/Call/CallController.swift#L207-L284) + +CallKit requires VoIP push for incoming calls on locked device: +- `PKPushRegistry` registers for `.voIP` push type +- VoIP push token is separate from regular APNs token +- When VoIP push received, **must** report an incoming call to CallKit within the callback (iOS requirement) + +### CallKit Actions + +| CXAction | Handler | Description | Line | +|----------|---------|-------------|------| +| `CXStartCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L55) | User starts outgoing call | L55 | +| `CXAnswerCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L66) | User answers incoming call from CallKit UI | L66 | +| `CXEndCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L96) | User ends call from CallKit UI | L96 | +| `CXSetMutedCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L112) | User mutes from CallKit UI | L112 | + +### [Lock Screen Answer](../../Shared/Views/Call/CallController.swift#L66-L94) + +When answering from the lock screen: +1. `CXAnswerCallAction` fires +2. CallController waits for chat to be ready ([`waitUntilChatStarted(timeoutMs: 30_000)`](../../Shared/Views/Call/CallController.swift#L183)) +3. WebRTC connection established +4. `fulfillOnConnect` action is fulfilled only when WebRTC reaches connected state (required for audio to work on lock screen) + +--- + +## [6. CallKit-Free Mode](../../Shared/Views/Call/CallController.swift#L21-L22) + +In regions where CallKit is unavailable (e.g., China, determined by `SKStorefront.countryCode == "CHN"`), the app falls back to in-app notifications: + +```swift +static let isInChina = SKStorefront().countryCode == "CHN" +static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } +``` + +### Non-CallKit Behavior +- Incoming calls shown as in-app banners (via `CallController.activeCallInvitation`) +- No lock screen call UI +- No system call integration +- User can also manually disable CallKit via settings (`callKitEnabledGroupDefault`) + +--- + +## [7. Audio Routing](../../Shared/Views/Call/WebRTCClient.swift#L907-L1005) + +### [AVAudioSession Management](../../Shared/Views/Call/WebRTCClient.swift#L907-L950) + +Audio routing is managed through `AVAudioSession`: +- **Receiver**: Default for audio-only calls (ear speaker) +- **Speaker**: For video calls or when user toggles speaker +- **Bluetooth**: Detected and used when available +- **Headphones**: Detected and used when connected + +### Route Change Handling + +The `WebRTCClient` observes `AVAudioSession.routeChangeNotification` to handle: +- Bluetooth device connection/disconnection +- Headphone plug/unplug +- Speaker/receiver toggle + +--- + +## [8. Key Types](../../SimpleXChat/CallTypes.swift#L1-L115) + +### [RcvCallInvitation](../../SimpleXChat/CallTypes.swift#L45-L71) + +```swift +struct RcvCallInvitation { + var user: User + var contact: Contact + var callType: CallType + var sharedKey: String? // Optional E2E encryption key + var callUUID: String? + var callTs: Date +} +``` + +### [CallType](../../SimpleXChat/CallTypes.swift#L74-L82) + +```swift +struct CallType { + var media: CallMediaType // .audio or .video + var capabilities: CallCapabilities +} + +enum CallMediaType: String { + case audio + case video +} +``` + +### [WebRTCCallOffer](../../SimpleXChat/CallTypes.swift#L14-L22) / [WebRTCSession](../../SimpleXChat/CallTypes.swift#L25-L33) + +```swift +struct WebRTCCallOffer { + var callType: CallType + var rtcSession: WebRTCSession +} + +struct WebRTCSession { + var rtcSession: String // SDP string + var rtcIceCandidates: String // ICE candidates JSON +} +``` + +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) + +```swift +struct WebRTCExtraInfo { + var rtcIceCandidates: String // Additional ICE candidates +} +``` + +### Call (Active Call State) + +Stored in `ChatModel.activeCall`: +- Contact reference +- Call UUID +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Media type +- WebRTCClient reference + +--- + +## [9. ActiveCallView](../../Shared/Views/Call/ActiveCallView.swift#L16-L285) + +**File**: `Shared/Views/Call/ActiveCallView.swift` + +Full-screen call UI when `ChatModel.showCallView == true`: + +### UI Elements +- Remote video (full screen background) +- Local video (PiP corner, draggable) +- Contact name and call duration +- Control buttons: mute, camera toggle, speaker toggle, camera flip, end call +- Minimize button (collapses to banner) + +### [ActiveCallOverlay](../../Shared/Views/Call/ActiveCallView.swift#L288-L522) + +| Control | Method | Line | +|---------|--------|------| +| Audio call info | [`audioCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L357) | L357 | +| Video call info | [`videoCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L377) | L377 | +| End call | [`endCallButton`](../../Shared/Views/Call/ActiveCallView.swift#L407) | L407 | +| Mute toggle | [`toggleMicButton`](../../Shared/Views/Call/ActiveCallView.swift#L418) | L418 | +| Audio device | [`audioDeviceButton`](../../Shared/Views/Call/ActiveCallView.swift#L428) | L428 | +| Speaker toggle | [`toggleSpeakerButton`](../../Shared/Views/Call/ActiveCallView.swift#L452) | L452 | +| Camera toggle | [`toggleCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L464) | L464 | +| Flip camera | [`flipCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L475) | L475 | + +### PiP (Picture-in-Picture) + +When `ChatModel.activeCallViewIsCollapsed == true`: +- Call view collapses to a small floating overlay +- User can return to full-screen by tapping the banner +- Navigation continues normally underneath + +--- + +## Source Files + +| File | Path | Lines | +|------|------|-------| +| [Call controller](../../Shared/Views/Call/CallController.swift) | `Shared/Views/Call/CallController.swift` | 449 | +| [WebRTC client](../../Shared/Views/Call/WebRTCClient.swift) | `Shared/Views/Call/WebRTCClient.swift` | 1139 | +| [Active call UI](../../Shared/Views/Call/ActiveCallView.swift) | `Shared/Views/Call/ActiveCallView.swift` | 528 | +| WebRTC helpers | `Shared/Views/Call/WebRTC.swift` | | +| [Call types (Swift)](../../SimpleXChat/CallTypes.swift) | `SimpleXChat/CallTypes.swift` | 115 | +| Call types (Haskell) | `../../src/Simplex/Chat/Call.hs` | | diff --git a/apps/ios/spec/services/files.md b/apps/ios/spec/services/files.md new file mode 100644 index 0000000000..7e1f8a2ad1 --- /dev/null +++ b/apps/ios/spec/services/files.md @@ -0,0 +1,368 @@ +# SimpleX Chat iOS -- File Transfer Service + +> Technical specification for file transfer: inline/XFTP protocols, auto-receive thresholds, CryptoFile encryption, and file constants. +> +> Related specs: [Compose Module](../client/compose.md) | [Chat View](../client/chat-view.md) | [API Reference](../api.md) | [Database](../database.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | [`ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | [`AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Transfer Methods](#2-transfer-methods) +3. [Auto-Receive Thresholds](#3-auto-receive-thresholds) +4. [File Size Constants](#4-file-size-constants) +5. [Image Handling](#5-image-handling) +6. [Voice Messages](#6-voice-messages) +7. [CryptoFile -- At-Rest Encryption](#7-cryptofile) +8. [File Storage Paths](#8-file-storage-paths) +9. [File Lifecycle](#9-file-lifecycle) +10. [API Commands](#10-api-commands) + +--- + +## 1. Overview + +SimpleX Chat supports two file transfer methods depending on file size: + +``` +File ≤ 255KB (inline) +├── Base64 encoded directly in SMP message +├── Single message delivery +└── No extra server infrastructure needed + +File > 255KB up to 1GB (XFTP) +├── Encrypted and chunked +├── Uploaded to XFTP relay servers +├── Recipient downloads chunks from relays +└── Files auto-deleted from relays after download or expiry +``` + +All files are end-to-end encrypted. The XFTP protocol adds a second encryption layer on top of the SMP channel encryption. + +--- + +## 2. Transfer Methods + +### Inline Transfer + +- Files up to [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB) are base64-encoded and embedded directly in the SMP message body +- No additional protocol or server needed +- Delivered with the same reliability guarantees as regular messages +- Used primarily for compressed images + +### XFTP Transfer + +For files exceeding the inline threshold (up to [`MAX_FILE_SIZE_XFTP`](../../SimpleXChat/FileUtils.swift#L30) = 1GB): + +1. **Sender side**: + - File is AES-encrypted with a random key + - Encrypted file is split into chunks + - Chunks are uploaded to one or more XFTP relay servers + - File metadata (key, chunk locations) sent to recipient via SMP message + +2. **Recipient side**: + - Receives file metadata via SMP + - Downloads chunks from XFTP relays + - Reassembles and decrypts the file + +3. **Cleanup**: + - XFTP relays delete chunks after download or after expiry period + - No persistent storage on relays + +### SMP Transfer (legacy) + +[`MAX_FILE_SIZE_SMP`](../../SimpleXChat/FileUtils.swift#L34) (8MB) exists as a constant for larger inline transfers through SMP, used in specific scenarios. + +--- + +## 3. Auto-Receive Thresholds + +Files below certain size thresholds are automatically accepted and downloaded without user confirmation: + +| Media Type | Auto-Receive Threshold | Constant | Line | +|------------|----------------------|----------|------| +| Images | 510 KB | [`MAX_IMAGE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L21) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| Voice messages | 510 KB | [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| Video | 1023 KB | [`MAX_VIDEO_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L27) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| Other files | Not auto-received | Requires manual acceptance | -- | + +### Behavior + +- When a message with a file attachment arrives: + 1. Check if file size is below the auto-receive threshold for its type + 2. If below: automatically call [`setFileToReceive(fileId:, userApprovedRelays:, encrypted:)`](../../Shared/Model/AppAPITypes.swift#L168) followed by download + 3. If above: show download button in chat item, wait for user action + 4. User manually triggers download via [`receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:)`](../../Shared/Model/AppAPITypes.swift#L167) + +### Relay Approval + +`userApprovedRelays` parameter: when the file is hosted on relays not in the user's configured server list, the user is asked for confirmation before connecting to unknown relays. + +--- + +## [4. File Size Constants](../../SimpleXChat/FileUtils.swift#L18) + +Defined in [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift): + +| Constant | Value | Line | +|----------|-------|------| +| `MAX_IMAGE_SIZE` | 261,120 (255 KB) | [L18](../../SimpleXChat/FileUtils.swift#L18) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 (1023 KB) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 (1 GB) | [L30](../../SimpleXChat/FileUtils.swift#L30) | +| `MAX_FILE_SIZE_LOCAL` | Int64.max (no limit) | [L32](../../SimpleXChat/FileUtils.swift#L32) | +| `MAX_FILE_SIZE_SMP` | 8,000,000 (~7.6 MB) | [L34](../../SimpleXChat/FileUtils.swift#L34) | +| `MAX_VOICE_MESSAGE_LENGTH` | 300 s (5 min) | [L36](../../SimpleXChat/FileUtils.swift#L36) | + +```swift +// Image compression target for inline transfer +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB + +// Auto-receive thresholds +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB + +// Transfer method limits +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit (local notes) + +// Voice message constraints +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes (300 seconds) +``` + +--- + +## 5. Image Handling + +### Compression Pipeline + +1. User selects image (camera or photo library) +2. Image is compressed to fit within [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB): + - Progressive JPEG compression with decreasing quality + - Resize if dimensions are too large +3. Compressed image is base64-encoded into the message content +4. For larger images that cannot compress to 255KB: sent via XFTP + +### Display + +- `CIImageView` renders images in chat bubbles with aspect-fit sizing +- Tapping opens `FullScreenMediaView` with zoom/pan/share capabilities +- Thumbnail is displayed immediately; full-size loaded on demand for XFTP images + +### Animated Images + +- GIFs are handled by `AnimatedImageView` +- Displayed inline with animation support + +--- + +## 6. Voice Messages + +### Recording + +1. `ComposeVoiceView` manages the recording UI +2. `AudioRecPlay` handles `AVAudioRecorder` lifecycle +3. Recorded in compressed audio format +4. Maximum duration: [`MAX_VOICE_MESSAGE_LENGTH`](../../SimpleXChat/FileUtils.swift#L36) = 300 seconds (5 minutes) +5. Waveform data extracted for visualization + +### Transfer + +- Voice files up to [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) (510KB) are auto-received +- Larger voice files follow standard file transfer flow +- Voice messages include waveform metadata for UI rendering + +### Playback + +- `CIVoiceView` / `FramedCIVoiceView` render voice messages +- Shows waveform visualization and play/pause control +- `ChatModel.stopPreviousRecPlay` ensures only one audio source plays at a time +- Playback position and progress tracked + +--- + +## [7. CryptoFile -- At-Rest Encryption](../../SimpleXChat/ChatTypes.swift#L4241) + +When [`apiSetEncryptLocalFiles(enable: true)`](../../Shared/Model/SimpleXAPI.swift#L384) is configured, files stored on the device are AES-encrypted. + +### [`CryptoFile`](../../SimpleXChat/ChatTypes.swift#L4241) Type + +```swift +struct CryptoFile { + var filePath: String + var cryptoArgs: CryptoFileArgs? // nil = unencrypted +} + +struct CryptoFileArgs { + var fileKey: String // AES encryption key + var fileNonce: String // AES nonce/IV +} +``` + +> Defined in [`ChatTypes.swift` L4241](../../SimpleXChat/ChatTypes.swift#L4241) (`CryptoFile`) and [L4289](../../SimpleXChat/ChatTypes.swift#L4289) (`CryptoFileArgs`). + +### Encryption Operations (C FFI) + +Implemented in [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`writeCryptoFile`](../../SimpleXChat/CryptoFile.swift#L18) | Write encrypted file, returns `CryptoFileArgs` | [L18](../../SimpleXChat/CryptoFile.swift#L18) | +| [`readCryptoFile`](../../SimpleXChat/CryptoFile.swift#L31) | Read and decrypt file, returns `Data` | [L31](../../SimpleXChat/CryptoFile.swift#L31) | +| [`encryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L54) | Encrypt existing file to new path | [L54](../../SimpleXChat/CryptoFile.swift#L54) | +| [`decryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L66) | Decrypt file to new path | [L66](../../SimpleXChat/CryptoFile.swift#L66) | + +### Storage + +- Encrypted files stored alongside unencrypted files in `Documents/files/` +- The `CryptoFileArgs` (key + nonce) are stored in the Haskell database, not on the filesystem +- Toggle via privacy settings: [`apiSetEncryptLocalFiles(enable:)`](../../Shared/Model/SimpleXAPI.swift#L384) + +--- + +## [8. File Storage Paths](../../SimpleXChat/FileUtils.swift#L199) + +### Directory Structure + +| Function | Path | Line | +|----------|------|------| +| [`getAppFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L208) | `Documents/files/` | [L208](../../SimpleXChat/FileUtils.swift#L208) | +| [`getTempFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L199) | `Documents/temp_files/` | [L199](../../SimpleXChat/FileUtils.swift#L199) | +| [`getWallpaperDirectory()`](../../SimpleXChat/FileUtils.swift#L217) | `Documents/wallpapers/` | [L217](../../SimpleXChat/FileUtils.swift#L217) | +| [`getAppFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L212) | `Documents/files/{filename}` | [L212](../../SimpleXChat/FileUtils.swift#L212) | +| [`getWallpaperFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L221) | `Documents/wallpapers/{filename}` | [L221](../../SimpleXChat/FileUtils.swift#L221) | + +```swift +func getAppFilesDirectory() -> URL // Documents/files/ +func getTempFilesDirectory() -> URL // Documents/temp_files/ +func getWallpaperDirectory() -> URL // Documents/wallpapers/ +``` + +### Path Management + +- Downloaded files: `Documents/files/{filename}` +- Temporary files during transfer: `Documents/temp_files/` +- Wallpaper images: `Documents/wallpapers/` +- File paths are set via [`apiSetAppFilePaths(filesFolder:, tempFolder:, assetsFolder:)`](../../Shared/Model/SimpleXAPI.swift#L377) at startup + +--- + +## 9. File Lifecycle + +### Sending + +``` +1. User selects file/image/video in compose +2. ComposeView creates ComposedMessage with file reference +3. apiSendMessages() → Haskell core processes: + a. File ≤ inline threshold: base64 encode into message + b. File > inline threshold: start XFTP upload +4. Upload events: + - ChatEvent.sndFileStart + - ChatEvent.sndFileProgressXFTP (periodic progress) + - ChatEvent.sndFileCompleteXFTP (upload done) + - ChatEvent.sndFileError (on failure) +``` + +### Receiving + +``` +1. Message with file attachment arrives +2. Auto-receive check: + a. Below threshold: automatic download starts + b. Above threshold: user sees download button +3. User triggers download (or auto-triggered): + - receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:) +4. Download events: + - ChatEvent.rcvFileStart + - ChatEvent.rcvFileProgressXFTP (periodic progress) + - ChatEvent.rcvFileComplete (download done) + - ChatEvent.rcvFileError (on failure) + - ChatEvent.rcvFileSndCancelled (sender cancelled) +``` + +### Cancellation + +```swift +ChatCommand.cancelFile(fileId: Int64) +``` + +Cancels an in-progress upload or download. For XFTP transfers, also requests chunk deletion from relays. + +### Cleanup + +| Function | Purpose | Line | +|----------|---------|------| +| [`cleanupFile(_:)`](../../SimpleXChat/FileUtils.swift#L267) | Remove file associated with a chat item | [L267](../../SimpleXChat/FileUtils.swift#L267) | +| [`cleanupDirectFile(_:)`](../../SimpleXChat/FileUtils.swift#L260) | Remove file only for direct chats | [L260](../../SimpleXChat/FileUtils.swift#L260) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L243) | Delete file at URL | [L243](../../SimpleXChat/FileUtils.swift#L243) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L251) | Delete file by name | [L251](../../SimpleXChat/FileUtils.swift#L251) | +| [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) | Remove all app files (preserving databases) | [L108](../../SimpleXChat/FileUtils.swift#L108) | +| [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) | Remove everything | [L86](../../SimpleXChat/FileUtils.swift#L86) | + +- When a `ChatItem` is deleted, its associated file is deleted from disk +- When a timed message expires, its file is deleted +- `ChatModel.filesToDelete` queues files for deferred deletion +- [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) removes all files (preserving databases) +- [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) removes everything + +--- + +## [10. API Commands](../../Shared/Model/AppAPITypes.swift#L167) + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| [`receiveFile`](../../Shared/Model/AppAPITypes.swift#L167) | `fileId, userApprovedRelays, encrypted, inline` | Accept and start downloading a file | [L167](../../Shared/Model/AppAPITypes.swift#L167) | +| [`setFileToReceive`](../../Shared/Model/AppAPITypes.swift#L168) | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive (no immediate download) | [L168](../../Shared/Model/AppAPITypes.swift#L168) | +| [`cancelFile`](../../Shared/Model/AppAPITypes.swift#L169) | `fileId` | Cancel in-progress transfer | [L169](../../Shared/Model/AppAPITypes.swift#L169) | +| [`apiUploadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L179) | `userId, file: CryptoFile` | Upload file to XFTP without a chat context | [L179](../../Shared/Model/AppAPITypes.swift#L179) | +| [`apiDownloadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L180) | `userId, url, file: CryptoFile` | Download from XFTP URL | [L180](../../Shared/Model/AppAPITypes.swift#L180) | +| [`apiStandaloneFileInfo`](../../Shared/Model/AppAPITypes.swift#L181) | `url` | Get metadata for an XFTP URL | [L181](../../Shared/Model/AppAPITypes.swift#L181) | + +### File Transfer Events + +| Event | Description | Line | +|-------|-------------|------| +| [`rcvFileAccepted`](../../Shared/Model/AppAPITypes.swift#L1095) | Download request accepted | [L1095](../../Shared/Model/AppAPITypes.swift#L1095) | +| [`rcvFileStart`](../../Shared/Model/AppAPITypes.swift#L1097) | Download started | [L1097](../../Shared/Model/AppAPITypes.swift#L1097) | +| [`rcvFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1098) | Download progress (receivedSize, totalSize) | [L1098](../../Shared/Model/AppAPITypes.swift#L1098) | +| [`rcvFileComplete`](../../Shared/Model/AppAPITypes.swift#L1099) | Download complete | [L1099](../../Shared/Model/AppAPITypes.swift#L1099) | +| [`rcvFileSndCancelled`](../../Shared/Model/AppAPITypes.swift#L1101) | Sender cancelled the transfer | [L1101](../../Shared/Model/AppAPITypes.swift#L1101) | +| [`rcvFileError`](../../Shared/Model/AppAPITypes.swift#L1102) | Download failed | [L1102](../../Shared/Model/AppAPITypes.swift#L1102) | +| [`rcvFileWarning`](../../Shared/Model/AppAPITypes.swift#L1103) | Download warning (non-fatal) | [L1103](../../Shared/Model/AppAPITypes.swift#L1103) | +| [`sndFileStart`](../../Shared/Model/AppAPITypes.swift#L1105) | Upload started | [L1105](../../Shared/Model/AppAPITypes.swift#L1105) | +| [`sndFileComplete`](../../Shared/Model/AppAPITypes.swift#L1106) | Inline upload complete | [L1106](../../Shared/Model/AppAPITypes.swift#L1106) | +| [`sndFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1108) | XFTP upload progress (sentSize, totalSize) | [L1108](../../Shared/Model/AppAPITypes.swift#L1108) | +| [`sndFileCompleteXFTP`](../../Shared/Model/AppAPITypes.swift#L1110) | XFTP upload complete | [L1110](../../Shared/Model/AppAPITypes.swift#L1110) | +| [`sndFileRcvCancelled`](../../Shared/Model/AppAPITypes.swift#L1107) | Receiver cancelled | [L1107](../../Shared/Model/AppAPITypes.swift#L1107) | +| [`sndFileError`](../../Shared/Model/AppAPITypes.swift#L1112) | Upload failed | [L1112](../../Shared/Model/AppAPITypes.swift#L1112) | +| [`sndFileWarning`](../../Shared/Model/AppAPITypes.swift#L1113) | Upload warning (non-fatal) | [L1113](../../Shared/Model/AppAPITypes.swift#L1113) | + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | `MAX_IMAGE_SIZE`, `saveFile`, `removeFile`, `getMaxFileSize` | +| CryptoFile FFI operations | [`SimpleXChat/CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | `writeCryptoFile`, `readCryptoFile`, `encryptCryptoFile`, `decryptCryptoFile` | +| CryptoFile / CryptoFileArgs types | [`SimpleXChat/ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | `CryptoFile` (L4241), `CryptoFileArgs` (L4289) | +| API command definitions | [`Shared/Model/AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | `receiveFile`, `cancelFile`, `ChatEvent` file events | +| API implementations | [`Shared/Model/SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) | `receiveFile` (L1471), `cancelFile` (L1590) | +| File view (chat item) | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | | +| Image view (chat item) | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | | +| Video view (chat item) | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | | +| Voice view (chat item) | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | | +| Compose file preview | [`Shared/Views/Chat/ComposeMessage/ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | | +| Compose image preview | [`Shared/Views/Chat/ComposeMessage/ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | | +| Compose voice preview | [`Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | | +| C FFI (file encryption) | [`SimpleXChat/SimpleX.h`](../../SimpleXChat/SimpleX.h) | `chat_write_file`, `chat_read_file`, `chat_encrypt_file`, `chat_decrypt_file` | +| Haskell file logic | `../../src/Simplex/Chat/Files.hs` | -- | +| Haskell file store | `../../src/Simplex/Chat/Store/Files.hs` | -- | diff --git a/apps/ios/spec/services/notifications.md b/apps/ios/spec/services/notifications.md new file mode 100644 index 0000000000..1062833f9c --- /dev/null +++ b/apps/ios/spec/services/notifications.md @@ -0,0 +1,390 @@ +# SimpleX Chat iOS -- Push Notification Service + +> Technical specification for the notification system: NtfManager, Notification Service Extension (NSE), notification modes, and token lifecycle. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Navigation](../client/navigation.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`NtfManager.swift`](../../Shared/Model/NtfManager.swift) | [`BGManager.swift`](../../Shared/Model/BGManager.swift) | [`Notifications.swift`](../../SimpleXChat/Notifications.swift) | [`NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Notification Modes](#2-notification-modes) +3. [NtfManager](#3-ntfmanager) +4. [Notification Service Extension (NSE)](#4-notification-service-extension) +5. [Token Lifecycle](#5-token-lifecycle) +6. [Notification Categories & Actions](#6-notification-categories--actions) +7. [Badge Management](#7-badge-management) +8. [Background Tasks (BGManager)](#8-background-tasks) + +--- + +## 1. Overview + +SimpleX Chat uses a privacy-preserving notification architecture. Because messages are end-to-end encrypted and the notification server never sees message content, the app uses a Notification Service Extension (NSE) to decrypt push payloads on-device before displaying notifications. + +``` +APNs Push → NSE receives encrypted payload + → NSE starts Haskell core (own chat_ctrl) + → NSE decrypts message using stored keys + → NSE creates UNNotificationContent with decrypted preview + → iOS displays notification to user +``` + +The notification system has three modes of operation, allowing users to choose their privacy/convenience tradeoff. + +--- + +## 2. Notification Modes + +| Mode | Description | Mechanism | +|------|-------------|-----------| +| **Instant** | Real-time notifications via Apple Push | APNs push triggers NSE, which decrypts and displays | +| **Periodic** | Background fetch every ~20 minutes | `BGAppRefreshTask` wakes app, checks for new messages | +| **Off** | No notifications | User must open app to see messages | + +### Configuration + +Notification mode is set via: +```swift +ChatCommand.apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) +``` + +`NotificationsMode` enum: `.instant`, `.periodic`, `.off` + +The mode is stored in `ChatModel.notificationMode` and persisted in `GroupDefaults`. + +--- + +## 3. NtfManager + +**File**: [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) + +Central notification coordinator. Singleton: `NtfManager.shared`. + +### [Class Definition](../../Shared/Model/NtfManager.swift#L27) + +```swift +class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + static let shared = NtfManager() + public var navigatingToChat = false + private var granted = false + private var prevNtfTime: Dictionary = [:] +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`registerCategories()`](../../Shared/Model/NtfManager.swift#L156) | Registers notification action categories with iOS | [156](../../Shared/Model/NtfManager.swift#L156) | +| [`requestAuthorization()`](../../Shared/Model/NtfManager.swift#L215) | Requests notification permission from user | [215](../../Shared/Model/NtfManager.swift#L215) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Updates app icon badge | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`processNotificationResponse(_:)`](../../Shared/Model/NtfManager.swift#L54) | Handles user interaction with notification | [54](../../Shared/Model/NtfManager.swift#L54) | +| [`notifyContactRequest(_:)`](../../Shared/Model/NtfManager.swift#L239) | Shows contact request notification | [239](../../Shared/Model/NtfManager.swift#L239) | +| [`notifyCallInvitation(_:)`](../../Shared/Model/NtfManager.swift#L258) | Shows incoming call notification | [258](../../Shared/Model/NtfManager.swift#L258) | +| [`notifyMessageReceived(_:)`](../../Shared/Model/NtfManager.swift#L250) | Shows message received notification | [250](../../Shared/Model/NtfManager.swift#L250) | + +### [Notification Response Processing](../../Shared/Model/NtfManager.swift#L40) + +When user taps a notification: + +1. `userNotificationCenter(didReceive:)` delegate method fires +2. If app is active: calls `processNotificationResponse()` immediately +3. If app is inactive: stores in `ChatModel.notificationResponse` for later processing +4. [`processNotificationResponse()`](../../Shared/Model/NtfManager.swift#L54): + - Extracts `userId` from `userInfo` -- switches user if needed + - Extracts `chatId` -- navigates to the conversation + - Handles action identifiers (accept contact, accept/reject call) + +### [Rate Limiting](../../Shared/Model/NtfManager.swift#L144) + +`prevNtfTime` dictionary prevents notification flooding: +- Each chat has a timestamp of its last notification +- New notifications are suppressed if within `ntfTimeInterval` (1 second) of the previous one for the same chat + +--- + +## 4. Notification Service Extension (NSE) + +**File**: [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +### Architecture + +The NSE is a separate process that iOS launches when a push notification arrives. It has: +- Its own Haskell runtime instance (`chat_ctrl`) +- Shared database access (via app group container) +- ~30 second execution window per notification +- No access to main app's in-memory state + +### [Processing Flow](../../SimpleX NSE/NotificationService.swift#L300) + +``` +1. didReceive(request:, withContentHandler:) L300 + ├── 2. Initialize Haskell core (if not already running) + │ └── chat_migrate_init_key() with shared DB path L861 + ├── 3. Decode encrypted notification payload + │ └── apiGetNtfConns(nonce:, encNtfInfo:) L1123 + ├── 4. Fetch and decrypt messages + │ └── apiGetConnNtfMessages(connMsgReqs:) L1140 + ├── 5. Create notification content + │ ├── Contact name as title + │ ├── Decrypted message preview as body + │ └── Thread identifier for grouping + └── 6. Deliver to content handler +``` + +### NSE Commands + +The NSE uses a subset of the chat API: + +| Command | Purpose | Line | +|---------|---------|------| +| [`apiGetNtfConns(nonce:, encNtfInfo:)`](../../SimpleX NSE/NotificationService.swift#L1123) | Decrypt notification connection info | [1123](../../SimpleX NSE/NotificationService.swift#L1123) | +| [`apiGetConnNtfMessages(connMsgReqs:)`](../../SimpleX NSE/NotificationService.swift#L1140) | Fetch messages for notification connections | [1140](../../SimpleX NSE/NotificationService.swift#L1140) | + +### Database Coordination + +- NSE checks `appStateGroupDefault` before processing +- If main app is `.active`, NSE may skip processing (main app handles notifications directly) +- NSE uses `chat_close_store` / `chat_reopen_store` for safe concurrent access + +### [Preview Modes](../../SimpleXChat/APITypes.swift#L664) + +`NotificationPreviewMode` controls what the NSE shows: + +| Mode | Title | Body | +|------|-------|------| +| `.message` | Contact name | Message text | +| `.contact` | Contact name | "New message" | +| `.hidden` | "SimpleX" | "New message" | + +### Key Internal Types + +| Type | Purpose | Line | +|------|---------|------| +| [`NSENotificationData`](../../SimpleX NSE/NotificationService.swift#L27) | Enum of possible notification payloads | [27](../../SimpleX NSE/NotificationService.swift#L27) | +| [`NSEThreads`](../../SimpleX NSE/NotificationService.swift#L82) | Concurrency coordinator for multiple NSE instances | [82](../../SimpleX NSE/NotificationService.swift#L82) | +| [`NotificationEntity`](../../SimpleX NSE/NotificationService.swift#L245) | Per-connection processing state | [245](../../SimpleX NSE/NotificationService.swift#L245) | +| [`NotificationService`](../../SimpleX NSE/NotificationService.swift#L287) | Main NSE class (`UNNotificationServiceExtension`) | [287](../../SimpleX NSE/NotificationService.swift#L287) | +| [`NSEChatState`](../../SimpleX NSE/NotificationService.swift#L781) | Singleton managing NSE lifecycle state | [781](../../SimpleX NSE/NotificationService.swift#L781) | + +### Key Internal Functions + +| Function | Purpose | Line | +|----------|---------|------| +| [`startChat()`](../../SimpleX NSE/NotificationService.swift#L836) | Initializes Haskell core for NSE | [836](../../SimpleX NSE/NotificationService.swift#L836) | +| [`doStartChat()`](../../SimpleX NSE/NotificationService.swift#L861) | Performs actual chat initialization (migration, config) | [861](../../SimpleX NSE/NotificationService.swift#L861) | +| [`activateChat()`](../../SimpleX NSE/NotificationService.swift#L907) | Reactivates suspended chat controller | [907](../../SimpleX NSE/NotificationService.swift#L907) | +| [`suspendChat(_:)`](../../SimpleX NSE/NotificationService.swift#L921) | Suspends chat controller with timeout | [921](../../SimpleX NSE/NotificationService.swift#L921) | +| [`receiveMessages()`](../../SimpleX NSE/NotificationService.swift#L954) | Main message-receive loop | [954](../../SimpleX NSE/NotificationService.swift#L954) | +| [`receivedMsgNtf(_:)`](../../SimpleX NSE/NotificationService.swift#L1003) | Maps chat events to notification data | [1003](../../SimpleX NSE/NotificationService.swift#L1003) | +| [`receiveNtfMessages(_:)`](../../SimpleX NSE/NotificationService.swift#L403) | Orchestrates notification message fetch and delivery | [403](../../SimpleX NSE/NotificationService.swift#L403) | +| [`deliverBestAttemptNtf()`](../../SimpleX NSE/NotificationService.swift#L604) | Delivers the best available notification content | [604](../../SimpleX NSE/NotificationService.swift#L604) | +| [`didReceive(_:withContentHandler:)`](../../SimpleX%20NSE/NotificationService.swift#L300) | Main NSE entry point -- processes incoming notification | [300](../../SimpleX%20NSE/NotificationService.swift#L300) | + +--- + +## 5. Token Lifecycle + +### Registration Flow + +``` +1. App starts → AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken + └── ChatModel.deviceToken = token + +2. Token registration (when chat running and token available): + └── apiRegisterToken(token, notificationMode) + └── Response: ntfToken(token, status, ntfMode, ntfServer) + └── ChatModel.tokenStatus = status + +3. Token verification (if server requires): + └── apiVerifyToken(token, nonce, code) + └── ChatModel.tokenRegistered = true + +4. Token check (periodic): + └── apiCheckToken(token) + └── Updates ChatModel.tokenStatus +``` + +### Token States (NtfTknStatus) + +| Status | Description | +|--------|-------------| +| `.new` | Token just registered, not yet verified | +| `.registered` | Token registered with notification server | +| `.confirmed` | Token confirmed and ready | +| `.active` | Token actively receiving notifications | +| `.expired` | Token expired, needs re-registration | +| `.invalid` | Token invalid, needs new registration | +| `.invalidBad` | Token invalid due to bad data | +| `.invalidTopic` | Token invalid due to wrong topic | +| `.invalidExpired` | Token invalid because it expired | +| `.invalidUnregistered` | Token invalid, was unregistered | + +### Token Deletion + +```swift +ChatCommand.apiDeleteToken(token: DeviceToken) +``` + +Called when: +- User switches to `.off` notification mode +- User deletes their profile +- Token becomes invalid and needs replacement + +--- + +## 6. Notification Categories & Actions + +Registered in [`NtfManager.registerCategories()`](../../Shared/Model/NtfManager.swift#L156): + +### Contact Request Category + +```swift +// Category: "NTF_CAT_CONTACT_REQUEST" +// Actions: +// - "NTF_ACT_ACCEPT_CONTACT": Accept contact request +``` + +When user taps "Accept" on a contact request notification: +1. `processNotificationResponse()` detects `ntfActionAcceptContact` +2. Calls `apiAcceptContact(incognito: false, contactReqId:)` +3. Navigates to the new contact's chat + +### Call Invitation Category + +```swift +// Category: "NTF_CAT_CALL_INVITATION" +// Actions: +// - "NTF_ACT_ACCEPT_CALL": Accept incoming call +// - "NTF_ACT_REJECT_CALL": Reject incoming call +``` + +When user taps "Accept" / "Reject" on a call notification: +1. `processNotificationResponse()` detects the action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept/.reject)` +3. Call controller picks up the pending action + +### Message Category + +Standard tap-to-open behavior navigates to the chat. + +### Many Events Category + +Batch notification for multiple events -- navigates to the app without specific chat context. + +--- + +## 7. Badge Management + +The app icon badge shows the total unread message count: + +```swift +// Updated when: +// 1. App enters background: +NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) + +// 2. Messages are read: +// Badge is recalculated and updated + +// 3. NSE receives notification: +// NSE updates badge based on its count +``` + +`totalUnreadCountForAllUsers()` sums unread counts across all user profiles (not just the active user). + +### NSE Badge Handling + +| Method | Purpose | Line | +|--------|---------|------| +| [`setBadgeCount()`](../../SimpleX NSE/NotificationService.swift#L592) | Increments badge via `ntfBadgeCountGroupDefault` | [592](../../SimpleX NSE/NotificationService.swift#L592) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Sets badge on `UIApplication` | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`changeNtfBadgeCount(by:)`](../../Shared/Model/NtfManager.swift#L270) | Adjusts badge by delta | [270](../../Shared/Model/NtfManager.swift#L270) | + +--- + +## 8. Background Tasks + +**File**: [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) + +### [BGManager](../../Shared/Model/BGManager.swift#L30) + +```swift +class BGManager { + static let shared = BGManager() + func register() // Register BGAppRefreshTask handlers + func schedule() // Schedule next background refresh +} +``` + +| Method | Purpose | Line | +|--------|---------|------| +| [`register()`](../../Shared/Model/BGManager.swift#L38) | Registers `BGAppRefreshTask` handler with iOS | [38](../../Shared/Model/BGManager.swift#L38) | +| [`schedule()`](../../Shared/Model/BGManager.swift#L46) | Schedules next background refresh request | [46](../../Shared/Model/BGManager.swift#L46) | +| [`handleRefresh(_:)`](../../Shared/Model/BGManager.swift#L74) | Processes background refresh task | [74](../../Shared/Model/BGManager.swift#L74) | +| [`completionHandler(_:)`](../../Shared/Model/BGManager.swift#L95) | Creates completion callback with cleanup | [95](../../Shared/Model/BGManager.swift#L95) | +| [`receiveMessages(_:)`](../../Shared/Model/BGManager.swift#L112) | Activates chat and receives pending messages | [112](../../Shared/Model/BGManager.swift#L112) | + +### Background Refresh (Periodic Mode) + +When notification mode is `.periodic`: + +1. `BGManager.schedule()` is called when app enters background +2. iOS wakes the app in the background approximately every 20 minutes +3. `BGAppRefreshTask` handler: + - Activates the chat engine: `apiActivateChat(restoreChat: true)` + - Checks for new messages + - Creates local notifications for any new messages + - Suspends chat: `apiSuspendChat(timeoutMicroseconds:)` + - Schedules next refresh +4. Must complete within ~30 seconds or iOS terminates the task + +### Background Task Protection + +All API calls use `beginBGTask()` / `endBackgroundTask()` to request extra execution time: + +```swift +func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { + var id: UIBackgroundTaskIdentifier! + // ... + id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask) + return endTask +} +``` + +Maximum task duration: `maxTaskDuration = 15` seconds. + +--- + +## Notification Content Builders + +**File**: [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) + +| Function | Purpose | Line | +|----------|---------|------| +| [`createContactRequestNtf()`](../../SimpleXChat/Notifications.swift#L27) | Builds notification for incoming contact request | [L27](../../SimpleXChat/Notifications.swift#L27) | +| [`createContactConnectedNtf()`](../../SimpleXChat/Notifications.swift#L46) | Builds notification for contact connected event | [L46](../../SimpleXChat/Notifications.swift#L46) | +| [`createMessageReceivedNtf()`](../../SimpleXChat/Notifications.swift#L66) | Builds notification for received message | [L66](../../SimpleXChat/Notifications.swift#L66) | +| [`createCallInvitationNtf()`](../../SimpleXChat/Notifications.swift#L86) | Builds notification for incoming call | [L86](../../SimpleXChat/Notifications.swift#L86) | +| [`createConnectionEventNtf()`](../../SimpleXChat/Notifications.swift#L102) | Builds notification for connection events | [L102](../../SimpleXChat/Notifications.swift#L102) | +| [`createErrorNtf()`](../../SimpleXChat/Notifications.swift#L134) | Builds notification for database/encryption errors | [L134](../../SimpleXChat/Notifications.swift#L134) | +| [`createAppStoppedNtf()`](../../SimpleXChat/Notifications.swift#L160) | Builds notification when app is stopped | [L160](../../SimpleXChat/Notifications.swift#L160) | +| [`createNotification()`](../../SimpleXChat/Notifications.swift#L175) | Generic notification builder (used by all above) | [L175](../../SimpleXChat/Notifications.swift#L175) | +| [`hideSecrets()`](../../SimpleXChat/Notifications.swift#L200) | Redacts secret-formatted text in previews | [L200](../../SimpleXChat/Notifications.swift#L200) | + +--- + +## Source Files + +| File | Path | +|------|------| +| Notification manager | [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) | +| Background manager | [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) | +| Notification types | [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) | +| NSE service | [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) | +| App delegate (token) | `Shared/AppDelegate.swift` | +| Notification settings UI | `Shared/Views/UserSettings/NotificationsView.swift` | diff --git a/apps/ios/spec/services/theme.md b/apps/ios/spec/services/theme.md new file mode 100644 index 0000000000..321f3307f9 --- /dev/null +++ b/apps/ios/spec/services/theme.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- Theme Engine + +> Technical specification for the theming system: ThemeManager, default themes, customization layers, wallpapers, and YAML export. +> +> Related specs: [State Management](../state.md) | [Architecture](../architecture.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | [`ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | [`ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | [`Theme.swift`](../../Shared/Theme/Theme.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Customization Layers](#4-customization-layers) +5. [Color System](#5-color-system) +6. [Wallpapers](#6-wallpapers) +7. [Chat Bubble Styling](#7-chat-bubble-styling) +8. [Color Scheme Mode](#8-color-scheme-mode) +9. [YAML Export/Import](#9-yaml-exportimport) + +--- + +## 1. Overview + +The theme engine provides a layered customization system where themes can be overridden at multiple levels: global defaults, per-user, and per-chat. + +``` +Theme Resolution Order (most specific wins): +┌─────────────────────┐ +│ Per-chat override │ apiSetChatUIThemes(chatId:, themes:) +├─────────────────────┤ +│ Per-user override │ apiSetUserUIThemes(userId:, themes:) +├─────────────────────┤ +│ App settings theme │ themeOverridesDefault (UserDefaults) +├─────────────────────┤ +│ Base theme │ Light / Dark / SimpleX / Black +└─────────────────────┘ +``` + +The resolved theme is published as `AppTheme.shared` and consumed by all SwiftUI views via `@EnvironmentObject`. + +--- + +## 2. [ThemeManager](../../Shared/Theme/ThemeManager.swift) (L15) + +**File**: [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) + +Static utility class that resolves the current theme by merging all customization layers. + +### [ActiveTheme](../../Shared/Theme/ThemeManager.swift#L17) + +The resolved theme output: + +```swift +struct ActiveTheme: Equatable { + let name: String // Theme name (e.g., "light", "dark", "simplex", "black", "system") + let base: DefaultTheme // Base theme enum + let colors: Colors // Resolved color palette + let appColors: AppColors // App-specific colors (sent/received bubbles, etc.) + var wallpaper: AppWallpaper // Resolved wallpaper +} +``` + +### Key Static Methods + +| Method | Purpose | Line | +|--------|---------|------| +| [`applyTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L124) | Apply a theme by name, updates `AppTheme.shared` | [L124](../../Shared/Theme/ThemeManager.swift#L124) | +| [`currentColors(...)`](../../Shared/Theme/ThemeManager.swift#L64) | Resolve full theme from all layers | [L64](../../Shared/Theme/ThemeManager.swift#L64) | +| [`defaultActiveTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L48) | Get default theme override from app settings | [L48](../../Shared/Theme/ThemeManager.swift#L48) | +| [`currentThemeOverridesForExport(...)`](../../Shared/Theme/ThemeManager.swift#L105) | Get current overrides for YAML export | [L105](../../Shared/Theme/ThemeManager.swift#L105) | +| [`adjustWindowStyle()`](../../Shared/Theme/ThemeManager.swift#L136) | Adjust window style after theme change | [L136](../../Shared/Theme/ThemeManager.swift#L136) | +| [`changeDarkTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L166) | Change the dark theme variant | [L166](../../Shared/Theme/ThemeManager.swift#L166) | +| [`saveAndApplyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L173) | Save and apply a theme color override | [L173](../../Shared/Theme/ThemeManager.swift#L173) | +| [`applyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L186) | Apply a theme color to a binding | [L186](../../Shared/Theme/ThemeManager.swift#L186) | +| [`saveAndApplyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L191) | Save and apply a wallpaper change | [L191](../../Shared/Theme/ThemeManager.swift#L191) | +| [`copyFromSameThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L213) | Copy overrides from matching theme | [L213](../../Shared/Theme/ThemeManager.swift#L213) | +| [`applyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L256) | Apply wallpaper to a binding | [L256](../../Shared/Theme/ThemeManager.swift#L256) | +| [`saveAndApplyThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L267) | Save and apply full theme overrides | [L267](../../Shared/Theme/ThemeManager.swift#L267) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L288) | Reset all color overrides (CodableDefault) | [L288](../../Shared/Theme/ThemeManager.swift#L288) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L302) | Reset all color overrides (Binding) | [L302](../../Shared/Theme/ThemeManager.swift#L302) | +| [`removeTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L311) | Remove a saved theme by ID | [L311](../../Shared/Theme/ThemeManager.swift#L311) | + +### Theme Resolution Algorithm + +[`currentColors()`](../../Shared/Theme/ThemeManager.swift#L64) in `ThemeManager.swift`: + +1. Determine base theme from `currentThemeDefault`: + - If `"system"`: use light or dark based on [`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95) + - Dark mode maps to `systemDarkThemeDefault` (Dark, SimpleX, or Black) +2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692)) +3. Look up app settings theme override (`themeOverridesDefault`) +4. Look up per-user theme override (`User.uiThemes`) +5. Look up per-chat theme override (from ChatInfo) +6. Look up wallpaper preset colors (if wallpaper has preset color overrides) +7. Merge layers: base <- app override <- preset wallpaper colors <- per-user <- per-chat +8. Return `ActiveTheme` with resolved colors, app colors, and wallpaper + +--- + +## 3. Default Themes + +Four built-in themes with pre-defined color palettes: + +| Theme | Enum | Key Characteristics | +|-------|------|---------------------| +| **Light** | `DefaultTheme.LIGHT` | White background, standard colors | +| **Dark** | `DefaultTheme.DARK` | Dark gray background, light text | +| **SimpleX** | `DefaultTheme.SIMPLEX` | Brand purple accents, dark background | +| **Black** | `DefaultTheme.BLACK` | Pure black background (OLED), high contrast | + +### [DefaultTheme](../../SimpleXChat/Theme/ThemeTypes.swift#L13) Enum + +```swift +enum DefaultTheme { + case LIGHT + case DARK + case SIMPLEX + case BLACK + + static let SYSTEM_THEME_NAME = "SYSTEM" + + var themeName: String { ... } + var mode: DefaultThemeMode { ... } // .light or .dark +} +``` + +### Color Palettes + +Each base theme defines two palette types: +- [`Colors`](../../SimpleXChat/Theme/ThemeTypes.swift#L44): Standard UI colors (primary, background, surface, error, onBackground, onSurface) +- [`AppColors`](../../SimpleXChat/Theme/ThemeTypes.swift#L90): App-specific colors (sentMessage, receivedMessage, title, primaryVariant2) + +Palette instances: +- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L662) +- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L641) +- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L683) +- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L704) + +--- + +## 4. Customization Layers + +### Layer 1: App Settings Theme + +Stored in `themeOverridesDefault` (UserDefaults). Contains `[ThemeOverrides]` -- an array of theme overrides, one per base theme. + +#### [`ThemeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L385) + +```swift +struct ThemeOverrides: Codable { + var base: DefaultTheme + var colors: ThemeColors? // Color overrides + var wallpaper: ThemeWallpaper? // Wallpaper setting +} +``` + +### Layer 2: Per-User Theme + +Stored on the `User` object (`User.uiThemes: ThemeModeOverrides?`), persisted in the Haskell database via `apiSetUserUIThemes(userId:, themes:)`. + +#### [`ThemeModeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L570) + +```swift +struct ThemeModeOverrides: Codable { + var light: ThemeModeOverride? + var dark: ThemeModeOverride? +} +``` + +#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L585) + +```swift +struct ThemeModeOverride: Codable { + var mode: DefaultThemeMode? + var colors: ThemeColors? + var wallpaper: ThemeWallpaper? + var type: WallpaperType? // Computed from wallpaper +} +``` + +### Layer 3: Per-Chat Theme + +Stored per-chat via `apiSetChatUIThemes(chatId:, themes:)`. Same `ThemeModeOverrides` structure. + +### Override Merging + +Colors are merged field-by-field: if a more-specific layer defines a color, it overrides; if nil, falls through to the next layer. + +--- + +## 5. Color System + +**File**: [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) + +### [ThemeColors](../../SimpleXChat/Theme/ThemeTypes.swift#L230) + +Overridable color definitions: + +```swift +struct ThemeColors: Codable { + var primary: String? // Primary brand color + var primaryVariant: String? // Primary variant + var secondary: String? // Secondary color + var secondaryVariant: String? // Secondary variant + var background: String? // Main background + var surface: String? // Card/surface background + var title: String? // Title text color + var primaryVariant2: String? // Additional variant + var sentMessage: String? // Sent message bubble + var sentQuote: String? // Sent quote background + var receivedMessage: String? // Received message bubble + var receivedQuote: String? // Received quote background +} +``` + +Colors are stored as hex strings (e.g., `"#FF6600"`) and converted to SwiftUI `Color` values at resolution time. + +### [Colors](../../SimpleXChat/Theme/ThemeTypes.swift#L44) (Resolved Palette) + +```swift +struct Colors { + var isLight: Bool + var primary: Color + var primaryVariant: Color + var secondary: Color + var secondaryVariant: Color + var background: Color + var surface: Color + var error: Color + var onBackground: Color + var onSurface: Color + // ... etc +} +``` + +### [AppColors](../../SimpleXChat/Theme/ThemeTypes.swift#L90) (Resolved App-Specific) + +```swift +struct AppColors { + var title: Color + var primaryVariant2: Color + var sentMessage: Color + var sentQuote: Color + var receivedMessage: Color + var receivedQuote: Color +} +``` + +--- + +## 6. Wallpapers + +**File**: [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) + +### [Preset Wallpapers](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L13) + +6 built-in wallpaper presets: + +| Preset | ID | Description | +|--------|-----|-------------| +| Cats | `cats` | Cat-themed pattern | +| Flowers | `flowers` | Floral pattern | +| Hearts | `hearts` | Heart pattern | +| Kids | `kids` | Children's pattern | +| School | `school` | School/notebook pattern (default) | +| Travel | `travel` | Travel-themed pattern | + +Each preset defines per-theme color tints (`PresetWallpaper.colors[DefaultTheme]`) that subtly adjust the color palette to complement the wallpaper. + +### Custom Wallpapers + +Users can set a custom image as wallpaper: +- Stored in `Documents/wallpapers/` directory +- Scaled and tiled to fill the chat background +- Custom wallpapers can be combined with color overrides + +### [WallpaperType](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L311) + +```swift +enum WallpaperType { + case preset(filename: String, scale: Float?) // Built-in wallpaper + case image(filename: String, scale: Float?) // Custom image + case empty // No wallpaper +} +``` + +### [AppWallpaper](../../SimpleXChat/Theme/ThemeTypes.swift#L142) (Resolved) + +```swift +struct AppWallpaper { + var background: Color? // Background color override + var tint: Color? // Tint/overlay color + var type: WallpaperType +} +``` + +--- + +## 7. Chat Bubble Styling + +Configurable bubble appearance properties: + +| Property | Description | Stored In | +|----------|-------------|-----------| +| `chatItemRoundness` | Corner radius of message bubbles | App settings | +| `chatItemTail` | Whether bubbles have a tail/arrow | App settings | +| Avatar corner radius | Roundness of profile avatars | App settings | + +These are configured in [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) ([L26](../../Shared/Views/UserSettings/AppearanceSettings.swift#L26)). + +--- + +## 8. Color Scheme Mode + +### System Follow + +When theme is set to `"system"` (DefaultTheme.SYSTEM_THEME_NAME): +- Light mode: uses `DefaultTheme.LIGHT` palette +- Dark mode: uses the configured dark theme (`systemDarkThemeDefault`), which can be Dark, SimpleX, or Black + +### Forced Mode + +Users can force light or dark mode regardless of system setting by selecting a specific theme other than "system". + +### Detection + +[`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95): + +```swift +var systemInDarkThemeCurrently: Bool { + return UITraitCollection.current.userInterfaceStyle == .dark +} +``` + +`ChatModel.currentUser` setter triggers [`ThemeManager.applyTheme()`](../../Shared/Theme/ThemeManager.swift#L124) to handle per-user theme overrides when switching users. + +--- + +## 9. YAML Export/Import + +Theme configurations can be exported as YAML for sharing: + +### Export + +[`ThemeManager.currentThemeOverridesForExport()`](../../Shared/Theme/ThemeManager.swift#L105) generates a `ThemeOverrides` representing the current resolved theme, which is then serialized to YAML using the Yams library. + +### Import + +YAML theme strings are parsed back into `ThemeOverrides` and applied as app settings theme overrides. + +Key functions in [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`ImportExportThemeSection`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | UI section for import/export controls | [L603](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | +| [`ThemeImporter`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | ViewModifier for YAML file import | [L640](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | +| [`decodeYAML(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | Parse YAML string into Decodable type | [L1150](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | +| [`encodeThemeOverrides(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | Encode ThemeOverrides to YAML string | [L1160](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | + +### Toolbar Material + +[`ToolbarMaterial`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L319) controls the navigation bar appearance: +- Configurable opacity/material (translucent, opaque) +- Stored in app settings + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| Theme manager | [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | `ThemeManager` (L15), `ActiveTheme` (L17) | +| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L585) | +| Wallpaper types | [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | `PresetWallpaper` (L13), `WallpaperType` (L311) | +| Color utilities | [`SimpleXChat/Theme/Color.swift`](../../SimpleXChat/Theme/Color.swift) | Hex color conversion | +| App theme observable | [`Shared/Theme/Theme.swift`](../../Shared/Theme/Theme.swift) | `AppTheme` (L22), `CurrentColors` (L14), `systemInDarkThemeCurrently` (L95) | +| Appearance settings UI | [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | `AppearanceSettings` (L26), `ToolbarMaterial` (L319), `ImportExportThemeSection` (L603) | +| Theme mode editor | `Shared/Views/Helpers/ThemeModeEditor.swift` | Theme mode selection UI | +| Haskell theme types | `../../src/Simplex/Chat/Types/UITheme.hs` | Server-side theme persistence | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md new file mode 100644 index 0000000000..68b5f3cbcc --- /dev/null +++ b/apps/ios/spec/state.md @@ -0,0 +1,463 @@ +# SimpleX Chat iOS -- State Management + +**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1375) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5284) + +> Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage. +> +> Related specs: [Architecture](architecture.md) | [API Reference](api.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel -- Primary App State](#2-chatmodel) +3. [ItemsModel -- Per-Chat Message State](#3-itemsmodel) +4. [ChatTagsModel -- Tag Filtering State](#4-chattagsmodel) +5. [Chat -- Single Conversation State](#5-chat) +6. [ChatInfo -- Conversation Metadata](#6-chatinfo) +7. [State Flow](#7-state-flow) +8. [Preference Storage](#8-preference-storage) + +--- + +## 1. Overview + +The app uses SwiftUI's `ObservableObject` pattern for reactive state management. The state hierarchy is: + +``` +ChatModel (singleton -- global app state) +├── currentUser: User? +├── users: [UserInfo] +├── chats: [Chat] (chat list) +├── chatId: String? (active chat ID) +├── im: ItemsModel.shared (primary chat items) +├── secondaryIM: ItemsModel? (secondary chat items, e.g. support scope) +├── activeCall: Call? +├── callInvitations: [ChatId: RcvCallInvitation] +├── deviceToken / savedToken / tokenStatus +├── notificationMode: NotificationsMode +├── onboardingStage: OnboardingStage? +├── migrationState: MigrationToState? +└── ... (50+ @Published properties) + +ItemsModel (singleton + secondary instances -- per-chat message state) +├── reversedChatItems: [ChatItem] (messages in reverse order) +├── chatState: ActiveChatState (pagination/split state) +├── isLoading / showLoadingProgress +└── preloadState: PreloadState + +Chat (per-conversation -- one per entry in chat list) +├── chatInfo: ChatInfo (type + metadata) +├── chatItems: [ChatItem] (preview items) +└── chatStats: ChatStats (unread counts) + +ChatTagsModel (singleton -- filter state) +├── userTags: [ChatTag] +├── activeFilter: ActiveFilter? +├── presetTags: [PresetTag: Int] +└── unreadTags: [Int64: Int] +``` + +--- + +## 2. [ChatModel](../Shared/Model/ChatModel.swift#L337-L1260) + +**Class**: `final class ChatModel: ObservableObject` +**Singleton**: `ChatModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) + +### Key Published Properties + +#### App Lifecycle +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L331](../Shared/Model/ChatModel.swift#L338) | +| `chatInitialized` | `Bool` | Whether chat has been initialized | [L340](../Shared/Model/ChatModel.swift#L347) | +| `chatRunning` | `Bool?` | Whether chat engine is running | [L341](../Shared/Model/ChatModel.swift#L348) | +| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L342](../Shared/Model/ChatModel.swift#L349) | +| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L343](../Shared/Model/ChatModel.swift#L350) | +| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L344](../Shared/Model/ChatModel.swift#L351) | +| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L345](../Shared/Model/ChatModel.swift#L352) | +| `migrationState` | `MigrationToState?` | Device migration state | [L390](../Shared/Model/ChatModel.swift#L398) | + +#### User State +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L334](../Shared/Model/ChatModel.swift#L341) | +| `users` | `[UserInfo]` | All user profiles | [L339](../Shared/Model/ChatModel.swift#L346) | +| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L333](../Shared/Model/ChatModel.swift#L340) | + +#### Chat List +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chats` | `[Chat]` (private set) | All conversations for current user | [L351](../Shared/Model/ChatModel.swift#L358) | +| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L352](../Shared/Model/ChatModel.swift#L359) | + +#### Active Chat +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatId` | `String?` | Currently open chat ID | [L354](../Shared/Model/ChatModel.swift#L361) | +| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L355](../Shared/Model/ChatModel.swift#L362) | +| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L356](../Shared/Model/ChatModel.swift#L363) | +| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L357](../Shared/Model/ChatModel.swift#L364) | +| `chatToTop` | `String?` | Chat to scroll to top | [L358](../Shared/Model/ChatModel.swift#L365) | +| `groupMembers` | `[GMember]` | Members of active group | [L359](../Shared/Model/ChatModel.swift#L366) | +| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L360](../Shared/Model/ChatModel.swift#L367) | +| `membersLoaded` | `Bool` | Whether members have been loaded | [L361](../Shared/Model/ChatModel.swift#L368) | +| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L408](../Shared/Model/ChatModel.swift#L416) | + +#### Authentication +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L348](../Shared/Model/ChatModel.swift#L355) | +| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L349](../Shared/Model/ChatModel.swift#L356) | + +#### Notifications +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `deviceToken` | `DeviceToken?` | Current APNs device token | [L369](../Shared/Model/ChatModel.swift#L376) | +| `savedToken` | `DeviceToken?` | Previously saved token | [L370](../Shared/Model/ChatModel.swift#L377) | +| `tokenRegistered` | `Bool` | Whether token is registered with server | [L371](../Shared/Model/ChatModel.swift#L378) | +| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L373](../Shared/Model/ChatModel.swift#L380) | +| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L374](../Shared/Model/ChatModel.swift#L381) | +| `notificationServer` | `String?` | Notification server URL | [L375](../Shared/Model/ChatModel.swift#L382) | +| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L376](../Shared/Model/ChatModel.swift#L383) | +| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L346](../Shared/Model/ChatModel.swift#L353) | +| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L378](../Shared/Model/ChatModel.swift#L385) | +| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L379](../Shared/Model/ChatModel.swift#L386) | + +#### Calls +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L381](../Shared/Model/ChatModel.swift#L388) | +| `activeCall` | `Call?` | Currently active call | [L382](../Shared/Model/ChatModel.swift#L389) | +| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L383](../Shared/Model/ChatModel.swift#L390) | +| `showCallView` | `Bool` | Whether to show full-screen call UI | [L384](../Shared/Model/ChatModel.swift#L391) | +| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L385](../Shared/Model/ChatModel.swift#L392) | + +#### Remote Desktop +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L387](../Shared/Model/ChatModel.swift#L395) | + +#### Misc +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userAddress` | `UserContactLink?` | User's SimpleX address | [L365](../Shared/Model/ChatModel.swift#L372) | +| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L366](../Shared/Model/ChatModel.swift#L373) | +| `appOpenUrl` | `URL?` | URL opened while app active | [L367](../Shared/Model/ChatModel.swift#L374) | +| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L368](../Shared/Model/ChatModel.swift#L375) | +| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L389](../Shared/Model/ChatModel.swift#L397) | +| `draft` | `ComposeState?` | Saved compose draft | [L393](../Shared/Model/ChatModel.swift#L401) | +| `draftChatId` | `String?` | Chat ID for saved draft | [L394](../Shared/Model/ChatModel.swift#L402) | +| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L395](../Shared/Model/ChatModel.swift#L403) | +| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L397](../Shared/Model/ChatModel.swift#L405) | +| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L392](../Shared/Model/ChatModel.swift#L400) | + +### Non-Published Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L399](../Shared/Model/ChatModel.swift#L407) | +| `filesToDelete` | `Set` | Files queued for deletion | [L401](../Shared/Model/ChatModel.swift#L409) | +| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L405](../Shared/Model/ChatModel.swift#L413) | + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `getUser(_ userId:)` | Find user by ID | [L427](../Shared/Model/ChatModel.swift#L436) | +| `updateUser(_ user:)` | Update user in list and current | [L437](../Shared/Model/ChatModel.swift#L447) | +| `removeUser(_ user:)` | Remove user from list | [L446](../Shared/Model/ChatModel.swift#L457) | +| `getChat(_ id:)` | Find chat by ID | [L456](../Shared/Model/ChatModel.swift#L468) | +| `addChat(_ chat:)` | Add chat to list | [L510](../Shared/Model/ChatModel.swift#L523) | +| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L523](../Shared/Model/ChatModel.swift#L537) | +| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L574](../Shared/Model/ChatModel.swift#L589) | +| `removeChat(_ id:)` | Remove chat from list | [L1180](../Shared/Model/ChatModel.swift#L1198) | +| `popChat(_ id:, _ ts:)` | Move chat to top of list | [L1157](../Shared/Model/ChatModel.swift#L1174) | +| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1058](../Shared/Model/ChatModel.swift#L1074) | + +--- + +## 3. [ItemsModel](../Shared/Model/ChatModel.swift#L74-L174) + +**Class**: `class ItemsModel: ObservableObject` +**Primary singleton**: `ItemsModel.shared` +**Secondary instances**: Created via `ItemsModel.loadSecondaryChat()` for scope-based views (e.g., group member support chat) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L74) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L78](../Shared/Model/ChatModel.swift#L80) | +| `itemAdded` | `Bool` | Flag indicating a new item was added | [L81](../Shared/Model/ChatModel.swift#L83) | +| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L85](../Shared/Model/ChatModel.swift#L87) | +| `isLoading` | `Bool` | Whether messages are currently loading | [L89](../Shared/Model/ChatModel.swift#L91) | +| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L90](../Shared/Model/ChatModel.swift#L92) | +| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L75](../Shared/Model/ChatModel.swift#L77) | +| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L74](../Shared/Model/ChatModel.swift#L76) | + +### Computed Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L95](../Shared/Model/ChatModel.swift#L97) | +| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L154](../Shared/Model/ChatModel.swift#L159) | +| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L162](../Shared/Model/ChatModel.swift#L167) | + +### Throttling + +`ItemsModel` uses a custom publisher throttle (0.2 seconds) to batch rapid updates to `reversedChatItems` and prevent excessive SwiftUI re-renders: + +```swift +publisher + .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) + .sink { self.objectWillChange.send() } + .store(in: &bag) +``` + +Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throttling for immediate UI response. + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L113](../Shared/Model/ChatModel.swift#L117) | +| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L138](../Shared/Model/ChatModel.swift#L143) | +| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L107](../Shared/Model/ChatModel.swift#L110) | + +### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70) + +Used for secondary chat views (e.g., group member support scope, content type filter): + +```swift +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) +} +``` + +--- + +## 4. [ChatTagsModel](../Shared/Model/ChatModel.swift#L189-L291) + +**Class**: `class ChatTagsModel: ObservableObject` +**Singleton**: `ChatTagsModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L189) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userTags` | `[ChatTag]` | User-defined tags | [L186](../Shared/Model/ChatModel.swift#L192) | +| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L187](../Shared/Model/ChatModel.swift#L193) | +| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L188](../Shared/Model/ChatModel.swift#L194) | +| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L189](../Shared/Model/ChatModel.swift#L195) | + +### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52) + +```swift +enum ActiveFilter { + case presetTag(PresetTag) // .favorites, .contacts, .groups, .business, .groupReports + case userTag(ChatTag) // User-defined tag + case unread // Unread conversations +} +``` + +--- + +## 5. [Chat](../Shared/Model/ChatModel.swift#L1311-L1323) + +**Class**: `final class Chat: ObservableObject, Identifiable, ChatLike` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1271) + +Represents a single conversation in the chat list. Each `Chat` is an independent observable object. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1253](../Shared/Model/ChatModel.swift#L1272) | +| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1254](../Shared/Model/ChatModel.swift#L1273) | +| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1255](../Shared/Model/ChatModel.swift#L1274) | +| `created` | `Date` | Creation timestamp | [L1256](../Shared/Model/ChatModel.swift#L1275) | + +### [ChatStats](../SimpleXChat/ChatTypes.swift#L1877-L1899) + +```swift +struct ChatStats: Decodable, Hashable { + var unreadCount: Int = 0 + var unreadMentions: Int = 0 + var reportsCount: Int = 0 + var minUnreadItemId: Int64 = 0 + var unreadChat: Bool = false +} +``` + +### Computed Properties + +| Property | Description | Line | +|----------|-------------|------| +| `id` | Chat ID from `chatInfo.id` | [L1287](../Shared/Model/ChatModel.swift#L1306) | +| `viewId` | Unique view identity including creation time | [L1289](../Shared/Model/ChatModel.swift#L1308) | +| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1279](../Shared/Model/ChatModel.swift#L1298) | +| `supportUnreadCount` | Unread count for group support scope | [L1291](../Shared/Model/ChatModel.swift#L1310) | + +--- + +## 6. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1372-L1852) + +**Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable` +**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1372) + +Represents the type and metadata of a conversation: + +```swift +public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { + case direct(contact: Contact) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) + case local(noteFolder: NoteFolder) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + case invalidJSON(json: Data?) +} +``` + +### Cases + +| Case | Associated Value | Description | +|------|-----------------|-------------| +| `.direct` | `Contact` | One-to-one conversation | +| `.group` | `GroupInfo, GroupChatScopeInfo?` | Group conversation (optional scope for member support threads) | +| `.local` | `NoteFolder` | Local notes (self-chat) | +| `.contactRequest` | `UserContactRequest` | Incoming contact request | +| `.contactConnection` | `PendingContactConnection` | Pending connection | +| `.invalidJSON` | `Data?` | Undecodable chat data | + +### Key Computed Properties on ChatInfo + +| Property | Type | Description | +|----------|------|-------------| +| `chatType` | `ChatType` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | +| `id` | `ChatId` | Prefixed ID (e.g., `"@1"` for direct, `"#5"` for group) | +| `displayName` | `String` | Contact/group name | +| `image` | `String?` | Profile image (base64) | +| `chatSettings` | `ChatSettings?` | Notification/favorite settings | +| `chatTags` | `[Int64]?` | Assigned tag IDs | + +--- + +## 7. State Flow + +### App Start +``` +SimpleXApp.init() + → haskell_init() + → initChatAndMigrate() + → chat_migrate_init_key() -- creates/opens DB + → startChat(mainApp: true) -- starts core + → apiGetChats(userId) -- populates ChatModel.chats + → UI renders ChatListView +``` + +### Opening a Chat +``` +User taps chat in ChatListView + → ItemsModel.loadOpenChat(chatId) + → 250ms delay for navigation animation + → ChatModel.chatId = chatId + → loadChat(chatId:, im:) + → apiGetChat(chatId, pagination: .last(count: 50)) + → ItemsModel.reversedChatItems = [ChatItem] + → ChatView renders messages +``` + +### Receiving a Message (Event) +``` +Haskell core generates ChatEvent.newChatItems + → Event loop calls chat_recv_msg_wait + → Decoded as ChatEvent.newChatItems(user, chatItems) + → ChatModel updates: + 1. Insert new Chat items into ChatModel.chats (preview) + 2. If chat is open: insert into ItemsModel.reversedChatItems + 3. Update ChatStats (unread counts) + 4. Update ChatTagsModel (tag unread counts) + → SwiftUI re-renders affected views via @Published observation +``` + +### Sending a Message +``` +User taps send in ComposeView + → apiSendMessages(type, id, scope, live, ttl, composedMessages) + → Haskell processes, returns ChatResponse1.newChatItems + → ChatModel.chats updated with new preview + → ItemsModel.reversedChatItems gets new item + → ChatView scrolls to bottom, shows sent message +``` + +--- + +## 8. Preference Storage + +### UserDefaults (via @AppStorage) + +App-level UI settings stored in `UserDefaults.standard`: + +| Key Constant | Type | Description | +|--------------|------|-------------| +| `DEFAULT_PERFORM_LA` | `Bool` | Enable local authentication | +| `DEFAULT_PRIVACY_PROTECT_SCREEN` | `Bool` | Hide screen in app switcher | +| `DEFAULT_SHOW_LA_NOTICE` | `Bool` | Show LA setup notice | +| `DEFAULT_NOTIFICATION_ALERT_SHOWN` | `Bool` | Notification permission alert shown | +| `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` | `Bool` | Show CallKit calls in recents | + +### GroupDefaults + +Settings shared between main app and extensions (NSE, SE) via app group `UserDefaults`: + +| Key | Description | +|-----|-------------| +| `appStateGroupDefault` | Current app state (.active/.suspended/.stopped) | +| `dbContainerGroupDefault` | Database container location (.group/.documents) | +| `ntfPreviewModeGroupDefault` | Notification preview mode | +| `storeDBPassphraseGroupDefault` | Whether to store DB passphrase | +| `callKitEnabledGroupDefault` | Whether CallKit is enabled | +| `onboardingStageDefault` | Current onboarding stage | +| `currentThemeDefault` | Current theme name | +| `systemDarkThemeDefault` | Dark mode theme name | +| `themeOverridesDefault` | Custom theme overrides | +| `currentThemeIdsDefault` | Active theme override IDs | + +### Keychain (KeyChain wrapper) + +Sensitive data stored in iOS Keychain: + +| Key | Description | +|-----|-------------| +| `kcDatabasePassword` | SQLite database encryption key | +| `kcAppPassword` | App lock password | +| `kcSelfDestructPassword` | Self-destruct trigger password | + +### Haskell DB (via apiSaveSettings / apiGetSettings) + +Chat-level preferences stored in the SQLite database (managed by Haskell core): + +- Per-contact preferences (timed messages, voice, calls, etc.) +- Per-group preferences +- Per-user notification settings +- Network configuration +- Server lists + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatModel, ItemsModel, Chat, ChatTagsModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) | +| ChatInfo, User, Contact, GroupInfo, ChatItem | [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift) | +| ActiveFilter | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift#L52) | +| Preference defaults | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift), [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | From fef919edd97f76a689380c9c398979a71b95b334 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sat, 21 Feb 2026 17:25:10 +0000 Subject: [PATCH 012/112] website: translations (#6649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (French) Currently translated at 77.7% (244 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (German) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Russian) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Indonesian) Currently translated at 86.6% (272 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/id/ * Translated using Weblate (German) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (French) Currently translated at 77.7% (244 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/ * Translated using Weblate (German) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Russian) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (314 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Indonesian) Currently translated at 86.6% (272 of 314 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/id/ * Translated using Weblate (German) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Japanese) Currently translated at 84.7% (267 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/ * Translated using Weblate (Indonesian) Currently translated at 86.6% (273 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/id/ * Translated using Weblate (Indonesian) Currently translated at 86.9% (274 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/id/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/ * Translated using Weblate (Czech) Currently translated at 90.7% (286 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/cs/ * Translated using Weblate (Russian) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 91.4% (288 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/ * Translated using Weblate (Turkish) Currently translated at 73.9% (233 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/tr/ * Translated using Weblate (Polish) Currently translated at 89.2% (281 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Czech) Currently translated at 99.0% (312 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/cs/ * Translated using Weblate (Polish) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pl/ * Translated using Weblate (Finnish) Currently translated at 66.3% (209 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fi/ * Translated using Weblate (Ukrainian) Currently translated at 73.0% (230 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/uk/ * Translated using Weblate (Arabic) Currently translated at 100.0% (315 of 315 strings) Translation: SimpleX Chat/SimpleX Chat website Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/ --------- Co-authored-by: Zephyris Co-authored-by: mlanp Co-authored-by: Random Co-authored-by: No name Co-authored-by: Skyward Copied Co-authored-by: summoner001 Co-authored-by: Rafi Co-authored-by: Ghost of Sparta Co-authored-by: Miyu Sakatsuki Co-authored-by: Dima Sivan Co-authored-by: noname Co-authored-by: Sarah Camila Lima Co-authored-by: Abdullah Koyuncu Co-authored-by: Wiesław Fijołek Co-authored-by: zenobit Co-authored-by: Michał Korczak Co-authored-by: dsflsdlf --- website/langs/ar.json | 7 ++- website/langs/cs.json | 104 ++++++++++++++++++++++++++-------- website/langs/de.json | 98 ++++++++++++++++---------------- website/langs/es.json | 4 +- website/langs/fi.json | 9 ++- website/langs/fr.json | 28 +++++++++- website/langs/hu.json | 34 +++++------ website/langs/id.json | 10 ++-- website/langs/it.json | 6 +- website/langs/ja.json | 44 ++++++++++++++- website/langs/pl.json | 118 +++++++++++++++++++++++++++++---------- website/langs/pt_BR.json | 62 +++++++++++++++++++- website/langs/ru.json | 4 +- website/langs/tr.json | 11 ++-- website/langs/uk.json | 5 +- 15 files changed, 403 insertions(+), 141 deletions(-) diff --git a/website/langs/ar.json b/website/langs/ar.json index 7467f657a9..e040826395 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -31,7 +31,7 @@ "simplex-explained-tab-2-p-1": "لكل اتصال، تستخدم قائمتي انتظار منفصلتين للمُراسلة لإرسال واستلام الرسائل عبر خوادم مختلفة.", "simplex-explained-tab-2-p-2": "تمرّر الخوادم الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", "simplex-explained-tab-3-p-1": "تحتوي الخوادم على بيانات اعتماد مجهولة منفصلة لكل قائمة انتظار، ولا تعرف المستخدمين الذين ينتمون إليهم.", - "copyright-label": "مشروع مفتوح المصدر © SimpleX 2020-2025", + "copyright-label": "مشروع مفتوح المصدر © © 2020-2025 SimpleX Chat | مشروع مفتوح المصدرSimpleX 2020-2025", "simplex-chat-protocol": "بروتوكول دردشة SimpleX", "developers": "المطورين", "hero-subheader": "أول نظام مُراسلة
دون معرّفات مُستخدم", @@ -310,5 +310,8 @@ "messengers-comparison-section-list-point-5": "تبادل المفاتيح الثنائي اختياري عبر التحقق من رمز الأمان.", "messengers-comparison-section-list-point-6": "اتفاقية مفتاح ما بعد الكم \"متفرقة\" — فهي تحمي بعض خطوات ratchet فقط.", "index-roadmap-2026": "2026", - "index-roadmap-2027": "2027" + "index-roadmap-2027": "2027", + "navbar-token": "رمز", + "index-token-cta": "تعرف على المزيد واحصل على NFT مجاني
للاختبار المبكر.", + "navbar-old-site": "الموقع القديم" } diff --git a/website/langs/cs.json b/website/langs/cs.json index fc609128bf..46a960359d 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -1,12 +1,12 @@ { "simplex-private-card-10-point-2": "Umožňuje doručovat zprávy bez identifikátoru uživatelských profilů, což poskytuje lepší soukromí metadat než alternativy.", "simplex-unique-4-overlay-1-title": "Plně decentralizované — uživatelé vlastní síť SimpleX", - "hero-overlay-card-1-p-6": "Přečtěte si více v SimpleX whitepaper.", + "hero-overlay-card-1-p-6": "Přečtěte si více v SimpleX whitepaper.", "hero-overlay-card-1-p-2": "K doručování zpráv používá SimpleX namísto ID uživatelů používaných všemi ostatními sítěmi, dočasné anonymní párové identifikátory front zpráv, oddělené pro každé z vašich připojení — neexistují žádné dlouhodobé identifikátory.", "hero-overlay-card-1-p-3": "Definujete, které servery se mají používat k přijímání zpráv, vašich kontaktů — servery, které používáte k odesílání zpráv. Každá konverzace bude pravděpodobně používat dva různé servery.", "hero-overlay-card-2-p-3": "I v těch nejsoukromějších aplikacích, které používají služby Tor v3, pokud mluvíte se dvěma různými kontakty prostřednictvím stejného profilu, může být prokázáno, že jsou spojeni se stejnou osobou.", - "simplex-network-overlay-card-1-p-1": "P2P protokoly a aplikace pro zasílání zpráv mají různé problémy, které je činí méně spolehlivými než SimpleX, složitějšími na analýzu a zranitelnými vůči několika typům útoků.", - "simplex-network-overlay-card-1-li-1": "P2P sítě spoléhají na nějakou variantu DHT pro směrování zpráv. Návrhy DHT musí vyvážit záruku dodávky a latenci. SimpleX má lepší záruku doručení a nižší latenci než P2P, protože zpráva může být redundantně předána přes několik serverů paralelně pomocí serverů vybraných příjemcem. V P2P sítích je zpráva předávána přes uzly O(log N) postupně pomocí uzlů vybraných algoritmem.", + "simplex-network-overlay-card-1-p-1": "P2P protokoly a aplikace pro zasílání zpráv mají různé problémy, které je činí méně spolehlivými než SimpleX, složitějšími na analýzu a zranitelnými vůči několika typům útoků.", + "simplex-network-overlay-card-1-li-1": "P2P sítě spoléhají na nějakou variantu DHT pro směrování zpráv. Návrhy DHT musí vyvážit záruku dodávky a latenci. SimpleX má lepší záruku doručení a nižší latenci než P2P, protože zpráva může být zároveň předávána přes několik serverů pomocí serverů vybraných příjemcem. V P2P sítích je zpráva předávána přes uzly O(log N) postupně pomocí uzlů vybraných algoritmem.", "home": "Úvod", "developers": "Vývojáři", "reference": "Odkazy", @@ -85,14 +85,14 @@ "hero-overlay-card-2-p-2": "Tyto informace by pak mohli korelovat se stávajícími veřejnými sociálními sítěmi a určit některé skutečné identity.", "hero-overlay-card-2-p-4": "SimpleX chrání před těmito útoky tím, že nemá žádné ID uživatele ve svém designu. A pokud používáte režim Inkognito, budete mít pro každý kontakt jiné zobrazované jméno, čímž se vyhnete sdílení dat mezi nimi.", "simplex-network-overlay-card-1-li-2": "SimpleX, na rozdíl od většiny P2P sítí, nemá žádné globální uživatelské identifikátory jakéhokoli druhu, dokonce ani dočasné, a používá pouze dočasné párové identifikátory, které poskytují lepší anonymitu a ochranu metadat.", - "simplex-network-overlay-card-1-li-3": "P2P neřeší problém MITM útoku a většina existujících implementací nepoužívá out-of-band zprávy pro počáteční výměnu klíčů. SimpleX používá out-of-band zprávy nebo v některých případech již existující zabezpečená a důvěryhodná připojení pro počáteční výměnu klíčů.", - "simplex-network-overlay-card-1-li-4": "Implementace P2P mohou být některými poskytovateli internetu blokovány (například BitTorrent). SimpleX je transportně nezávislý — může pracovat přes standardní webové protokoly, např. WebSockety.", + "simplex-network-overlay-card-1-li-3": "P2P neřeší problém MITM útoku a většina existujících implementací nepoužívá out-of-band zprávy pro počáteční výměnu klíčů. SimpleX používá out-of-band zprávy nebo v některých případech již existující zabezpečená a důvěryhodná připojení pro počáteční výměnu klíčů.", + "simplex-network-overlay-card-1-li-4": "Implementace P2P mohou být některými poskytovateli internetu blokovány (například BitTorrent). SimpleX je transportně nezávislý — může pracovat přes standardní webové protokoly, např. WebSockety.", "privacy-matters-overlay-card-1-p-1": "Mnoho velkých společností používá informace o tom, s kým jste ve spojení, k odhadu vašich příjmů, prodeji produktů, které ve skutečnosti nepotřebujete, a ke stanovení cen.", - "privacy-matters-overlay-card-1-p-3": "Některé finanční a pojišťovací společnosti používají sociální grafy k určení úrokových sazeb a pojistného. Často přiměje lidi s nižšími příjmy platit více —, je to známé jako „prémie za chudobu“.", - "privacy-matters-overlay-card-2-p-1": "Není to tak dávno, co jsme pozorovali, jak velké volby zmanipulovala renomovaná poradenská společnost, která používala naše sociální grafy ke zkreslení našeho pohledu na skutečný svět a manipulovala s našimi hlasy.", + "privacy-matters-overlay-card-1-p-3": "Některé finanční a pojišťovací společnosti používají sociální grafy k určení úrokových sazeb a pojistného. Často přiměje lidi s nižšími příjmy platit více —, je to známé jako \"prémie za chudobu\".", + "privacy-matters-overlay-card-2-p-1": "Není to tak dávno, co jsme pozorovali, jak velké volby zmanipulovala renomovaná poradenská společnost, která používala naše sociální grafy ke zkreslení našeho pohledu na skutečný svět a manipulovala s našimi hlasy.", "privacy-matters-overlay-card-2-p-3": "SimpleX je první síť, která nemá žádné uživatelské identifikátory záměrně, a tímto způsobem chrání váš graf připojení lépe než jakákoli známá alternativa.", "privacy-matters-overlay-card-3-p-1": "Každý by se měl starat o soukromí a bezpečnost své komunikace — neškodné rozhovory vás mohou vystavit nebezpečí, i když nemáte co skrývat.", - "privacy-matters-overlay-card-3-p-3": "Obyčejní lidé jsou zatčeni za to, co sdílejí online, dokonce i prostřednictvím svých „anonymních“ účtů, i v demokratických zemích.", + "privacy-matters-overlay-card-3-p-3": "Obyčejní lidé jsou zatčeni za to, co sdílejí online, dokonce i prostřednictvím svých \"anonymních\" účtů, i v demokratických zemích.", "privacy-matters-overlay-card-3-p-4": "Nestačí používat end-to-end šifrovaný messenger, všichni bychom měli používat messengery, které chrání soukromí našich osobních sítí — s kým jsme spojeni.", "simplex-unique-overlay-card-1-p-3": "Tento design chrání soukromí těch, s kým komunikujete, skrývá ho před servery sítě SimpleX a před jakýmikoli pozorovateli. Pro skrytí své IP adresy před servery, můžete se připojit k serverům SimpleX přes Tor.", "simplex-unique-overlay-card-2-p-1": "Protože na síti SimpleX nemáte žádný identifikátor, nikdo vás nemůže kontaktovat pokud nenasdílíte jednorázovou nebo dočasnou uživatelskou adresu, jako QR kód nebo odkaz.", @@ -100,7 +100,7 @@ "simplex-unique-overlay-card-3-p-2": "End-to-end šifrované zprávy jsou dočasně uchovávány na přenosových serverech SimpleX, dokud nejsou přijaty, poté jsou trvale odstraněny.", "simplex-unique-overlay-card-3-p-3": "Na rozdíl od serverů federovaných sítí (e-mail, XMPP nebo Matrix) servery SimpleX neukládají uživatelské účty, pouze předávají zprávy, čímž chrání soukromí obou stran.", "simplex-unique-overlay-card-4-p-1": "Můžete použít SimpleX se svými vlastními servery a přesto komunikovat s lidmi, kteří používají přednastavené servery v aplikacích.", - "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", + "simplex-unique-overlay-card-4-p-3": "Pokud uvažujete o vývoji pro SimpleX síť, například chat bot pro uživatele aplikace SimpleX nebo integraci knihovny SimpleX chat do Vasí mobilní aplikace, prosím buďte ve spojení pro jakoukoli radu a podporu.", "simplex-unique-card-1-p-1": "SimpleX chrání soukromí vašeho profilu, kontaktů a metadat a skrývá je před servery SimpleX sítě a jakýmikoli pozorovateli.", "simplex-unique-card-1-p-2": "Na rozdíl od jakékoli jiné existující síti pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům — ani náhodná čísla.", "simplex-unique-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", @@ -115,7 +115,7 @@ "sign-up-to-receive-our-updates": "Přihlaste se k odběru novinek", "enter-your-email-address": "vložte svou e-mailovou adresu", "get-simplex": "Získat SimpleX desktop app", - "why-simplex-is-unique": "Proč je SimpleX jedinečný", + "why-simplex-is-unique": "Proč je SimpleX jedinečný", "learn-more": "Další informace", "more-info": "Více informací", "hide-info": "Skrýt informace", @@ -128,7 +128,7 @@ "install-simplex-app": "Instalace aplikace SimpleX", "connect-in-app": "Se připojit v aplikaci", "open-simplex-app": "Otevřete aplikaci Simplex", - "tap-the-connect-button-in-the-app": "Klepněte na tlačítko ‘připojit‘ v aplikaci", + "tap-the-connect-button-in-the-app": "Klepněte na tlačítko \"připojit\" v aplikaci", "scan-the-qr-code-with-the-simplex-chat-app": "Naskenujte QR kód pomocí aplikace SimpleX Chat", "installing-simplex-chat-to-terminal": "Instalace SimpleX chat do terminálu", "use-this-command": "Použijte tento příkaz:", @@ -137,19 +137,19 @@ "the-instructions--source-code": "Pro návod, jak stáhnout nebo zkompilovat ze zdrojového kódu.", "simplex-chat-for-the-terminal": "SimpleX Chat pro terminál", "copy-the-command-below-text": "Zkopírujte níže uvedený příkaz a použijte jej v chatu:", - "privacy-matters-section-header": "Proč na soukromí záleží", - "privacy-matters-section-subheader": "Zachování soukromí vašich metadat — s kým mluvíte — vás chrání před:", + "privacy-matters-section-header": "Proč na soukromí záleží", + "privacy-matters-section-subheader": "Zachování soukromí vašich metadat — s kým mluvíte — vás chrání před:", "privacy-matters-section-label": "Ujistěte se, že váš messenger nemá přístup k vašim datům!", - "simplex-private-section-header": "Co dělá SimpleX soukromým", + "simplex-private-section-header": "Co dělá SimpleX soukromým", "tap-to-close": "Klepnutím zavřete", - "simplex-network-section-header": "SimpleX Síť", + "simplex-network-section-header": "SimpleX Síť", "simplex-network-section-desc": "Simplex Chat poskytuje nejlepší soukromí tím, že kombinuje výhody P2P a federovaných sítí.", "simplex-network-1-header": "Na rozdíl od P2P sítí", "simplex-network-1-overlay-linktext": "problémům P2P sítí", "simplex-network-2-header": "Na rozdíl od federovaných sítí", "simplex-network-2-desc": "Přenosové servery SimpleX NEUKLADAJÍ uživatelské profily, kontakty a doručené zprávy, NEPŘIPOJUJÍ se k sobě a NEEXISTUJE ŽÁDNÝ adresář serverů.", "simplex-network-3-header": "SimpleX síť", - "simplex-network-3-desc": "servery poskytují jednosměrné fronty pro připojení uživatelů, ale nemají žádnou viditelnost grafu síťového připojení — pouze uživatelé mají.", + "simplex-network-3-desc": "servery poskytují jednosměrné fronty pro připojení uživatelů, ale nemají žádnou viditelnost grafu síťového připojení — pouze uživatelé mají.", "comparison-section-header": "Srovnání s jinými protokoly", "protocol-1-text": "Signál, velké platformy", "protocol-2-text": "XMPP, Matrix", @@ -174,20 +174,20 @@ "comparison-section-list-point-6": "Zatímco P2P jsou distribuovány, nejsou federované — fungují jako jediná síť", "comparison-section-list-point-7": "P2P sítě mají buď centrální autoritu, nebo může být ohrožena celá síť", "see-here": "viz zde", - "simplex-network-overlay-card-1-li-5": "Všechny známé P2P sítě mohou být zranitelné vůči Sybil útoku, protože každý uzel je zjistitelný a síť funguje jako celek. Známá opatření ke zmírnění tohoto problému vyžadují buď centralizovanou součást, nebo drahé prokázání práce. Síť SimpleX nemá možnost zjistitelnosti serveru, je fragmentovaná a funguje jako několik izolovaných podsítí, což znemožňuje útoky v celé síti.", - "simplex-network-overlay-card-1-li-6": "Sítě P2P mohou být zranitelné vůči útoku DRDoS, kdy klienti mohou znovu vysílat a zesílit provoz, což vede k odmítnutí služby v celé síti. SimpleX klienti pouze přenášejí provoz ze známého spojení a nemohou být zneužiti útočníkem k zesílení provozu v celé síti.", + "simplex-network-overlay-card-1-li-5": "Všechny známé P2P sítě mohou být zranitelné vůči Sybil útoku, protože každý uzel je zjistitelný a síť funguje jako celek. Známá opatření ke zmírnění tohoto problému vyžadují buď centralizovanou součást, nebo drahé prokázání práce. Síť SimpleX nemá možnost zjistitelnosti serveru, je fragmentovaná a funguje jako několik izolovaných podsítí, což znemožňuje útoky v celé síti.", + "simplex-network-overlay-card-1-li-6": "Sítě P2P mohou být zranitelné vůči útoku DRDoS, kdy klienti mohou znovu vysílat a zesílit provoz, což vede k odmítnutí služby v celé síti. SimpleX klienti pouze přenášejí provoz ze známého spojení a nemohou být zneužiti útočníkem k zesílení provozu v celé síti.", "privacy-matters-overlay-card-1-p-2": "Internetoví prodejci vědí, že lidé s nižšími příjmy častěji provádějí urgentní nákupy, takže mohou účtovat vyšší ceny nebo odebírat slevy.", "privacy-matters-overlay-card-1-p-4": "SimpleX síť chrání soukromí vašich připojení lépe než jakákoli jiná alternativa a plně zabraňuje tomu, aby byl váš sociální graf dostupný všem společnostem nebo organizacím. I když lidé používají servery přednastavené v SimpleX Chat apce, operátoři serverů neznají počet uživatelů ani jejich připojení.", "privacy-matters-overlay-card-2-p-2": "Chcete-li být objektivní a činit nezávislá rozhodnutí, musíte mít svůj informační prostor pod kontrolou. Je to možné pouze v případě, že používáte soukromou komunikační síť, která nemá přístup k vašemu sociálnímu grafu.", - "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", - "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", + "simplex-unique-overlay-card-1-p-2": "K doručování zpráv SimpleX používá párové anonymní adresy jednosměrných front zpráv, oddělených pro přijaté a odeslané zprávy, obvykle přes různé servery.", + "privacy-matters-overlay-card-3-p-2": "Jedním z nejvíce šokujících příběhů je zkušenost Mohamedoua Oulda Salahiho popsaná v jeho pamětech a zobrazená v Mauritánském filmu. Byl umístěn do tábora na Guantánamu bez soudu a byl tam 15 let mučen po telefonátu svému příbuznému v Afghánistánu pro podezření z účasti na útocích z 11. září, i když předchozích 10 let žil v Německu.", "simplex-unique-overlay-card-1-p-1": "Na rozdíl od jiných sítí pro zasílání zpráv nemá SimpleX žádné identifikátory přiřazené uživatelům. Nespoléhá na telefonní čísla, adresy založené na doméně (jako e-mail nebo XMPP), uživatelská jména, veřejné klíče nebo dokonce náhodná čísla k identifikaci svých uživatelů — Operátoři SimpleX serverů neví, kolik lidí používá jejich servery.", "invitation-hero-header": "Byl vám zaslán odkaz pro připojení na SimpleX Chat", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat ukládá všechna uživatelská data pouze na klientských zařízeních pomocí přenosného šifrovaného databázového formátu, který lze exportovat a přenést na jakékoli podporované zařízení.", "contact-hero-p-1": "Veřejné klíče a adresa fronty zpráv v tomto odkazu NEJSOU při zobrazení této stránky odesílány přes síť — jsou obsaženy ve fragmentu kontrolního součtu adresy URL odkazu.", "simplex-unique-overlay-card-3-p-4": "Mezi odeslaným a přijatým provozem serveru nejsou žádné společné identifikátory ani šifrovaný text — pokud to někdo pozoruje, nemůže snadno určit, kdo s kým komunikuje, i když je TLS kompromitován.", "simplex-unique-card-2-p-1": "Protože na SimpleX síti nemáte žádný identifikátor nebo pevnou adresu, nikdo vás nemůže kontaktovat pokud nenasdílíte jednorázovou nebo dočasnou uživatelskou adresu, jako QR kód nebo odkaz.", - "simplex-unique-overlay-card-4-p-2": "SimpleX síť používá otevřený protokol a poskytuje SDK k vytváření chat botů, což umožní implementaci služeb, díky nimž boudou moci uživatelé komunikovat prostřednictvím aplikací SimpleX Chat — Opravdu se těšíme, jaké služby SimpleX vytvoříte.", + "simplex-unique-overlay-card-4-p-2": "SimpleX síť používá otevřený protokol a poskytuje SDK k vytváření chat botů, což umožní implementaci služeb, díky nimž boudou moci uživatelé komunikovat prostřednictvím aplikací SimpleX Chat — Opravdu se těšíme, jaké služby SimpleX vytvoříte.", "simplex-network": "SimpleX síť", "simplex-explained-tab-2-p-1": "Pro každé připojení používáte dvě samostatné fronty zasílání zpráv k odesílání a přijímání zpráv prostřednictvím různých serverů.", "simplex-explained-tab-1-p-1": "Můžete vytvářet kontakty a skupiny a vést obousměrné konverzace, stejně jako v jakémkoli jiném messengeru.", @@ -235,8 +235,8 @@ "hero-overlay-3-title": "Hodnocení zabezpečení", "hero-overlay-3-textlink": "Hodnocení zabezpečení", "hero-overlay-card-3-p-1": "Trail of Bits je přední bezpečnostní a technologické poradenství, jejichž klienti zahrnují velké technologické firmy, vládní agentury a významné blockchainové projekty.", - "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", + "f-droid-page-simplex-chat-repo-section-text": "Chcete-li jej přidat do vašeho F-Droid clienta, naskenujte QR kód nebo použijte tuto adresu URL:", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat a F-Droid.org repozitáře jsou podepsané různými klíči. Chcete-li přepnout, prosím exportujte chat databázi a přeinstalujte aplikaci.", "comparison-section-list-point-4a": "SimpleX relé nemůže ohrozit šifrování e2e. Ověřte bezpečnostní kód, který zmírňuje mimo pásmový útok na kanál", "docs-dropdown-8": "SimpleX Directory", "please-enable-javascript": "Prosím, povolte JavaScript k zobrazení QR kódu.", @@ -257,5 +257,61 @@ "hero-overlay-card-3-p-3": "Trail of Bits přezkoumala kryptografický design síťových protokolů SimpleX v červenci 2024. Přečíst více.", "docs-dropdown-14": "SimpleX pro podnikání", "directory": "Složka", - "about-and-contact-us": "O nás & Kontakt" + "about-and-contact-us": "O nás & Kontakt", + "navbar-token": "Token", + "index-hero-h1": "Žít
svobodně", + "index-hero-h2": "Svoboda a Bezpečnost Vaší Komunikace", + "index-hero-p1": "První síť, kde vaše identita, kontakty a skupiny patří vám.", + "index-hero-download-desktop-btn-title": "Stáhněte si desktopovou aplikaci SimpleX", + "index-testflight-title": "Beta verze SimpleX pro iOS na TestFlight", + "index-f-droid-title": "Stáhnout aplikaci SimpleX přes F-Droid", + "index-security-assessment-title": "Bezpečnostní audity", + "index-security-review-2022-title": "Bezpečnostní audit 2022", + "index-security-review-2024-title": "Bezpečnostní audit 2024", + "index-security-audits-label": "Bezpečnostní
audity", + "index-publications-privacy-guides-title": "Doporučení messengerů od Privacy Guides", + "index-publications-whonix-title": "doporučení messengerů od Whonix", + "index-publications-heise-title": "Publikace Heise Online", + "index-publications-kuketz-title": "Rezence od Mike Kuketz", + "index-publications-optout-title": "Rozhovor v podcastu OptOut", + "worlds-most-secure-messaging": "Nejbezpečnější komunikační platforma na světě", + "index-messaging-p1": "Zprávy v SimpleX jsou chráněny nejpokročilejším koncovým šifrováním (end-to-end).", + "index-messaging-p2": "Pro vaše soukromí servery nevidí vaše zprávy ani to, s kým si píšete.", + "index-messaging-cta": "Zjistit více o zprávách v SimpleX", + "index-nextweb-h2": "Váš internet
budoucnosti", + "index-nextweb-p1": "SimpleX je postaven na myšlence, že vaše data, kontakty a skupiny patří vám.", + "index-nextweb-p2": "Otevřená decentralizovaná síť vám umožňuje spojovat se s ostatními a komunikovat svobodně a bezpečně.", + "index-token-h2": "Stabilní komunity", + "index-token-p1": "Své oblíbené skupiny budete moci podpořit pomocí připravovaných Skupinových Voucherů.", + "index-token-p2": "Vouchery budou sloužit k úhradě provozu serverů, aby skupiny zůstaly svobodné a nezávislé.", + "index-token-cta": "Zjistěte více a získejte bezplatný přístup do testování.", + "index-roadmap-h2": "Plán SimpleX ke svobodnému internetu", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Škálování pro velké komunity", + "index-roadmap-2025-desc": "Odchod od centralizovaných platforem", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Udržitelné komunity a servery", + "index-roadmap-2026-desc": "Spuštění Skupinových Voucherů", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Pomozte své komunitě růst", + "index-roadmap-2027-desc": "Nástroje na podporu vašich komunit", + "index-directory-h2": "Zapojte se do komunit SimpleX", + "index-directory-p1": "Statisíce lidí už důvěřují komunikaci přes SimpleX.", + "index-directory-p2": "Najděte své komunity v katalogu SimpleX a vytvořte si vlastní!", + "index-directory-cta": "Zobrazit katalog SimpleX", + "index-directory-users-group-title": "Uživatelské skupiny SimpleX", + "how-secure-comparison-title": "Porovnání bezpečnosti koncového šifrování (end-to-end) v různých messengerech", + "how-secure-message-padding": "Doplnění zpráv", + "how-secure-repudiation-deniability": "Možnost popřít autorství zprávy", + "how-secure-forward-secrecy": "Ochrana minulých zpráv při úniku klíčů", + "how-secure-break-in-recovery": "Bezpečnost po kompromitaci", + "how-secure-two-factor-key-exchange": "Dvoufaktorová výměna klíčů", + "how-secure-post-quantum-hybrid-crypto": "Hybridní šifrování odolné vůči kvantovým počítačům", + "messengers-comparison-section-list-point-1": "Briar zarovnává velikost zpráv na 1024 bajtů, zatímco Signal na 160 bajtů", + "messengers-comparison-section-list-point-2": "Popiratelnost se netýká komunikace mezi klientem a serverem.", + "messengers-comparison-section-list-point-3": "Použití kryptografických podpisů může oslabit popiratelnost, ale tato otázka vyžaduje další upřesnění.", + "messengers-comparison-section-list-point-4": "Podpora více zařízení může oslabit post-kompromitační bezpečnost protokolu Double Ratchet", + "messengers-comparison-section-list-point-5": "Dvoufaktorovou výměnu klíčů lze volitelně aktivovat pomocí ověření bezpečnostním kódem.", + "messengers-comparison-section-list-point-6": "Postkvantová dohoda o klíči je omezená — chrání pouze některé kroky ratchet mechanismu.", + "navbar-old-site": "Starý web" } diff --git a/website/langs/de.json b/website/langs/de.json index 18f56b5066..fe5bce826d 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -23,7 +23,7 @@ "donate": "Spenden", "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source-Projekt", "chat-protocol": "Chat-Protokoll", - "simplex-chat-protocol": "SimpleX-Chat-Protokoll", + "simplex-chat-protocol": "SimpleX Chat-Protokoll", "terminal-cli": "Terminal-Kommandozeilen-Schnittstelle", "terms-and-privacy-policy": "Datenschutzrichtlinie", "hero-header": "Privatsphäre neu definiert", @@ -44,7 +44,7 @@ "feature-6-title": "Ende-zu-Ende-verschlüsselte Sprach- und Videoanrufe", "feature-7-title": "Portable und verschlüsselte App-Datenspeicherung — verschieben Sie das komplette Profil einfach auf ein anderes Gerät", "feature-8-title": "Inkognito-Modus —
Einzigartig in SimpleX Chat", - "simplex-network-overlay-1-title": "Vergleich mit P2P Nachrichten-Protokollen", + "simplex-network-overlay-1-title": "Vergleich mit P2P-Nachrichten-Protokollen", "simplex-private-1-title": "Zwei Schichten der
Ende-zu-Ende-Verschlüsselung", "simplex-private-2-title": "Zusätzliche Schicht der
Server-Verschlüsselung", "simplex-private-3-title": "Sichere authentifizierte
TLS-Transportschicht", @@ -62,7 +62,7 @@ "simplex-private-card-3-point-3": "Die Wiederaufnahme von Verbindungen ist deaktiviert, um Sitzungs-Angriffe zu verhindern.", "simplex-private-card-4-point-1": "Um Ihre IP-Adresse zu schützen, können Sie per Tor oder irgendeinem anderen Transportschichten-Netzwerk auf Server zugreifen.", "simplex-private-card-4-point-2": "Um SimpleX per Tor zu nutzen, installieren Sie unter Android bitte die Orbot-App und aktivieren Sie den SOCKS5-Proxy oder unter iOS per VPN.", - "simplex-private-card-5-point-1": "SimpleX nutzt Inhalte-Auffüllung für jede Verschlüsselungs-Schicht, um Angriffe auf die Nachrichtengröße zu vereiteln.", + "simplex-private-card-5-point-1": "SimpleX nutzt Inhalteauffüllung für jede Verschlüsselungs-Schicht, um Angriffe auf die Nachrichtengröße zu vereiteln.", "simplex-private-card-5-point-2": "Erzeugt Nachrichten mit unterschiedlichen Größen, die für Server und Netzwerk-Beobachter identisch aussehen.", "simplex-private-card-6-point-1": "Viele Kommunikations-Netzwerke sind für MITM-Angriffe durch Server oder Netzwerk-Anbieter anfällig.", "simplex-private-card-9-point-1": "Jede Nachrichten-Warteschlange leitet Nachrichten mit unterschiedlichen Sende- und Empfängeradressen jeweils nur in einer Richtung weiter.", @@ -92,7 +92,7 @@ "simplex-private-card-8-point-1": "Die SimpleX-Server arbeiten als Mix-Knoten mit geringer Verzögerung — eingehende und ausgehende Nachrichten haben eine unterschiedliche Reihenfolge.", "hero-overlay-card-1-p-3": "Sie definieren, welche(n) Server Sie für den Empfang von Nachrichten nutzen. Ihre Kontakte — nutzen diese Server, um ihnen Nachrichten darüber zu senden. Jede Konversation nutzt üblicherweise also zwei unterschiedliche Server.", "hero-overlay-card-1-p-5": "Die Benutzer-Profile, Kontakte und Gruppen werden nur auf den Endgerät des Nutzers gespeichert. Die Nachrichten werden mit einer 2-Schichten-Ende-zu-Ende-Verschlüsselung versendet.", - "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX-Whitepaper.", + "hero-overlay-card-1-p-6": "Lesen Sie mehr darüber im SimpleX Whitepaper.", "hero-overlay-card-2-p-2": "Sie können diese Informationen mit bestehenden öffentlichen sozialen Netzwerken korrelieren und damit wahre Identitäten herausfinden.", "hero-overlay-card-2-p-3": "Wenn Sie sich mit zwei unterschiedlichen Kontakten über dasselbe Profil unterhalten, können sie, selbst bei sehr auf Privatsphäre bedachten Apps, die Tor-v3-Dienste nutzen, feststellen, dass diese Kontakte mit derselben Person verbunden sind.", "hero-overlay-card-1-p-2": "Um Nachrichten auszuliefern, nutzt SimpleX statt Benutzerkennungen wie alle anderen Netzwerke nur temporäre, anonyme und paarweise Kennungen für Nachrichten-Warteschlangen, die für jede Ihrer Verbindungen unterschiedlich sind — es gibt keinerlei Langzeit-Kennungen.", @@ -100,26 +100,26 @@ "hero-overlay-card-2-p-1": "Wenn Nutzer dauerhafte Identitäten besitzen, selbst wenn diese eine Zufallsnummer, wie eine Sitzungs-ID, ist, besteht ein Risiko, das Provider oder Angreifer feststellen können, wie Nutzer miteinander verbunden sind und wie viele Nachrichten sie versenden.", "hero-overlay-card-2-p-4": "SimpleX schützt gegen solche Angriffe, weil es vom Design her keinerlei Benutzerkennungen besitzt. Und Sie haben sogar unterschiedliche Anzeigenamen für jeden Kontakt und vermeiden jegliche geteilte Daten zwischen diesen, wenn Sie den Inkognito-Modus nutzen.", "simplex-network-overlay-card-1-p-1": "Peer-to-Peer-Nachrichten-Protokolle und -Applikationen haben verschiedene Probleme, die diese weniger vertrauenswürdig, die Analyse wesentlich komplexer und anfälliger gegen verschiedene Arten von Angriffen, als bei SimpleX machen.", - "simplex-network-overlay-card-1-li-2": "Das SimpleX Design hat, im Gegensatz zu den meisten P2P-Netzwerken, keinerlei globalen Benutzerkennungen, auch keine temporären. Es nutzt ausschließlich temporäre paarweise Kennungen, die bessere Anonymität und Metadaten-Schutz bieten.", + "simplex-network-overlay-card-1-li-2": "Das SimpleX-Design hat, im Gegensatz zu den meisten P2P-Netzwerken, keinerlei globalen Benutzerkennungen, auch keine temporären. Es nutzt ausschließlich temporäre paarweise Kennungen, die bessere Anonymität und Metadaten-Schutz bieten.", "simplex-network-overlay-card-1-li-4": "——P2P-Implementierungen können durch Internetanbieter blockiert werden, wie beispielweise BitTorrent). SimpleX ist transportunabhängig — es kann über Standard-Web-Protokolle, wie beispielsweise WebSockets, arbeiten.", "simplex-network-overlay-card-1-li-6": "P2P-Netzwerke können anfällig für DRDoS-Angriffe sein, wenn die Clients den Datenverkehr erneut senden und verstärken können, was zu einem netzwerkweiten Denial-of-Service führt. SimpleX-Clients leiten nur Datenverkehr von bekannten Verbindungen weiter und können von einem Angreifer nicht dazu verwendet werden, den Datenverkehr im gesamten Netzwerk zu verstärken.", "privacy-matters-overlay-card-1-p-1": "Viele große Unternehmen nutzen Informationen, mit wem Sie in Verbindung stehen, um Ihr Einkommen zu schätzen, Ihnen Produkte zu verkaufen, die Sie nicht wirklich benötigen und um die Preise zu bestimmen.", "privacy-matters-overlay-card-1-p-2": "Online-Händler wissen, dass Menschen mit geringerem Einkommen eher dringende Einkäufe tätigen, sodass sie möglicherweise höhere Preise verlangen können oder Rabatte streichen.", "privacy-matters-overlay-card-1-p-3": "Einige Finanz- und Versicherungsunternehmen verwenden soziale Graphen, um Zinssätze und Prämien zu ermitteln. Menschen mit niedrigerem Einkommen zahlen so häufig mehr — dies ist als \"Armutsprämie\" bekannt.", "privacy-matters-overlay-card-2-p-2": "Um objektiv zu sein und unabhängige Entscheidungen treffen zu können, müssen Sie die Kontrolle über Ihren Informationsraum haben. Dies ist nur möglich, wenn Sie ein privates Kommunikations-Netzwerk verwenden, welches keinen Zugriff auf Ihren sozialen Graphen hat.", - "privacy-matters-overlay-card-2-p-3": "SimpleX ist das erste Netzwerk, welches per Design keinerlei Benutzerkennungen hat und auf diese Weise Ihren Verbindungsgraphen besser schützt als jede andere bekannte Alternative.", + "privacy-matters-overlay-card-2-p-3": "SimpleX ist das erste Netzwerk, das konzeptionell ohne Benutzerkennungen auskommt und dadurch Ihren Verbindungsgraphen besser schützt als jede bekannte Alternative.", "privacy-matters-overlay-card-3-p-1": "Jede Person sollte sich um ihre Privatsphäre und die Sicherheit ihrer Kommunikation kümmern — Harmlose Gespräche könnten Sie in Gefahr bringen, selbst wenn Sie nichts zu verbergen haben.", "privacy-matters-overlay-card-3-p-4": "Es reicht nicht aus, einfach einen Ende-zu-Ende-verschlüsselten Messenger zu verwenden. Wir alle sollten den Messenger verwenden, der die Privatsphäre unserer persönlichen Netzwerke schützt, mit welchen wir verbunden sind.", - "simplex-unique-overlay-card-1-p-3": "Dieses Design schützt die Privatsphäre von Ihnen und der Nutzer, mit denen Sie kommunizieren, und verbirgt die Verbindungen vor den SimpleX-Netzwerk-Servern und möglichen Beobachtern. Um Ihre IP-Adresse vor den Servern zu verbergen, können Sie sich per Tor mit den SimpleX-Servern verbinden.", + "simplex-unique-overlay-card-1-p-3": "Dieses Design schützt die Privatsphäre von Ihnen und der Nutzer, mit denen Sie kommunizieren, und verbirgt die Verbindungen vor den SimpleX Netzwerk-Servern und möglichen Beobachtern. Um Ihre IP-Adresse vor den Servern zu verbergen, können Sie sich per Tor mit den SimpleX-Servern verbinden.", "simplex-unique-overlay-card-2-p-1": "Da Sie im SimpleX-Netzwerk keine Kennungen haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder eines Links weiter.", - "simplex-unique-overlay-card-3-p-2": "Die Ende-zu-Ende-verschlüsselten Nachrichten werden vorübergehend auf SimpleX-Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", + "simplex-unique-overlay-card-3-p-2": "Ende-zu-Ende verschlüsselte Nachrichten werden vorübergehend auf SimpleX Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", "simplex-unique-overlay-card-3-p-3": "Im Gegensatz zu föderierten Netzwerkservern (wie z.B. Mail, XMPP oder Matrix) speichern die SimpleX-Server keine Benutzerkonten, sondern leiten Nachrichten nur weiter, so dass die Privatsphäre der beteiligten Parteien geschützt ist.", - "simplex-unique-overlay-card-4-p-1": "Sie können SimpleX mit Ihren eigenen Servern verwenden und trotzdem mit Nutzern kommunizieren, welche die vorkonfigurierten Server der App verwenden.", - "simplex-unique-card-1-p-1": "SimpleX schützt die Privatsphäre Ihres Profils, Ihrer Kontakte und Metadaten und verbirgt sie vor den SimpleX-Netzwerk-Servern und weiteren möglichen Beobachtern.", + "simplex-unique-overlay-card-4-p-1": "Sie können SimpleX mit eigenen Servern verwenden und trotzdem mit Nutzern kommunizieren, welche die vorkonfigurierten Server der App verwenden.", + "simplex-unique-card-1-p-1": "SimpleX schützt die Privatsphäre Ihres Profils, Ihrer Kontakte und Metadaten und verbirgt sie vor den SimpleX Netzwerk-Servern und weiteren möglichen Beobachtern.", "simplex-unique-card-1-p-2": "Im Gegensatz zu allen anderen bestehenden Messaging-Netzwerken werden den Nutzern von SimpleX keine Kennungen zugewiesen — nicht einmal Zufallszahlen.", "simplex-unique-card-2-p-1": "Da Sie keine Kennung oder feste Adresse im SimpleX-Netzwerk haben, kann Sie niemand kontaktieren, es sei denn, Sie geben eine einmalige oder vorübergehende Benutzeradresse in Form eines QR-Codes oder Links weiter.", "simplex-unique-card-3-p-1": "SimpleX speichert Benutzerdaten nur auf den Endgeräten und das in einem portablen, verschlüsselten Datenbankformat — welches auf ein anderes Gerät übertragen werden kann.", - "simplex-unique-card-3-p-2": "Die Ende-zu-Ende-verschlüsselten Nachrichten werden vorübergehend auf SimpleX-Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", + "simplex-unique-card-3-p-2": "Ende-zu-Ende verschlüsselte Nachrichten werden vorübergehend auf SimpleX Relay-Servern gespeichert, bis sie vom Endgerät empfangen und danach endgültig gelöscht werden.", "join": "Nutzen Sie", "we-invite-you-to-join-the-conversation": "Wir laden Sie ein, sich an der Diskussion zu beteiligen", "join-the-REDDIT-community": "Treten Sie der Reddit-Gruppe bei", @@ -131,7 +131,7 @@ "learn-more": "Erfahren Sie mehr darüber", "more-info": "Weitere Informationen", "hide-info": "Informationen verbergen", - "contact-hero-subheader": "Scannen Sie den QR-Code mit der SimpleX-Chat-Applikation auf Ihrem Mobilgerät oder Tablet.", + "contact-hero-subheader": "Scannen Sie den QR-Code mit der SimpleX Chat-App auf Ihrem Mobilgerät oder Tablet.", "contact-hero-p-2": "SimpleX Chat noch nicht heruntergeladen?", "scan-qr-code-from-mobile-app": "Scannen Sie den QR-Code aus der mobilen Applikation", "install-simplex-app": "Installieren Sie die SimpleX-App", @@ -144,7 +144,7 @@ "github-repository": "GitHub-Repository", "the-instructions--source-code": "Für die Anleitungen, wie Sie es herunterladen oder aus dem Quellcode kompilieren.", "if-you-already-installed": "Wenn Sie es schon installiert haben", - "simplex-chat-for-the-terminal": "SimpleX Chat für das Terminal", + "simplex-chat-for-the-terminal": "SimpleX-Chat für das Terminal", "privacy-matters-section-header": "Warum es auf Privatsphäre ankommt", "privacy-matters-section-label": "Stellen Sie sicher, dass Ihr Messenger keinen Zugriff auf Ihre Daten hat!", "tap-to-close": "Zum Schließen drücken", @@ -152,7 +152,7 @@ "simplex-network-1-header": "Im Gegensatz zu P2P-Netzwerken", "simplex-network-1-desc": "Alle Nachrichten werden über Server versandt, was sowohl einen besseren Schutz der Metadaten als auch eine zuverlässigere asynchrone Nachrichtenübermittlung ermöglicht und dabei Vieles vermeidet", "simplex-network-1-overlay-linktext": "Probleme von P2P-Netzwerken", - "simplex-network-2-desc": "SimpleX-Relay-Server speichern KEINE Benutzerprofile, Kontakte und zugestellte Nachrichten und stellen KEINE Verbindungen untereinander her, und es gibt KEIN Serververzeichnis.", + "simplex-network-2-desc": "SimpleX Relay-Server speichern KEINE Benutzerprofile, Kontakte und zugestellte Nachrichten und stellen KEINE Verbindungen untereinander her, und es gibt KEIN Serververzeichnis.", "simplex-network-3-header": "SimpleX-Netzwerk", "comparison-section-header": "Vergleich mit anderen Protokollen", "protocol-1-text": "Signal, große Plattformen", @@ -177,14 +177,14 @@ "simplex-unique-card-4-p-1": "Das SimpleX-Netzwerk ist vollständig dezentralisiert und unabhängig von Kryptowährungen oder anderen Netzwerken außer dem Internet.", "privacy-matters-overlay-card-2-p-1": "Vor nicht allzu langer Zeit beobachteten wir, wie große Wahlen von einem angesehenen Beratungsunternehmen manipuliert wurden, welches unsere sozialen Graphen nutzte, um unsere Sicht auf die reale Welt zu verzerren und unsere Stimmen zu manipulieren.", "privacy-matters-overlay-card-3-p-2": "Eine der schockierendsten Geschichten ist die Erfahrung von Mohamedou Ould Salahi, welche in seinen Memoiren beschrieben und im Film „The Mauritanian“ gezeigt wird. Er kam nach einem Anruf bei seinen Verwandten in Afghanistan und ohne Gerichtsverfahren in das Guantanamo-Lager und wurde dort einige Jahre lang gefoltert, weil er verdächtigt wurde, an den 9/11-Angriffen beteiligt gewesen zu sein, obwohl er die vorhergehenden 10 Jahre in Deutschland gelebt hatte.", - "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Netzwerken weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder Zufallszahlen, um seine Benutzer zu identifizieren — selbst SimpleX-Server-Betreiber wissen nicht, wie viele Personen deren Server verwenden.", + "simplex-unique-overlay-card-1-p-1": "Im Gegensatz zu anderen Nachrichten-Netzwerken weist SimpleX den Benutzern keine Kennungen zu. Es verlässt sich nicht auf Telefonnummern, domänenbasierte Adressen (wie E-Mail oder XMPP), Benutzernamen, öffentliche Schlüssel oder Zufallszahlen, um seine Benutzer zu identifizieren — selbst SimpleX Server-Betreiber wissen nicht, wie viele Personen deren Server verwenden.", "simplex-unique-overlay-card-1-p-2": "Um Nachrichten auszuliefern nutzt SimpleX paarweise anonyme Adressen aus unidirektionalen Nachrichten-Warteschlangen, die für empfangene und gesendete Nachrichten separiert sind und gewöhnlich über verschiedene Server gesendet werden.", "simplex-network-overlay-card-1-li-5": "Alle bekannten P2P-Netzwerke können anfällig für Sybil-Angriffe sein, da jeder Knoten ermittelbar ist und das Netzwerk als Ganzes funktioniert. Bekannte Maßnahmen zur Verhinderung erfordern entweder eine zentralisierte Komponente oder einen teuren Ausführungsnachweis. Das SimpleX-Netzwerk bietet keine Ermittlung der Server, ist fragmentiert und arbeitet mit mehreren isolierten Subnetzwerken, wodurch netzwerkweite Angriffe unmöglich werden.", "simplex-network-overlay-card-1-li-3": "P2P löst nicht das Problem des MITM-Angriffs und die meisten bestehenden Implementierungen nutzen für den initialen Schlüsselaustausch keine Out-of-Band-Nachrichten. Im Gegensatz hierzu nutzt SimpleX für den initialen Schlüsselaustausch Out-of-Band-Nachrichten oder zum Teil schon bestehende sichere und vertrauenswürdige Verbindungen.", "tap-the-connect-button-in-the-app": "Drücken Sie die „Verbinden“-Taste in der Applikation", "to-make-a-connection": "Um eine Verbindung zu starten:", "contact-hero-p-3": "Nutzen Sie die unten genannten Links, um die Applikation herunterzuladen.", - "scan-the-qr-code-with-the-simplex-chat-app": "Scannen Sie den QR-Code mit der SimpleX-Chat-Applikation", + "scan-the-qr-code-with-the-simplex-chat-app": "Scannen Sie den QR-Code mit der SimpleX Chat-App", "copy-the-command-below-text": "Kopieren Sie sich das unten genannte Kommando und nutzen Sie es im Chat:", "privacy-matters-section-subheader": "Die Wahrung der Privatsphäre Ihrer Metadaten — mit wem Sie wann Kontakt haben — schützt Sie vor:", "simplex-private-section-header": "Was macht SimpleX vertraulich", @@ -195,22 +195,22 @@ "no-decentralized": "Nein – dezentralisiert", "comparison-section-list-point-5": "Die Privatsphäre-Metadaten des Nutzers werden nicht geschützt", "simplex-network-overlay-card-1-li-1": "P2P-Netzwerke vertrauen auf Varianten von DHT, um Nachrichten zu routen. DHT-Designs müssen zwischen Zustellungsgarantie und Latenz ausgleichen. Verglichen mit P2P bietet SimpleX sowohl eine bessere Zustellungsgarantie als auch eine niedrigere Latenz, weil eine Nachricht redundant und parallel über mehrere Server gesendet werden kann, wobei die durch den Empfänger ausgewählten Server genutzt werden. In P2P-Netzwerken werden Nachrichten sequentiell über O(log N)-Knoten gesendet, wobei die Knoten durch einen Algorithmus ausgewählt werden.", - "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", - "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX-App-Nutzer zu entwickeln oder die Integration der SimpleX-Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", - "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn Anwender die in der SimpleX-Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", + "simplex-unique-overlay-card-3-p-4": "Zwischen dem gesendeten und dem empfangenen Serververkehr gibt es keine gemeinsamen Kennungen oder Chiffriertexte — sodass ein Beobachter nicht ohne weiteres feststellen kann, wer mit wem kommuniziert, selbst wenn TLS kompromittiert wurde.", + "simplex-unique-overlay-card-4-p-3": "Falls Sie Interesse daran haben, aktiv bei der Entwicklung des SimpleX-Netzwerks mitzuhelfen, z.B. einen Chatbot für SimpleX App-Nutzer zu entwickeln oder die Integration der SimpleX Chat-Bibliothek in mobile Apps voranzutreiben, kontaktieren Sie uns bitte für eine weitere Beratung und Unterstützung.", + "privacy-matters-overlay-card-1-p-4": "Das SimpleX-Netzwerk schützt die Privatsphäre Ihrer Verbindungen besser als jede Alternative und verhindert vollständig, dass Ihr sozialer Graph für Unternehmen oder Organisationen verfügbar wird. Selbst wenn Anwender die in der SimpleX Chat-App vorkonfigurierten Server verwenden, kennen die Server-Betreiber die Anzahl der Benutzer oder deren Verbindungen nicht.", "contact-hero-header": "Sie haben eine Adresse zur Verbindung mit SimpleX Chat erhalten", "invitation-hero-header": "Sie haben einen Einmal-Link zur Verbindung mit SimpleX Chat erhalten", "privacy-matters-overlay-card-3-p-3": "Normale Menschen werden für das, was sie online teilen, sogar unter Nutzung ihrer „anonymen“ Konten, selbst in demokratischen Ländern verhaftet.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat speichert alle Benutzerdaten ausschließlich auf den Endgeräten in einem portablen und verschlüsselten Datenbankformat, welches exportiert und auf jedes unterstützte Gerät übertragen werden kann.", "simplex-unique-overlay-card-2-p-2": "Auch wenn die optionale Benutzeradresse zum Versenden von Spam-Kontaktanfragen verwendet werden kann, können Sie sie ändern oder ganz löschen, ohne dass Ihre Verbindungen verloren gehen.", - "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX-Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", - "simplex-unique-card-4-p-2": "Sie können SimpleX mit Ihren eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", + "simplex-unique-overlay-card-4-p-2": "Das SimpleX-Netzwerk verwendet ein offenes Protokoll und bietet ein SDK zur Erstellung von Chatbots an. Dies ermöglicht die Erstellung von Diensten, mit denen Nutzer über SimpleX Chat-Apps interagieren können — wir sind gespannt, welche SimpleX-Dienste Sie entwickeln werden.", + "simplex-unique-card-4-p-2": "Sie können SimpleX mit eigenen Servern oder mit den von uns zur Verfügung gestellten Servern verwenden — und sich trotzdem mit jedem Benutzer verbinden.", "why-simplex-is-unique": "Warum ist SimpleX einmalig", "contact-hero-p-1": "Die öffentlichen Schlüssel und die Adresse der Nachrichtenwarteschlange in diesem Link werden NICHT über das Netzwerk gesendet, wenn Sie diese Seite aufrufen — sie sind in dem Hash-Fragment der Link-URL enthalten.", - "if-you-already-installed-simplex-chat-for-the-terminal": "Wenn Sie SimpleX schon für das Terminal installiert haben", + "if-you-already-installed-simplex-chat-for-the-terminal": "Wenn Sie den SimpleX-Chat schon für das Terminal installiert haben", "simplex-network-3-desc": "Die Server stellen unidirektionale Warteschlangen zur Verfügung, um die Benutzer miteinander zu verbinden. Sie haben aber keinen Einblick in den Verbindungs-Graphen des Netzwerks — Diesen haben nur die Benutzer selbst.", "guide-dropdown-1": "Schnellstart", - "guide-dropdown-2": "Nachrichten senden", + "guide-dropdown-2": "Nachrichten versenden", "guide-dropdown-4": "Chat-Profile", "guide-dropdown-5": "Daten verwalten", "guide-dropdown-6": "Audio- & Videoanrufe", @@ -221,8 +221,8 @@ "docs-dropdown-1": "SimpleX-Netzwerk", "docs-dropdown-2": "Zugriff auf Android-Dateien", "docs-dropdown-3": "Zugriff auf die Chat-Datenbank", - "docs-dropdown-4": "Den SMP-Server hosten", - "docs-dropdown-5": "Den XFTP-Server hosten", + "docs-dropdown-4": "Eigenen SMP-Server hosten", + "docs-dropdown-5": "Eigenen XFTP-Server hosten", "docs-dropdown-6": "WebRTC-Server", "newer-version-of-eng-msg": "Es gibt eine aktuellere Version dieser Webseite auf Englisch.", "click-to-see": "Klicken Sie hier zum Ansehen", @@ -235,11 +235,11 @@ "signing-key-fingerprint": "Fingerabdruck des Signaturschlüssels (SHA-256)", "f-droid-org-repo": "F-Droid.org-Repository", "stable-versions-built-by-f-droid-org": "Von F-Droid.org erstellte stabile Versionen", - "f-droid-page-f-droid-org-repo-section-text": "SimpleX-Chat- und F-Droid.org-Repositorys signieren ihre Builds mit verschiedenen Schlüsseln. Zum Umschalten bitte die Chat-Datenbank exportieren und die App neu installieren.", + "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat- und F-Droid.org-Repositorys signieren ihre Builds mit verschiedenen Schlüsseln. Zum Umschalten bitte die Chat-Datenbank exportieren und die App neu installieren.", "releases-to-this-repo-are-done-1-2-days-later": "Die Versionen für dieses Repository werden einige Tage später erstellt", "docs-dropdown-8": "SimpleX-Verzeichnis", - "simplex-chat-via-f-droid": "SimpleX Chat per F-Droid", - "simplex-chat-repo": "SimpleX-Chat-Repository", + "simplex-chat-via-f-droid": "SimpleX-Chat per F-Droid", + "simplex-chat-repo": "SimpleX Chat-Repository", "stable-and-beta-versions-built-by-developers": "Von den Entwicklern erstellte stabile und Beta-Versionen", "f-droid-page-simplex-chat-repo-section-text": "Um es Ihrem F-Droid-Client hinzuzufügen, scannen Sie den QR-Code oder nutzen Sie diese URL:", "comparison-section-list-point-4a": "SimpleX-Relais können die E2E-Verschlüsselung nicht kompromittieren. Überprüfen Sie den Sicherheitscode, um einen möglichen Angriff auf den Out-of-Band-Kanal zu entschärfen", @@ -251,16 +251,16 @@ "docs-dropdown-9": "Downloads", "please-enable-javascript": "Bitte aktivieren Sie JavaScript, um den QR-Code zu sehen.", "please-use-link-in-mobile-app": "Bitte nutzen Sie den Link in der Mobiltelefon-App", - "docs-dropdown-10": "Transparent", + "docs-dropdown-10": "Transparenzberichte", "docs-dropdown-11": "FAQ", - "docs-dropdown-12": "Sicherheit", + "docs-dropdown-12": "Sicherheitskonzept", "hero-overlay-card-3-p-3": "Trail of Bits hat das kryptografische Design des Netzwerk-Protokolls von SimpleX im Juli 2024 überprüft. Hier finden Sie weitere Informationen dazu.", "docs-dropdown-14": "SimpleX für geschäftliche Anwendungen", "about-and-contact-us": "Über uns & Kontakt", "directory": "Verzeichnis", "index-hero-h1": "Sei
frei", "index-hero-h2": "Freiheit & Sicherheit
Ihrer Kommunikation", - "index-hero-p1": "Das erste Netzwerk, in dem Sie Ihre Identität, Kontakte und Gruppen besitzen.", + "index-hero-p1": "Das erste Netzwerk, in welchem Sie die volle Kontrolle über Ihre Identität, Kontakte und Gruppen behalten.", "index-hero-download-desktop-btn-title": "Download der SimpleX Desktop-App", "index-testflight-title": "Öffentlicher iOS-Preview auf TestFlight", "index-f-droid-title": "SimpleX-App über das F-Droid-Repository", @@ -277,39 +277,41 @@ "index-messaging-p1": "SimpleX-Messaging verfügt über modernste Ende-zu-Ende-Verschlüsselung.", "index-messaging-p2": "Zu Ihrer Sicherheit und zum Schutz Ihrer Privatsphäre können Server Ihre Nachrichten weder sehen, noch mit wem Sie kommunizieren.", "index-messaging-cta": "Lernen Sie mehr über SimpleX-Messaging", - "index-nextweb-h2": "Sie besitzen
The Next Web", - "index-nextweb-p1": "SimpleX basiert auf der Überzeugung, dass Sie Eigentümer Ihrer Identität, Ihrer Kontakte und Ihrer Communities sein müssen.", + "index-nextweb-h2": "Sie besitzen die
Zukunft des Webs", + "index-nextweb-p1": "SimpleX basiert auf der Überzeugung, dass Sie Eigentümer Ihrer Identität, Ihrer Kontakte und Ihrer Communitys bleiben müssen.", "index-nextweb-p2": "Ein offenes und dezentrales Netzwerk ermöglicht es Ihnen, mit Menschen in Kontakt zu treten und Ideen auszutauschen: Seien Sie frei und sicher.", - "index-token-h2": "Communities, die Bestand haben", - "index-token-p1": "Sie werden Ihre Lieblingsgruppen mit zukünftigen Community-Gutscheinen unterstützen.", - "index-token-p2": "Server werden mit Gutscheinen bezahlt, damit Ihre Communities kostenlos und unabhängig bleiben können.", - "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT zum frühzeitigen ausprobieren.", - "index-roadmap-h2": "SimpleX - Roadmap zum freien Internet", + "index-token-h2": "Communitys, die Bestand haben", + "index-token-p1": "Sie werden Ihre Lieblingsgruppen in Zukunft mit Community-Gutscheinen unterstützen können.", + "index-token-p2": "Server werden mit Gutscheinen bezahlt, damit Ihre Communitys kostenlos und unabhängig bleiben können.", + "index-token-cta": "Erfahren Sie mehr und holen Sie sich Ihren kostenlosen NFT ab
, um es frühzeitig auszuprobieren.", + "index-roadmap-h2": "SimpleX - Der Weg zum freien Internet", "index-roadmap-2025": "2025", - "index-roadmap-2025-title": "Skalierung auf große Communities", + "index-roadmap-2025-title": "Skalierung auf große Communitys", "index-roadmap-2025-desc": "Ausstieg aus zentralisierten Plattformen", "index-roadmap-2026": "2026", - "index-roadmap-2026-title": "Nachhaltige Communities & Server", + "index-roadmap-2026-title": "Nachhaltige Communitys & Server", "index-roadmap-2026-desc": "Einführung von Community-Gutscheinen", "index-roadmap-2027": "2027", - "index-roadmap-2027-title": "Lassen Sie Ihre Communities wachsen", - "index-roadmap-2027-desc": "Tools zur Förderung Ihrer Communities", - "index-directory-h2": "Treten Sie SimpleX-Communities bei", - "index-directory-p1": "Hunderttausende Menschen vertrauen bereits SimpleX-Messaging.", - "index-directory-p2": "Finden Sie Ihre Communities im SimpleX-Verzeichnis und erstellen Sie Ihre Eigenen!", + "index-roadmap-2027-title": "Lassen Sie Ihre Communitys wachsen", + "index-roadmap-2027-desc": "Tools zur Förderung Ihrer Communitys", + "index-directory-h2": "Treten Sie SimpleX-Communitys bei", + "index-directory-p1": "Hunderttausende Nutzer vertrauen bereits dem Messaging per SimpleX.", + "index-directory-p2": "Finden Sie Communitys im SimpleX-Verzeichnis oder erstellen Sie Ihre Eigenen!", "index-directory-cta": "SimpleX-Verzeichnis anzeigen", "index-directory-users-group-title": "SimpleX-Nutzergruppe", "how-secure-comparison-title": "Sicherheitsvergleich der Ende-zu-Ende-Verschlüsselung in verschiedenen Messengern", - "how-secure-message-padding": "Nachrichten-Padding", + "how-secure-message-padding": "Nachrichten-Inhalteauffüllung", "how-secure-repudiation-deniability": "Glaubhafte Abstreitbarkeit", "how-secure-forward-secrecy": "Forward Secrecy", "how-secure-break-in-recovery": "Sicherheit nach Kompromittierung", "how-secure-two-factor-key-exchange": "2-Faktor-Schlüsselaustausch", "how-secure-post-quantum-hybrid-crypto": "Post-Quanten-Hybridkryptografie", - "messengers-comparison-section-list-point-1": "Briar verwendet Padding, um Nachrichten auf eine Länge von mindestens 1024 Byte aufzurunden; Signal - auf 160 Byte", + "messengers-comparison-section-list-point-1": "Briar verwendet Inhalteauffüllung, um Nachrichten auf eine Länge von mindestens 1024 Byte aufzurunden; Signal - auf 160 Byte", "messengers-comparison-section-list-point-2": "Abstreitbarkeit gilt nicht für die Client-Server-Verbindung.", "messengers-comparison-section-list-point-3": "Es scheint, dass die Verwendung kryptografischer Signaturen die Abstreitbarkeit beeinträchtigt, aber dies muss noch geklärt werden.", "messengers-comparison-section-list-point-4": "Die Multi-Geräte-Implementierung beeinträchtigt die Sicherheit nach Kompromittierung des Double Ratchet-Algorithmus", "messengers-comparison-section-list-point-5": "Ein 2-Faktor-Schlüsselaustausch ist optional und kann über die Überprüfung eines Sicherheitscodes erfolgen.", - "messengers-comparison-section-list-point-6": "Die Schlüsselvereinbarung per Post-Quanten-Security ist nicht durchgängig — Sie schützt lediglich ausgewählte Schritte innerhalb des Ratchet-Prozesses." + "messengers-comparison-section-list-point-6": "Die Schlüsselvereinbarung per Post-Quanten-Security ist nicht durchgängig — Sie schützt lediglich ausgewählte Schritte innerhalb des Ratchet-Prozesses.", + "navbar-token": "Token", + "navbar-old-site": "Alte Webseite" } diff --git a/website/langs/es.json b/website/langs/es.json index 3489fb3b9b..c0ea5e320b 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -311,5 +311,7 @@ "messengers-comparison-section-list-point-3": "Parece que el uso de firmas criptográficas compromete el repudio (negabilidad) pero es necesario aclararlo.", "messengers-comparison-section-list-point-4": "La implementación multi‑dispositivo compromete la seguridad posterior a ser comprometida del Double Ratchet", "messengers-comparison-section-list-point-5": "El intercambio de clave de doble factor es opcional mediante la verificación del código de seguridad.", - "messengers-comparison-section-list-point-6": "El acuerdo de claves postcuántico es \"parcial\", solo protege determinados pasos del ratchet." + "messengers-comparison-section-list-point-6": "El acuerdo de claves postcuántico es \"parcial\", solo protege determinados pasos del ratchet.", + "navbar-token": "Token", + "navbar-old-site": "Web antigua" } diff --git a/website/langs/fi.json b/website/langs/fi.json index d30c465222..10697898cb 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -250,5 +250,12 @@ "hero-overlay-3-textlink": "Turvallisuusarviointi", "hero-overlay-card-3-p-2": "Trail of Bits tarkasteli SimpleX-alustan salaus- ja verkkokomponentteja marraskuussa 2022. Lue lisää ilmoituksesta.", "please-enable-javascript": "Ota JavaScript käyttöön nähdäksesi QR-koodin.", - "please-use-link-in-mobile-app": "Käytä mobiilisovelluksessa olevaa linkkiä" + "please-use-link-in-mobile-app": "Käytä mobiilisovelluksessa olevaa linkkiä", + "directory": "Hakemisto", + "navbar-token": "Tunnus", + "hero-overlay-card-3-p-3": "Trail of Bits arvioi SimpleX-verkkojen salaustekniikan suunnittelun heinäkuussa 2024. Lue lisää.", + "docs-dropdown-10": "Läpinäkyvyys", + "docs-dropdown-11": "Usein kysytyt kysymykset", + "docs-dropdown-12": "Turvallisuus", + "docs-dropdown-14": "SimpleX yrityksille" } diff --git a/website/langs/fr.json b/website/langs/fr.json index 8ff71af8dc..879931547c 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -12,7 +12,7 @@ "simplex-explained-tab-2-text": "2. Comment ça marche", "simplex-explained-tab-3-text": "3. Ce que voient les serveurs", "simplex-explained-tab-1-p-1": "Vous pouvez créer des contacts et des groupes, et avoir des conversations bidirectionnelles, comme dans n'importe quelle autre messagerie.", - "simplex-explained-tab-1-p-2": "Comment cela peut-il fonctionner avec des files d'attente unidirectionnelles et sans identifiants de profil utilisateur ?", + "simplex-explained-tab-1-p-2": "Comment cela peut-il fonctionner avec des files d'attente unidirectionnelles et sans identifiants de profil utilisateur ?", "simplex-explained-tab-2-p-1": "Pour chaque connexion, vous utilisez deux files d'attente de messages distinctes pour envoyer et recevoir des messages via des serveurs différents.", "simplex-explained-tab-2-p-2": "Les serveurs ne transmettent les messages que dans une direction, sans connaître la totalité de la conversation ou des connexions de l'utilisateur.", "simplex-explained-tab-3-p-1": "Les serveurs disposent d'identifiants anonymes distincts pour chaque file d'attente, et ne savent pas à quels utilisateurs ils appartiennent.", @@ -256,5 +256,29 @@ "docs-dropdown-12": "Sécurité", "docs-dropdown-11": "FAQ", "hero-overlay-card-3-p-3": "Trail of Bits a examiné la conception cryptographique des protocoles réseau SimpleX en juillet 2024.", - "docs-dropdown-14": "SimpleX pour les entreprises" + "docs-dropdown-14": "SimpleX pour les entreprises", + "directory": "Dossier", + "navbar-token": "Jeton", + "about-and-contact-us": "À propos & Contactez-nous", + "index-hero-h1": "Soyez
Libre", + "index-hero-h2": "La liberté et la sécurité
de vos communications", + "index-hero-p1": "Le premier réseau où vous possédez votre identité, vos contacts et vos groupes.", + "index-hero-download-desktop-btn-title": "Téléchargez l’application de bureau SimpleX", + "index-testflight-title": "Version bêta de SimpleX pour iOS sur TestFlight", + "index-f-droid-title": "Application SimpleX via F-Droid", + "index-security-assessment-title": "Audits de sécurité", + "index-security-review-2022-title": "Audit de sécurité 2022", + "index-security-review-2024-title": "Audit de sécurité 2024", + "index-security-audits-label": "Audit de
sécurité", + "index-publications-privacy-guides-title": "Messagerie recommandée par Privacy Guides", + "index-publications-whonix-title": "Messagerie recommandée par Whonix", + "index-publications-kuketz-title": "Analyse de Mike Kuketz", + "index-messaging-p1": "La messagerie SimpleX utilise un chiffrement de bout en bout à la pointe de la technologie.", + "index-messaging-cta": "En savoir plus sur la messagerie SimpleX", + "index-nextweb-h2": "Prenez le contrôle
du Web de demain", + "index-nextweb-p2": "Un réseau ouvert et décentralisé vous permet de communiquer avec les autres et de partager vos idées : soyez libre et en sécurité.", + "index-token-h2": "Des communautés qui durent", + "index-token-cta": "En savoir plus et obtenez votre NFT gratuit
pour les premiers tests.", + "index-roadmap-h2": "Feuille de route de SimpleX vers un Internet libre", + "index-roadmap-2025": "2025" } diff --git a/website/langs/hu.json b/website/langs/hu.json index 77d3ce878b..9f6bcde61e 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -59,7 +59,7 @@ "simplex-private-card-3-point-1": "A kliens és a kiszolgálók közötti kapcsolatokhoz csak az erős algoritmusokkal rendelkező TLS 1.2/1.3 protokollt használja.", "simplex-private-card-3-point-2": "A kiszolgáló ujjlenyomata és a csatornakötés megakadályozza a MITM- és a visszajátszási támadásokat.", "simplex-private-card-3-point-3": "Az újrakapcsolódás le van tiltva a munkamenet elleni támadások megelőzése érdekében.", - "simplex-private-card-4-point-1": "Az IP-címe védelme érdekében a kiszolgálókat a Tor hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", + "simplex-private-card-4-point-1": "Az IP-cím védelme érdekében a kiszolgálókat a Tor hálózaton vagy más átvitelátfedő hálózaton keresztül is elérheti.", "simplex-private-card-6-point-1": "Számos kommunikációs hálózat sebezhető a kiszolgálók vagy a hálózat-szolgáltatók MITM-támadásaival szemben.", "simplex-private-card-6-point-2": "Ennek megakadályozása érdekében a SimpleX alkalmazások egyszeri kulcsokat adnak át sávon kívül, amikor egy címet hivatkozásként vagy QR-kódként oszt meg.", "simplex-private-card-7-point-1": "Az integritás garantálása érdekében az üzenetek sorszámozással vannak ellátva, és tartalmazzák az előző üzenet kivonatát.", @@ -81,9 +81,9 @@ "simplex-unique-1-overlay-1-title": "Személyazonosságának, profiljának, partnereinek és a metaadatok teljes körű védelme", "simplex-unique-2-title": "Véd a kéretlen tartalmaktól
és a visszaélésektől", "simplex-unique-2-overlay-1-title": "A legjobb védelem a kéretlen tartalmak és a visszaélések ellen", - "simplex-unique-3-title": "Ön kezeli a saját adatait", - "simplex-unique-3-overlay-1-title": "Az adatok biztonsága és kezelése az Ön kezében van", - "simplex-unique-4-title": "Öné a SimpleX hálózat", + "simplex-unique-3-title": "A saját adatai felett rendelkezhet", + "simplex-unique-3-overlay-1-title": "Az adatok biztonságát és kezelését teljes egészében kézben tarthatja", + "simplex-unique-4-title": "A felhasználóké a SimpleX hálózat", "simplex-unique-4-overlay-1-title": "Teljesen decentralizált — a SimpleX hálózat a felhasználóké", "hero-overlay-card-1-p-1": "Sok felhasználó kérdezte: ha a SimpleXnek nincsenek felhasználói azonosítói, honnan tudja, hogy hová kell eljuttatni az üzeneteket?", "hero-overlay-card-1-p-2": "Az üzenetek kézbesítéséhez az összes többi hálózat által használt felhasználói azonosítók helyett a SimpleX az üzenetek várólistába rendezéséhez ideiglenes, névtelen, páros azonosítókat használ, külön-külön minden egyes kapcsolathoz — nincsenek hosszú távú azonosítók.", @@ -101,12 +101,12 @@ "simplex-network-overlay-card-1-li-3": "A P2P nem oldja meg a MITM-támadás problémát, és a legtöbb létező implementáció nem használ sávon kívüli üzeneteket a kezdeti kulcscseréhez. A SimpleX a kezdeti kulcscseréhez sávon kívüli üzeneteket, vagy bizonyos esetekben már meglévő biztonságos és megbízható kapcsolatokat használ.", "simplex-network-overlay-card-1-li-6": "A P2P-hálózatok sebezhetőek lehetnek a DRDoS-támadással szemben, amikor a kliensek képesek a forgalmat újraközvetíteni és felerősíteni, ami az egész hálózatra kiterjedő szolgáltatásmegtagadást eredményez. A SimpleX kliensek csak az ismert kapcsolatból származó forgalmat továbbítják, és a támadó nem használhatja őket arra, hogy az egész hálózatban felerősítse a forgalmat.", "simplex-network-overlay-card-1-li-5": "Minden ismert P2P-hálózat sebezhető Sybil támadással, mert minden egyes csomópont felderíthető, és a hálózat egészként működik. A támadások enyhítésére szolgáló ismert intézkedés lehet egy központi kiszolgáló (például: tracker), vagy egy drága tanúsítvány. A SimpleX hálózat nem ismeri fel a kiszolgálókat, töredezett és több elszigetelt alhálózatként működik, ami lehetetlenné teszi az egész hálózatra kiterjedő támadásokat.", - "privacy-matters-overlay-card-1-p-1": "Sok nagyvállalat arra használja fel az önnel kapcsolatban álló személyek adatait, hogy megbecsülje az ön jövedelmét, hogy olyan termékeket adjon el önnek, amelyekre valójában nincs is szüksége, és hogy meghatározza az árakat.", + "privacy-matters-overlay-card-1-p-1": "Sok nagyvállalat arra használja fel a felhasználóival kapcsolatban álló személyek adatait, hogy megbecsülje a jövedelmi helyzetüket és olyan termékeket kínáljon fel, amelyekre valójában nincs is szükségük, valamint hogy meghatározza az árakat.", "privacy-matters-overlay-card-1-p-2": "Az online kiskereskedők tudják, hogy az alacsonyabb jövedelműek nagyobb valószínűséggel vásárolnak azonnal, ezért magasabb árakat számíthatnak fel, vagy eltörölhetik a kedvezményeket.", "privacy-matters-overlay-card-1-p-3": "Egyes pénzügyi és biztosítótársaságok szociális grafikonokat használnak a kamatlábak és a díjak meghatározásához. Ez gyakran arra készteti az alacsonyabb jövedelmű embereket, hogy többet fizessenek — ez az úgynevezett „szegénységi prémium”.", - "privacy-matters-overlay-card-1-p-4": "A SimpleX hálózat minden alternatívánál jobban védi a kapcsolatainak adatait, teljes mértékben megakadályozva, hogy az ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által előre beállított kiszolgálókat is használják, sem az alkalmazások, sem a kiszolgálók üzemeltetői nem ismerik, sem felhasználók számát, sem a kapcsolataikat.", + "privacy-matters-overlay-card-1-p-4": "A SimpleX hálózat minden alternatívánál jobban védi a kapcsolatai adatait, teljes mértékben megakadályozva, hogy az ismeretségi-hálója bármilyen vállalat vagy szervezet számára elérhetővé váljon. Még ha az emberek a SimpleX Chat által előre beállított kiszolgálókat is használják, sem az alkalmazások, sem a kiszolgálók üzemeltetői nem ismerik, sem felhasználók számát, sem a kapcsolataikat.", "privacy-matters-overlay-card-2-p-1": "Nem is olyan régen megfigyelhettük, hogy a nagy választásokat manipulálta egy neves tanácsadó cég, amely az ismeretségi-háló segítségével eltorzította a valós világról alkotott képünket, és manipulálta a szavazatainkat.", - "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs hálózatot használ, amely nem fér hozzá az ismeretségi-hálójához.", + "privacy-matters-overlay-card-2-p-2": "Ahhoz, hogy objektív legyen és független döntéseket tudjon hozni, az információs terét is kézben kell tartania. Ez csak akkor lehetséges, ha privát kommunikációs hálózatot használ, amely nem fér hozzá az ismeretségi hálójához.", "privacy-matters-overlay-card-2-p-3": "A SimpleX az első olyan hálózat, amely eleve nem rendelkezik felhasználói azonosítókkal, így jobban védi az ismeretségi-hálóját, mint bármely ismert alternatíva.", "privacy-matters-overlay-card-3-p-1": "Mindenkinek törődnie kell a magánélet és a kommunikáció biztonságával — az ártalmatlan beszélgetések veszélybe sodorhatják, még akkor is, ha nincs semmi rejtegetnivalója.", "privacy-matters-overlay-card-3-p-2": "Az egyik legmegdöbbentőbb a Mohamedou Ould Salahi memoárjában leírt és az „A mauritániai” c. filmben bemutatott történet. Őt bírósági tárgyalás nélkül a guantánamói táborba zárták, és ott kínozták 15 éven át, miután egy afganisztáni rokonát telefonon felhívta, akit azzal gyanúsítottak a hatóságok, hogy köze van a 9/11-es merényletekhez, holott Salahi az előző 10 évben Németországban élt.", @@ -126,13 +126,13 @@ "simplex-unique-overlay-card-4-p-3": "Ha a SimpleX hálózatra való fejlesztést fontolgatja, például a SimpleX alkalmazások felhasználóinak szánt csevegési botot, vagy a SimpleX Chat könyvtárbotjának integrálását más mobilalkalmazásba, lépjen velünk kapcsolatba, ha bármilyen tanácsot vagy támogatást szeretne kapni.", "simplex-unique-card-1-p-1": "A SimpleX megvédi a profilhoz tartozó partnereket és azok metaadatait is, elrejtve azokat a SimpleX hálózat kiszolgálói és a megfigyelők elől.", "simplex-unique-card-1-p-2": "Minden más létező üzenetküldő hálózattól eltérően a SimpleX nem rendelkezik a felhasználókhoz rendelt azonosítókkal — még véletlenszerű számokkal sem.", - "simplex-unique-card-2-p-1": "Mivel a SimpleX hálózaton senkinek sincs azonosítója vagy állandó címe, ezért senki sem tud kapcsolatba lépni Önnel, hacsak nem oszt meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", + "simplex-unique-card-2-p-1": "Mivel a SimpleX hálózaton senkinek sincs azonosítója vagy állandó címe, ezért senki sem tud kapcsolatba lépni a felhasználókkal, hacsak nem osztanak meg egy egyszeri vagy ideiglenes felhasználói címet, például QR-kódot vagy hivatkozást.", "simplex-unique-card-3-p-1": "A SimpleX Chat az összes felhasználói adatot kizárólag a klienseken tárolja egy hordozható titkosított adatbázis-formátumban —, amely exportálható és átvihető bármely más támogatott eszközre.", "simplex-unique-card-3-p-2": "A végpontok között titkosított üzenetek átmenetileg a SimpleX továbbítókiszolgálóin tartózkodnak, amíg be nem érkeznek a címzetthez, majd automatikusan véglegesen törlődnek onnan.", "simplex-unique-card-4-p-1": "A SimpleX hálózat teljesen decentralizált és független bármely kriptopénztől vagy bármely más hálózattól, kivéve az internetet.", "simplex-unique-card-4-p-2": "Használhatja a SimpleXet a saját kiszolgálóival vagy az általunk biztosított kiszolgálókkal, és továbbra is kapcsolódhat bármely felhasználóhoz.", "join": "Csatlakozzon a közösségeinkhez", - "we-invite-you-to-join-the-conversation": "Meghívjuk Önt, hogy csatlakozzon a beszélgetésekhez", + "we-invite-you-to-join-the-conversation": "Csatlakozzon a beszélgetéseinkhez", "join-the-REDDIT-community": "Csatlakozzon a REDDIT közösséghez", "join-us-on-GitHub": "Csatlakozzon hozzánk a GitHubon", "donate-here-to-help-us": "Adományozzon és segítsen nekünk", @@ -227,7 +227,7 @@ "simplex-private-card-5-point-1": "A SimpleX minden titkosítási réteghez tartalomkitöltést használ az üzenetméretre irányuló támadások meghiúsítása érdekében.", "simplex-private-card-5-point-2": "A kiszolgálók és a hálózatot megfigyelők számára a különböző méretű üzenetek egyformának tűnnek.", "privacy-matters-1-title": "Hirdetés és árdiszkrimináció", - "hero-overlay-card-1-p-3": "Ön határozza meg, hogy melyik kiszolgáló(ka)t használja az üzenetek fogadására, a partnereihez — azokat a kiszolgálókat, amelyeket az üzenetek küldésére használ. Minden beszélgetés két különböző kiszolgálót használ.", + "hero-overlay-card-1-p-3": "Meghatározhatja, hogy melyik kiszolgáló(ka)t használja az üzenetek fogadására, a partnereihez — azokat a kiszolgálókat, amelyeket az üzenetek küldésére használ. Minden beszélgetés két különböző kiszolgálót használ.", "simplex-network-overlay-card-1-p-1": "A P2P üzenetküldő protokollok és alkalmazások számos problémával küzdenek, amelyek miatt kevésbé megbízhatóak, mint a SimpleX, bonyolultabb az elemzésük és többféle támadással szemben sebezhetőek.", "chat-bot-example": "Példa egy csevegési botra", "simplex-private-3-title": "Biztonságos, hitelesített
TLS-adatátvitel", @@ -259,14 +259,14 @@ "directory": "Csoportjegyzék", "about-and-contact-us": "Névjegy és kapcsolat", "index-hero-h1": "Legyen
szabad", - "index-hero-p1": "Az első olyan hálózat, ahol Ön a tulajdonosa saját identitásának, partnereinek és csoportjainak.", + "index-hero-p1": "Az első olyan hálózat, ahol a felhasználó a tulajdonosa saját identitásának, partnereinek és csoportjainak.", "index-hero-download-desktop-btn-title": "SimpleX számítógépes alkalmazásának letöltése", "index-security-assessment-title": "Biztonsági auditok", "index-security-review-2022-title": "Biztonsági audit 2022", "index-security-review-2024-title": "Biztonsági audit 2024", "index-security-audits-label": "Biztonsági
auditok", "index-publications-heise-title": "A Heise Online kiadványai", - "index-hero-h2": "Az Ön kommunikációjának
szabadsága és biztonsága", + "index-hero-h2": "A kommunikációjának
szabadsága és biztonsága", "index-testflight-title": "Nyilvános betekintés az iOS alkalmazás fejlesztésébe a TestFlighton", "index-f-droid-title": "SimpleX alkalmazás az F-Droidon keresztül", "index-publications-privacy-guides-title": "A Privacy Guides üzenetváltó ajánlásai", @@ -275,10 +275,10 @@ "index-publications-optout-title": "OptOut podcast interjú", "worlds-most-secure-messaging": "A világ legbiztonságosabb üzenetváltó alkalmazása", "index-messaging-p1": "Az üzenetváltás a SimpleXben élvonalbeli végpontok közötti titkosítással rendelkezik.", - "index-messaging-p2": "Az Ön biztonsága és magánszférájának védelme érdekében a kiszolgálók nem látják az üzeneteit, és azt sem, hogy kivel beszélget.", + "index-messaging-p2": "A biztonsága és magánszférájának védelme érdekében a kiszolgálók nem látják az üzeneteit, és azt sem, hogy kivel beszélget.", "index-messaging-cta": "Tudjon meg többet a SimpleX üzenetváltó alkalmazásról", - "index-nextweb-h2": "Legyen az Öné
A jövő hálózata", - "index-nextweb-p1": "A SimpleX arra a meggyőződésre épül, hogy Önnek kell birtokolnia a saját identitását és a kapcsolatot partnereivel és a közösségeivel.", + "index-nextweb-h2": "Vegye birtokba
A jövő hálózatát", + "index-nextweb-p1": "A SimpleX arra a meggyőződésre épül, hogy a felhasználóknak kell birtokolnia a saját identitásukat, valamint a kapcsolataikat a partnereikkel és a közösségeikkel.", "index-nextweb-p2": "A nyílt és decentralizált hálózat lehetővé teszi, hogy kapcsolatba lépjen másokkal és ötleteket osszon meg: legyen szabad biztonságban.", "index-token-h2": "Időtálló közösségek", "index-token-p1": "A jövőben közösségi utalványokkal támogathatja a kedvenc csoportjait.", @@ -311,5 +311,7 @@ "messengers-comparison-section-list-point-3": "Úgy tűnik, hogy a kriptográfiai aláírások használata rontja a letagadhatóságot, de ez tisztázásra szorul.", "messengers-comparison-section-list-point-4": "A többeszközös megvalósítás rontja a dupla racsni kompromittálás utáni biztonságát", "messengers-comparison-section-list-point-5": "A kétlépcsős kulcscsere nem követelmény a biztonsági kód ellenőrzéséhez.", - "messengers-comparison-section-list-point-6": "A kvantumbiztos kulcscsere „ritka” — csak a racsnis lépések egy részét védi." + "messengers-comparison-section-list-point-6": "A kvantumbiztos kulcscsere „ritka” — csak a racsnis lépések egy részét védi.", + "navbar-token": "Token", + "navbar-old-site": "Régi oldal" } diff --git a/website/langs/id.json b/website/langs/id.json index 4c3320c204..1875a16bea 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -72,7 +72,7 @@ "simplex-explained-tab-1-p-1": "Anda dapat membuat kontak dan grup, dan melakukan percakapan dua arah, seperti pada aplikasi perpesanan lainnya.", "simplex-explained-tab-1-p-2": "Bagaimana cara kerjanya dengan antrean searah dan tanpa ID profil pengguna?", "simplex-explained-tab-2-p-1": "Untuk setiap koneksi, Anda menggunakan dua antrean pesan terpisah untuk mengirim dan menerima pesan melalui server yang berbeda.", - "simplex-explained-tab-2-p-2": "Server hanya menyampaikan pesan satu arah, tanpa memiliki gambaran lengkap mengenai percakapan atau koneksi pengguna.", + "simplex-explained-tab-2-p-2": "Server hanya menyampaikan pesan satu arah, tanpa memiliki gambaran lengkap tentang percakapan atau koneksi pengguna.", "simplex-explained-tab-3-p-1": "Server memiliki kredensial anonim terpisah untuk setiap antrean, dan tidak mengetahui pengguna mana yang menjadi milik mereka.", "simplex-explained-tab-3-p-2": "Pengguna dapat tingkatkan privasi metadata dengan memakai Tor untuk akses server, mencegah korelasi berdasarkan alamat IP.", "chat-bot-example": "Contoh chat bot", @@ -144,7 +144,7 @@ "simplex-private-card-1-point-2": "Kotak kripto NaCL di setiap antrean untuk mencegah korelasi lalu lintas antara antrean pesan jika TLS disusupi.", "simplex-private-card-2-point-1": "Lapisan enkripsi server tambahan untuk pengiriman ke penerima, untuk mencegah korelasi antara lalu lintas server yang diterima dan dikirim jika TLS disusupi.", "hero-overlay-card-1-p-5": "Hanya perangkat klien yang menyimpan profil pengguna, kontak, dan grup; pesan dikirim dengan enkripsi end-to-end 2 lapis.", - "hero-overlay-card-1-p-6": "Selengkapnya di Whitepaper SimpleX.", + "hero-overlay-card-1-p-6": "Selengkapnya di whitepaper SimpleX.", "hero-overlay-card-2-p-1": "Bila pengguna memiliki identitas persisten, meskipun ini hanya angka acak, seperti ID Sesi, ada risiko bahwa penyedia atau penyerang dapat mengamati bagaimana pengguna terhubung dan berapa banyak pesan yang mereka kirim.", "hero-overlay-card-2-p-2": "Mereka kemudian dapat menghubungkan informasi ini dengan jaringan sosial publik yang ada, dan menentukan beberapa identitas sebenarnya.", "hero-overlay-card-2-p-3": "Bahkan dengan aplikasi paling pribadi yang menggunakan layanan Tor v3, jika Anda berbicara dengan dua kontak berbeda melalui profil yang sama, mereka dapat membuktikan bahwa mereka terhubung dengan orang yang sama.", @@ -226,7 +226,7 @@ "hero-overlay-card-3-p-1": "Trail of Bits adalah konsultan keamanan dan teknologi terkemuka yang kliennya meliputi perusahaan teknologi besar, lembaga pemerintah, dan proyek blockchain besar.", "hero-overlay-card-3-p-2": "Trail of Bits meninjau kriptografi jaringan SimpleX dan komponen jaringan pada November 2022. Baca selengkapnya.", "hero-overlay-card-3-p-3": "Trail of Bits mengulas desain kriptografi protokol jaringan SimpleX pada Juli 2024. Baca selengkapnya.", - "simplex-network-overlay-card-1-p-1": "Protokol dan aplikasi perpesanan P2P memiliki berbagai masalah yang membuatnya kurang dapat diandalkan dibandingkan SimpleX, lebih rumit untuk dianalisis, dan rentan terhadap beberapa jenis serangan.", + "simplex-network-overlay-card-1-p-1": "Protokol dan aplikasi perpesanan P2P memiliki berbagai masalah yang membuatnya kurang dapat diandalkan dibandingkan SimpleX, lebih rumit untuk dianalisis, dan rentan terhadap beberapa jenis serangan.", "simplex-network-overlay-card-1-li-1": "Jaringan P2P andalkan beberapa varian DHT untuk merutekan pesan. Desain DHT harus menyeimbangkan jaminan pengiriman dan latensi. SimpleX memiliki jaminan pengiriman lebih baik dan latensi yang lebih rendah daripada P2P, karena pesan dapat diteruskan secara redundan melalui beberapa server secara paralel, menggunakan server yang dipilih oleh penerima. Dalam jaringan P2P, pesan diteruskan melalui node O(log N) secara berurutan, menggunakan node yang dipilih oleh algoritma.", "simplex-network-overlay-card-1-li-2": "Desain SimpleX, tidak seperti kebanyakan jaringan P2P, tidak memiliki ID pengguna global apa pun, bahkan yang sementara, dan hanya menggunakan pengenal penghubung sementara, sehingga memberikan anonimitas dan perlindungan metadata yang lebih baik.", "simplex-network-overlay-card-1-li-3": "P2P tidak menyelesaikan masalah serangan MITM, dan sebagian besar implementasi yang ada tidak menggunakan pesan out-of-band untuk pertukaran kunci awal. SimpleX menggunakan pesan out-of-band atau, dalam beberapa kasus, koneksi aman dan tepercaya yang sudah ada sebelumnya untuk pertukaran kunci awal.", @@ -311,5 +311,7 @@ "messengers-comparison-section-list-point-3": "Tampaknya penggunaan signature kriptografi membahayakan penolakan (penyangkalan), tetapi hal ini perlu diperjelas.", "messengers-comparison-section-list-point-4": "Implementasi multi-perangkat membahayakan keamanan post-compromise Double Ratchet", "messengers-comparison-section-list-point-5": "Pertukaran kunci 2 faktor bersifat opsional via verifikasi kode keamanan.", - "messengers-comparison-section-list-point-6": "Kesepakatan kunci Post-quantum \"jarang\" — hanya melindungi beberapa langkah ratchet." + "messengers-comparison-section-list-point-6": "Kesepakatan kunci Post-quantum \"jarang\" — hanya melindungi beberapa langkah ratchet.", + "navbar-token": "Token", + "navbar-old-site": "Situs lama" } diff --git a/website/langs/it.json b/website/langs/it.json index f6b35588f6..8c253111bc 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -273,7 +273,7 @@ "index-publications-heise-title": "Pubblicazioni di Heise Online", "index-publications-kuketz-title": "Recensione di Mike Kuketz", "index-publications-optout-title": "Intervista podcast di OptOut", - "worlds-most-secure-messaging": "La messaggistica più sicuro del mondo", + "worlds-most-secure-messaging": "La messaggistica più sicura del mondo", "index-messaging-p1": "SimpleX usa una crittografia end-to-end all'avanguardia.", "index-messaging-p2": "Per la tua sicurezza e privacy, i server non possono vedere i messaggi e con chi parli.", "index-messaging-cta": "Scopri di più sui messaggi di SimpleX", @@ -311,5 +311,7 @@ "messengers-comparison-section-list-point-3": "Sembra che l'uso di firme crittografiche comprometta il ripudio (negabilità), ma occorrono chiarimenti.", "messengers-comparison-section-list-point-4": "L'implementazione multi-dispositivo compromette la sicurezza post-compromissione di Double Ratchet", "messengers-comparison-section-list-point-5": "Lo scambio di chiavi a 2 fattori è facoltativo tramite la verifica del codice di sicurezza.", - "messengers-comparison-section-list-point-6": "L'accordo sulle chiavi post-quantistico è “scarno” — protegge solo alcuni dei passaggi del ratchet." + "messengers-comparison-section-list-point-6": "L'accordo sulle chiavi post-quantistico è “scarno” — protegge solo alcuni dei passaggi del ratchet.", + "navbar-token": "Token", + "navbar-old-site": "Sito vecchio" } diff --git a/website/langs/ja.json b/website/langs/ja.json index 31ef2669ef..4c57cfa0a0 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -27,7 +27,7 @@ "simplex-unique-overlay-card-4-p-3": "例えば、SimpleXアプリユーザへのチャットボットやSimpleX Chatライブラリーの携帯アプリへの統合など、SimpleXネットワークに関する開発を検討してくださっているようでしたら、どのようなアドバイスや支援のことでもご連絡ください 。", "simplex-unique-overlay-card-4-p-2": "SimpleXネットワークは、SimpleX Chatアプリを介してユーザが交流するサービスを実装させつつオープンプロトコルを使い、チャットボットを作成するためにSDKを提供します—私たちはあなた達がどのようなSimpleXのサービスを築くか本当に楽しみです。", "simplex-unique-overlay-card-4-p-1": "あなたが、自分自身のサーバでSimpleXを使っても、アプリで予め設定されたサーバを使う方々と連絡を取ることができます。", - "reference": "参考文献", + "reference": "リファレンス", "simplex-explained-tab-1-text": "1. ユーザーが経験すること", "simplex-explained-tab-1-p-2": "ユーザー プロファイル識別子なしで単方向キューをどのように処理できるのでしょうか?", "simplex-chat-protocol": "SimpleX チャットプロトコル", @@ -255,5 +255,45 @@ "docs-dropdown-11": "よくある質問", "docs-dropdown-12": "セキュリティ", "docs-dropdown-14": "ビジネス向けSimpleX", - "hero-overlay-card-3-p-3": "Trail of Bits は 2024年7月に SimpleX ネットワーク プロトコルの暗号設計をレビューしました。続きを読む。" + "hero-overlay-card-3-p-3": "Trail of Bits は 2024年7月に SimpleX ネットワーク プロトコルの暗号設計をレビューしました。続きを読む。", + "directory": "ディレクトリ", + "about-and-contact-us": "概要・お問い合わせ", + "index-hero-h1": "自由で
あれ", + "index-hero-h2": "あなたのコミュニケーションに
自由とセキュリティを", + "index-hero-p1": "アイデンティティ、連絡先、グループをあなた自身が所有できる、最初のネットワーク。", + "index-hero-download-desktop-btn-title": "SimpleX デスクトップアプリをダウンロード", + "index-security-assessment-title": "セキュリティ監査", + "index-security-review-2022-title": "セキュリティ監査 2022", + "index-security-review-2024-title": "セキュリティ監査 2024", + "index-security-audits-label": "セキュリティ
監査", + "worlds-most-secure-messaging": "世界で最も安全なメッセージングサービス", + "index-messaging-p1": "SimpleXの通信は、最先端のエンドツーエンド暗号化によって保護されています。", + "index-messaging-p2": "安全とプライバシーのため、サーバーはメッセージの内容や、誰とやり取りしているかを知ることができません。", + "index-messaging-cta": "SimpleXのメッセージ機能について詳しく知る", + "index-nextweb-h2": "次のWebは
あなたのもの", + "index-nextweb-p1": "SimpleXは、 アイデンティティ・連絡先・コミュニティはあなたのものであるべきだという考えに基づいています。", + "index-nextweb-p2": "オープンで分散型のネットワークで、自由で安全に人とつながり、アイデアを共有できます。", + "index-token-h2": "続いていくコミュニティ", + "index-token-p1": "コミュニティバウチャーを通じて、お気に入りのグループをサポートできます。", + "index-token-p2": "バウチャーはサーバー費用に充てられ、コミュニティが自由で独立した状態を保ち続けられるようにします。", + "index-roadmap-h2": "自由なインターネットを目指す SimpleX ロードマップ", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "大規模コミュニティへの拡張", + "index-roadmap-2025-desc": "中央集権型プラットフォームからの脱却", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "サステナブルなコミュニティ&サーバ", + "index-roadmap-2026-desc": "コミュニティバウチャーの開始", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "コミュニティの成長", + "index-roadmap-2027-desc": "コミュニティを広げるツール", + "index-directory-h2": "SimpleXコミュニティに参加する", + "index-directory-p1": "既に何十万人もの人々がSimpleXメッセージングを信頼しています。", + "index-directory-p2": "SimpleXディレクトリでコミュニティを見つけたり、あなた自身のコミュニティを作成しましょう!", + "index-directory-cta": "SimpleXディレクトリを見る", + "index-directory-users-group-title": "SimpleXユーザグループ", + "index-publications-privacy-guides-title": "Privacy Guide 推奨", + "index-publications-whonix-title": "Whonix推奨", + "index-publications-heise-title": "Heise Online の記事", + "index-publications-kuketz-title": "Mike Kuketzによるレビュー", + "index-publications-optout-title": "OptOut ポッドキャストインタビュー" } diff --git a/website/langs/pl.json b/website/langs/pl.json index 54a552685f..975389f06d 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -81,8 +81,8 @@ "privacy-matters-2-overlay-1-title": "Prywatność daje Ci władzę", "privacy-matters-2-overlay-1-linkText": "Prywatność daje Ci władzę", "simplex-unique-card-3-p-2": "Wiadomości zaszyfrowane end-to-end są przechowywane tymczasowo na serwerach przekaźnikowych SimpleX do momentu ich odebrania, po czym są trwale usuwane.", - "simplex-unique-card-4-p-1": "Sieć SimpleX jest w pełni zdecentralizowana i niezależna od jakiejkolwiek krypto-waluty lub jakiejkolwiek innej platformy, poza Internetem.", - "simplex-unique-card-2-p-1": "Ponieważ na platformie SimpleX nie masz identyfikatora ani stałego adresu, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", + "simplex-unique-card-4-p-1": "Sieć SimpleX jest w pełni zdecentralizowana i niezależna od jakiejkolwiek krypto-waluty lub jakiejkolwiek innej sieci, poza Internetem.", + "simplex-unique-card-2-p-1": "Ponieważ w sieci SimpleX nie masz identyfikatora ani stałego adresu, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", "simplex-unique-card-3-p-1": "SimpleX przechowuje wszystkie dane użytkownika na urządzeniach klienckich w formacie przenośnej zaszyfrowanej bazy danych — można je przenieść na inne urządzenie.", "comparison-point-1-text": "Wymaga globalnej tożsamości", "comparison-point-2-text": "Możliwość ataku MITM", @@ -114,52 +114,52 @@ "hero-overlay-card-1-p-1": "Wielu użytkowników pytało: jeśli SimpleX nie ma identyfikatorów użytkowników, skąd może wiedzieć, gdzie dostarczyć wiadomości?", "hero-overlay-card-1-p-2": "Aby dostarczyć wiadomości, zamiast identyfikatorów użytkownika używanych przez wszystkie inne sieci, SimpleX używa tymczasowych anonimowych identyfikatorów kolejek wiadomości, oddzielnych dla każdego z twoich połączeń — nie ma żadnych długoterminowych identyfikatorów.", "simplex-network-overlay-card-1-li-1": "Sieci P2P opierają się na pewnym wariancie DHT do przekazywania wiadomości. DHT muszą równoważyć gwarancję dostarczenia i opóźnienie. SimpleX ma zarówno lepszą gwarancję dostarczenia, jak i mniejsze opóźnienia niż P2P, ponieważ wiadomość może być nadmiarowo przepuszczona przez kilka serwerów równolegle, z wykorzystaniem serwerów wybranych przez odbiorcę. W sieciach P2P wiadomość jest przepuszczana przez , O(log N) węzłów sekwencyjnie, z wykorzystaniem węzłów wybranych przez algorytm.", - "privacy-matters-overlay-card-3-p-3": "Zwykli ludzie są aresztowani za to, co udostępniają w sieci, nawet za pośrednictwem swoich \"anonimowych\" kont, nawet w krajach demokratycznych.", - "hero-overlay-card-1-p-6": "Więcej w SimpleX whitepaper.", + "privacy-matters-overlay-card-3-p-3": "Zwykli ludzie są aresztowani za to, co udostępniają w sieci, nawet za pośrednictwem swoich \"anonimowych\" kont, nawet w krajach demokratycznych.", + "hero-overlay-card-1-p-6": "Więcej w SimpleX whitepaper.", "hero-overlay-card-2-p-1": "Gdy użytkownicy mają trwałe tożsamości, nawet jeśli jest to tylko losowa liczba, jak ID sesji, istnieje ryzyko, że dostawca lub atakujący może obserwować, jak użytkownicy są połączeni i ile wiadomości wysyłają.", "hero-overlay-card-2-p-2": "Następnie mogliby skorelować te informacje z istniejącymi publicznymi sieciami społecznymi i określić niektóre prawdziwe tożsamości.", "hero-overlay-card-2-p-4": "SimpleX chroni przed tymi atakami, nie mając w swojej konstrukcji żadnych identyfikatorów użytkowników. A jeśli używasz trybu Incognito, będziesz miał inną nazwę wyświetlania dla każdego kontaktu, unikając wszelkich wspólnych danych między nimi.", - "simplex-network-overlay-card-1-p-1": "P2P protokoły i aplikacje do przesyłania wiadomości mają różne problemy, które czynią je mniej niezawodnymi niż SimpleX, bardziej skomplikowanymi w analizie i podatnymi na kilka rodzajów ataków.", + "simplex-network-overlay-card-1-p-1": "Protokoły P2P i aplikacje do przesyłania wiadomości mają różne problemy, które czynią je mniej niezawodnymi niż SimpleX, bardziej skomplikowanymi w analizie i podatnymi na kilka rodzajów ataków.", "simplex-network-overlay-card-1-li-2": "Projekt SimpleX, w przeciwieństwie do większości sieci P2P, nie posiada żadnych globalnych identyfikatorów użytkowników, nawet tymczasowych, a jedynie używa tymczasowych identyfikatorów parami, zapewniając lepszą anonimowość i ochronę metadanych.", "simplex-network-overlay-card-1-li-4": "Implementacje P2P mogą być blokowane przez niektórych dostawców Internetu (np. BitTorrent). SimpleX jest agnostykiem transportowym — może działać przez standardowe protokoły internetowe, np. WebSockets.", - "privacy-matters-overlay-card-1-p-3": "Niektóre firmy finansowe i ubezpieczeniowe wykorzystują wykresy społeczne do określania stóp procentowych i składek. Często powoduje to, że osoby o niższych dochodach płacą więcej — jest to znane jako 'premia za ubóstwo'.", + "privacy-matters-overlay-card-1-p-3": "Niektóre firmy finansowe i ubezpieczeniowe wykorzystują wykresy społeczne do określania stóp procentowych i składek. Często powoduje to, że osoby o niższych dochodach płacą więcej — jest to znane jako \"premia za ubóstwo\".", "privacy-matters-overlay-card-1-p-4": "Sieć SimpleX chroni prywatność Twoich połączeń lepiej niż jakakolwiek alternatywa, w pełni zapobiegając udostępnieniu Twojego wykresu społecznego jakimkolwiek firmom lub organizacjom. Nawet gdy ludzie korzystają z serwerów prekonfigurowanych przez SimpleX Chat, aplikacje i operatorzy serwerów nie znają liczby użytkowników ani ich połączeń.", "privacy-matters-overlay-card-1-p-2": "Sprzedawcy internetowi wiedzą, że osoby o niższych dochodach częściej dokonują pilnych zakupów, więc mogą naliczać wyższe ceny lub usuwać rabaty.", "privacy-matters-overlay-card-2-p-2": "Aby być obiektywnym i podejmować niezależne decyzje musisz mieć kontrolę nad swoją przestrzenią informacyjną. Jest to możliwe tylko wtedy, gdy korzystasz z prywatnej sieci komunikacyjnej, która nie ma dostępu do Twojego wykresu społecznego.", - "privacy-matters-overlay-card-2-p-3": "SimpleX to pierwsza sieć, która z założenia nie posiada żadnych identyfikatorów użytkowników, w ten sposób chroniąc Twój wykres połączeń lepiej niż jakakolwiek znana alternatywa.", - "simplex-network-overlay-card-1-li-5": "Wszystkie znane sieci P2P mogą być podatne na atak Sybil, ponieważ każdy węzeł jest możliwy do odkrycia, a sieć działa jako całość. Znane środki łagodzące wymagają albo scentralizowanego komponentu, albo kosztownego proof of work. SimpleX nie ma możliwości odkrywania serwerów, jest pofragmentowana i działa jako wiele odizolowanych podsieci, co uniemożliwia ataki na całą sieć.", - "privacy-matters-overlay-card-2-p-1": "Jeszcze nie tak dawno obserwowaliśmy, jak główne wybory były manipulowane przez szacowną firmę konsultingową, która wykorzystywała nasze wykresy społeczne do zniekształcania naszego spojrzenia na świat rzeczywisty i manipulowania naszymi głosami.", + "privacy-matters-overlay-card-2-p-3": "SimpleX to pierwsza sieć, która z założenia nie posiada żadnych identyfikatorów użytkowników, w ten sposób chroniąc Twój graf połączeń lepiej niż jakakolwiek znana alternatywa.", + "simplex-network-overlay-card-1-li-5": "Wszystkie znane sieci P2P mogą być podatne na atak Sybil, ponieważ każdy węzeł jest możliwy do odkrycia, a sieć działa jako całość. Znane środki łagodzące wymagają albo scentralizowanego komponentu, albo kosztownego proof of work. SimpleX nie ma możliwości odkrywania serwerów, jest pofragmentowana i działa jako wiele odizolowanych podsieci, co uniemożliwia ataki na całą sieć.", + "privacy-matters-overlay-card-2-p-1": "Jeszcze nie tak dawno obserwowaliśmy, jak główne wybory były manipulowane przez szacowną firmę konsultingową, która wykorzystywała nasze wykresy społeczne do zniekształcania naszego spojrzenia na świat rzeczywisty i manipulowania naszymi głosami.", "privacy-matters-overlay-card-3-p-1": "Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji — nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia.", - "privacy-matters-overlay-card-3-p-2": "Jedną z najbardziej wstrząsających historii jest doświadczenie Mohamedou Ould Salahi opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech.", + "privacy-matters-overlay-card-3-p-2": "Jedną z najbardziej wstrząsających historii jest doświadczenie Mohamedou Ould Salahi opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech.", "privacy-matters-overlay-card-3-p-4": "Nie wystarczy używać komunikatora szyfrowanego end-to-end, wszyscy powinniśmy używać komunikatorów, które chronią prywatność naszych sieci osobistych — z kim jesteśmy połączeni.", "simplex-unique-overlay-card-1-p-1": "W przeciwieństwie do innych sieci komunikacyjnych, SimpleX ma brak identyfikatorów przypisanych do użytkowników. Nie polega na numerach telefonów, adresach opartych na domenie (jak e-mail lub XMPP), nazwach użytkowników, kluczach publicznych czy nawet losowych liczbach, aby zidentyfikować swoich użytkowników — operatorzy serwerów SimpleX nie wiedzą, ile osób korzysta z ich serwerów.", "hero-overlay-card-2-p-3": "Nawet w przypadku najbardziej prywatnych aplikacji, które korzystają z trzeciej wersji usług Tor, jeśli rozmawiasz z dwoma różnymi kontaktami za pośrednictwem tego samego profilu, mogą one udowodnić, że są połączone z tą samą osobą.", "simplex-network-overlay-card-1-li-3": "P2P nie rozwiązuje ataku MITM problemu, a większość istniejących implementacji nie używa wiadomości out-of-band do początkowej wymiany klucza. SimpleX używa wiadomości out-of-band lub, w niektórych przypadkach, wcześniej istniejących bezpiecznych i zaufanych połączeń do początkowej wymiany klucza.", "hero-overlay-card-1-p-5": "Tylko urządzenia klienckie przechowują profile użytkowników, kontakty i grupy; wiadomości są przesyłane z 2-warstwowym szyfrowaniem end-to-end.", "simplex-unique-overlay-card-3-p-2": "Wiadomości zaszyfrowane end-to-end są przechowywane tymczasowo na serwerach przekaźnikowych SimpleX do momentu ich odebrania, po czym są trwale usuwane.", - "simplex-network-overlay-card-1-li-6": "Sieci P2P mogą być podatne na atak DRDoS, kiedy klienci mogą rozgłaszać i wzmacniać ruch, co powoduje odmowę usługi w całej sieci. Klienci SimpleX przekazują jedynie ruch ze znanego połączenia i nie mogą być wykorzystani przez atakującego do wzmocnienia ruchu w całej sieci.", + "simplex-network-overlay-card-1-li-6": "Sieci P2P mogą być podatne na atak DRDoS, kiedy klienci mogą rozgłaszać i wzmacniać ruch, co powoduje odmowę usługi w całej sieci. Klienci SimpleX przekazują jedynie ruch ze znanego połączenia i nie mogą być wykorzystani przez atakującego do wzmocnienia ruchu w całej sieci.", "privacy-matters-overlay-card-1-p-1": "Wiele dużych firm wykorzystuje informacje o tym, z kim jesteś połączony, aby oszacować Twoje dochody, sprzedać Ci produkty, których tak naprawdę nie potrzebujesz, oraz ustalić ceny.", "simplex-unique-overlay-card-2-p-2": "Nawet w przypadku opcjonalnego adresu użytkownika, podczas gdy może on być używany do wysyłania spamowych zapytań o kontakt, możesz go zmienić lub całkowicie usunąć bez utraty jakichkolwiek połączeń.", - "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery.", + "simplex-unique-overlay-card-1-p-2": "Do dostarczania wiadomości SimpleX używa parami anonimowych adresów jednokierunkowych kolejek z wiadomościami, oddzielnych dla wiadomości odbieranych i wysyłanych, zwykle przez różne serwery.", "simplex-unique-overlay-card-1-p-3": "Taka konstrukcja chroni prywatność tego, z kim się komunikujesz, ukrywając ją przed serwerami sieci SimpleX i przed wszelkimi obserwatorami. Aby ukryć swój adres IP przed serwerami, możesz połączyć się z serwerami SimpleX za pośrednictwem sieci Tor.", "simplex-unique-overlay-card-3-p-4": "Nie ma żadnych identyfikatorów ani szyfrogramów wspólnych między wysyłanym i odbieranym ruchem serwera — jeśli ktokolwiek to obserwuje, nie może łatwo określić, kto komunikuje się z kim, nawet jeśli bezpieczeństwo protokołu TLS zostało zagrożone.", "simplex-unique-overlay-card-2-p-1": "Ponieważ nie masz identyfikatora w sieci SimpleX, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku.", "simplex-unique-overlay-card-3-p-1": "SimpleX Chat przechowuje wszystkie dane użytkowników tylko na urządzeniach klienckich przy użyciu przenośnego formatu zaszyfrowanej bazy danych, który można wyeksportować i przenieść na dowolne obsługiwane urządzenie.", "simplex-unique-overlay-card-3-p-3": "W przeciwieństwie do serwerów sieci federacyjnych (e-mail, XMPP czy Matrix), serwery SimpleX nie przechowują kont użytkowników, a jedynie przekazują wiadomości, chroniąc prywatność obu stron.", - "simplex-unique-overlay-card-4-p-1": "Możesz używać SimpleX z własnymi serwerami i nadal komunikować się z osobami, które korzystają z dostarczonych przez nas prekonfigurowanych serwerów.", + "simplex-unique-overlay-card-4-p-1": "Możesz używać SimpleX z własnymi serwerami i nadal komunikować się z osobami, które korzystają z prekonfigurowanych w aplikacji serwerów.", "donate-here-to-help-us": "Przekaż darowiznę tutaj, aby nam pomóc", - "simplex-unique-overlay-card-4-p-3": "Jeśli rozważasz rozwój dla platformy SimpleX, na przykład czatbotu dla użytkowników aplikacji SimpleX lub integrację biblioteki SimpleX Chat w swoich aplikacjach mobilnych, prosimy o kontakt dla wszelkich porad i wsparcia.", - "simplex-unique-card-1-p-1": "SimpleX chroni prywatność Twojego profilu, kontaktów i metadanych, ukrywając je przed serwerami platformy SimpleX i wszelkimi obserwatorami.", - "simplex-unique-overlay-card-4-p-2": "Platforma SimpleX wykorzystuje otwarty protokół i udostępnia SDK do tworzenia chatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat — naprawdę' nie możemy się doczekać, aby zobaczyć, jakie usługi SimpleX możesz zbudować.", - "simplex-unique-card-1-p-2": "W przeciwieństwie do każdej innej istniejącej platformy komunikacyjnej, SimpleX nie ma żadnych identyfikatorów przypisanych do użytkowników — nawet losowych liczb.", - "tap-the-connect-button-in-the-app": "Stuknij przycisk 'połącz się' w aplikacji", + "simplex-unique-overlay-card-4-p-3": "Jeśli rozważasz rozwój dla sieci SimpleX, na przykład czatbotu dla użytkowników aplikacji SimpleX lub integrację biblioteki SimpleX Chat w swoich aplikacjach mobilnych, prosimy o kontakt dla wszelkich porad i wsparcia.", + "simplex-unique-card-1-p-1": "SimpleX chroni prywatność Twojego profilu, kontaktów i metadanych, ukrywając je przed serwerami sieci SimpleX i wszelkimi obserwatorami.", + "simplex-unique-overlay-card-4-p-2": "Sieć SimpleX wykorzystuje otwarty protokół i udostępnia SDK do tworzenia chatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat — naprawdę nie możemy się doczekać, aby zobaczyć, jakie usługi SimpleX zbudujesz.", + "simplex-unique-card-1-p-2": "W przeciwieństwie do każdej innej istniejącej sieci komunikacyjnej, SimpleX nie ma żadnych identyfikatorów przypisanych do użytkowników — nawet losowych liczb.", + "tap-the-connect-button-in-the-app": "Stuknij przycisk \"połącz się\" w aplikacji", "join-the-REDDIT-community": "Dołącz do społeczności REDDIT", "hide-info": "Ukryj informacje", "simplex-unique-card-4-p-2": "Możesz używać SimpleX z własnymi serwerami lub z serwerami dostarczonymi przez nas — i nadal łączyć się z dowolnym użytkownikiem.", "we-invite-you-to-join-the-conversation": "Zapraszamy do udziału w rozmowie", "enter-your-email-address": "Wpisz swój adres e-mail", "get-simplex": "Pobierz SimpleX desktop app", - "why-simplex-is-unique": "Dlaczego SimpleX jest unikalny", + "why-simplex-is-unique": "Dlaczego SimpleX jest unikalny", "join": "Dołącz do", "join-us-on-GitHub": "Dołącz do nas na GitHubie", "sign-up-to-receive-our-updates": "Zapisz się, aby otrzymywać nasze aktualizacje", @@ -186,27 +186,27 @@ "see-simplex-chat": "Zobacz SimpleX Chat", "the-instructions--source-code": "instrukcje jak to pobrać lub skompilować z kodu źródłowego.", "simplex-chat-for-the-terminal": "SimpleX Chat dla terminala", - "privacy-matters-section-header": "Dlaczego prywatność ma znaczenie", - "privacy-matters-section-subheader": "Zachowanie prywatności Twoich metadanych — z kim rozmawiasz — chroni Cię przed:", + "privacy-matters-section-header": "Dlaczego prywatność ma znaczenie", + "privacy-matters-section-subheader": "Zachowanie prywatności Twoich metadanych — z kim rozmawiasz — chroni Cię przed:", "copy-the-command-below-text": "skopiuj poniższe polecenie i użyj go na czacie:", "privacy-matters-section-label": "Upewnij się, że Twój komunikator nie ma dostępu do Twoich danych!", "simplex-network-1-header": "W przeciwieństwie do sieci P2P", "simplex-network-section-desc": "Simplex Chat zapewnia najlepszą prywatność dzięki połączeniu zalet sieci P2P i sieci federacyjnych.", "simplex-network-2-header": "W przeciwieństwie do sieci federacyjnych", - "simplex-private-section-header": "Co sprawia, że SimpleX jest prywatny", + "simplex-private-section-header": "Co sprawia, że SimpleX jest prywatny", "tap-to-close": "Stuknij, aby zamknąć", - "simplex-network-section-header": "SimpleX Sieć", + "simplex-network-section-header": "Sieć SimpleX", "simplex-network-2-desc": "Serwery przekaźnikowe SimpleX NIE przechowują profili użytkowników, kontaktów i dostarczonych wiadomości, NIE łączą się ze sobą i NIE ma katalogu serwerów.", "simplex-network-1-desc": "Wszystkie wiadomości są wysyłane przez serwery, co zapewnia lepszą prywatność metadanych i niezawodne asynchroniczne dostarczanie wiadomości, przy jednoczesnym unikaniu wielu", "protocol-1-text": "Signal, duże platformy", "simplex-network-1-overlay-linktext": "problemów sieci P2P", "simplex-network-3-header": "Sieć SimpleX", - "simplex-network-3-desc": "serwerów zapewniają jednokierunkowe kolejki do łączenia użytkowników, ale nie mają wglądu w wykres połączeń sieciowych — robią to tylko użytkownicy.", + "simplex-network-3-desc": "serwery zapewniają jednokierunkowe kolejki do łączenia użytkowników, ale nie mają wglądu w graf połączeń sieciowych — robią to tylko użytkownicy.", "comparison-section-header": "Porównanie z innymi protokołami", "comparison-section-list-point-3": "Klucz publiczny lub inny globalnie unikalny identyfikator (ID)", "protocol-2-text": "XMPP, Matrix", "protocol-3-text": "Protokoły sieci P2P", - "comparison-section-list-point-6": "Podczas gdy sieci P2P są rozproszone, nie są sfederowane - działają jako jedna sieć", + "comparison-section-list-point-6": "Podczas gdy sieci P2P są rozproszone, nie są sfederowane — działają jako jedna sieć", "comparison-section-list-point-4": "Jeśli bezpieczeństwo serwerów operatora zostało naruszone. Zweryfikuj kody bezpieczeństwa w Signalu lub innej aplikacji aby to złagodzić", "comparison-section-list-point-7": "Sieci P2P albo mają centralny organ, albo cała sieć może zostać skompromitowana", "guide-dropdown-1": "Szybki start", @@ -219,7 +219,7 @@ "guide-dropdown-8": "Ustawienia aplikacji", "guide-dropdown-9": "Nawiązywanie połączeń", "guide": "Przewodnik", - "docs-dropdown-1": "Platforma SimpleX", + "docs-dropdown-1": "Sieć SimpleX", "docs-dropdown-2": "Dostęp do plików Androida", "docs-dropdown-3": "Dostęp do bazy danych czatu", "docs-dropdown-4": "Hostuj serwer SMP", @@ -232,9 +232,9 @@ "on-this-page": "Na tej stronie", "back-to-top": "Powrót do góry", "glossary": "Słowniczek", - "f-droid-page-simplex-chat-repo-section-text": "Aby dodać do Twojego klienta F-Droid, zeskanuj kod QR lub użyj tego URL:", - "f-droid-page-f-droid-org-repo-section-text": "Repozytoria SimpleX Chat i F-Droid.org mają podpisane budowy z innymi kluczami. Aby zmienić, proszę wyeksportuj bazę czatu i przeinstaluj aplikację.", - "docs-dropdown-8": "Serwis katalogowy SimpleX", + "f-droid-page-simplex-chat-repo-section-text": "Aby dodać do Twojego klienta F-Droid, zeskanuj kod QR lub użyj tego URL:", + "f-droid-page-f-droid-org-repo-section-text": "Repozytoria SimpleX Chat i F-Droid.org mają podpisane budowy z innymi kluczami. Aby zmienić, proszę wyeksportuj bazę czatu i przeinstaluj aplikację.", + "docs-dropdown-8": "Katalog SimpleX", "simplex-chat-via-f-droid": "SimpleX Chat na F-Droid", "simplex-chat-repo": "Repo SimpleX", "stable-and-beta-versions-built-by-developers": "Wersje stabilne i beta zbudowane przez deweloperów", @@ -255,5 +255,63 @@ "docs-dropdown-12": "Bezpieczeństwo", "docs-dropdown-11": "Często zadawane pytania", "hero-overlay-card-3-p-3": "Firma Trail of Bits dokonała analizy projektu kryptograficznego protokołów sieciowych SimpleX w lipcu 2024 roku. Dowiedz się więcej.", - "docs-dropdown-14": "SimpleX dla firm" + "docs-dropdown-14": "SimpleX dla firm", + "directory": "Katalog", + "navbar-token": "Token", + "about-and-contact-us": "O nas i Kontakt", + "index-hero-h1": "Bądź
Wolny", + "index-hero-h2": "Wolność i bezpieczeństwo
Twojej Komunikacji", + "index-hero-p1": "Pierwsza sieć, w której posiadasz na własność swoją tożsamość, kontakty i grupy.", + "index-hero-download-desktop-btn-title": "Pobierz Aplikację SimpleX na Komputer", + "index-testflight-title": "SimpleX iOS beta-wydanie na TestFlight", + "index-f-droid-title": "SimpleX app z F-Droid", + "index-security-assessment-title": "Audyty Bezpieczeństwa", + "index-security-review-2022-title": "Audyt Bezpieczeństwa z 2022", + "index-security-review-2024-title": "Audyt Bezpieczeństwa 2024", + "index-security-audits-label": "Audyty
Bezpieczeństwa", + "index-publications-privacy-guides-title": "Wskazówki dotyczące prywatności – zalecenia dotyczące komunikatorów", + "index-publications-whonix-title": "Rekomendowane komunikatory przez Whonix", + "index-publications-heise-title": "Publikacje Online Heise", + "index-publications-kuketz-title": "Recenzja od Mike'a Kuketz'a", + "index-publications-optout-title": "Wywiad w formie podcastu od OptOut", + "worlds-most-secure-messaging": "Najbardziej bezpieczne wiadomości na świecie", + "index-messaging-p1": "Komunikator SimpleX posiada najbardziej zaawansowane szyfrowanie end-to-end.", + "index-messaging-p2": "Dla Twojego bezpieczeństwa i prywatności, serwery nie mogą zobaczyć wiadomości i tego z kim rozmawiasz.", + "index-messaging-cta": "Dowiedz się więcej o komunikatorze SimpleX", + "index-nextweb-h2": "Ty Posiadasz
Sieć Kolejnej Generacji", + "index-nextweb-p1": "SimpleX opiera się na przekonaniu, że to Ty musisz posiadać na własność swoją tożsamość, kontakty i społeczności.", + "index-nextweb-p2": "Otwarta i zdecentralizowana sieć pozwala połączyć się z ludźmi i dzielić się pomysłami: bądź wolny i bezpieczny.", + "index-token-h2": "Społeczności, Które Trwają", + "index-token-p1": "Będziesz mógł wspierać swoje ulubione grupy dzięki przyszłym Voucherom Społeczności.", + "index-token-p2": "Vouchery opłacą serwery, aby Twoje społeczności pozostały wolne i niezależne.", + "index-token-cta": "Dowiedz się więcej i uzyskuj swój darmowy NFT
do wczesnych testów.", + "index-roadmap-h2": "Plan Działania SimpleX dla Wolnego Internetu", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Wyskalowany dla Dużych Społeczności", + "index-roadmap-2025-desc": "Wymyka się scentralizowanym platformom", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Zrównoważone Społeczności i Serwery", + "index-roadmap-2026-desc": "Uruchomienie Voucherów Społeczności", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Spraw, aby Twoje społeczności Rosły", + "index-roadmap-2027-desc": "Narzędzia do promowania Twoich społeczności", + "index-directory-h2": "Dołącz do Społeczności SimpleX", + "index-directory-p1": "Setki tysięcy ludzi już ufają wiadomościom SimpleX.", + "index-directory-p2": "Znajdź swoje społeczności w katalogu SimpleX i stwórz własne!", + "index-directory-cta": "Zobacz katalog SimpleX", + "index-directory-users-group-title": "Grupa użytkowników SimpleX", + "how-secure-comparison-title": "Porównanie zabezpieczeń szyfrowania end-to-end w różnych komunikatorach", + "how-secure-message-padding": "Wypełnianie wiadomości", + "how-secure-repudiation-deniability": "Wyrzeczenie (wiarygodne zaprzeczenie)", + "how-secure-forward-secrecy": "Forward secrecy", + "how-secure-break-in-recovery": "Bezpieczeństwo po naruszeniu zabezpieczeń", + "how-secure-two-factor-key-exchange": "Wymiana kluczy 2-składnikowych", + "how-secure-post-quantum-hybrid-crypto": "Post-quantum hybrid crypto", + "messengers-comparison-section-list-point-1": "Briar wypełnia wiadomości do zaokrąglonego rozmiaru do maksymalnie 1024 bajtów, Signal - do 160 bajtów", + "messengers-comparison-section-list-point-2": "Repudiatacja (wiarygodne zaprzeczenie) nie obejmuje połączenia klient-serwer.", + "messengers-comparison-section-list-point-3": "Wydaje się, że użycie podpisów kryptograficznych zagraża repudiatacji (wiarygodnemu zaprzeczeniu), ale należy je wyjaśnić.", + "messengers-comparison-section-list-point-4": "Wdrożenie wielu urządzeń zagraża zabezpieczeniu po naruszeniu bezpieczeństwa systemu Double Ratchet", + "messengers-comparison-section-list-point-5": "Wymiana kluczy 2-składnikowych jest opcjonalna poprzez weryfikację kodu bezpieczeństwa.", + "messengers-comparison-section-list-point-6": "Post-kwantowe, kluczowe porozumienie jest \"rzadkie\" — chroni tylko niektóre kroki systemu Ratchet.", + "navbar-old-site": "Stara strona" } diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 3b7cc23703..b43b521444 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -91,7 +91,7 @@ "simplex-private-card-3-point-1": "Somente o TLS 1.2/1.3 com algoritmos fortes é usado para conexões cliente-servidor.", "simplex-private-card-3-point-2": "A impressão digital do servidor e a vinculação de canais evitam ataques MITM e de repetição.", "simplex-private-card-3-point-3": "A retomada da conexão é desativada para evitar ataques à sessão.", - "simplex-private-card-5-point-1": "O SimpleX usa preenchimento de conteúdo em cada camada de criptografia para impedir ataques ao tamanho da mensagem.", + "simplex-private-card-5-point-1": "O SimpleX utiliza preenchimento de conteúdo (padding) em cada camada de criptografia para dificultar ataques baseados no tamanho da mensagem.", "simplex-private-5-title": "Múltiplas camadas de
preenchimento de conteúdos", "simplex-private-card-5-point-2": "Isso faz com que mensagens de tamanhos diferentes tenham a mesma aparência para os servidores e observadores de rede.", "simplex-private-card-6-point-1": "Muitas plataformas de comunicação são vulneráveis a ataques MITM por servidores ou provedores de rede.", @@ -118,7 +118,7 @@ "hero-overlay-card-1-p-6": "Leia mais no informativo técnico do SimpleX.", "hero-overlay-card-2-p-2": "Eles poderiam então correlacionar essas informações com as redes sociais públicas existentes e determinar algumas identidades reais.", "hero-overlay-card-2-p-4": "O SimpleX se protege contra esses ataques por não ter nenhum ID de usuário em seu design. E, se você usar o modo Anônimo, terá um nome de exibição diferente para cada contato, evitando qualquer compartilhamento de dados entre eles.", - "simplex-network-overlay-card-1-p-1": "Os protocolos e aplicativos de mensagens P2P têm vários problemas que os tornam menos confiáveis do que o SimpleX, mais complexos de analisar e vulneráveis a vários tipos de ataque.", + "simplex-network-overlay-card-1-p-1": "Os protocolos e aplicativos de mensagens P2P têm vários problemas que os tornam menos confiáveis do que o SimpleX, mais complexos de analisar e vulneráveis a vários tipos de ataque.", "simplex-network-overlay-card-1-li-2": "O design do SimpleX, ao contrário da maioria das redes P2P, não tem identificadores de usuário globais de qualquer tipo, mesmo temporários, e usa apenas identificadores temporários em pares, proporcionando melhor anonimato e proteção de metadados.", "simplex-network-overlay-card-1-li-3": "O P2P não resolve o problema do ataque MITM, e a maioria das implementações existentes não usa mensagens fora de banda para a troca de chaves inicial. O SimpleX usa mensagens fora de banda ou, em alguns casos, conexões pré-existentes seguras e confiáveis para a troca de chaves inicial.", "simplex-network-overlay-card-1-li-6": "As redes P2P podem ser vulneráveis a ataques DRDoS, quando os clientes podem retransmitir e amplificar o tráfego, resultando em uma negação de serviço em toda a rede. Os clientes SimpleX apenas retransmitem o tráfego de uma conexão conhecida e não podem ser usados por um invasor para amplificar o tráfego em toda a rede.", @@ -257,5 +257,61 @@ "hero-overlay-card-3-p-3": "Trail of Bits revisou o design criptografico das redes utilizadas pelo SimpleX em julho de 2024. Read more.", "docs-dropdown-14": "SimpleX para negócios", "directory": "Diretório", - "about-and-contact-us": "Sobre e Contato" + "about-and-contact-us": "Sobre e Contato", + "navbar-token": "Token", + "index-hero-h1": "Seja
Livre", + "index-hero-h2": "Liberdade & Segurança
Em Suas Comunicações", + "index-hero-p1": "A primeira rede onde você é dono da sua identidade, contatos e grupos.", + "index-hero-download-desktop-btn-title": "Baixe o aplicativo SimpleX Desktop", + "index-testflight-title": "SimpleX iOS - versão beta no TestFlight", + "index-f-droid-title": "Aplicativo SimpleX no F-Droid", + "index-security-assessment-title": "Auditorias de Segurança", + "index-security-review-2022-title": "Auditoria de Segurança - 2022", + "index-security-review-2024-title": "Auditoria de Segurança - 2024", + "index-security-audits-label": "Auditorias
de Segurança", + "index-publications-privacy-guides-title": "Recomendaçẽos de mensageiros do Privacy Guides", + "index-publications-whonix-title": "Recomendações de mensageria do Whonix", + "index-publications-heise-title": "Publicações da Heise Online", + "index-publications-kuketz-title": "Análise por Mike Kuketz", + "index-publications-optout-title": "Entrevista no podcast OptOut", + "worlds-most-secure-messaging": "O sistema de mensagens mais seguro do mundo", + "index-messaging-p1": "O SimpleX possui criptografia de ponta a ponta de última geração.", + "index-messaging-p2": "Para sua segurança e privacidade, os servidores não podem ver suas mensagensnem com quem você conversa.", + "index-messaging-cta": "Saiba mais sobre SimpleX Messaging", + "index-nextweb-h2": "Você é Dono
da Próxima Web", + "index-nextweb-p1": "SimpleX é fundado na crença de que você deve ser dono da sua identidade, contatos e comunidades.", + "index-nextweb-p2": "Rede aberta e descentralizada permite que você se conecte com pessoas e compartilhe ideias: seja livre e seguro.", + "index-token-h2": "Comunidades Duradouras", + "index-token-p1": "Você apoiará seus grupos favoritos com futuros Vouchers da Comunidade.", + "index-token-p2": "Os vouchers pagarão pelos servidores, permitindo que suas comunidades continuem gratuitas e independentes.", + "index-token-cta": "Saiba mais e pegue sua NFT gratuita
para testes antecipados.", + "index-roadmap-h2": "Roteiro do SimpleX para uma Internet Livre", + "index-roadmap-2025": "2025", + "index-roadmap-2025-title": "Escala para Grandes Comunidades", + "index-roadmap-2025-desc": "Fugindo de plataformas centralizadas", + "index-roadmap-2026": "2026", + "index-roadmap-2026-title": "Comunidades e Servidores Sustentáveis", + "index-roadmap-2026-desc": "Lançamento dos Vouchers da Comunidade", + "index-roadmap-2027": "2027", + "index-roadmap-2027-title": "Faça Suas Comunidades Crescerem", + "index-roadmap-2027-desc": "Ferramentas para promover suas comunidades", + "index-directory-h2": "Participe das Comunidades SimpleX", + "index-directory-p1": "Centenas de milhares de pessoas já confiam no SimpleX Messaging.", + "index-directory-p2": "Encontre suas comunidades no diretório SimpleX e crie a sua própria!", + "index-directory-cta": "Ver diretório do SimpleX", + "how-secure-comparison-title": "Comparação da segurança da criptografia de ponta a ponta em diferentes mensageiros", + "how-secure-message-padding": "Preenchimento de mensagem", + "how-secure-repudiation-deniability": "Repúdio (negação plausível)", + "how-secure-forward-secrecy": "Sigilo de Encaminhamento", + "how-secure-break-in-recovery": "Segurança pós-comprometimento", + "how-secure-two-factor-key-exchange": "Troca de chaves de dois fatores", + "how-secure-post-quantum-hybrid-crypto": "Criptografia híbrida pós-quântica", + "messengers-comparison-section-list-point-1": "O Briar preenche as mensagens até que o tamanho seja arredondado para 1024 bytes, enquanto o Signal as preenche para 160 bytes", + "messengers-comparison-section-list-point-2": "O repúdio não inclui a conexão cliente-servidor.", + "messengers-comparison-section-list-point-3": "Parece que o uso de assinaturas criptográficas compromete o repúdio (denegabilidade), mas isso precisa ser esclarecido.", + "messengers-comparison-section-list-point-4": "A implementação de múltiplos dispositivos compromete a segurança pós-comprometimento do Double Ratchet", + "messengers-comparison-section-list-point-5": "A troca de chaves de dois fatores é opcional por meio da verificação do código de segurança.", + "messengers-comparison-section-list-point-6": "O acordo de chaves pós-quântico é 'esparso' — ele protege apenas algumas das etapas da catraca (ratchet).", + "navbar-old-site": "Site antigo", + "index-directory-users-group-title": "Grupos de usuários do SimpleX" } diff --git a/website/langs/ru.json b/website/langs/ru.json index fbfa6d3539..4990c5b512 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -311,5 +311,7 @@ "messengers-comparison-section-list-point-3": "По всей видимости, использование криптографической подписи исключает отражаемость, но это требует уточнения.", "messengers-comparison-section-list-point-4": "Реализация поддержки нескольких устройств подрывает безопасность после взлома алгоритма Double Ratchet.", "messengers-comparison-section-list-point-5": "2-факторный обмен ключами опциональный, через верификацию кода безопасности.", - "messengers-comparison-section-list-point-6": "Пост-квантовое согласование ключей происходит не всегда, защищая только часть согласований." + "messengers-comparison-section-list-point-6": "Пост-квантовое согласование ключей происходит не всегда, защищая только часть согласований.", + "navbar-token": "Токен", + "navbar-old-site": "Старый сайт" } diff --git a/website/langs/tr.json b/website/langs/tr.json index ad8914fa18..6c9849306f 100644 --- a/website/langs/tr.json +++ b/website/langs/tr.json @@ -14,7 +14,7 @@ "simplex-explained-tab-1-p-1": "Kişiler ve gruplar oluşturabilir ve tıpkı diğer mesajlaşma programlarında olduğu gibi iki yönlü iletişim kurabilirsiniz.", "simplex-explained-tab-1-p-2": "Tek yönlü kuyruklarla ve kullanıcı profil tanımlayıcıları olmadan nasıl çalışabilir?", "simplex-explained-tab-2-p-1": "Her bağlantı için, farklı sunucular üzerinden mesaj göndermek ve almak üzere iki ayrı mesaj kuyruğu kullanırsınız.", - "simplex-explained-tab-2-p-2": "Sunucular, kullanıcı konuşmalarının veya bağlantılarının tüm geçmişini bilmeden mesajları yalnızca tek bir yönde iletir.", + "simplex-explained-tab-2-p-2": "Sunucular, kullanıcıların konuşmalarının veya bağlantılarının tam resmini görmeden mesajları tek yönlü olarak iletir.", "simplex-explained-tab-3-p-1": "Sunucular her kuyruk için ayrı, anonim oturum açma bilgileri kullanır ve hangi kullanıcıya ait olduğunu bilmez.", "simplex-explained-tab-3-p-2": "Tor erişim sunucuları kullanarak kullanıcılar, meta veri gizliliğini artırabilir ve IP adresi korelasyonunu önleyebilir.", "chat-bot-example": "Sohbet Botu Örneği", @@ -96,7 +96,7 @@ "hero-overlay-card-1-p-3": "Hangi sunucuları mesaj almak için kullanacağınızı siz belirlersiniz. Kişileriniz — bu sunucuları size mesaj göndermek için kullanır. Her konuşma genellikle iki farklı sunucu kullanır.", "hero-overlay-card-1-p-4": "Bu tasarım, uygulama düzeyinde kullanıcı meta verilerinin sızmasını önler. Mesaj sunucularına Tor üzerinden bağlanarak gizliliğinizi ve IP adresinizi koruyabilirsiniz.", "hero-overlay-card-1-p-5": "Kullanıcı profilleri, kişiler ve gruplar yalnızca kullanıcının cihazında saklanır. Mesajlar, iki katmanlı uçtan uca şifreleme ile gönderilir.", - "hero-overlay-card-1-p-6": "Daha fazla bilgi için SimpleX teknik dokümanını okuyun.", + "hero-overlay-card-1-p-6": "Daha fazla bilgi için SimpleX teknik dokümanını okuyun.", "hero-overlay-card-2-p-1": "Kullanıcıların kalıcı kimlikleri olduğunda, bu bir oturum kimliği gibi rastgele bir sayı olsa bile, sağlayıcıların veya saldırganların kullanıcıların birbirleriyle nasıl bağlantılı olduğunu ve kaç mesaj gönderdiklerini tespit etme riski vardır.", "hero-overlay-card-2-p-2": "Bu bilgiler, mevcut halka açık sosyal ağlarla ilişkilendirilebilir ve gerçek kimlikler ortaya çıkarılabilir.", "hero-overlay-card-2-p-3": "Aynı profil üzerinden iki farklı kişiyle sohbet ederseniz, en gizlilik odaklı uygulamalarda bile (ör. Tor v3 hizmetleri kullananlar) bu kişiler, aynı kişiyle bağlantılı olduklarını anlayabilir.", @@ -104,7 +104,7 @@ "hero-overlay-card-3-p-1": "Trail of Bits, büyük teknoloji şirketleri, devlet kurumları ve büyük blokzincir projeleriyle çalışan lider bir güvenlik ve teknoloji danışmanlık şirketidir.", "hero-overlay-card-3-p-2": "Trail of Bits, Kasım 2022'de SimpleX platformunun kriptografik ve ağ bileşenlerini inceledi. Detaylar için duyuruya göz atın.", "hero-overlay-card-3-p-3": "Trail of Bits, Temmuz 2024'te SimpleX'in ağ protokolünün kriptografik tasarımını inceledi. Daha fazla bilgi için buraya bakın.", - "simplex-network-overlay-card-1-p-1": "Eşler arası mesajlaşma protokolleri ve uygulamaları, SimpleX'e kıyasla daha az güvenilir, analiz edilmesi daha karmaşık ve çeşitli saldırı türlerine karşı daha savunmasız olmalarına neden olan çeşitli sorunlara sahiptir.", + "simplex-network-overlay-card-1-p-1": "Eşler arası mesajlaşma protokolleri ve uygulamaları, SimpleX'e kıyasla daha az güvenilir, analiz edilmesi daha karmaşık ve çeşitli saldırı türlerine karşı daha savunmasız olmalarına neden olan çeşitli sorunlara sahiptir.", "simplex-network-overlay-card-1-li-1": "P2P ağları, mesajları yönlendirmek için DHT türlerine güvenir. DHT tasarımları, teslimat garantisi ile gecikme arasında denge kurmak zorundadır. P2P'ye kıyasla SimpleX, daha iyi teslimat garantisi ve daha düşük gecikme sunar çünkü bir mesaj birden fazla sunucu üzerinden yedekli ve paralel olarak gönderilebilir ve alıcı tarafından seçilen sunucular kullanılır. P2P ağlarında mesajlar O(log N) düğüm üzerinden sıralı olarak gönderilir ve düğümler bir algoritma ile seçilir.", "simplex-network-overlay-card-1-li-2": "SimpleX tasarımı, çoğu P2P ağının aksine, hiçbir küresel kullanıcı kimliği içermez, geçici olanlar bile yoktur. Sadece geçici, ikili kimlikler kullanır ve bu da daha iyi anonimlik ve meta veri koruması sağlar.", "simplex-network-overlay-card-1-li-3": "P2P, MITM saldırısı sorununu çözmez ve mevcut uygulamaların çoğu ilk anahtar değişimi için ağ dışı mesajlar kullanmaz. SimpleX ise ilk anahtar değişimi için ağ dışı mesajlar veya mevcut güvenli ve güvenilir bağlantılar kullanır.", @@ -255,5 +255,8 @@ "f-droid-page-f-droid-org-repo-section-text": "SimpleX Chat ve F-Droid.org depoları farklı anahtarlarla imzalanır. Geçiş yapmak için sohbet veritabanınızı dışa aktarın ve uygulamayı yeniden yükleyin.", "jobs": "Ekibe katılın", "please-enable-javascript": "QR kodunu görebilmek için lütfen JavaScript'i etkinleştirin.", - "please-use-link-in-mobile-app": "Lütfen bağlantıyı mobil uygulamada kullanın" + "please-use-link-in-mobile-app": "Lütfen bağlantıyı mobil uygulamada kullanın", + "directory": "Dizin", + "navbar-token": "Token", + "about-and-contact-us": "Hakkımızda & İletişim" } diff --git a/website/langs/uk.json b/website/langs/uk.json index c2b02d7cf2..f5347bda87 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -255,5 +255,8 @@ "docs-dropdown-10": "Прозорість", "docs-dropdown-12": "Безпека", "hero-overlay-card-3-p-3": "Trail of Bits переглянув криптографічний дизайн мережевих протоколів SimpleX в липні 2024 року. Детальніше.", - "docs-dropdown-14": "SimpleX для бізнесу" + "docs-dropdown-14": "SimpleX для бізнесу", + "directory": "Каталог", + "navbar-token": "Токен", + "about-and-contact-us": "Про нас & Контакти" } From 60ff28d8f87290de124e9a99d9f74e4db8445c57 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 24 Feb 2026 17:49:26 +0000 Subject: [PATCH 013/112] website: update home page (#6647) * website: update home page * translate * layout * add languages to home page --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- cabal.project | 2 +- website/langs/ar.json | 4 ++-- website/langs/cs.json | 6 +++--- website/langs/de.json | 4 ++-- website/langs/en.json | 4 ++-- website/langs/es.json | 4 ++-- website/langs/fr.json | 4 ++-- website/langs/hu.json | 6 +++--- website/langs/id.json | 6 +++--- website/langs/it.json | 4 ++-- website/langs/ja.json | 4 ++-- website/langs/pl.json | 4 ++-- website/langs/pt_BR.json | 4 ++-- website/langs/ru.json | 6 +++--- website/src/_data/languages.json | 15 ++++++++++----- website/src/css/design3.css | 20 ++++++++++++-------- website/src/index.html | 2 +- 17 files changed, 54 insertions(+), 45 deletions(-) diff --git a/cabal.project b/cabal.project index 0b2104ba76..07a9a0f217 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8fdc0703bc9b89dae8b2fe6820b705580a669281 + tag: 6be64cd369b548c956204b4ffd12c92d7d3d0618 source-repository-package type: git diff --git a/website/langs/ar.json b/website/langs/ar.json index e040826395..c6549a8df5 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -259,8 +259,8 @@ "directory": "دليل", "about-and-contact-us": "عن واتصل بنا", "index-hero-h1": "كن
حراً", - "index-hero-h2": "حرية وأمن
اتصالاتك", - "index-hero-p1": "الشبكة الأولى التي تمتلك فيها هويتك وجهات اتصالك ومجموعاتك.", + "index-hero-h2": "في شبكتك", + "index-hero-p1": "مراسلة خاصة وآمنة.
الشبكة الأولى التي تمتلك فيها جهات اتصالك ومجموعاتك.", "index-hero-download-desktop-btn-title": "نزّل تطبيق سطح مكتب SimpleX", "index-testflight-title": "إصدار SimpleX التجريبي ل iOS على TestFlight", "index-f-droid-title": "تطبيق SimpleX من خلال F-Droid", diff --git a/website/langs/cs.json b/website/langs/cs.json index 46a960359d..7e141b581c 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -259,9 +259,9 @@ "directory": "Složka", "about-and-contact-us": "O nás & Kontakt", "navbar-token": "Token", - "index-hero-h1": "Žít
svobodně", - "index-hero-h2": "Svoboda a Bezpečnost Vaší Komunikace", - "index-hero-p1": "První síť, kde vaše identita, kontakty a skupiny patří vám.", + "index-hero-h1": "
Žijte
svobodně", + "index-hero-h2": "Ve Své Síti", + "index-hero-p1": "Soukromé a bezpečné zasílání zpráv.
První síť, kde vaše kontakty a skupiny patří vám.", "index-hero-download-desktop-btn-title": "Stáhněte si desktopovou aplikaci SimpleX", "index-testflight-title": "Beta verze SimpleX pro iOS na TestFlight", "index-f-droid-title": "Stáhnout aplikaci SimpleX přes F-Droid", diff --git a/website/langs/de.json b/website/langs/de.json index fe5bce826d..42ff308014 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -259,8 +259,8 @@ "about-and-contact-us": "Über uns & Kontakt", "directory": "Verzeichnis", "index-hero-h1": "Sei
frei", - "index-hero-h2": "Freiheit & Sicherheit
Ihrer Kommunikation", - "index-hero-p1": "Das erste Netzwerk, in welchem Sie die volle Kontrolle über Ihre Identität, Kontakte und Gruppen behalten.", + "index-hero-h2": "In Deinem Netzwerk", + "index-hero-p1": "Privater und sicherer Nachrichtenaustausch.
Das erste Netzwerk, in dem Ihnen
Ihre Kontakte und Gruppen gehören.", "index-hero-download-desktop-btn-title": "Download der SimpleX Desktop-App", "index-testflight-title": "Öffentlicher iOS-Preview auf TestFlight", "index-f-droid-title": "SimpleX-App über das F-Droid-Repository", diff --git a/website/langs/en.json b/website/langs/en.json index eca5f5beb8..907459285a 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -261,8 +261,8 @@ "please-enable-javascript": "Please enable JavaScript to see the QR code.", "please-use-link-in-mobile-app": "Please use the link in the mobile app", "index-hero-h1": "Be
Free", - "index-hero-h2": "Freedom & Security
of Your Communications", - "index-hero-p1": "The first network where you own your identity, contacts, and groups.", + "index-hero-h2": "In Your Network", + "index-hero-p1": "Private and secure messaging.
The first network where you own
your contacts and groups.", "index-hero-download-desktop-btn-title": "Download SimpleX Desktop App", "index-testflight-title": "SimpleX iOS beta-release on TestFlight", "index-f-droid-title": "SimpleX app via F-Droid", diff --git a/website/langs/es.json b/website/langs/es.json index c0ea5e320b..f36ee7c962 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -259,8 +259,8 @@ "directory": "Directorio", "about-and-contact-us": "Quiénes somos & Contacto", "index-hero-h1": "Sé
Libre", - "index-hero-h2": "Libertad y Seguridad
en Tus Comunicaciones", - "index-hero-p1": "La primera red donde tu identidad, contactos y grupos te pertenecen.", + "index-hero-h2": "En Tu Red", + "index-hero-p1": "Mensajería privada y segura.
La primera red donde
tus contactos y grupos te pertenecen.", "index-hero-download-desktop-btn-title": "Descargar SimpleX Desktop App", "index-testflight-title": "Betas SimpleX para iOS en TestFlight", "index-f-droid-title": "SimpleX app vía F-Droid", diff --git a/website/langs/fr.json b/website/langs/fr.json index 879931547c..fc1c6301f7 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -261,8 +261,8 @@ "navbar-token": "Jeton", "about-and-contact-us": "À propos & Contactez-nous", "index-hero-h1": "Soyez
Libre", - "index-hero-h2": "La liberté et la sécurité
de vos communications", - "index-hero-p1": "Le premier réseau où vous possédez votre identité, vos contacts et vos groupes.", + "index-hero-h2": "Dans Votre Réseau", + "index-hero-p1": "Messagerie privée et sécurisée.
Le premier réseau où vous possédez vos contacts et vos groupes.", "index-hero-download-desktop-btn-title": "Téléchargez l’application de bureau SimpleX", "index-testflight-title": "Version bêta de SimpleX pour iOS sur TestFlight", "index-f-droid-title": "Application SimpleX via F-Droid", diff --git a/website/langs/hu.json b/website/langs/hu.json index 9f6bcde61e..811662d8a5 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -258,15 +258,15 @@ "docs-dropdown-14": "SimpleX üzleti célra", "directory": "Csoportjegyzék", "about-and-contact-us": "Névjegy és kapcsolat", - "index-hero-h1": "Legyen
szabad", - "index-hero-p1": "Az első olyan hálózat, ahol a felhasználó a tulajdonosa saját identitásának, partnereinek és csoportjainak.", + "index-hero-h1": "
Legyen
szabad
", + "index-hero-p1": "Privát és biztonságos üzenetküldés.
Az első olyan hálózat, ahol Ön a tulajdonosa saját partnereinek és csoportjainak.", "index-hero-download-desktop-btn-title": "SimpleX számítógépes alkalmazásának letöltése", "index-security-assessment-title": "Biztonsági auditok", "index-security-review-2022-title": "Biztonsági audit 2022", "index-security-review-2024-title": "Biztonsági audit 2024", "index-security-audits-label": "Biztonsági
auditok", "index-publications-heise-title": "A Heise Online kiadványai", - "index-hero-h2": "A kommunikációjának
szabadsága és biztonsága", + "index-hero-h2": "A Saját Hálózatában", "index-testflight-title": "Nyilvános betekintés az iOS alkalmazás fejlesztésébe a TestFlighton", "index-f-droid-title": "SimpleX alkalmazás az F-Droidon keresztül", "index-publications-privacy-guides-title": "A Privacy Guides üzenetváltó ajánlásai", diff --git a/website/langs/id.json b/website/langs/id.json index 1875a16bea..bbdd195115 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -258,9 +258,9 @@ "simplex-unique-overlay-card-4-p-3": "Jika Anda mempertimbangkan pengembangan untuk jaringan SimpleX, misalnya, bot obrolan untuk pengguna aplikasi SimpleX, atau integrasi pustaka Obrolan SimpleX ke dalam aplikasi seluler Anda, silakan hubungi kami untuk saran dan dukungan apa pun.", "simplex-unique-card-1-p-1": "SimpleX melindungi privasi profil, kontak, dan metadata Anda, menyembunyikannya dari server jaringan SimpleX dan pengamat mana pun.", "get-simplex": "Dapatkan aplikasi desktop SimpleX", - "index-hero-h1": "Bebaslah", - "index-hero-h2": "Kebebasan & Keamanan
Komunikasi Anda", - "index-hero-p1": "Jaringan pertama tempat Anda memiliki identitas, kontak, dan grup Anda.", + "index-hero-h1": "
Bebaslah
", + "index-hero-h2": "Di Jaringan Anda", + "index-hero-p1": "Pesan yang privat dan aman.
Jaringan pertama tempat Anda memiliki kontak dan grup Anda.", "index-hero-download-desktop-btn-title": "Unduh Aplikasi Desktop SimpleX", "index-testflight-title": "Pratinjau iOS publik di TestFlight", "index-f-droid-title": "Repositori SimpleX F-Droid", diff --git a/website/langs/it.json b/website/langs/it.json index 8c253111bc..e8311bbd56 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -258,8 +258,8 @@ "docs-dropdown-14": "SimpleX per il lavoro", "about-and-contact-us": "Informazioni e contatti", "directory": "Directory", - "index-hero-h2": "Libertà e sicurezza
delle tue comunicazioni", - "index-hero-p1": "La prima rete in cui possiedi la tua identità, i contatti e i gruppi.", + "index-hero-h2": "Nella Tua Rete", + "index-hero-p1": "Messaggistica privata e sicura.
La prima rete in cui possiedi
i tuoi contatti e i tuoi gruppi.", "index-hero-download-desktop-btn-title": "Scarica l'app desktop di SimpleX", "index-testflight-title": "Anteprima pubblica per iOS su TestFlight", "index-f-droid-title": "SimpleX via F-Droid", diff --git a/website/langs/ja.json b/website/langs/ja.json index 4c57cfa0a0..0f7e9ff525 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -259,8 +259,8 @@ "directory": "ディレクトリ", "about-and-contact-us": "概要・お問い合わせ", "index-hero-h1": "自由で
あれ", - "index-hero-h2": "あなたのコミュニケーションに
自由とセキュリティを", - "index-hero-p1": "アイデンティティ、連絡先、グループをあなた自身が所有できる、最初のネットワーク。", + "index-hero-h2": "あなたのネットワークで", + "index-hero-p1": "プライベートで安全なメッセージング。
連絡先とグループをあなた自身が所有できる最初のネットワーク。", "index-hero-download-desktop-btn-title": "SimpleX デスクトップアプリをダウンロード", "index-security-assessment-title": "セキュリティ監査", "index-security-review-2022-title": "セキュリティ監査 2022", diff --git a/website/langs/pl.json b/website/langs/pl.json index 975389f06d..2d82529d26 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -260,8 +260,8 @@ "navbar-token": "Token", "about-and-contact-us": "O nas i Kontakt", "index-hero-h1": "Bądź
Wolny", - "index-hero-h2": "Wolność i bezpieczeństwo
Twojej Komunikacji", - "index-hero-p1": "Pierwsza sieć, w której posiadasz na własność swoją tożsamość, kontakty i grupy.", + "index-hero-h2": "W Swojej Sieci", + "index-hero-p1": "Prywatne i bezpieczne wiadomości.
Pierwsza sieć, w której posiadasz na własność swoje kontakty i grupy.", "index-hero-download-desktop-btn-title": "Pobierz Aplikację SimpleX na Komputer", "index-testflight-title": "SimpleX iOS beta-wydanie na TestFlight", "index-f-droid-title": "SimpleX app z F-Droid", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index b43b521444..018389db8d 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -260,8 +260,8 @@ "about-and-contact-us": "Sobre e Contato", "navbar-token": "Token", "index-hero-h1": "Seja
Livre", - "index-hero-h2": "Liberdade & Segurança
Em Suas Comunicações", - "index-hero-p1": "A primeira rede onde você é dono da sua identidade, contatos e grupos.", + "index-hero-h2": "Na Sua Rede", + "index-hero-p1": "Mensagens privadas e seguras.
A primeira rede onde você é dono dos seus contatos e grupos.", "index-hero-download-desktop-btn-title": "Baixe o aplicativo SimpleX Desktop", "index-testflight-title": "SimpleX iOS - versão beta no TestFlight", "index-f-droid-title": "Aplicativo SimpleX no F-Droid", diff --git a/website/langs/ru.json b/website/langs/ru.json index 4990c5b512..5f51fc0a05 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -258,9 +258,9 @@ "docs-dropdown-12": "Безопасность", "docs-dropdown-11": "Часто задаваемые вопросы", "docs-dropdown-14": "SimpleX для бизнеса", - "index-hero-h1": "
Жить
Свободно
", - "index-hero-h2": "Свобода и Безопасность
Ваших Коммуникаций", - "index-hero-p1": "Первая сеть, в которой Вам принадлежат Ваши данные, контакты и группы.", + "index-hero-h1": "
Будь
Свободен
", + "index-hero-h2": "В Своей Сети", + "index-hero-p1": "Конфиденциальная и безопасная передача сообщений.
Первая сеть, где Вам принадлежат Ваши контакты и группы.", "index-hero-download-desktop-btn-title": "Загрузить приложение SimpleX для компьютера", "index-testflight-title": "Бета-релиз для iOS на TestFlight", "index-f-droid-title": "Загрузить через F-Droid", diff --git a/website/src/_data/languages.json b/website/src/_data/languages.json index 362e8c4120..2f51ea6b7b 100644 --- a/website/src/_data/languages.json +++ b/website/src/_data/languages.json @@ -21,7 +21,8 @@ "label": "cs", "name": "Čeština", "flag": "/img/flags/cs.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "de", @@ -47,7 +48,8 @@ "label": "fr", "name": "Français", "flag": "/img/flags/fr.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "he", @@ -81,7 +83,8 @@ "label": "ja", "name": "日本語", "flag": "/img/flags/jp.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "nl", @@ -93,13 +96,15 @@ "label": "pl", "name": "Polski", "flag": "/img/flags/pl.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "pt_BR", "name": "Português", "flag": "/img/flags/br.svg", - "enabled": true + "enabled": true, + "home": true }, { "label": "uk", diff --git a/website/src/css/design3.css b/website/src/css/design3.css index b669c139cb..822ddd9f30 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -407,16 +407,20 @@ section.cover div.content h1 { section.cover div.content h1 .small { font-size: calc(var(--sec-vwu) * 8); + line-height: 1; + margin-bottom: -10px; } section.cover div.content h1 .medium { font-size: calc(var(--sec-vwu) * 10); + line-height: 1; + margin-bottom: -10px; } section.cover div.content h2 { font-family: "GT-Walsheim", "Manrope", sans-serif; - font-weight: 300; - font-size: calc(var(--sec-vwu) * 2.7); + font-weight: 400; + font-size: calc(var(--sec-vwu) * 5); letter-spacing: -0.025em; line-height: 1.2; } @@ -424,10 +428,10 @@ section.cover div.content h2 { section.cover div.content p { font-family: "Manrope", "GT-Walsheim", sans-serif; font-weight: 200; - font-size: calc(var(--sec-vwu) * 1.6); + font-size: calc(var(--sec-vwu) * 2.4); align-items: center; color: #ffffff; - max-width: calc(var(--sec-vwu) * 25); + max-width: calc(var(--sec-vwu) * 53); } .publications-btns { @@ -907,14 +911,14 @@ main .section-bg { } section.cover div.content h2 { - font-weight: 400; - font-size: calc(var(--sec-vwu) * 7); + font-weight: 500; + font-size: calc(var(--sec-vwu) * 11.5); } section.cover div.content p { font-weight: 400; - font-size: calc(var(--sec-vwu) * 4.3); - max-width: calc(var(--sec-vwu) * 65); + font-size: calc(var(--sec-vwu) * 5.5); + max-width: calc(var(--sec-vwu) * 93); } diff --git a/website/src/index.html b/website/src/index.html index 110b91164e..c9d60bedd6 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -71,7 +71,7 @@ active_home: true

{{ "index-hero-h1" | i18n({}, lang) | safe }}

{{ "index-hero-h2" | i18n({}, lang) | safe }}

-

{{ "index-hero-p1" | i18n({}, lang) }}

+

{{ "index-hero-p1" | i18n({}, lang) | safe }}

+ {{ "why-footer-link" | i18n({}, lang ) | safe }} + {{ "about-and-contact-us" | i18n({}, lang ) | safe }} diff --git a/website/src/_includes/layouts/main.html b/website/src/_includes/layouts/main.html index 999e3af901..26a0d034bc 100644 --- a/website/src/_includes/layouts/main.html +++ b/website/src/_includes/layouts/main.html @@ -38,7 +38,7 @@ {% include "navbar.html" %} - {{ content | applyGlossary | safe }} + {% if noGlossary %}{{ content | safe }}{% else %}{{ content | applyGlossary | safe }}{% endif %} {% include "footer.html" %} diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 003c069d9c..d948ece195 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -162,7 +162,7 @@ {% endfor %} {% else %} {% for language in languages.languages %} - {% if language.enabled and (language.home or (page.url != '/' and page.url != '/' + lang + '/')) %} + {% if language.enabled and (language.home or (page.url != '/' and page.url != '/' + lang + '/')) and (language.why or ('why' not in page.url)) %}
  • {{ language.name }}

    diff --git a/website/src/css/design3.css b/website/src/css/design3.css index 822ddd9f30..001e02eedd 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -816,6 +816,10 @@ main .section-bg { max-width: calc(var(--sec-vwu)*31) !important; } +.page-3 .text-container a { + max-width: calc(var(--sec-vwu)*31) !important; +} + .page-3 .text-container, .page-4 .text-container { margin-left: auto; @@ -921,7 +925,6 @@ main .section-bg { max-width: calc(var(--sec-vwu) * 93); } - /* --- MAIN SECTIONS --- */ .page .text-container { justify-content: flex-end; diff --git a/website/src/index.html b/website/src/index.html index c9d60bedd6..347874a6f7 100644 --- a/website/src/index.html +++ b/website/src/index.html @@ -145,6 +145,7 @@ active_home: true

    {{ "index-nextweb-h2" | i18n({}, lang) | safe }}

    {{ "index-nextweb-p1" | i18n({}, lang) | safe }}

    {{ "index-nextweb-p2" | i18n({}, lang) }}

    +
    {{ "why-footer-link" | i18n({}, lang) | safe }}
  • diff --git a/website/src/why.html b/website/src/why.html new file mode 100644 index 0000000000..e16034106a --- /dev/null +++ b/website/src/why.html @@ -0,0 +1,51 @@ +--- +layout: layouts/main.html +title: "Why we are building SimpleX Network" +description: "A network with no accounts, no identities, and no way to know who you are." +templateEngineOverride: njk +noGlossary: true +--- + +{% set lang = page.url | getlang %} + +
    +
    + +

    + {{ "why-p1" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p2" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p3" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p4" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p5" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p6" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p7" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-p8" | i18n({}, lang) | safe }} +

    + +

    + {{ "why-tagline" | i18n({}, lang) | safe }} +

    + +
    +
    diff --git a/website/web.sh b/website/web.sh index cc08ae57e9..68a8f24a70 100755 --- a/website/web.sh +++ b/website/web.sh @@ -10,6 +10,7 @@ rm -rf website/src/docs/dependencies cp -R blog website/src cp -R images website/src rm website/src/blog/README.md +rm -rf website/src/blog/new cp PRIVACY.md website/src/privacy.md cd website @@ -40,6 +41,7 @@ for lang in "${langs[@]}"; do cp src/contact.html src/$lang cp src/invitation.html src/$lang cp src/fdroid.html src/$lang + cp src/why.html src/$lang echo "{\"lang\":\"$lang\"}" > src/$lang/$lang.json echo "done $lang copying" done @@ -71,4 +73,4 @@ done # done # main_json_obj=$(echo "$main_json_obj" | jq ". + {\"$key\": $val_json_obj}") # done -# echo "$main_json_obj" > translations.json \ No newline at end of file +# echo "$main_json_obj" > translations.json From a09acda329f2b1035b460e69a9766d848dd88718 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:54:44 +0000 Subject: [PATCH 015/112] multiplatform: product specification (#6655) --- apps/multiplatform/CODE.md | 309 ++++++++++ .../common/views/call/CallView.android.kt | 1 + .../kotlin/chat/simplex/common/App.kt | 4 + .../kotlin/chat/simplex/common/AppLock.kt | 1 + .../chat/simplex/common/model/ChatModel.kt | 5 + .../chat/simplex/common/model/CryptoFile.kt | 1 + .../chat/simplex/common/model/SimpleXAPI.kt | 14 + .../chat/simplex/common/platform/Core.kt | 6 + .../chat/simplex/common/platform/Files.kt | 1 + .../simplex/common/platform/NtfManager.kt | 1 + .../chat/simplex/common/ui/theme/Theme.kt | 11 + .../simplex/common/ui/theme/ThemeManager.kt | 1 + .../simplex/common/views/call/CallView.kt | 1 + .../views/call/IncomingCallAlertView.kt | 1 + .../chat/simplex/common/views/call/WebRTC.kt | 1 + .../simplex/common/views/chat/ChatView.kt | 1 + .../simplex/common/views/chat/ComposeView.kt | 4 + .../simplex/common/views/chat/SendMsgView.kt | 1 + .../common/views/chat/item/ChatItemView.kt | 1 + .../views/chatlist/ChatListNavLinkView.kt | 1 + .../common/views/chatlist/ChatListView.kt | 1 + .../common/views/chatlist/ChatPreviewView.kt | 1 + .../common/views/chatlist/TagListView.kt | 1 + .../common/views/chatlist/UserPicker.kt | 1 + .../common/views/helpers/DatabaseUtils.kt | 1 + .../simplex/common/views/helpers/Utils.kt | 1 + .../common/views/call/CallView.desktop.kt | 1 + apps/multiplatform/product/README.md | 396 +++++++++++++ apps/multiplatform/product/concepts.md | 120 ++++ apps/multiplatform/product/flows/calling.md | 220 +++++++ .../multiplatform/product/flows/connection.md | 233 ++++++++ .../product/flows/file-transfer.md | 252 ++++++++ .../product/flows/group-lifecycle.md | 283 +++++++++ apps/multiplatform/product/flows/messaging.md | 195 ++++++ .../multiplatform/product/flows/onboarding.md | 205 +++++++ apps/multiplatform/product/gaps.md | 290 +++++++++ apps/multiplatform/product/glossary.md | 561 ++++++++++++++++++ apps/multiplatform/product/rules.md | 253 ++++++++ apps/multiplatform/product/views/call.md | 115 ++++ apps/multiplatform/product/views/chat-list.md | 136 +++++ apps/multiplatform/product/views/chat.md | 135 +++++ .../product/views/contact-info.md | 104 ++++ .../multiplatform/product/views/group-info.md | 145 +++++ apps/multiplatform/product/views/new-chat.md | 96 +++ .../multiplatform/product/views/onboarding.md | 139 +++++ apps/multiplatform/product/views/settings.md | 159 +++++ .../product/views/user-profiles.md | 122 ++++ apps/multiplatform/spec/README.md | 137 +++++ apps/multiplatform/spec/api.md | 435 ++++++++++++++ apps/multiplatform/spec/architecture.md | 423 +++++++++++++ apps/multiplatform/spec/client/chat-list.md | 314 ++++++++++ apps/multiplatform/spec/client/chat-view.md | 324 ++++++++++ apps/multiplatform/spec/client/compose.md | 399 +++++++++++++ apps/multiplatform/spec/client/navigation.md | 379 ++++++++++++ apps/multiplatform/spec/database.md | 393 ++++++++++++ apps/multiplatform/spec/impact.md | 532 +++++++++++++++++ apps/multiplatform/spec/services/calls.md | 175 ++++++ apps/multiplatform/spec/services/files.md | 213 +++++++ .../spec/services/notifications.md | 261 ++++++++ apps/multiplatform/spec/services/theme.md | 498 ++++++++++++++++ apps/multiplatform/spec/state.md | 486 +++++++++++++++ 61 files changed, 9501 insertions(+) create mode 100644 apps/multiplatform/CODE.md create mode 100644 apps/multiplatform/product/README.md create mode 100644 apps/multiplatform/product/concepts.md create mode 100644 apps/multiplatform/product/flows/calling.md create mode 100644 apps/multiplatform/product/flows/connection.md create mode 100644 apps/multiplatform/product/flows/file-transfer.md create mode 100644 apps/multiplatform/product/flows/group-lifecycle.md create mode 100644 apps/multiplatform/product/flows/messaging.md create mode 100644 apps/multiplatform/product/flows/onboarding.md create mode 100644 apps/multiplatform/product/gaps.md create mode 100644 apps/multiplatform/product/glossary.md create mode 100644 apps/multiplatform/product/rules.md create mode 100644 apps/multiplatform/product/views/call.md create mode 100644 apps/multiplatform/product/views/chat-list.md create mode 100644 apps/multiplatform/product/views/chat.md create mode 100644 apps/multiplatform/product/views/contact-info.md create mode 100644 apps/multiplatform/product/views/group-info.md create mode 100644 apps/multiplatform/product/views/new-chat.md create mode 100644 apps/multiplatform/product/views/onboarding.md create mode 100644 apps/multiplatform/product/views/settings.md create mode 100644 apps/multiplatform/product/views/user-profiles.md create mode 100644 apps/multiplatform/spec/README.md create mode 100644 apps/multiplatform/spec/api.md create mode 100644 apps/multiplatform/spec/architecture.md create mode 100644 apps/multiplatform/spec/client/chat-list.md create mode 100644 apps/multiplatform/spec/client/chat-view.md create mode 100644 apps/multiplatform/spec/client/compose.md create mode 100644 apps/multiplatform/spec/client/navigation.md create mode 100644 apps/multiplatform/spec/database.md create mode 100644 apps/multiplatform/spec/impact.md create mode 100644 apps/multiplatform/spec/services/calls.md create mode 100644 apps/multiplatform/spec/services/files.md create mode 100644 apps/multiplatform/spec/services/notifications.md create mode 100644 apps/multiplatform/spec/services/theme.md create mode 100644 apps/multiplatform/spec/state.md diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md new file mode 100644 index 0000000000..26a36e75bb --- /dev/null +++ b/apps/multiplatform/CODE.md @@ -0,0 +1,309 @@ +# Coding and building + +You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context. + +## Three-Layer Documentation Architecture + +### Why this structure exists + +LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results. + +The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves. + +### The layers + +| Layer | Contains | Question it answers | +|-------|----------|-------------------| +| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? | +| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? | +| `common/src/commonMain/` | Shared Kotlin/Compose code (Android + Desktop) | What does it **execute** on both platforms? | +| `common/src/androidMain/` | Android-specific Kotlin (platform implementations) | What does it execute on **Android**? | +| `common/src/desktopMain/` | Desktop-specific Kotlin (platform implementations) | What does it execute on **Desktop**? | +| `android/src/main/` | Android app module (Application, Activity, Services) | What is the **Android entry point**? | +| `desktop/src/jvmMain/` | Desktop app module (main function) | What is the **Desktop entry point**? | +| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? | + +Each layer links to the next: +- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point +- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents +- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec +- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift. +- Reverse direction: the Document Map (end of this file) maps source → spec → product + +### Navigation workflow + +When the user requests any change, you MUST follow these steps before writing any code: + +1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas. + +2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract. + +3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees. + +4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior. + +5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information. + +For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6. + +6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below. + +### Key navigation documents + +| Document | Purpose | When to read | +|----------|---------|-------------| +| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change | +| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior | +| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms | +| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature | +| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change | +| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation | + +--- + +## Code Security + +When designing code and planning implementations, you MUST: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries. +- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses. + +--- + +## Code Style + +**Follow existing code patterns — you MUST:** +- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise. +- Use Kotlin data classes for value types, regular classes for reference types, and sealed classes/interfaces for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive `when` expressions over `else` branches. Why: `else` branches bypass compiler checks for new sealed subclasses and hide bugs. + +**Comments policy — you MUST:** +- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code. +- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments. +- Assume a competent Kotlin reader. Why: over-explaining trivial Kotlin adds noise without value. + +**Diff and refactoring — you MUST:** +- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff. +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit. +- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert. + +**Document and code structure — you MUST:** +- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame. +- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability. +- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult. + +**Code analysis and review — you MUST:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior. +- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs. + +--- + +## Plans + +When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was. + +### Plan requirements + +1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions. + +2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent. + +3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth. + +4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation. + +--- + +## Change Protocol + +### The rule + +Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change. + +### What to update + +1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification. + +2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion. + +3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value. + +4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work. + +5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates. + +6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context. + +7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt. + +8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later. + +9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable. + +### Adversarial self-review + +After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers. + +**Within-layer coherence — you MUST verify:** +- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact +- product/ is internally consistent — flows match views, rules match behavior descriptions + +**Across-layer coherence — you MUST verify:** +- Every new or changed function in source appears in the corresponding spec/ document +- Every user-visible behavior change in source appears in the relevant product/ document +- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines +- All cross-references resolve — product → spec links, spec → source links +- `spec/impact.md` covers all affected product concepts for the changed source files +- `product/concepts.md` rows are current for any affected concepts + +**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent. + +--- + +## Multiplatform Architecture Notes + +### Kotlin Multiplatform (KMP) + Compose Multiplatform + +The app uses Kotlin Multiplatform with Compose Multiplatform for shared UI. The project has three Gradle modules: + +- **common/** — Shared library containing all models, views, platform abstractions, and theme system +- **android/** — Android app module (Application, Activity, Services) +- **desktop/** — Desktop JVM app module (main entry point) + +### expect/actual Pattern + +Platform-specific code uses Kotlin's `expect`/`actual` mechanism. The `commonMain` source set declares `expect` functions/classes, and `androidMain`/`desktopMain` provide `actual` implementations. Files follow the naming convention: +- `commonMain`: `FileName.kt` (contains `expect` declarations) +- `androidMain`: `FileName.android.kt` (contains `actual` implementations) +- `desktopMain`: `FileName.desktop.kt` (contains `actual` implementations) + +When modifying platform abstractions, you MUST update both `actual` implementations. + +### Source Set Structure + +``` +common/src/ +├── commonMain/kotlin/chat/simplex/common/ -- Shared code (195 files) +│ ├── model/ -- ChatModel, SimpleXAPI, CryptoFile +│ ├── platform/ -- expect/actual platform abstractions +│ ├── ui/theme/ -- Theme system (ThemeManager, colors, types) +│ └── views/ -- Compose UI (chat, chatlist, call, settings, etc.) +├── androidMain/kotlin/chat/simplex/common/ -- Android actuals (55 files) +│ ├── platform/ -- actual implementations +│ └── views/ -- Android-specific view variants +├── desktopMain/kotlin/chat/simplex/common/ -- Desktop actuals (56 files) +│ ├── platform/ -- actual implementations +│ └── views/ -- Desktop-specific view variants +android/src/main/java/chat/simplex/app/ -- Android app (8 files) +desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) +``` + +### Platform Differences + +| Aspect | Android | Desktop | +|--------|---------|---------| +| Layout | 2-column (chat list → chat) | 3-column (sidebar → chat list → details) | +| Background messaging | SimplexService (foreground service) + MessagesFetcherWorker (WorkManager) | Continuous (always-on process) | +| Notifications | Android NotificationManager with channels | Desktop system notifications | +| Calls | CallActivity (separate Activity) + CallService | In-window call view | +| Video playback | ExoPlayer | VLC (VLCJ) | +| Authentication | Android BiometricPrompt | Passcode only | +| Auto-update | Play Store / manual APK | Built-in AppUpdater | +| Window management | Standard Activity lifecycle | StoreWindowState persistence | +| Entry point | SimplexApp (Application) + MainActivity | Main.kt → initHaskell() → showApp() | + +--- + +## Document Map + +### Shared Sources (commonMain) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| common/.../common/App.kt | spec/architecture.md | product/views/chat-list.md | +| common/.../common/AppLock.kt | spec/architecture.md | product/views/settings.md | +| common/.../common/model/ChatModel.kt | spec/state.md | product/concepts.md | +| common/.../common/model/SimpleXAPI.kt | spec/api.md, spec/architecture.md | product/concepts.md | +| common/.../common/model/CryptoFile.kt | spec/services/files.md | product/flows/file-transfer.md | +| common/.../common/platform/Core.kt | spec/architecture.md | product/concepts.md | +| common/.../common/platform/AppCommon.kt | spec/architecture.md | product/flows/onboarding.md | +| common/.../common/platform/Notifications.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/NtfManager.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/Files.kt | spec/services/files.md | product/flows/file-transfer.md | +| common/.../common/platform/SimplexService.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/Share.kt | spec/architecture.md | product/concepts.md | +| common/.../common/platform/VideoPlayer.kt | spec/services/files.md | product/views/chat.md | +| common/.../common/platform/RecAndPlay.kt | spec/services/files.md | product/views/chat.md | +| common/.../common/platform/UI.kt | spec/architecture.md | product/views/chat.md | +| common/.../common/platform/Platform.kt | spec/architecture.md | product/concepts.md | +| common/.../common/ui/theme/ThemeManager.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/TagListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chat/ChatView.kt | spec/client/chat-view.md | product/views/chat.md | +| common/.../common/views/chat/ComposeView.kt | spec/client/compose.md | product/views/chat.md | +| common/.../common/views/chat/SendMsgView.kt | spec/client/compose.md | product/views/chat.md | +| common/.../common/views/chat/ChatInfoView.kt | spec/client/chat-view.md | product/views/contact-info.md | +| common/.../common/views/chat/group/ | spec/client/chat-view.md | product/views/group-info.md | +| common/.../common/views/chat/item/ | spec/client/chat-view.md | product/views/chat.md | +| common/.../common/views/call/CallView.kt | spec/services/calls.md | product/views/call.md | +| common/.../common/views/call/IncomingCallAlertView.kt | spec/services/calls.md | product/views/call.md | +| common/.../common/views/call/WebRTC.kt | spec/services/calls.md | product/flows/calling.md | +| common/.../common/views/newchat/NewChatView.kt | spec/client/navigation.md | product/views/new-chat.md | +| common/.../common/views/newchat/AddGroupView.kt | spec/client/navigation.md | product/views/new-chat.md | +| common/.../common/views/usersettings/SettingsView.kt | spec/client/navigation.md | product/views/settings.md | +| common/.../common/views/usersettings/Appearance.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/views/usersettings/PrivacySettings.kt | spec/client/navigation.md | product/views/settings.md | +| common/.../common/views/usersettings/networkAndServers/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/usersettings/UserProfilesView.kt | spec/client/navigation.md | product/views/user-profiles.md | +| common/.../common/views/onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| common/.../common/views/localauth/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/database/ | spec/database.md | product/views/settings.md | +| common/.../common/views/migration/ | spec/database.md | product/flows/onboarding.md | +| common/.../common/views/remote/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/contacts/ | spec/client/chat-view.md | product/views/contact-info.md | +| common/.../common/views/helpers/ | spec/architecture.md | product/concepts.md | + +### Android-Specific Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| android/.../app/SimplexApp.kt | spec/architecture.md | product/flows/onboarding.md | +| android/.../app/MainActivity.kt | spec/architecture.md | product/views/chat-list.md | +| android/.../app/SimplexService.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/CallService.kt | spec/services/calls.md | product/flows/calling.md | +| android/.../app/MessagesFetcherWorker.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/model/NtfManager.android.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/views/call/CallActivity.kt | spec/services/calls.md | product/views/call.md | + +### Desktop-Specific Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md | +| common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | +| common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/multiplatform/`) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md | +| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md | +| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md | +| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md | +| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md | +| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md | diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 22e53af849..56279a5143 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -108,6 +108,7 @@ class ActiveCallState: Closeable { } +// Spec: spec/services/calls.md#ActiveCallView @SuppressLint("SourceLockedOrientationActivity") @Composable actual fun ActiveCallView() { 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 70e0067260..d9439a5474 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 @@ -42,6 +42,7 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +// Spec: spec/client/navigation.md#AppScreen @Composable fun AppScreen() { AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() } @@ -78,6 +79,7 @@ fun AppScreen() { } } +// Spec: spec/client/navigation.md#MainScreen @Composable fun MainScreen() { val chatModel = ChatModel @@ -289,6 +291,7 @@ fun AndroidWrapInCallLayout(content: @Composable () -> Unit) { } } +// Spec: spec/client/navigation.md#AndroidScreen @Composable fun AndroidScreen(userPickerState: MutableStateFlow) { BoxWithConstraints { @@ -402,6 +405,7 @@ fun EndPartOfScreen() { ModalManager.end.showInView() } +// Spec: spec/client/navigation.md#DesktopScreen @Composable fun DesktopScreen(userPickerState: MutableStateFlow) { Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index d6f9640cb9..32a5ce1ef1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.* +// Spec: spec/client/navigation.md#AppLock object AppLock { /** * We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user 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 8db2cc1a76..01f8197beb 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 @@ -81,6 +81,7 @@ val connectProgressManager = ConnectProgressManager /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ +// Spec: spec/state.md#ChatModel @Stable object ChatModel { val controller: ChatController = ChatController @@ -334,6 +335,7 @@ object ChatModel { } } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on. @@ -1321,6 +1323,7 @@ interface SomeChat { val updatedAt: Instant } +// Spec: spec/state.md#Chat @Serializable @Stable data class Chat( val remoteHostId: Long?, @@ -1362,6 +1365,7 @@ data class Chat( true } + // Spec: spec/state.md#ChatStats @Serializable data class ChatStats( val unreadCount: Int = 0, @@ -1382,6 +1386,7 @@ data class Chat( } } +// Spec: spec/state.md#ChatInfo @Serializable sealed class ChatInfo: SomeChat, NamedChat { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 6ef56a9124..60f5c9e2ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -20,6 +20,7 @@ sealed class WriteFileResult { } * */ +// Spec: spec/services/files.md#writeCryptoFile fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) 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 31edeec55a..388a8064c4 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 @@ -90,6 +90,7 @@ enum class SimplexLinkMode { } } +// Spec: spec/state.md#AppPreferences class AppPreferences { // deprecated, remove in 2024 private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) @@ -491,6 +492,7 @@ private const val MESSAGE_TIMEOUT: Int = 300_000_000 object ChatController { private var chatCtrl: ChatCtrl? = -1 + // Spec: spec/state.md#appPrefs val appPrefs: AppPreferences by lazy { AppPreferences() } val messagesChannel: Channel = Channel() @@ -654,6 +656,7 @@ object ChatController { chatModel.updateChatTags(rhId) } + // Spec: spec/api.md#startReceiver private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") if (receiverJob != null || chatCtrl == null) return @@ -797,6 +800,7 @@ object ChatController { return null } + // Spec: spec/api.md#sendCmd suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API { val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized") @@ -821,6 +825,7 @@ object ChatController { } } + // Spec: spec/api.md#recvMsg fun recvMsg(ctrl: ChatCtrl): API? { val rStr = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (rStr == "") { @@ -2559,6 +2564,7 @@ object ChatController { AlertManager.shared.showAlertMsg(title, errMsg) } + // Spec: spec/api.md#processReceivedMsg private suspend fun processReceivedMsg(msg: API) { lastMsgReceivedTimestamp = System.currentTimeMillis() val rhId = msg.rhId @@ -3519,6 +3525,7 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { } // ChatCommand +// Spec: spec/api.md#CC sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() @@ -4150,9 +4157,11 @@ class UpdatedMessage(val msgContent: MsgContent, val mentions: Map @Serializable class ChatTagData(val emoji: String?, val text: String) +// Spec: spec/api.md#ArchiveConfig @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) +// Spec: spec/database.md#DBEncryptionConfig @Serializable class DBEncryptionConfig(val currentKey: String, val newKey: String) @@ -5960,6 +5969,7 @@ val yaml = Yaml(configuration = YamlConfiguration( codePointLimit = 5500000, )) +// Spec: spec/api.md#API @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = APISerializer::class) sealed class API { @@ -6099,6 +6109,7 @@ private fun decodeObject(deserializer: DeserializationStrategy, obj: Json runCatching { json.decodeFromJsonElement(deserializer, obj!!) }.getOrNull() // ChatResponse +// Spec: spec/api.md#CR @Serializable sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() @@ -6958,6 +6969,7 @@ data class RemoteFile( val fileSource: CryptoFile ) +// Spec: spec/api.md#ChatError @Serializable sealed class ChatError { val string: String get() = when (this) { @@ -7641,6 +7653,7 @@ sealed class RCErrorType { @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() } +// Spec: spec/database.md#ArchiveError @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -7722,6 +7735,7 @@ sealed class RemoteCtrlError { @Serializable @SerialName("protocolError") object ProtocolError: RemoteCtrlError() } +// Spec: spec/services/notifications.md#NotificationsMode enum class NotificationsMode() { OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index d0ce703033..36a7ae1a80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -14,12 +14,14 @@ import java.io.File import java.nio.ByteBuffer // ghc's rts +// Spec: spec/architecture.md#initHS external fun initHS() // android-support external fun pipeStdOutToSocket(socketName: String) : Int // SimpleX API typealias ChatCtrl = Long +// Spec: spec/architecture.md#chatMigrateInit external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array external fun chatCloseStore(ctrl: ChatCtrl): String external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String @@ -45,6 +47,7 @@ val appPreferences: AppPreferences val chatController: ChatController = ChatController +// Spec: spec/architecture.md#initChatControllerOnStart fun initChatControllerOnStart() { withLongRunningApi { if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) { @@ -55,6 +58,7 @@ fun initChatControllerOnStart() { } } +// Spec: spec/architecture.md#initChatController suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred = { CompletableDeferred(true) }) { Log.d(TAG, "initChatController") try { @@ -182,6 +186,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } +// Spec: spec/architecture.md#chatInitTemporaryDatabase fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair { val dbKey = key ?: randomDatabasePassword() Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath") @@ -193,6 +198,7 @@ fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: return res to migrated[1] as ChatCtrl } +// Spec: spec/architecture.md#chatInitControllerRemovingDatabases fun chatInitControllerRemovingDatabases() { val dbPath = dbAbsolutePrefixPath // Remove previous databases, otherwise, can be .errorNotADatabase with null controller diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 0a4f670fe0..88d9fbb705 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -14,6 +14,7 @@ import java.net.URLEncoder import java.nio.file.Files import java.nio.file.StandardCopyOption +// Spec: spec/services/files.md#dataDir expect val dataDir: File expect val tmpDir: File expect val filesDir: File diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index d906ef7baf..39fcea3981 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -13,6 +13,7 @@ enum class NotificationAction { ACCEPT_CONTACT_REQUEST } +// Spec: spec/services/notifications.md#ntfManager lateinit var ntfManager: NtfManager abstract class NtfManager { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index df9af7fbf6..1b5a81a819 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -22,6 +22,7 @@ import chat.simplex.res.MR import kotlinx.serialization.Transient import java.util.UUID +// Spec: spec/services/theme.md#DefaultTheme enum class DefaultTheme { LIGHT, DARK, SIMPLEX, BLACK; @@ -47,6 +48,7 @@ enum class DefaultThemeMode { @SerialName("dark") DARK } +// Spec: spec/services/theme.md#AppColors @Stable class AppColors( title: Color, @@ -99,6 +101,7 @@ class AppColors( } } +// Spec: spec/services/theme.md#AppWallpaper @Stable class AppWallpaper( background: Color? = null, @@ -133,6 +136,7 @@ class AppWallpaper( } } +// Spec: spec/services/theme.md#ThemeColor enum class ThemeColor { PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT; @@ -174,6 +178,7 @@ enum class ThemeColor { } } +// Spec: spec/services/theme.md#ThemeColors @Serializable data class ThemeColors( @SerialName("accent") @@ -214,6 +219,7 @@ data class ThemeColors( } } +// Spec: spec/services/theme.md#ThemeWallpaper @Serializable data class ThemeWallpaper ( val preset: String? = null, @@ -293,6 +299,7 @@ data class ThemesFile( val themes: List = emptyList() ) +// Spec: spec/services/theme.md#ThemeOverrides @Serializable data class ThemeOverrides ( val themeId: String = UUID.randomUUID().toString(), @@ -463,6 +470,7 @@ fun List.skipDuplicates(): List { return res } +// Spec: spec/services/theme.md#ThemeModeOverrides @Serializable data class ThemeModeOverrides ( val light: ThemeModeOverride? = null, @@ -474,6 +482,7 @@ data class ThemeModeOverrides ( } } +// Spec: spec/services/theme.md#ThemeModeOverride @Serializable data class ThemeModeOverride ( val mode: DefaultThemeMode = CurrentColors.value.base.mode, @@ -714,6 +723,7 @@ val BlackColorPaletteApp = AppColors( var systemInDarkThemeCurrently: Boolean = isInNightMode() +// Spec: spec/services/theme.md#CurrentColors val CurrentColors: MutableStateFlow = MutableStateFlow(ThemeManager.currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())) @Composable @@ -758,6 +768,7 @@ fun reactOnDarkThemeChanges(isDark: Boolean) { } } +// Spec: spec/services/theme.md#SimpleXTheme @Composable fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { // TODO: Fix preview working with dark/light theme diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 07f2b678cf..7d8c79b4a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -53,6 +53,7 @@ object ThemeManager { ?: ThemeWallpaper.from(PresetWallpaper.SCHOOL.toType(CurrentColors.value.base), null, null)) } + // Spec: spec/services/theme.md#currentColors fun currentColors(themeOverridesForType: WallpaperType?, perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverrides?, appSettingsTheme: List): ActiveTheme { val themeName = appPrefs.currentTheme.get()!! val nonSystemThemeName = nonSystemThemeName() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt index 8f5aba138d..7a92bc8c39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt @@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import kotlinx.coroutines.* +// Spec: spec/services/calls.md#ActiveCallView @Composable expect fun ActiveCallView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 4d8c1fae46..563f4c3b83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -22,6 +22,7 @@ import chat.simplex.common.views.usersettings.ProfilePreview import chat.simplex.res.MR import kotlinx.datetime.Clock +// Spec: spec/services/calls.md#IncomingCallAlertView @Composable fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) { val cm = chatModel.callManager diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 705fc6a28f..6fa99283d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -46,6 +46,7 @@ data class Call( get() = localMediaSources.hasVideo || peerMediaSources.hasVideo } +// Spec: spec/services/calls.md#CallState enum class CallState { WaitCapabilities, InvitationSent, 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 7322e3b17d..1344f13c84 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 @@ -93,6 +93,7 @@ fun ConnectInProgressView(s: String) { @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts +// Spec: spec/client/chat-view.md#ChatView fun ChatView( chatsCtx: ChatModel.ChatsContext, staleChatId: State, 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 af4baad90f..eebf4a7bf8 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 @@ -47,6 +47,7 @@ import kotlin.math.min const val MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @@ -92,6 +93,7 @@ object ComposeMessageSerializer : KSerializer { decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } } +// Spec: spec/client/compose.md#ComposeState @Serializable data class ComposeState( val message: ComposeMessage = ComposeMessage(), @@ -259,6 +261,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { } } +// Spec: spec/client/compose.md#AttachmentSelection @Composable expect fun AttachmentSelection( composeState: MutableState, @@ -341,6 +344,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } +// Spec: spec/client/compose.md#ComposeView @Composable fun ComposeView( rhId: Long?, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 467f1e52af..4de0175457 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -31,6 +31,7 @@ import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* import java.net.URI +// Spec: spec/client/compose.md#SendMsgView @Composable fun SendMsgView( composeState: MutableState, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 758980059d..633d6c454e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -61,6 +61,7 @@ data class ChatItemReactionMenuItem ( val onClick: (() -> Unit)? ) +// Spec: spec/client/chat-view.md#ChatItemView @Composable fun ChatItemView( chatsCtx: ChatModel.ChatsContext, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 014a180712..293a93b15a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -32,6 +32,7 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock +// Spec: spec/client/chat-list.md#ChatListNavLinkView @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { val showMenu = remember { mutableStateOf(false) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 2109e21bfe..a42f66c6cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -122,6 +122,7 @@ fun ToggleChatListCard() { } } +// Spec: spec/client/chat-list.md#ChatListView @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 4280845867..9248ac6efe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -35,6 +35,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource +// Spec: spec/client/chat-list.md#ChatPreviewView @Composable fun ChatPreviewView( chat: Chat, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 8dfe138da1..c6cc887655 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -43,6 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +// Spec: spec/client/chat-list.md#TagListView @Composable fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { val userTags = remember { chatModel.userTags } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index ed74e083e7..a02e0dc768 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.* private val USER_PICKER_SECTION_SPACING = 32.dp +// Spec: spec/client/chat-list.md#UserPicker @Composable fun UserPicker( chatModel: ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 4827e6ae61..300e5f44fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -74,6 +74,7 @@ object DatabaseUtils { } } +// Spec: spec/database.md#DBMigrationResult @Serializable sealed class DBMigrationResult { @Serializable @SerialName("ok") object OK: DBMigrationResult() 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 db1a0be9da..5c18fa3d47 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 @@ -114,6 +114,7 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr 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 const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 9be10a584b..ed2f6e7859 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -23,6 +23,7 @@ private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 val connections = ArrayList() +// Spec: spec/services/calls.md#ActiveCallView @Composable actual fun ActiveCallView() { val scope = rememberCoroutineScope() diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md new file mode 100644 index 0000000000..173def8ae7 --- /dev/null +++ b/apps/multiplatform/product/README.md @@ -0,0 +1,396 @@ +# SimpleX Chat Android & Desktop -- Product Overview + +> SimpleX Chat multiplatform product specification (Android + Desktop). Bidirectional code links: product docs reference source files, source files reference product docs. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Vision](#vision) +3. [Target Users](#target-users) +4. [Capability Map](#capability-map) +5. [Navigation Map](#navigation-map) +6. [Related Specifications](#related-specifications) + +## Executive Summary + +SimpleX Chat is the first messaging platform with no user identifiers of any kind -- not even random numbers. It provides end-to-end encrypted messaging (with optional post-quantum cryptography), audio/video calls, file sharing, and group communication through a fully decentralized architecture where users control their own SMP relay servers. + +The Android and Desktop apps share a single **Kotlin Multiplatform + Compose Multiplatform** codebase. Common UI and business logic lives in a shared `common/` module, while platform-specific behavior (notifications, audio, video playback, file system access, call management) is abstracted through the Kotlin `expect`/`actual` pattern and a runtime `PlatformInterface` delegate. The Haskell core library is loaded via **JNI** (`external fun` declarations in `Core.kt`), exposing the full SimpleX Chat API (message send/receive, encryption, migration, file handling) through native FFI. + +Key platform differences: + +- **Android** uses a 2-column layout (`AndroidScreen`): chat list slides to chat view. Background messaging is handled by `SimplexService` (foreground service) + `MessagesFetcherWorker` (WorkManager periodic fetch). Calls use a dedicated `CallService` + `CallActivity`. +- **Desktop** uses a 3-column layout (`DesktopScreen`): chat list (start) | chat view (center) | detail panel (`ModalManager.end`). It includes `AppUpdater` for in-app update checking, `StoreWindowState` for window geometry persistence, and VLC-based video playback. Calls use browser-based WebRTC rendered inline. + +--- + +## Vision + +SimpleX Chat is the first messaging platform that has no user identifiers -- not even random numbers. It uses double-ratchet end-to-end encryption with optional post-quantum cryptography. The system is fully decentralized with user-controlled SMP relay servers. + +The protocol design ensures that no server or network observer can determine who communicates with whom. Each conversation uses separate unidirectional messaging queues on potentially different servers, and there is no shared identifier between the sender and receiver queues. + +--- + +## Target Users + +- **Privacy-conscious individuals** wanting secure messaging without phone-number or email-based identity +- **Groups and communities** needing encrypted group communication with role-based access control +- **Users avoiding identity linkage** who want to communicate without any persistent user identifier +- **Organizations** needing self-hosted messaging infrastructure with full control over relay servers +- **Desktop users** wanting a native desktop client with the same privacy guarantees as the mobile app + +--- + +## Capability Map + +All source paths below are relative to `apps/multiplatform/`. The common source root is `common/src/commonMain/kotlin/chat/simplex/common/`. + +### 1. Messaging + +Core message composition, delivery, and interaction features. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Text with markdown | Rich text formatting with SimpleX markdown syntax | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Images | Compressed inline images with full-screen gallery | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt` | +| Video | Video message recording and playback | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt` | +| Voice messages | Audio recording and playback (5min / 510KB limit) | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt` | +| File sharing | Files up to 1GB via XFTP protocol | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt` | +| Link previews | OpenGraph metadata extraction and display | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt` | +| Message reactions | Emoji reactions on sent/received messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt` | +| Message editing | Edit previously sent messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Message deletion | Broadcast delete (for recipient) or internal-only delete | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt` | +| Timed messages | Self-destructing messages with configurable TTL | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt` | +| Quoted replies | Reply to specific messages with quote context | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt` | +| Forwarding | Forward messages between chats | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Search | Full-text search within conversations | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt` | +| Message reports | Report messages to group moderators | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt` | +| Send message bar | Composable message input with attachments, voice, send button | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` | + +### 2. Contacts + +Establishing, managing, and verifying contacts. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Add via SimpleX address | Connect using a SimpleX contact address | `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt` | +| Add via QR code | Scan QR code to establish connection | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt` | +| Contact requests | Accept or reject incoming contact requests | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt` | +| Local aliases | Set private display names for contacts | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` | +| Contact verification | Compare security codes out-of-band | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt` | +| Blocking | Block contacts from sending messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` | +| Incognito mode | Per-contact random profile generation | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt` | +| Bot detection | Identify automated/bot contacts | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Contact list | Dedicated contact browsing view | `common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt` | + +### 3. Groups + +Multi-party encrypted conversations with role-based management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Create groups | Create new group with initial members | `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt` | +| Invite members | Invite by individual contact or link | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt` | +| Member roles | Owner, admin, moderator, member, observer | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Member admission | Queue-based admission with review workflow | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt` | +| Group links | Shareable invite links for groups | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt` | +| Business chat mode | Structured business communication groups | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt` | +| Content moderation | Member reports and moderator actions | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt` | +| Group preferences | Configure group-level feature settings | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt` | +| Member direct contacts | Establish direct chats from group membership | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt` | +| Group mentions | @-mention members in group messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt` | +| Welcome message | Custom welcome message for new group members | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt` | +| Group profile | Edit group name, image, description | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt` | +| Member support chat | Scoped support threads between members and admins | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt` | + +### 4. Calling + +End-to-end encrypted audio and video communication. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt` | +| Call manager | Call state machine and lifecycle management | `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` | +| Call history | Call events displayed as chat items | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt` | +| Incoming call view | Dedicated UI for incoming call notifications | `common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt` | +| Android CallService | Foreground service for active calls on Android | `android/src/main/java/chat/simplex/app/CallService.kt` | +| Android CallActivity | Dedicated Activity for call UI on Android | `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` | +| Desktop inline calls | Browser-based WebRTC rendered inline in desktop window | `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt` | + +### 5. Privacy & Security + +Encryption, authentication, and privacy controls. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| E2E encryption | Double-ratchet encryption for all messages | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Post-quantum encryption | Optional PQ key exchange for direct chats | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Local authentication | Biometric (fingerprint/face) or app passcode lock | `common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt` | +| Passcode entry | Custom numeric/alphanumeric passcode UI | `common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt` | +| Hidden profiles | Password-protected profiles invisible in UI | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt` | +| Database encryption | AES encryption of local SQLite database | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt` | +| Screen privacy | Blur/hide app content when in app switcher | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt` | +| Encrypted file storage | Local files encrypted at rest | `common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt` | +| Delivery receipts control | Toggle delivery/read receipts per contact/group | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt` | +| App lock | Automatic lock on background/timeout with configurable delay | `common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt` | + +### 6. User Management + +Multiple profiles and identity management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Multiple profiles | Multiple user profiles within one app | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt` | +| Active user switching | Switch between profiles via user picker | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt` | +| Incognito contacts | Per-contact random identities | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt` | +| Profile sharing | Share profile via contact address link | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt` | +| User muting | Mute notifications for specific profiles | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt` | +| User profile editing | Edit display name and profile image | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt` | + +### 7. Network + +Server configuration, proxy support, and connectivity. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Custom SMP servers | Configure personal SMP relay servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt` | +| Custom XFTP servers | Configure personal XFTP file servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt` | +| Tor/onion support | Route traffic through Tor .onion addresses | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| SOCKS5 proxy | Route connections through SOCKS5 proxy | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| Custom ICE servers | Configure WebRTC ICE/TURN servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt` | +| Network timeouts | Configure connection timeout parameters | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| Server operators | Configure and manage SMP/XFTP server operators | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt` | +| Server status | View aggregate server connectivity status | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt` | +| Network & servers hub | Central network configuration entry point | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt` | + +### 8. Customization + +Visual appearance and UI preferences. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Themes | Light, dark, SimpleX, black, and custom themes | `common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt` | +| Wallpapers | Preset and custom chat wallpapers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt` | +| Chat bubble styling | Customize message bubble appearance | `common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt` | +| One-handed UI mode | Compact layout for single-hand use (Android) | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt` | +| Language selection | In-app language override | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt` | +| Theme mode editor | Interactive theme color and mode customization | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt` | + +### 9. Data Management + +Import, export, encryption, and storage management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Export/import profiles | Full database export and import | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt` | +| Database encryption | Encrypt/decrypt local database with passphrase | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt` | +| Local file encryption | Encrypt stored media and attachments | `common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt` | +| Database error handling | Recovery UI for database migration failures | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt` | +| Device-to-device migration | Migrate full profile between devices | `common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt` | +| Receive migration | Accept incoming device migration transfer | `common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt` | +| Database utilities | Key storage, password management, helper functions | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | + +### 10. Desktop Features + +Desktop-specific functionality not present on Android. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| 3-column layout | Start (chat list) / center (chat) / end (detail) panels | `common/src/commonMain/kotlin/chat/simplex/common/App.kt` (`DesktopScreen`) | +| ModalManager.end | Third-column detail panel for settings/info views | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt` (`ModalManager`) | +| App update checker | In-app notification for available updates | `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | +| Window state persistence | Save/restore window position and dimensions | `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | +| VLC video playback | Desktop video playback via VLC native libraries | `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | +| Desktop app entry | Main function, Haskell init, VLC loading | `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | +| Desktop notification manager | Platform-native desktop notifications | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt` | +| Connect mobile device | Pair desktop with a mobile device for remote access | `common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt` | +| Desktop platform abstraction | Desktop-specific PlatformInterface implementation | `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt` | +| Desktop app shell | Compose Desktop window, theming, lifecycle | `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | + +--- + +## Navigation Map + +### Android Navigation (2-column slide) + +``` +Onboarding + views/onboarding/SimpleXInfo.kt + -> SimpleXInfo -> CreateFirstProfile -> SetupDatabasePassphrase + -> ChooseServerOperators -> SetNotificationsMode + -> ChatListView (home) + +ChatListView (home) + views/chatlist/ChatListView.kt + -> ChatView .................. (tap conversation row, slides in) + -> NewChatSheet .............. (+ FAB button) + -> SettingsView .............. (gear icon) + -> UserPicker ................ (avatar tap) + -> TagListView ............... (tag filter bar) + -> ServersSummaryView ........ (server status indicator) + -> ShareListView ............. (share intent from external apps) + -> ChatHelpView .............. (empty state help) + +ChatView + views/chat/ChatView.kt + -> ChatInfoView .............. (contact name tap, direct chat) + -> GroupChatInfoView ......... (group name tap, group chat) + -> ActiveCallView ............ (call button, launches CallActivity) + -> ComposeView ............... (message input area) + -> ChatItemInfoView .......... (long press -> info) + -> MemberSupportChatView ..... (member support thread) + -> ScanCodeView .............. (scan QR) + -> CommandsMenuView .......... (/ commands) + +ChatInfoView + views/chat/ChatInfoView.kt + -> ContactPreferences ........ (preferences) + -> VerifyCodeView ............ (verify security code) + +GroupChatInfoView + views/chat/group/GroupChatInfoView.kt + -> GroupProfileView .......... (edit profile) + -> AddGroupMembersView ....... (invite members) + -> GroupLinkView ............. (manage group link) + -> MemberAdmission ........... (admission settings) + -> GroupPreferences .......... (group feature settings) + -> GroupMemberInfoView ....... (tap member) + -> WelcomeMessageView ........ (welcome message) + -> GroupReportsView .......... (view reports) + +NewChatSheet + views/newchat/NewChatSheet.kt + -> NewChatView ............... (QR scanner / paste link) + -> AddGroupView .............. (create group) + -> UserAddressView ........... (create SimpleX address) + +SettingsView + views/usersettings/SettingsView.kt + -> AppearanceView ............ (themes, wallpapers, UI) + -> NetworkAndServers ......... (SMP/XFTP/proxy config) + -> PrivacySettings ........... (privacy toggles) + -> NotificationsSettingsView . (notification mode) + -> DatabaseView .............. (export/import/encrypt) + -> CallSettings .............. (call preferences) + -> VersionInfoView ........... (about/version) + -> DeveloperView ............. (developer options) + -> HelpView .................. (help & support) + +UserPicker + views/chatlist/UserPicker.kt + -> UserProfilesView .......... (manage all profiles) + -> UserAddressView ........... (SimpleX address) + -> Preferences ............... (user preferences) + -> SettingsView .............. (app settings) + -> ConnectDesktopView ........ (pair with desktop) +``` + +### Desktop Navigation (3-column panels) + +``` ++---------------------------+----------------------------------+----------------------------+ +| START PANEL | CENTER PANEL | END PANEL | +| (DEFAULT_START_MODAL_ | (flexible width, min | (DEFAULT_END_MODAL_ | +| WIDTH) | DEFAULT_MIN_CENTER_MODAL_ | WIDTH) | +| | WIDTH) | | ++---------------------------+----------------------------------+----------------------------+ +| | | | +| ChatListView | ChatView | ChatInfoView | +| - chat rows | - message list | GroupChatInfoView | +| - search | - ComposeView | GroupMemberInfoView | +| - tag filters | - media viewer | ContactPreferences | +| - server status | | GroupPreferences | +| | OR (when no chat selected): | GroupProfileView | +| UserPicker (overlay) | "No selected chat" | AddGroupMembersView | +| - profile switcher | | MemberAdmission | +| - quick settings | OR (when modal open): | VerifyCodeView | +| | ModalManager.center content | SettingsView subtabs | +| ModalManager.start | (settings, new chat, etc.) | | +| - secondary modals | | ModalManager.end | +| | | - detail modals | ++---------------------------+----------------------------------+----------------------------+ + +ModalManager Placement (Desktop): + - ModalManager.start -> left panel overlay (settings subviews) + - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.end -> right panel (detail/info views) + - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) + +On Android, all ModalManager instances (start/center/end/fullscreen) collapse to a +single shared ModalManager that presents modals as full-screen overlays. + +Desktop-only navigation targets: + ConnectMobileView ......... (pair with mobile device) + AppUpdater notice ......... (update available notification) + Floating terminal ......... (developer console) + ActiveCallView ............ (inline WebRTC call, not separate Activity) +``` + +--- + +## Platform Abstraction + +The codebase uses two mechanisms for platform-specific behavior: + +### 1. `expect`/`actual` Declarations + +Kotlin Multiplatform `expect` declarations in `common/src/commonMain/kotlin/chat/simplex/common/platform/` with corresponding `actual` implementations in: +- `common/src/androidMain/kotlin/chat/simplex/common/platform/*.android.kt` +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/*.desktop.kt` + +Key `expect`/`actual` abstractions: `appPlatform`, `BackHandler`, `VideoPlayer`, `AudioPlayer`, `RecorderNative`, `NtfManager`, `showToast`, `getKeyboardState`, `PlatformTextField`, image processing, file sharing, and more. + +### 2. Runtime `PlatformInterface` + +Defined in `common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`, this interface provides platform-specific callbacks that cannot use `expect`/`actual` (because `android/` module code cannot be called from `common/androidMain/`). The `platform` variable is reassigned at app startup: +- **Android:** `SimplexApp` sets `platform` to an implementation with `CallService`, notification channels, orientation locking, status bar theming, and PiP support. +- **Desktop:** `Main.kt` sets `platform` to an implementation with `desktopShowAppUpdateNotice()`. + +### 3. Haskell Core (JNI/FFI) + +Native FFI bindings are declared in `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` as `external fun` declarations. These include: `chatMigrateInit`, `chatSendCmdRetry`, `chatRecvMsg`, `chatParseMarkdown`, `chatPasswordHash`, `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`, and more. The native library (`libapp-lib`) is loaded at startup from platform-specific resource directories. + +--- + +## Background Messaging (Android) + +Android has no equivalent to iOS NSE (Notification Service Extension). Instead, it uses: + +- **`SimplexService`** (`android/src/main/java/chat/simplex/app/SimplexService.kt`) -- A foreground service that keeps the Haskell core running to receive messages in real-time. Displays a persistent notification while active. +- **`MessagesFetcherWorker`** (`android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt`) -- A WorkManager-based periodic task that wakes the app at configurable intervals to fetch messages when the foreground service is not running (battery-optimized mode). +- **Notification modes:** Instant (foreground service always running), Periodic (WorkManager fetch every N minutes), Off. + +--- + +## Related Specifications + +### Product Layer (this directory) + +- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links +- [glossary.md](glossary.md) -- Terminology definitions +- [rules.md](rules.md) -- Business rules and constraints +- [gaps.md](gaps.md) -- Known documentation gaps +- Views: [chat-list](views/chat-list.md), [chat](views/chat.md), [new-chat](views/new-chat.md), [settings](views/settings.md), [call](views/call.md), [contact-info](views/contact-info.md), [group-info](views/group-info.md), [onboarding](views/onboarding.md), [user-profiles](views/user-profiles.md) +- Flows: [messaging](flows/messaging.md), [calling](flows/calling.md), [onboarding](flows/onboarding.md), [group-lifecycle](flows/group-lifecycle.md), [connection](flows/connection.md), [file-transfer](flows/file-transfer.md) + +### Spec Layer + +- [spec/README.md](../spec/README.md) -- Technical specification overview +- [spec/architecture.md](../spec/architecture.md) -- JNI bridge, startup, lifecycle +- [spec/state.md](../spec/state.md) -- ChatModel, ChatsContext, Chat, AppPreferences +- [spec/api.md](../spec/api.md) -- Command/response protocol (CC, CR, ChatError) +- [spec/database.md](../spec/database.md) -- Migration, encryption, export/import +- Client: [navigation](../spec/client/navigation.md), [chat-list](../spec/client/chat-list.md), [chat-view](../spec/client/chat-view.md), [compose](../spec/client/compose.md) +- Services: [calls](../spec/services/calls.md), [theme](../spec/services/theme.md), [files](../spec/services/files.md), [notifications](../spec/services/notifications.md) + +### Source Entry Points + +- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs` +- Kotlin model: `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` +- Kotlin API bridge: `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` +- Kotlin FFI: `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` +- Android entry: `android/src/main/java/chat/simplex/app/SimplexApp.kt`, `MainActivity.kt` +- Desktop entry: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md new file mode 100644 index 0000000000..da33bf11d7 --- /dev/null +++ b/apps/multiplatform/product/concepts.md @@ -0,0 +1,120 @@ +# SimpleX Chat Android & Desktop -- Concept Index + +> SimpleX Chat multiplatform concept index. Maps every product concept to its documentation and source code with bidirectional links. +> +> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Feature Concepts](#section-1-feature-concepts) +2. [Entity Index](#section-2-entity-index) + +## Executive Summary + +This document provides a structured mapping between product-level concepts, their documentation, and their implementation in both the Kotlin multiplatform layer and the Haskell core library. All Kotlin source paths are relative to `apps/multiplatform/`. Haskell paths use `../../src/` prefix (relative to `apps/multiplatform/`). The common source root abbreviation used below is `common/src/commonMain/kotlin/chat/simplex/common/`. + +--- + +## Section 1: Feature Concepts + +| # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) | +|---|---------|-------------|-----------|----------------------|----------------------| +| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) | +| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | +| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | +| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | +| PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) | +| PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) | +| PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) | +| PC8 | Timed Messages | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/CIChatFeatureView.kt` | `Types/Preferences.hs` (`TimedMessagesPreference`) | +| PC9 | Voice Messages | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/item/CIVoiceView.kt`, `ComposeVoiceView.kt`, `platform/RecAndPlay.kt` | `Protocol.hs` (`MCVoice`) | +| PC10 | File Transfer | [README.md](README.md) (Messaging, Data Management) | [spec/services/files.md](../spec/services/files.md) | `common/.../views/chat/item/CIFileView.kt`, `platform/Files.kt` | `Files.hs`, `Store/Files.hs` | +| PC11 | Link Previews | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/helpers/LinkPreviews.kt` | `Protocol.hs` (`MCLink`) | +| PC12 | Contact Connection | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/newchat/NewChatView.kt`, `QRCode.kt`, `QRCodeScanner.kt`, `ConnectPlan.kt` | `Controller.hs` (`APIConnect`, `APIAddContact`) | +| PC13 | Contact Verification | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/chat/VerifyCodeView.kt` | `Controller.hs` (`APIVerifyContact`) | +| PC14 | Group Management | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/newchat/AddGroupView.kt`, `group/GroupChatInfoView.kt`, `group/GroupProfileView.kt` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` | +| PC15 | Group Links | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/GroupLinkView.kt` | `Controller.hs` (`APICreateGroupLink`) | +| PC16 | Member Roles | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../model/ChatModel.kt`, `group/GroupMemberInfoView.kt` | `Types/Shared.hs` (`GroupMemberRole`) | +| PC17 | Audio/Video Calls | [README.md](README.md) (Calling) | [spec/services/calls.md](../spec/services/calls.md) | `common/.../views/call/CallView.kt`, `CallManager.kt`, `WebRTC.kt`, `android/.../CallService.kt`, `android/.../views/call/CallActivity.kt` | `Call.hs` (`RcvCallInvitation`, `CallType`) | +| PC18 | Notifications | [README.md](README.md) (Background Messaging) | [spec/services/notifications.md](../spec/services/notifications.md) | `common/.../platform/NtfManager.kt`, `Notifications.kt`, `android/.../SimplexService.kt`, `android/.../MessagesFetcherWorker.kt`, `common/.../views/usersettings/NotificationsSettingsView.kt` | `Controller.hs` | +| PC19 | User Profiles | [README.md](README.md) (User Management) | [spec/state.md](../spec/state.md) | `common/.../views/usersettings/UserProfilesView.kt`, `UserProfileView.kt`, `views/chatlist/UserPicker.kt` | `Types.hs` (`User`), `Store/Profiles.hs` | +| PC20 | Incognito Mode | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/IncognitoView.kt` | `ProfileGenerator.hs`, `Types.hs` | +| PC21 | Hidden Profiles | [README.md](README.md) (Privacy & Security) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/HiddenProfileView.kt` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) | +| PC22 | Local Authentication | [README.md](README.md) (Privacy & Security) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/localauth/LocalAuthView.kt`, `PasscodeView.kt`, `SetAppPasscodeView.kt`, `PasswordEntry.kt`, `AppLock.kt` | N/A (client-only) | +| PC23 | Database Encryption | [README.md](README.md) (Data Management) | [spec/database.md](../spec/database.md) | `common/.../views/database/DatabaseEncryptionView.kt`, `DatabaseView.kt`, `views/helpers/DatabaseUtils.kt` | `Controller.hs` (`APIExportArchive`) | +| PC24 | Theme System | [README.md](README.md) (Customization) | [spec/services/theme.md](../spec/services/theme.md) | `common/.../ui/theme/ThemeManager.kt`, `Theme.kt`, `Color.kt`, `Type.kt`, `Shape.kt` | `Types/UITheme.hs` | +| PC25 | Network Configuration | [README.md](README.md) (Network) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/usersettings/networkAndServers/NetworkAndServers.kt`, `ProtocolServersView.kt`, `AdvancedNetworkSettings.kt`, `OperatorView.kt` | `Controller.hs` (`APISetNetworkConfig`) | +| PC26 | Device Migration | [README.md](README.md) (Data Management) | [spec/database.md](../spec/database.md) | `common/.../views/migration/MigrateFromDevice.kt`, `MigrateToDevice.kt` | `Archive.hs` | +| PC27 | Remote Desktop | [README.md](README.md) (Desktop Features) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/remote/ConnectDesktopView.kt`, `ConnectMobileView.kt` | `Remote.hs`, `Remote/Types.hs` | +| PC28 | Chat Tags | [README.md](README.md) (Navigation Map) | [spec/state.md](../spec/state.md) | `common/.../views/chatlist/TagListView.kt`, `ChatListView.kt` | `Types.hs` (`ChatTag`), `Controller.hs` | +| PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) | +| PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | + +**Legend for abbreviated paths:** +- `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` +- `android/.../` expands to `android/src/main/java/chat/simplex/app/` +- Haskell files are in `../../src/Simplex/Chat/` (relative to `apps/multiplatform/`) + +--- + +## Section 2: Entity Index + +Core data entities, their storage, and the operations that manage their lifecycle. + +| Entity | DB Table (Haskell) | Created By | Read By | Mutated By | Deleted By | +|--------|-------------------|------------|---------|------------|------------| +| **User** | `users` | `CreateActiveUser` in `Controller.hs` | `ListUsers`, `APISetActiveUser` in `Controller.hs` | `APISetActiveUser`, `APIHideUser`, `APIUnhideUser`, `APIMuteUser`, `APIUpdateProfile` in `Controller.hs` | `APIDeleteUser` in `Controller.hs`; `Store/Profiles.hs` | +| **Contact** | `contacts`, `contact_profiles` | `APIAddContact`, `APIConnect` in `Controller.hs` | `APIGetChat` in `Controller.hs`; `Store/Direct.hs` (`getContact`) | `APISetContactAlias`, `APISetConnectionAlias` in `Controller.hs`; `Store/Direct.hs` | `APIDeleteChat` in `Controller.hs`; `Store/Direct.hs` (`deleteContact`) | +| **GroupInfo** | `groups`, `group_profiles` | `APINewGroup` in `Controller.hs`; `Store/Groups.hs` (`createNewGroup`) | `APIGetChat`, `APIGroupInfo` in `Controller.hs`; `Store/Groups.hs` | `APIUpdateGroupProfile` in `Controller.hs`; `Store/Groups.hs` (`updateGroupProfile`) | `APIDeleteChat` in `Controller.hs`; `Store/Groups.hs` (`deleteGroup`) | +| **GroupMember** | `group_members`, `contact_profiles` | `APIAddMember`, `APIJoinGroup` in `Controller.hs`; `Store/Groups.hs` (`createNewGroupMember`) | `APIListMembers` in `Controller.hs`; `Store/Groups.hs` (`getGroupMembers`) | `APIMembersRole` in `Controller.hs`; `Store/Groups.hs` (`updateGroupMemberRole`) | `APIRemoveMembers` in `Controller.hs`; `Store/Groups.hs` (`deleteGroupMember`) | +| **ChatItem** | `chat_items`, `chat_item_versions` | `APISendMessages` in `Controller.hs`; `Store/Messages.hs` (`createNewChatItem`) | `APIGetChat`, `APIGetChatItems` in `Controller.hs`; `Store/Messages.hs` (`getChatItems`) | `APIUpdateChatItem`, `APIChatItemReaction` in `Controller.hs`; `Store/Messages.hs` (`updateChatItem`) | `APIDeleteChatItem` in `Controller.hs`; `Store/Messages.hs` (`deleteChatItem`) | +| **Connection** | `connections` | `createConnection` via SMP agent; `Store/Connections.hs` | `Store/Connections.hs` (`getConnectionEntity`) | `Store/Connections.hs` (`updateConnectionStatus`) | `Store/Connections.hs` (`deleteConnection`) | +| **FileTransfer** | `files`, `snd_files`, `rcv_files`, `xftp_file_descriptions` | `APISendMessages` (with file), `ReceiveFile` in `Controller.hs`; `Store/Files.hs` | `Store/Files.hs` (`getFileTransfer`) | `Store/Files.hs` (`updateFileStatus`, `updateFileProgress`) | `Store/Files.hs` (`deleteFileTransfer`) | +| **GroupLink** | `user_contact_links` | `APICreateGroupLink` in `Controller.hs`; `Store/Groups.hs` | `APIGetGroupLink` in `Controller.hs`; `Store/Groups.hs` | N/A (recreated on change) | `APIDeleteGroupLink` in `Controller.hs`; `Store/Groups.hs` | +| **ChatTag** | `chat_tags`, `chat_tags_chats` | `APICreateChatTag` in `Controller.hs` | `APIGetChats` in `Controller.hs` | `APIUpdateChatTag`, `APISetChatTags` in `Controller.hs` | `APIDeleteChatTag` in `Controller.hs` | +| **RcvCallInvitation** | In-memory (not persisted) | Received via `XCallInv` message in `Library/Subscriber.hs`; stored in `ChatModel.activeCallInvitation` | `CallManager.kt`, `IncomingCallAlertView.kt` | Updated on call accept/reject in `CallManager.kt` | Removed on call end/reject; `Controller.hs` | + +--- + +## Platform-Specific Source Index + +Key files that exist only on one platform, grouped by concern. + +### Android-Only + +| File | Purpose | +|------|---------| +| `android/src/main/java/chat/simplex/app/SimplexApp.kt` | Application subclass, PlatformInterface setup, Haskell init | +| `android/src/main/java/chat/simplex/app/MainActivity.kt` | Main Activity, deep link handling, lifecycle | +| `android/src/main/java/chat/simplex/app/SimplexService.kt` | Foreground service for persistent messaging | +| `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` | WorkManager periodic message fetch | +| `android/src/main/java/chat/simplex/app/CallService.kt` | Foreground service for active calls | +| `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` | Dedicated Activity for call UI | +| `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` | Android notification channels and manager | +| `common/src/androidMain/kotlin/chat/simplex/common/platform/*.android.kt` | All `actual` implementations for Android | + +### Desktop-Only + +| File | Purpose | +|------|---------| +| `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup | +| `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle | +| `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence | +| `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt` | Desktop platform detection (Linux/macOS/Windows) | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/*.desktop.kt` | All `actual` implementations for Desktop | + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Haskell core controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell store layer: `../../src/Simplex/Chat/Store/` (`Direct.hs`, `Groups.hs`, `Messages.hs`, `Files.hs`, `Profiles.hs`, `Connections.hs`) +- Kotlin model: `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` +- Kotlin API bridge: `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` +- Kotlin FFI layer: `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` +- Platform abstraction: `common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt` (`PlatformInterface`) diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md new file mode 100644 index 0000000000..fae7f42031 --- /dev/null +++ b/apps/multiplatform/product/flows/calling.md @@ -0,0 +1,220 @@ +# Calling Flow + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Overview + +SimpleX Chat supports audio and video calls using WebRTC, with signaling delivered over the existing SMP messaging channels. Calls are end-to-end encrypted with an additional shared key layer on top of WebRTC's SRTP encryption. + +The architecture differs by platform: +- **Android**: Calls run in a dedicated `CallActivity` (separate from `MainActivity`) with a `WebView` hosting the WebRTC JavaScript. A foreground `CallService` keeps the process alive and shows a persistent notification. +- **Desktop**: Calls open the system browser pointed at a local NanoHTTPD/NanoWSD embedded server on `localhost:50395`, which serves the WebRTC HTML/JS and communicates with the app via WebSocket. + +Both platforms share a common signaling flow through the Haskell core API. + +## Prerequisites + +- Both parties must have an established direct contact connection. +- Microphone permission is required; camera permission is required for video calls. +- On Android, the `CallOnLockScreen` preference controls lock-screen call behavior: `DISABLE`, `SHOW`, or `ACCEPT`. + +--- + +## 1. Outgoing Call (Caller Side) + +### 1.1 Initiate Call + +1. User taps the audio or video call button in `ChatView`. +2. `startChatCall(remoteHostId, chatInfo, media)` is called (in `ChatView.kt`). +3. A `Call` object is created with `callState = CallState.WaitCapabilities`: + ```kotlin + Call( + remoteHostId = remoteHostId, + contact = contact, + callUUID = null, + callState = CallState.WaitCapabilities, + initialCallType = media, // Audio or Video + userProfile = profile, + androidCallState = platform.androidCreateActiveCallState() + ) + ``` +4. `ChatModel.activeCall` is set and `ChatModel.showCallView` is set to `true`. +5. A `WCallCommand.Capabilities(media)` command is added to `ChatModel.callCommand`. + +### 1.2 WebRTC Capabilities Response + +1. The WebRTC engine (WebView on Android, browser on Desktop) receives the `Capabilities` command. +2. It responds with `WCallResponse.Capabilities(capabilities)` containing encryption support info. +3. The app calls `ChatController.apiSendCallInvitation(rh, contact, callType)` to send the invitation via SMP. +4. Call state transitions to `CallState.InvitationSent`. +5. A connecting sound starts playing via `CallSoundsPlayer.startConnectingCallSound`. + +### 1.3 Offer Exchange + +1. When the callee accepts, the WebRTC engine generates an offer. +2. `WCallResponse.Offer(offer, iceCandidates, capabilities)` is received. +3. `ChatController.apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` sends it. +4. Call state transitions to `CallState.OfferSent`. + +### 1.4 Answer and Connection + +1. The callee's answer arrives via SMP as a chat event. +2. The app dispatches `WCallCommand.Answer(answer, iceCandidates)` to the WebRTC engine. +3. Call state transitions to `CallState.Negotiated`, then to `CallState.Connected` once the ICE connection succeeds. +4. `Call.connectedAt` is set to the current timestamp. + +--- + +## 2. Incoming Call (Callee Side) + +### 2.1 Receive Invitation + +1. An incoming call event arrives from the core as `CR.CallInvitation`. +2. `CallManager.reportNewIncomingCall(invitation)` is called. +3. A `RcvCallInvitation` is stored in `ChatModel.callInvitations` keyed by contact ID. +4. If the invitation is recent (within 3 minutes), a system notification is shown and `ChatModel.activeCallInvitation` is set. +5. On Android, `CallActivity` may be launched on the lock screen if `callOnLockScreen` is `SHOW` or `ACCEPT`. + +### 2.2 Accept Call + +1. User taps "Accept" on the `IncomingCallAlertView` or lock-screen alert. +2. `CallManager.acceptIncomingCall(invitation)` is called. +3. If another call is active, it is ended first (with `switchingCall` flag set). +4. A new `Call` is created with `callState = CallState.InvitationAccepted`. +5. ICE servers are loaded from preferences (`getIceServers()`). +6. `WCallCommand.Start(media, aesKey, iceServers, relay)` is dispatched to the WebRTC engine. +7. The call invitation is removed from `callInvitations` and the notification is cancelled. + +### 2.3 Reject Call + +1. User taps "Reject" or the invitation times out. +2. `CallManager.endCall(invitation)` is called. +3. `ChatController.apiRejectCall(rh, contact)` notifies the caller. +4. The invitation is removed from `callInvitations`. + +--- + +## 3. Call State Machine + +``` +Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended +Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +``` + +| State | Description | +|-------|-------------| +| `WaitCapabilities` | Querying local WebRTC capabilities | +| `InvitationSent` | Caller sent invitation via SMP | +| `InvitationAccepted` | Callee accepted, starting WebRTC | +| `OfferSent` | Caller sent SDP offer | +| `OfferReceived` | Callee received SDP offer | +| `AnswerReceived` | Caller received SDP answer | +| `Negotiated` | ICE negotiation complete | +| `Connected` | Media flowing | +| `Ended` | Call terminated | + +--- + +## 4. Ending a Call + +1. User taps the end-call button, or the remote side ends the call. +2. `CallManager.endCall(call)` is called. +3. `ChatController.apiEndCall(rh, contact)` notifies the remote side via SMP. +4. `ChatModel.showCallView` is set to `false`. +5. `ChatModel.activeCall` is set to `null`. +6. On Android, `CallService` is stopped and the `WebView` is destroyed. +7. On Desktop, `WCallCommand.End` is sent to the browser via WebSocket, and the NanoWSD server is stopped. + +--- + +## 5. Android-Specific: CallActivity and CallService + +### 5.1 CallActivity + +- `CallActivity` is a separate `ComponentActivity` (not `MainActivity`). +- It is launched via `platform.androidStartCallActivity(acceptCall, remoteHostId, chatId)`. +- It hosts `ActiveCallView` with a `WebView` for WebRTC. +- Supports lock-screen display: `setShowWhenLocked(true)` and `setTurnScreenOn(true)`. +- Supports Picture-in-Picture (PiP) mode for video calls. + - On Android 12+, PiP auto-enters when the user navigates away. + - On older versions, PiP is entered via `enterPictureInPictureMode()` on `onUserLeaveHint`. + - PiP layout switches to `LayoutType.RemoteVideo` to show only the remote video feed. +- The activity finishes itself when both `invitation == null` and (`!showCallView || call == null`) and `!switchingCall`. + +### 5.2 CallService + +- `CallService` is a foreground `Service` that keeps the process alive during calls. +- Started via `CallService.startService()` which calls `ContextCompat.startForegroundService`. +- Acquires a partial `WakeLock` to prevent CPU sleep. +- Shows a persistent notification with: + - Contact name and call type (audio/video). + - An "End Call" action button. + - A chronometer showing call duration (from `connectedAt`). +- The notification taps open `CallActivity`. +- Foreground service type includes `MICROPHONE`, `CAMERA` (if video), and `MEDIA_PLAYBACK`. + +--- + +## 6. Desktop-Specific: Browser-Based WebRTC + +### 6.1 NanoWSD Embedded Server + +1. When a call starts, `startServer(onResponse)` creates a `NanoWSD` server on `localhost:50395`. +2. The server serves static WebRTC HTML/JS from bundled resources at `/assets/www/desktop/call.html`. +3. The system browser is opened to `http://localhost:50395/simplex/call/`. + +### 6.2 WebSocket Communication + +1. The browser page connects back via WebSocket to the same `localhost:50395` server. +2. Commands from the app to the browser are serialized as `WVAPICall(corrId, command)` JSON. +3. Responses from the browser arrive as `WVAPIMessage(corrId, resp, command)` JSON. +4. The `WebRTCController` composable manages the command queue: + - Collects commands from `ChatModel.callCommand` (a `SnapshotStateList`). + - Sends them to the browser via the WebSocket connection. + - Processes responses through the same `WCallResponse` handling as Android. +5. On dispose, `WCallCommand.End` is sent, the server is stopped, and connections are cleared. + +--- + +## 7. Common Signaling API + +| API Function | Purpose | +|-------------|---------| +| `apiSendCallInvitation(rh, contact, callType)` | Send call invitation via SMP | +| `apiRejectCall(rh, contact)` | Reject incoming call | +| `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | +| `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | +| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiEndCall(rh, contact)` | End active call | +| `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | + +--- + +## 8. In-Call Media Controls + +During an active call, the user can toggle media sources via `WCallCommand.Media(source, enable)`: + +| Source | Control | +|--------|---------| +| `CallMediaSource.Mic` | Mute/unmute microphone | +| `CallMediaSource.Camera` | Enable/disable camera | +| `CallMediaSource.ScreenAudio` | Screen share audio | +| `CallMediaSource.ScreenVideo` | Screen share video | + +Camera switching (front/back) is done via `WCallCommand.Camera(VideoCamera.User / VideoCamera.Environment)`. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `Call` | `views/call/WebRTC.kt` | Active call state: contact, callState, media sources, encryption | +| `CallState` | `views/call/WebRTC.kt` | Enum: WaitCapabilities through Ended | +| `RcvCallInvitation` | `views/call/WebRTC.kt` | Incoming call invitation with contact, callType, sharedKey | +| `CallManager` | `views/call/CallManager.kt` | Manages call lifecycle: accept, end, report | +| `WCallCommand` | `views/call/WebRTC.kt` | Commands to WebRTC engine: Capabilities, Start, Offer, Answer, Ice, Media, Camera, End | +| `WCallResponse` | `views/call/WebRTC.kt` | Responses from WebRTC: Capabilities, Offer, Answer, Ice, Connection, Connected, End | +| `CallActivity` | `android/.../views/call/CallActivity.kt` | Android Activity hosting the call UI and WebView | +| `CallService` | `android/.../CallService.kt` | Android foreground Service for call persistence | +| `NanoWSD` | `desktopMain/.../views/call/CallView.desktop.kt` | Desktop embedded HTTP+WebSocket server | diff --git a/apps/multiplatform/product/flows/connection.md b/apps/multiplatform/product/flows/connection.md new file mode 100644 index 0000000000..1b1123b535 --- /dev/null +++ b/apps/multiplatform/product/flows/connection.md @@ -0,0 +1,233 @@ +# Connection Flow + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/api.md](../../spec/api.md) + +## Overview + +Establishing a contact connection in SimpleX Chat follows an invitation-link model. One party creates a connection link (one-time invitation or long-term address), shares it out-of-band, and the other party connects via that link. The process uses SMP queues for the handshake, with no central server involved in identity management. + +Connections support incognito mode, where a random profile is used per-connection instead of the user's real profile. + +## Prerequisites + +- Chat is initialized and running. +- An active user profile exists. +- For connecting: a valid SimpleX connection link (invitation or address). + +--- + +## 1. Creating a Connection Link (Inviter Side) + +### 1.1 One-Time Invitation Link + +1. User navigates to "New Chat" and selects "Add Contact" (or uses the "+" action). +2. `ChatController.apiAddContact(rh, incognito)` is called: + +```kotlin +suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> +``` + +3. Internally, `CC.APIAddContact(userId, incognito)` is sent to the core. +4. The core creates a new SMP queue pair and returns: + - `CR.Invitation` with `connLinkInvitation: CreatedConnLink` and `connection: PendingContactConnection`. +5. The `CreatedConnLink` contains the invitation URI (long form and short link). +6. The link is displayed as a QR code in `NewChatView` and can be copied or shared. +7. A `PendingContactConnection` appears in the chat list while waiting. + +### 1.2 Long-Term Contact Address + +1. User goes to Settings and creates a SimpleX address. +2. This creates a persistent address link that multiple people can use. +3. Incoming connection requests from the address require explicit acceptance (see section 4). + +--- + +## 2. Connecting via Link (Connector Side) + +### 2.1 Preview the Connection Plan + +Before connecting, the link is analyzed: + +```kotlin +suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState): Pair? +``` + +1. User pastes or scans a link. +2. `apiConnectPlan` sends `CC.APIConnectPlan(userId, connLink)` to the core. +3. The core resolves short links, validates the link, and returns a `ConnectionPlan`: + +```kotlin +sealed class ConnectionPlan { + class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() + class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() + class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + class Error(val chatError: ChatError): ConnectionPlan() +} +``` + +4. For `InvitationLinkPlan`: + - `Ok`: Fresh invitation, safe to connect. + - `OwnLink`: User's own link, alert shown. + - `Connecting(contact_)`: Already connecting to this contact. + - `Known(contact)`: Already connected, existing contact shown. + +5. For `ContactAddressPlan`: + - `Ok`: Fresh address, safe to connect. + - `OwnLink`: User's own address. + - `ConnectingConfirmReconnect`: Was connecting, offer to retry. + - `ConnectingProhibit(contact)`: Connection in progress, cannot duplicate. + - `Known(contact)`: Already a contact. + - `ContactViaAddress(contact)`: Contact already exists via this address. + +6. For `GroupLinkPlan`: + - `Ok`: Fresh group link, safe to join. + - `OwnLink(groupInfo)`: User's own group. + - `ConnectingConfirmReconnect`: Was connecting, offer to retry. + - `ConnectingProhibit(groupInfo_)`: Connection in progress. + - `Known(groupInfo)`: Already a member. + +### 2.2 High-Level Connect Flow: planAndConnect + +The `planAndConnect` function in `ConnectPlan.kt` orchestrates the full connect experience: + +```kotlin +suspend fun planAndConnect( + rhId: Long?, + shortOrFullLink: String, + close: (() -> Unit)?, + cleanup: (() -> Unit)? = null, + filterKnownContact: ((Contact) -> Unit)? = null, + filterKnownGroup: ((GroupInfo) -> Unit)? = null, +): CompletableDeferred +``` + +1. A progress indicator is shown. +2. `apiConnectPlan` is called to analyze the link. +3. Based on the plan type, the appropriate UI is shown: + - For `Ok` plans: proceed to `apiConnect`. + - For `Known`: navigate to the existing contact/group. + - For `OwnLink`: show alert. + - For `Connecting`: show reconnect confirmation or prohibit. +4. Returns a `CompletableDeferred` indicating success. + +### 2.3 Execute Connection + +```kotlin +suspend fun apiConnect(rh: Long?, incognito: Boolean, connLink: CreatedConnLink): PendingContactConnection? +``` + +1. `CC.APIConnect(userId, incognito, connLink)` is sent to the core. +2. The core initiates the SMP handshake: + - For invitation links: `CR.SentConfirmation` is returned. + - For contact addresses: `CR.SentInvitation` is returned. +3. A `PendingContactConnection` is returned and appears in the chat list. +4. The connect progress indicator is shown via `ConnectProgressManager`. + +--- + +## 3. Connection Handshake Completion + +### 3.1 For Invitation Links + +1. After the connector sends confirmation, the inviter's core receives it. +2. Both sides complete the SMP handshake automatically. +3. A `CR.ContactConnected` event is received on both sides. +4. The `PendingContactConnection` in the chat list is replaced by a full `Contact`. +5. Both parties can now exchange messages. + +### 3.2 For Contact Addresses + +1. The connector's confirmation arrives as a `ContactRequest` on the address owner's side. +2. The address owner must explicitly accept or reject (see section 4). +3. Once accepted, the handshake completes and `CR.ContactConnected` fires. + +--- + +## 4. Contact Request Acceptance + +### 4.1 Accept a Contact Request + +```kotlin +suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? +``` + +1. The address owner sees a contact request notification in the chat list. +2. User taps to open and selects "Accept". +3. `CC.ApiAcceptContact(incognito, contactReqId)` is sent to the core. +4. The core responds with `CR.AcceptingContactRequest` and a `Contact` object. +5. The SMP handshake continues; once complete, `CR.ContactConnected` fires. +6. The `incognito` flag determines whether the real profile or a random profile is shared. + +### 4.2 Reject a Contact Request + +```kotlin +suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Contact? +``` + +1. User selects "Reject" on the contact request. +2. `CC.ApiRejectContact(contactReqId)` is sent to the core. +3. The core responds with `CR.ContactRequestRejected`. +4. The contact request is removed from the chat list. +5. The connector's side eventually times out or receives an error. + +--- + +## 5. Incognito Mode + +### 5.1 Per-Connection Incognito + +1. The `incognito` parameter is available on both `apiAddContact` and `apiConnect`. +2. When `incognito = true`: + - A random display name is generated for this connection. + - The real user profile is not shared with the contact. + - The incognito profile is stored per-connection in the database. +3. The global incognito toggle is in `AppPreferences.incognito`. +4. Incognito status is visible in the chat info view. + +### 5.2 Accept with Incognito + +1. When accepting a contact request with `incognito = true`, a random profile is used. +2. The accepted contact only sees the random profile. +3. The user can have some contacts with real profile and others with incognito profiles. + +--- + +## 6. Connection Progress and UI + +### 6.1 ConnectProgressManager + +```kotlin +object ConnectProgressManager { + fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) + fun stopConnectProgress() + fun cancelConnectProgress() +} +``` + +1. When a connection is initiated, `startConnectProgress` is called. +2. After a 1-second delay, a progress indicator appears if the operation is still in progress. +3. On completion (success or failure), `stopConnectProgress` is called. +4. The user can cancel via `cancelConnectProgress`. + +### 6.2 Pending Connection States + +While connecting, the chat list shows a `PendingContactConnection` with status: +- Waiting for the other party to scan/use the link. +- Connecting (handshake in progress). +- Connected (transitions to a full Contact chat). + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `CreatedConnLink` | `model/SimpleXAPI.kt` | Connection link with full URI and short link | +| `PendingContactConnection` | `model/ChatModel.kt` | In-progress connection shown in chat list | +| `ConnectionPlan` | `model/SimpleXAPI.kt` | Sealed class: InvitationLink, ContactAddress, GroupLink, Error | +| `InvitationLinkPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, Connecting, Known | +| `ContactAddressPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, ConnectingConfirmReconnect, ConnectingProhibit, Known | +| `GroupLinkPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, ConnectingConfirmReconnect, ConnectingProhibit, Known | +| `ConnectProgressManager` | `model/ChatModel.kt` | Manages connect progress indicator with timeout | +| `Contact` | `model/ChatModel.kt` | Established contact with profile, connection status | +| `ContactRequest` | `model/ChatModel.kt` | Pending inbound contact request | diff --git a/apps/multiplatform/product/flows/file-transfer.md b/apps/multiplatform/product/flows/file-transfer.md new file mode 100644 index 0000000000..edbb565c07 --- /dev/null +++ b/apps/multiplatform/product/flows/file-transfer.md @@ -0,0 +1,252 @@ +# File Transfer Flow + +> **Related spec:** [spec/services/files.md](../../spec/services/files.md) + +## Overview + +SimpleX Chat transfers files using two protocols based on file size: inline delivery through SMP messages for small files, and XFTP (SimpleX File Transfer Protocol) for larger files. All locally stored files can be AES-encrypted via CryptoFile. The system supports automatic receiving of small media, manual download for larger files, and cancellation at any stage. + +## Prerequisites + +- An active chat connection (direct contact or group). +- Sufficient storage space on the device. +- For XFTP: network connectivity to XFTP relay servers. + +--- + +## 1. File Size Thresholds and Constants + +| Constant | Value | Purpose | +|----------|-------|---------| +| `MAX_IMAGE_SIZE` | 261,120 bytes (255 KB) | Maximum inline image thumbnail size (base64 in message body) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 bytes (510 KB) | Auto-receive threshold for images | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 bytes (510 KB) | Auto-receive threshold for voice messages | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 bytes (1023 KB) | Auto-receive threshold for video thumbnails | +| `MAX_FILE_SIZE_SMP` | 8,000,000 bytes (~7.6 MB) | Maximum file size for SMP inline transfer | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 bytes (1 GB) | Maximum file size for XFTP transfer | +| `MAX_FILE_SIZE_LOCAL` | `Long.MAX_VALUE` | No limit for local files | + +These constants are defined in `views/helpers/Utils.kt`. + +The core decides the transfer protocol: +- Files within the SMP inline threshold are embedded directly in SMP messages. +- Files exceeding the inline threshold (up to 1 GB) use XFTP with chunked, encrypted upload/download through relay servers. + +--- + +## 2. CryptoFile Encryption + +### 2.1 Overview + +When `privacyEncryptLocalFiles` is enabled (default: `true`), files stored on device are AES-GCM encrypted. The encryption/decryption is performed via JNI calls to the Haskell core. + +### 2.2 Key Types + +```kotlin +// model/ChatModel.kt +@Serializable +data class CryptoFileArgs( + val fileKey: String, // AES-256 key (base64) + val fileNonce: String // GCM nonce (base64) +) + +@Serializable +data class CryptoFile { + val filePath: String + val cryptoArgs: CryptoFileArgs? // null for unencrypted files +} +``` + +### 2.3 Write (Encrypt) + +```kotlin +fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs +``` + +1. `ChatController.getChatCtrl()` obtains the active controller handle. +2. Data is placed in a `DirectByteBuffer`. +3. `chatWriteFile(ctrl, path, buffer)` is called via JNI. +4. The core generates a random AES key and nonce, encrypts the data, writes to `path`. +5. Returns `CryptoFileArgs(fileKey, fileNonce)` needed for decryption. +6. On error, throws an exception with the error message. + +### 2.4 Read (Decrypt) + +```kotlin +fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray +``` + +1. `chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce)` is called via JNI. +2. Returns a two-element array: `[status: Int, data: ByteArray]`. +3. If `status == 0`, the decrypted data is returned. +4. Otherwise, an exception is thrown with the error message. + +### 2.5 File-to-File Encryption + +```kotlin +fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs +``` + +Encrypts a plaintext file at `fromPath` to an encrypted file at `toPath`. Used when saving user-selected files to the app's encrypted storage. + +### 2.6 File-to-File Decryption + +```kotlin +fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) +``` + +Decrypts an encrypted file at `fromPath` to plaintext at `toPath`. Used when exporting/sharing files. + +--- + +## 3. Sending Files + +### 3.1 Attach and Send via ComposeView + +1. User attaches a file via the file picker. +2. File size is validated: `fileSize <= MAX_FILE_SIZE_XFTP` (1 GB). +3. If valid, `ComposeState.preview` is set to `ComposePreview.FilePreview(fileName, uri)`. +4. If too large, an alert is shown with the maximum supported size. +5. On send, the file is copied to the app files directory. +6. If `privacyEncryptLocalFiles` is enabled, the file is encrypted via `encryptCryptoFile`, producing a `CryptoFile` with `cryptoArgs`. +7. A `ComposedMessage` is created with: + - `fileSource`: the `CryptoFile` (path + optional cryptoArgs). + - `msgContent`: `MsgContent.MCFile(text)` for generic files, `MsgContent.MCImage(text, thumbnail)` for images, `MsgContent.MCVideo(text, thumbnail, duration)` for videos, or `MsgContent.MCVoice(text, duration)` for voice. +8. `ChatController.apiSendMessages(...)` dispatches the message. +9. The core determines the transfer protocol and begins the upload. + +### 3.2 Standalone File Upload (XFTP) + +For uploading files outside of a chat message context: + +```kotlin +suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair +``` + +1. `CC.ApiUploadStandaloneFile(userId, file)` is sent to the core. +2. On success, `CR.SndStandaloneFileCreated` returns a `FileTransferMeta`. +3. The meta contains a file description URI that can be shared for download. + +### 3.3 Upload Progress + +1. The core emits `SndFileProgressXFTP` events periodically during upload. +2. `CIFileStatus` on the chat item transitions through: + - `SndStored` (queued) + - `SndTransfer(sndProgress, sndTotal)` (uploading) + - `SndComplete` (upload finished, link sent) +3. The UI updates the progress indicator on the file attachment. + +--- + +## 4. Receiving Files + +### 4.1 Auto-Receive + +When `privacyAcceptImages` is enabled (default: `true`), small media files are auto-received: + +1. On receiving a message with a file attachment, the auto-receive logic checks: + - `MCImage` files with `fileSize <= MAX_IMAGE_SIZE_AUTO_RCV` (510 KB) + - `MCVideo` files with `fileSize <= MAX_VIDEO_SIZE_AUTO_RCV` (1023 KB) + - `MCVoice` files with `fileSize <= MAX_VOICE_SIZE_AUTO_RCV` (510 KB) and not already accepted +2. If criteria are met, `receiveFile` is called automatically. + +### 4.2 Manual Receive + +For files that are not auto-received: + +1. The chat item shows a download button with file size info. +2. File size is validated: `fileSizeValid(file)` checks `file.fileSize <= getMaxFileSize(file.fileProtocol)`. +3. User taps the download button. +4. `ChatController.receiveFile(rhId, user, fileId, userApprovedRelays, auto)` is called: + +```kotlin +suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) +``` + +5. This delegates to `receiveFiles` which handles relay approval: + +```kotlin +suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, userApprovedRelays: Boolean = false, auto: Boolean = false) +``` + +6. For each file, `CC.ReceiveFile(fileId, userApprovedRelays, encrypted, inline)` is sent to the core. +7. If the file requires unapproved XFTP relays, the user is prompted to approve them. +8. Relay approval errors (`FileError.Auth` with `SMP AUTH` and `PROXY BROKER`) trigger relay approval alerts. +9. Other errors are collected and shown after all files are processed. + +### 4.3 Batch Receive + +Multiple files can be received at once: + +```kotlin +suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, ...) +``` + +1. Iterates through all `fileIds`. +2. Files needing relay approval are batched and prompted once. +3. After approval, those files are retried with `userApprovedRelays = true`. +4. Errors for individual files are aggregated. + +### 4.4 Download Progress + +1. The core emits `RcvFileProgressXFTP` events during download. +2. `CIFileStatus` transitions through: + - `RcvAccepted` (download initiated) + - `RcvTransfer(rcvProgress, rcvTotal)` (downloading) + - `RcvComplete` (download finished) +3. On completion, if the file is encrypted, it remains encrypted on disk with `cryptoArgs` stored in the database. +4. When the user opens/views the file, `readCryptoFile` or `decryptCryptoFile` is called on demand. + +--- + +## 5. Cancelling a File Transfer + +### 5.1 Cancel via API + +```kotlin +suspend fun cancelFile(rh: Long?, user: User, fileId: Long) +``` + +1. `apiCancelFile(rh, fileId)` sends `CC.CancelFile(fileId)` to the core. +2. The core cancels any in-progress upload or download. +3. On success, the chat item is updated via `chatItemSimpleUpdate`. +4. `cleanupFile(chatItem)` removes any partial local files. + +### 5.2 Cancel via UI + +1. User long-presses a file message and selects "Cancel". +2. `cancelFileAlertDialog(fileId, cancelFile, cancelAction)` shows a confirmation dialog. +3. `CancelAction` provides the appropriate alert text based on direction (sending/receiving). +4. On confirmation, `cancelFile` is called. + +### 5.3 Compose Cancel + +Before sending, user can cancel the file attachment: + +1. User taps the "X" on the file preview in the compose area. +2. `ComposeState.preview` is reset to `ComposePreview.NoPreview`. +3. No API call is needed since the file was not yet sent. + +--- + +## 6. File Cleanup + +1. Files pending deletion are tracked in `ChatModel.filesToDelete`. +2. When a chat item with a file is deleted, the file path is added to `filesToDelete`. +3. The actual file deletion happens asynchronously. +4. Encrypted files require no special cleanup beyond deleting the encrypted file; the key exists only in the database record. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `CryptoFile` | `model/ChatModel.kt` | File reference with path and optional encryption args | +| `CryptoFileArgs` | `model/ChatModel.kt` | AES key + nonce for encrypted files | +| `WriteFileResult` | `model/CryptoFile.kt` | Result of `writeCryptoFile`: success with args or error | +| `CIFile` | `model/ChatModel.kt` | Chat item file metadata: fileId, fileName, fileSize, fileStatus, fileProtocol | +| `CIFileStatus` | `model/ChatModel.kt` | File transfer status: SndStored, SndTransfer, SndComplete, RcvInvitation, RcvAccepted, RcvTransfer, RcvComplete, etc. | +| `FileProtocol` | `model/ChatModel.kt` | Transfer protocol: XFTP, SMP, LOCAL | +| `FileTransferMeta` | `model/ChatModel.kt` | Metadata for standalone XFTP uploads | +| `ComposePreview.FilePreview` | `views/chat/ComposeView.kt` | Compose state for file attachment | diff --git a/apps/multiplatform/product/flows/group-lifecycle.md b/apps/multiplatform/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..60311f7b47 --- /dev/null +++ b/apps/multiplatform/product/flows/group-lifecycle.md @@ -0,0 +1,283 @@ +# Group Lifecycle Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Overview + +Groups in SimpleX Chat are decentralized: there is no central group server. The group owner's device coordinates membership, and messages are delivered via pairwise SMP connections between members. Groups support roles, invitation links, member admission review, blocking, and profile updates. + +## Prerequisites + +- Chat is initialized and running. +- An active user profile exists. +- For creating a group: no special requirements. +- For joining: a group invitation link or a direct invitation from an existing member. + +--- + +## 1. Creating a Group + +### 1.1 Create Group + +1. User navigates to "New Chat" and selects "Create Group". +2. The `AddGroupView` collects a group profile: display name, full name, optional image, and optional description. +3. `ChatController.apiNewGroup(rh, incognito, groupProfile)` is called: + +```kotlin +suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? +``` + +4. `CC.ApiNewGroup(userId, incognito, groupProfile)` is sent to the core. +5. The core creates the group and returns `CR.GroupCreated` with a `GroupInfo` object. +6. The creating user is automatically assigned the `Owner` role. +7. The new group appears in the chat list. +8. If `incognito = true`, a random profile is used for the user within this group. + +### 1.2 Update Group Profile + +```kotlin +suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? +``` + +1. Owner or Admin navigates to group info and edits the profile. +2. `CC.ApiUpdateGroupProfile(groupId, groupProfile)` is sent to the core. +3. On success, `CR.GroupUpdated` returns the updated `GroupInfo` with `toGroup`. +4. The chat model is updated via `chatModel.chatsContext.updateGroup(rh, groupInfo)`. +5. Profile changes are propagated to all connected members. + +--- + +## 2. Adding Members + +### 2.1 Invite a Contact + +1. Owner or Admin opens group info and taps "Add Members". +2. `AddGroupMembersView` displays the user's contacts eligible for invitation. +3. A role is selected for the invitee (default: `Member`). +4. `ChatController.apiAddMember(rh, groupId, contactId, memberRole)` is called: + +```kotlin +suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? +``` + +5. `CC.ApiAddMember(groupId, contactId, memberRole)` is sent to the core. +6. The core sends a group invitation to the contact via their direct SMP connection. +7. `CR.SentGroupInvitation` returns a `GroupMember` in `Invited` status. +8. The member list updates to show the pending invitation. + +### 2.2 Invitee Joins + +1. The invited contact receives a group invitation event. +2. A group invitation chat item appears in their chat list. +3. The invitee taps "Join" to accept. +4. `ChatController.apiJoinGroup(rh, groupId)` is called. +5. `CC.ApiJoinGroup(groupId)` is sent to the core. +6. `CR.UserAcceptedGroupSent` confirms the join request was sent. +7. The owner's/admin's device processes the join and establishes pairwise connections with existing members. +8. `CR.MemberConnected` events fire as connections to each member are established. + +--- + +## 3. Member Roles + +### 3.1 Role Hierarchy + +```kotlin +enum class GroupMemberRole(val memberRole: String) { + Observer("observer"), // Can only read messages + Author("author"), // Can send messages but limited + Member("member"), // Standard member + Moderator("moderator"), // Can moderate content + Admin("admin"), // Can manage members + Owner("owner") // Full control, can delete group +} +``` + +Selectable roles for assignment: `Observer`, `Member`, `Moderator`, `Admin`, `Owner`. + +### 3.2 Change Member Role + +```kotlin +suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List +``` + +1. Owner or Admin navigates to member info in `GroupMemberInfoView`. +2. Selects a new role from the role picker. +3. `CC.ApiMembersRole(groupId, memberIds, memberRole)` is sent to the core. +4. The core responds with `CR.MembersRoleUser` returning updated `GroupMember` objects. +5. The change is propagated to all group members. +6. Supports batch role changes (multiple `memberIds`). + +--- + +## 4. Removing and Blocking Members + +### 4.1 Remove Members + +```kotlin +suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? +``` + +1. Owner or Admin selects a member and taps "Remove". +2. `CC.ApiRemoveMembers(groupId, memberIds, withMessages)` is sent. +3. If `withMessages = true`, the removed member's messages are also deleted from all members. +4. `CR.UserDeletedMembers` returns the updated `GroupInfo` and removed `GroupMember` list. +5. The removed member receives a notification and loses access to the group. + +### 4.2 Block Members for All + +```kotlin +suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List +``` + +1. Owner, Admin, or Moderator selects a member and taps "Block for all". +2. `CC.ApiBlockMembersForAll(groupId, memberIds, blocked)` is sent. +3. `blocked = true` blocks; `blocked = false` unblocks. +4. `CR.MembersBlockedForAllUser` returns the updated member list. +5. Blocked members' messages are hidden from all group members. +6. The blocked member can still see the group but their messages are not delivered. + +--- + +## 5. Group Links + +### 5.1 Create Group Link + +```kotlin +suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? +``` + +1. Owner or Admin navigates to group info and taps "Create Group Link". +2. `CC.APICreateGroupLink(groupId, memberRole)` is sent. +3. A default role for joiners is specified (default: `Member`). +4. `CR.GroupLinkCreated` returns a `GroupLink` containing the link URI. +5. The link is displayed in `GroupLinkView` as a QR code and copyable text. + +### 5.2 Update Group Link Role + +```kotlin +suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? +``` + +1. Owner or Admin changes the default role for new members joining via link. +2. `CC.APIGroupLinkMemberRole(groupId, memberRole)` is sent. +3. `CR.CRGroupLink` returns the updated link with the new default role. + +### 5.3 Get Group Link + +```kotlin +suspend fun apiGetGroupLink(rh: Long?, groupId: Long): GroupLink? +``` + +1. Retrieves the existing group link for display. +2. `CC.APIGetGroupLink(groupId)` is sent. +3. Returns `null` if no link exists. + +### 5.4 Delete Group Link + +```kotlin +suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean +``` + +1. Owner or Admin navigates to group link settings and taps "Delete Link". +2. `CC.APIDeleteGroupLink(groupId)` is sent. +3. `CR.GroupLinkDeleted` confirms deletion. +4. The link becomes invalid; anyone with the old link can no longer join. + +--- + +## 6. Member Admission Workflow + +### 6.1 Admission Configuration + +Group owners can require review of new members before they are fully admitted: + +```kotlin +data class GroupMemberAdmission( + val review: MemberCriteria? = null +) + +enum class MemberCriteria { + All // All joining members require review +} +``` + +1. Owner opens group info and navigates to "Member Admission" (`MemberAdmissionView`). +2. The `review` field is set to `MemberCriteria.All` to require review of all new members. +3. The admission configuration is saved by updating the group profile: + - `groupProfile.copy(memberAdmission = admission)` is passed to `apiUpdateGroup`. +4. Changes are tracked with unsaved-changes detection (save/discard prompt on navigation). + +### 6.2 Accept a Pending Member + +```kotlin +suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair? +``` + +1. When admission review is enabled, new members joining via link arrive in a pending state. +2. Owner or Admin sees pending members in the member support chat / member list. +3. User selects "Accept" and optionally adjusts the role. +4. `CC.ApiAcceptMember(groupId, groupMemberId, memberRole)` is sent. +5. `CR.MemberAccepted` returns the updated `GroupInfo` and accepted `GroupMember`. +6. The member is now fully connected and can participate in the group. + +### 6.3 Reject a Pending Member + +1. Owner or Admin selects "Reject" on a pending member. +2. The member is removed via `apiRemoveMembers`. +3. The rejected member receives a removal notification. + +--- + +## 7. Leaving a Group + +```kotlin +suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? +``` + +1. User navigates to group info and taps "Leave Group". +2. A confirmation dialog is shown. +3. `CC.ApiLeaveGroup(groupId)` is sent to the core. +4. `CR.LeftMemberUser` returns the updated `GroupInfo`. +5. The user's membership status changes and they can no longer send or receive messages. +6. The group remains in the chat list in a "left" state, and can be deleted locally. + +--- + +## 8. Listing Members + +```kotlin +suspend fun apiListMembers(rh: Long?, groupId: Long): List +``` + +1. When opening group info or the member list, `apiListMembers` is called. +2. `CC.ApiListMembers(groupId)` is sent to the core. +3. `CR.GroupMembers` returns the member list. +4. `ChatModel.groupMembers` and `ChatModel.groupMembersIndexes` are updated. +5. `ChatModel.membersLoaded` is set to `true`. + +--- + +## 9. Group Chat Scope (Support Channels) + +Groups support scoped conversations for member support: + +- `GroupChatScope` parameter on message APIs allows sending messages within a specific scope (e.g., member support chat). +- `MemberSupportChatView` and `MemberSupportView` provide UI for admin-to-member private conversations within the group context. +- `GroupReportsView` shows moderation reports scoped to the group. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `GroupInfo` | `model/ChatModel.kt` | Group metadata: groupId, groupProfile, membership, fullGroupPreferences | +| `GroupProfile` | `model/ChatModel.kt` | Group display info: displayName, fullName, description, image, memberAdmission | +| `GroupMember` | `model/ChatModel.kt` | Member info: groupMemberId, memberRole, memberStatus, memberProfile | +| `GroupMemberRole` | `model/ChatModel.kt` | Enum: Observer, Author, Member, Moderator, Admin, Owner | +| `GroupMemberAdmission` | `model/ChatModel.kt` | Admission settings: review criteria | +| `MemberCriteria` | `model/ChatModel.kt` | Enum: All (require review for all) | +| `GroupLink` | `model/SimpleXAPI.kt` | Group link: connLinkContact, acceptMemberRole, userContactLinkId, shortLinkDataSet, shortLinkLargeDataSet, groupLinkId | +| `GroupChatScope` | `model/ChatModel.kt` | Scoped conversation within a group | +| `ConnectionPlan.GroupLink` | `model/SimpleXAPI.kt` | Plan result when connecting via a group link | diff --git a/apps/multiplatform/product/flows/messaging.md b/apps/multiplatform/product/flows/messaging.md new file mode 100644 index 0000000000..771eae1c4e --- /dev/null +++ b/apps/multiplatform/product/flows/messaging.md @@ -0,0 +1,195 @@ +# Messaging Flow + +> **Related spec:** [spec/client/compose.md](../../spec/client/compose.md) | [spec/api.md](../../spec/api.md) + +## Overview + +Messaging is the core interaction in SimpleX Chat. Users compose and send text, images, video, voice notes, files, and link previews. Messages can be replied to, edited, deleted, forwarded, and reacted to with emoji. Special modes include timed (disappearing) messages, live messages (real-time typing), and message reports for moderation. + +All message operations flow through the Haskell core via `ChatController.apiSendMessages`, with responses updating `ChatModel` and triggering Compose UI recomposition. + +## Prerequisites + +- Chat is initialized and running (`ChatModel.chatRunning == true`). +- An active user exists (`ChatModel.currentUser != null`). +- A chat is open (`ChatModel.chatId != null`) with an established connection. + +--- + +## 1. Sending a Text Message + +### 1.1 Compose and Send + +1. User types in the compose field. `ComposeState.message` is updated as a `ComposeMessage(text, selection)`. +2. The compose area tracks context via `ComposeContextItem`: `NoContextItem` for a fresh message, `QuotedItem` for a reply, `EditingItem` for an edit, `ForwardingItems` for forwarding, or `ReportedItem` for a report. +3. User taps the send button. The `ComposeView` builds a `ComposedMessage`: + ```kotlin + class ComposedMessage( + val fileSource: CryptoFile?, + val quotedItemId: Long?, + val msgContent: MsgContent, + val mentions: Map + ) + ``` +4. For plain text, `msgContent` is `MsgContent.MCText(text)`. +5. `ChatController.apiSendMessages(rh, type, id, scope, live, ttl, composedMessages)` is called. +6. The core command `CC.ApiSendMessages` is dispatched via `sendCmd`. +7. On success, the response `CR.NewChatItems` returns a list of `AChatItem`. +8. `ChatModel` is updated and the chat item list recomposes to show the new message. +9. `ComposeState` is reset to its default. + +### 1.2 Link Preview + +1. As the user types, the text is parsed for URLs. +2. If `privacyLinkPreviews` preference is enabled and a URL is detected, a `LinkPreview` is fetched asynchronously. +3. The compose preview is set to `ComposePreview.CLinkPreview(linkPreview)`. +4. When sent, the `msgContent` is `MsgContent.MCLink(text, preview)`. + +--- + +## 2. Sending Media (Image, Video, Voice) + +### 2.1 Image + +1. User picks or captures an image. +2. The image is resized (max inline data size `MAX_IMAGE_SIZE` = 255 KB for the preview thumbnail). +3. The full-size file is saved to the app files directory. +4. If local file encryption is enabled (`privacyEncryptLocalFiles`), the file is encrypted via `encryptCryptoFile`, producing a `CryptoFile` with `CryptoFileArgs(fileKey, fileNonce)`. +5. Compose preview becomes `ComposePreview.MediaPreview(images, content)`. +6. On send, `msgContent` is `MsgContent.MCImage(text, imageBase64)` and `fileSource` is the `CryptoFile`. +7. The core handles inline delivery (for small files) or XFTP upload (for larger files). + +### 2.2 Video + +1. User picks or records a video. +2. A thumbnail image is extracted and resized. +3. The video file is saved and optionally encrypted. +4. On send, `msgContent` is `MsgContent.MCVideo(text, image, duration)`. + +### 2.3 Voice Message + +1. User records a voice note. Recording state is tracked via `RecordingState` (NotStarted, Started, Finished). +2. The compose preview becomes `ComposePreview.VoicePreview(voice, durationMs, finished)`. +3. On send, `msgContent` is `MsgContent.MCVoice(text, durationSeconds)`. +4. A file attachment carries the actual audio data. + +--- + +## 3. Sending Files + +1. User picks a file via the file chooser. +2. File size is validated against `MAX_FILE_SIZE_XFTP` (1 GB). +3. Compose preview becomes `ComposePreview.FilePreview(fileName, uri)`. +4. On send, `msgContent` is `MsgContent.MCFile(text)` and the `fileSource` is populated. +5. Delivery via inline (small files under SMP threshold) or XFTP (large files) is determined by the core. + +--- + +## 4. Receiving Messages + +1. The `ChatController` receiver loop calls `chatRecvMsgWait` on the Haskell core. +2. Incoming messages arrive as `CR.NewChatItems` events. +3. `ChatModel` chat items list is updated, triggering recomposition. +4. For media messages, images below `MAX_IMAGE_SIZE_AUTO_RCV` (510 KB), videos below `MAX_VIDEO_SIZE_AUTO_RCV` (1023 KB), and voice notes below `MAX_VOICE_SIZE_AUTO_RCV` (510 KB) are auto-received if `privacyAcceptImages` is enabled. +5. Larger files require manual download initiation (see File Transfer Flow). + +--- + +## 5. Editing a Message + +1. User long-presses a sent message and selects "Edit". +2. `ComposeContextItem` becomes `EditingItem(chatItem)`. +3. The original text populates the compose field. +4. On send, `ChatController.apiUpdateChatItem(rh, type, id, scope, itemId, updatedMessage, live)` is called. +5. `updatedMessage` is an `UpdatedMessage(msgContent, mentions)`. +6. The core responds with `CR.ChatItemUpdated` or `CR.ChatItemNotChanged`. +7. The chat item in `ChatModel` is updated in place. + +--- + +## 6. Deleting a Message + +1. User long-presses a message and selects "Delete". +2. A delete mode is chosen: `CIDeleteMode.cidmBroadcast` (delete for everyone), `CIDeleteMode.cidmInternal` (delete for self), or `CIDeleteMode.cidmInternalMark` (mark as deleted internally). +3. `ChatController.apiDeleteChatItems(rh, type, id, scope, itemIds, mode)` is called. +4. The core responds with `CR.ChatItemsDeleted`, returning a list of `ChatItemDeletion`. +5. For group chats by moderators, `apiDeleteMemberChatItems(rh, groupId, itemIds)` is used. +6. Deleted items are either removed from the UI or replaced with a "deleted" marker. + +--- + +## 7. Reacting to a Message + +1. User long-presses a message and selects an emoji reaction. +2. `ChatController.apiChatItemReaction(rh, type, id, scope, itemId, add, reaction)` is called. +3. `reaction` is a `MsgReaction` (typically emoji). +4. `add = true` to add, `add = false` to remove a reaction. +5. The core responds with `CR.ChatItemReaction`, and the chat item's reaction list is updated. +6. In groups, `apiGetReactionMembers` can be called to see who reacted. + +--- + +## 8. Replying to a Message + +1. User swipes or long-presses a message and selects "Reply". +2. `ComposeContextItem` becomes `QuotedItem(chatItem)`. +3. The quoted item preview is shown above the compose field. +4. On send, the `ComposedMessage.quotedItemId` is set to the quoted item's ID. +5. The sent message renders with the quoted content inline. + +--- + +## 9. Forwarding Messages + +1. User selects one or more messages and taps "Forward". +2. `ChatController.apiPlanForwardChatItems(rh, fromChatType, fromChatId, fromScope, chatItemIds)` is called first to get a `CR.ForwardPlan` with forwardable/non-forwardable item categorization. +3. `ComposeContextItem` becomes `ForwardingItems(chatItems, fromChatInfo)`. +4. User picks a destination chat. +5. `ChatController.apiForwardChatItems(rh, toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl)` is called. +6. New chat items are created in the destination chat. + +--- + +## 10. Timed (Disappearing) Messages + +1. Timed messages are enabled per-chat via chat feature preferences. +2. When composing, a TTL (time-to-live) in seconds is passed as the `ttl` parameter to `apiSendMessages`. +3. The core attaches the TTL to the message metadata. +4. After the TTL expires, the message is automatically deleted on both sides. +5. The UI shows a countdown indicator on timed messages via `CIMetaView`. + +--- + +## 11. Live Messages + +1. User enables live message mode (long-press on send button if `liveMessageAlertShown` preference allows). +2. `ComposeState.liveMessage` is set to a `LiveMessage(chatItem, typedMsg, sentMsg, sent)`. +3. As the user types, `apiSendMessages` is called with `live = true` for the initial send, then `apiUpdateChatItem` with `live = true` for subsequent updates. +4. The recipient sees the message content updating in real-time. +5. When the user finalizes (taps send), a final `apiUpdateChatItem` with `live = false` is sent. + +--- + +## 12. Message Reports + +1. User long-presses a message and selects "Report". +2. `ComposeContextItem` becomes `ReportedItem(chatItem, reason)` where `reason` is a `ReportReason`. +3. On send, `msgContent` is `MsgContent.MCReport(text, reason)`. +4. The report is sent to group owners/admins for moderation review. +5. Group admins see reports in the `GroupReportsView`. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `ComposeState` | `views/chat/ComposeView.kt` | Tracks compose field state | +| `ComposePreview` | `views/chat/ComposeView.kt` | Preview type: NoPreview, CLinkPreview, MediaPreview, VoicePreview, FilePreview | +| `ComposeContextItem` | `views/chat/ComposeView.kt` | Context: NoContextItem, QuotedItem, EditingItem, ForwardingItems, ReportedItem | +| `ComposedMessage` | `model/SimpleXAPI.kt` | Wire format for sending: fileSource, quotedItemId, msgContent, mentions | +| `UpdatedMessage` | `model/SimpleXAPI.kt` | Wire format for editing: msgContent, mentions | +| `MsgContent` | `model/ChatModel.kt` | Sealed class: MCText, MCLink, MCImage, MCVideo, MCVoice, MCFile, MCReport, MCChat, MCUnknown | +| `LiveMessage` | `views/chat/ComposeView.kt` | Tracks live message state | +| `MsgReaction` | `model/ChatModel.kt` | Emoji reaction type | +| `ChatItemDeletion` | `model/ChatModel.kt` | Deletion result with old/new item | diff --git a/apps/multiplatform/product/flows/onboarding.md b/apps/multiplatform/product/flows/onboarding.md new file mode 100644 index 0000000000..b6b3e835a5 --- /dev/null +++ b/apps/multiplatform/product/flows/onboarding.md @@ -0,0 +1,205 @@ +# Onboarding Flow + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Overview + +Onboarding is the first-run experience that initializes the Haskell chat core, creates the local database, sets up the user profile, configures server operators, and (on Android) selects the notification mode. The flow is tracked by the `OnboardingStage` enum persisted in `AppPreferences.onboardingStage`. + +The initialization path differs slightly between Android and Desktop, but both converge on the common `chatMigrateInit` JNI call and shared `ChatController` logic. + +## Prerequisites + +- Fresh install or database reset. +- On Android: `SimplexApp.onCreate()` has been called. +- On Desktop: `main()` has been called. + +--- + +## 1. Platform Initialization + +### 1.1 Android: SimplexApp.onCreate() + +1. `SimplexApp.onCreate()` is called by the Android framework. +2. `AppContextProvider.initialize(this)` sets the application context. +3. Phoenix process detection: if this is a restart process, return early. +4. A global error handler is registered. +5. `initHaskell(packageName)` loads the native `libapp-lib.so` and calls `initHS()` to initialize the Haskell runtime. +6. `initMultiplatform()` sets up: + - `androidAppContext` reference. + - `ntfManager` (notification manager bridge to Android `NtfManager`). + - `platform` interface implementation with Android-specific callbacks for services, notifications, call management, and UI configuration. +7. `reconfigureBroadcastReceivers()` ensures notification-related receivers match saved preferences. +8. `runMigrations()` performs any pending app-level data migrations. +9. Temp directory is cleaned and recreated. +10. If a migration state exists (`chatModel.migrationState.value != null`), onboarding is forced to `Step1_SimpleXInfo`. +11. Otherwise, if authentication keys are available, `initChatControllerOnStart()` is called. + +### 1.2 Desktop: Main.kt main() + +1. `initHaskell()` loads native libraries: + - On Linux/macOS: `libapp-lib.so` / `libapp-lib.dylib`. + - On Windows: `libcrypto-3-x64.dll`, `libsimplex.dll`, `libapp-lib.dll` plus VLC libraries. +2. `initHS()` initializes the Haskell runtime. +3. `platform` interface is set with Desktop-specific callbacks (app update notice). +4. `runMigrations()` performs pending app-level data migrations. +5. `setupUpdateChecker()` configures the desktop update channel. +6. `initApp()` initializes common app state. +7. Temp directory is cleaned and recreated. +8. `showApp()` launches the Compose Desktop window, which renders the `AppView`. + +--- + +## 2. Database Initialization (chatMigrateInit) + +### 2.1 initChatController + +1. `initChatController(useKey, confirmMigrations, startChat)` is called (from `Core.kt`). +2. If `ctrlInitInProgress` is already true, return (prevents double initialization). +3. The database key is resolved: + - From `useKey` parameter if provided. + - Otherwise from `DatabaseUtils.useDatabaseKey()` which reads from the keystore. +4. Migration confirmation mode is determined: + - `MigrationConfirmation.YesUp` (auto-confirm forward migrations) by default. + - `MigrationConfirmation.Error` if developer tools + confirm upgrades are enabled. +5. `chatMigrateInit(dbPath, dbKey, confirm)` is called via JNI. This: + - Opens (or creates) the SQLite database at `dbAbsolutePrefixPath`. + - Runs all pending schema migrations. + - Returns a `ChatCtrl` handle (Long) and a `DBMigrationResult`. +6. On `DBMigrationResult.OK`: + - The `ChatCtrl` is stored globally. + - `ChatModel.chatDbStatus` is set. + - App file paths are configured via `apiSetAppFilePaths`. + - `apiGetActiveUser` checks for an existing user. +7. If an active user exists, `startChat(user)` is called. +8. If no user exists, `startChatWithoutUser()` is called and onboarding begins at `Step1_SimpleXInfo`. + +### 2.2 Error Handling + +- `DBMigrationResult.ErrorNotADatabase`: Wrong passphrase or corrupted DB. User is prompted. +- `DBMigrationResult.ErrorMigration`: Migration failed. Details shown to user. +- `DBMigrationResult.ErrorKeyNotSet`: Encryption key missing. +- `DBMigrationResult.InvalidConfirmation`: Migrations need manual confirmation (developer mode). +- On any error, `ChatModel.chatDbStatus` is set and the UI shows the appropriate database error screen. + +--- + +## 3. Onboarding Stages + +The onboarding flow is controlled by `OnboardingStage`, persisted in `AppPreferences.onboardingStage`: + +```kotlin +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + LinkAMobile, + Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, + Step3_CreateSimpleXAddress, + Step4_SetNotificationsMode, + OnboardingComplete +} +``` + +### 3.1 Step1_SimpleXInfo + +1. The `SimpleXInfo` screen is shown. +2. Explains what SimpleX Chat is: privacy, no user identifiers, decentralized. +3. User taps "Create your profile" to proceed. +4. On Desktop, a "Link a Mobile" option is also available. + +### 3.2 Step2_CreateProfile + +1. The `CreateProfile` screen is shown. +2. User enters a display name (validated via `chatValidName` JNI) and optional full name. +3. On submit, `ChatController.apiCreateActiveUser(rh, profile)` is called: + ```kotlin + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? + ``` +4. The core command `CC.CreateActiveUser(p, pastTimestamp)` creates the user in the database. +5. On success, `CR.ActiveUser` returns the new `User` object. +6. `ChatModel.currentUser` is set. +7. If the chat is not yet running, `startChat(user)` is called: + - `apiSetNetworkConfig` configures network settings. + - `apiStartChat` starts the message receiver. + - `startReceiver()` begins polling for incoming messages. +8. Onboarding advances to `Step3_ChooseServerOperators`. + +### 3.3 LinkAMobile (Desktop Only) + +1. Available as an alternative to creating a profile on Desktop. +2. Shows a QR code for linking with a mobile device. +3. The desktop acts as a remote host controlled by the mobile app. + +### 3.4 Step2_5_SetupDatabasePassphrase (Desktop Only) + +1. On Desktop, after profile creation, the user is optionally prompted to set a database passphrase. +2. If skipped, a random passphrase is used (`desktopOnboardingRandomPassword` flag). +3. `ChatModel.desktopOnboardingRandomPassword` tracks this state. + +### 3.5 Step3_ChooseServerOperators + +1. The `ChooseServerOperators` screen is shown. +2. User selects which preset server operators to use for messaging and file transfer. +3. Server operator conditions may need to be accepted. +4. The selection is saved via the server configuration APIs. + +### 3.6 Step3_CreateSimpleXAddress + +1. User is prompted to create a SimpleX address for receiving contact requests. +2. This calls the address creation API. +3. Can be skipped. + +### 3.7 Step4_SetNotificationsMode (Android Only) + +1. The `SetNotificationsMode` screen is shown. +2. Three modes are available: + - `NotificationsMode.SERVICE`: Persistent background service (instant notifications). + - `NotificationsMode.PERIODIC`: Periodic background work (delayed notifications). + - `NotificationsMode.OFF`: No background processing (manual check only). +3. On selection, `appPrefs.notificationsMode` is set. +4. On Desktop, this step is skipped entirely. + +### 3.8 OnboardingComplete + +1. `appPrefs.onboardingStage` is set to `OnboardingComplete`. +2. The chat list view (`ChatListView`) is shown. +3. On Android, `SimplexService.showBackgroundServiceNoticeIfNeeded()` may show additional setup prompts. +4. On Android with `NotificationsMode.SERVICE`, `SimplexService.start()` is called. + +--- + +## 4. startChat Flow + +After the user is created and onboarding progresses, `ChatController.startChat(user)` orchestrates the final setup: + +1. `apiSetNetworkConfig(getNetCfg())` applies network configuration. +2. `apiCheckChatRunning()` checks if the core is already running. +3. `listUsers(null)` loads all user profiles into `ChatModel.users`. +4. If chat is not running: + - `ChatModel.currentUser` is set. + - `apiStartChat()` starts the core's message processing. + - `startReceiver()` begins the message receive loop. + - `setLocalDeviceName` sets the device name for remote access. +5. `apiGetChats` loads the chat list. +6. `chatModel.chatsContext.updateChats(chats)` populates the UI. +7. User address and chat item TTL are loaded. +8. `appPrefs.chatLastStart` is updated. +9. `ChatModel.chatRunning` is set to `true`. +10. `platform.androidChatInitializedAndStarted()` is called for Android-specific post-start tasks. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `OnboardingStage` | `views/onboarding/OnboardingView.kt` | Enum tracking onboarding progress | +| `SimplexApp` | `android/.../SimplexApp.kt` | Android Application class, entry point | +| `Main.kt` | `desktop/.../Main.kt` | Desktop entry point | +| `ChatController` | `model/SimpleXAPI.kt` | Core API controller, manages chat lifecycle | +| `ChatModel` | `model/ChatModel.kt` | Global observable state | +| `DBMigrationResult` | `views/helpers/DatabaseUtils.kt` | Database migration outcome | +| `chatMigrateInit` | `platform/Core.kt` | JNI function: initialize DB and run migrations | +| `initChatController` | `platform/Core.kt` | High-level initialization orchestrator | +| `AppPreferences` | `model/SimpleXAPI.kt` | Persistent user preferences | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md new file mode 100644 index 0000000000..25535d8003 --- /dev/null +++ b/apps/multiplatform/product/gaps.md @@ -0,0 +1,290 @@ +# Known Gaps & Recommendations -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This document catalogs known gaps in the multiplatform codebase (Android and Desktop) with severity, impact, and recommendations. + +--- + +## Table of Contents + +1. [UI: Error Feedback](#gap-01-ui-error-feedback) +2. [UI: Loading States](#gap-02-ui-loading-states) +3. [Security: Database Passphrase Not Enforced](#gap-03-security-database-passphrase-not-enforced) +4. [Security: No Forward Secrecy Indicator](#gap-04-security-no-forward-secrecy-indicator) +5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) +6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) +7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) + +--- + +## GAP-01: UI Error Feedback + +**Severity:** Medium +**Category:** UI / UX +**Platforms:** Android, Desktop + +### Description + +Many API calls through `ChatController.sendCmd()` return `API.Error` responses that are logged but not surfaced to the user. The general pattern is: + +```kotlin +val r = sendCmd(rh, cmd) +if (r is API.Result && r.res is CR.ExpectedResponse) return r.res.value +Log.e(TAG, "someFunction bad response: ${r.responseType} ${r.details}") +return null +``` + +When the call fails, the caller receives `null` and either silently does nothing or shows a generic error. The specific `ChatError` details (which may contain actionable information like quota exceeded, server unreachable, or store errors) are lost to the user. + +### Affected Locations + +- `SimpleXAPI.kt` -- `getAgentSubsTotal()`, `getAgentServersSummary()`, and dozens of similar `api*` functions +- Throughout the codebase wherever `sendCmd` results are pattern-matched + +### Impact + +Users experience silent failures with no indication of what went wrong. This is particularly problematic for: +- Connection attempts that fail due to network issues +- File transfer failures +- Group operations that fail due to role permissions +- Server configuration errors + +### Recommendation + +1. Introduce a structured error-handling utility that maps `ChatError` subtypes to user-visible messages, similar to how `retryableNetworkErrorAlert` already handles a subset of `AgentErrorType.BROKER` errors. +2. At minimum, surface a dismissible snackbar/toast with a summary when an API call fails unexpectedly. +3. For critical operations (send message, join group, create connection), show a dialog with retry/cancel options (the `sendCmdWithRetry` pattern already exists for some cases -- extend it). + +--- + +## GAP-02: UI Loading States + +**Severity:** Low-Medium +**Category:** UI / UX +**Platforms:** Android, Desktop + +### Description + +Several long-running operations lack loading indicators, leaving the user uncertain whether the action is in progress. The `ComposeState.inProgress` flag and `progressByTimeout` mechanism exist for the compose area, and `ConnectProgressManager` handles connection progress, but many other flows have no visual feedback. + +### Affected Locations + +- Group member list loading (`ChatModel.membersLoaded` exists but is not always checked before displaying stale data) +- Server configuration validation (`ApiValidateServers` can take several seconds with no indicator) +- Database export/import (`ApiExportArchive`, `ApiImportArchive`) +- Profile switching (`changeActiveUser_` acquires `changingActiveUserMutex` but the UI may appear frozen) + +### Impact + +Users may tap actions multiple times, causing duplicate requests, or assume the app is frozen and force-quit during a long operation like database export. + +### Recommendation + +1. Introduce a centralized `ProgressOverlay` composable that can be shown/hidden via a `ChatModel` flag. +2. Wrap all operations that acquire `changingActiveUserMutex` or take > 1 second with a visible loading state. +3. Use `ChatModel.switchingUsersAndHosts` (which already exists) more consistently as a gate for showing a blocking progress indicator. + +--- + +## GAP-03: Security: Database Passphrase Not Enforced + +**Severity:** High +**Category:** Security +**Platforms:** Android, Desktop + +### Description + +When the app is first installed, a random database passphrase is generated and stored in encrypted preferences. The user is never required to set a custom passphrase. The `initialRandomDBPassphrase` flag tracks this state, and a setup prompt exists in onboarding (`SetupDatabasePassphrase`), but the user can skip it. + +On Android, the encrypted passphrase is stored via the Android Keystore, which provides hardware-backed security. On Desktop, the `Cryptor` is a **placeholder** (see GAP-07), meaning the passphrase is stored in plaintext. + +### Affected Locations + +- `SimpleXAPI.kt` -- `AppPreferences.storeDBPassphrase`, `AppPreferences.initialRandomDBPassphrase`, `AppPreferences.encryptedDBPassphrase` +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt` + +### Impact + +- Users who skip passphrase setup rely entirely on device security. If the device is compromised, the database can be decrypted using the stored passphrase. +- On Desktop, the passphrase is effectively stored in plaintext (see GAP-07), meaning anyone with filesystem access can read the database. + +### Recommendation + +1. Consider making passphrase setup mandatory during onboarding (or at least prominently warn users who skip it). +2. On Desktop, implement proper key storage (GAP-07) before any passphrase enforcement is meaningful. +3. Add a periodic reminder for users who still have `initialRandomDBPassphrase == true`. + +--- + +## GAP-04: Security: No Forward Secrecy Indicator + +**Severity:** Medium +**Category:** Security / UI +**Platforms:** Android, Desktop + +### Description + +The double-ratchet algorithm provides forward secrecy per message, and PQ key exchange provides resistance to quantum attacks. The `Connection` type tracks `pqSupport`, `pqEncryption`, `pqSndEnabled`, and `pqRcvEnabled`. However, the UI does not prominently display the current forward secrecy state or PQ encryption status for a given conversation. + +### Affected Locations + +- `ChatModel.kt` -- `Connection.pqSupport`, `Connection.pqEncryption`, `Connection.pqSndEnabled`, `Connection.pqRcvEnabled` +- Contact info views, group member info views + +### Impact + +Users cannot easily verify whether their conversations are using PQ-enhanced encryption. Security-conscious users have no visual indicator of the ratchet state or whether PQ key exchange was successful. + +### Recommendation + +1. Add a security badge/icon in the chat header or contact info screen showing: + - Whether PQ key exchange is active (both peers support it) + - Whether the connection has been verified (security code comparison) + - The ratchet state (in-sync vs. needs re-sync) +2. The `connectionCode` field on `Connection` can be used to show verification status. +3. The `Call.encryptionStatus` pattern (used in call views) could be adapted for the chat view. + +--- + +## GAP-05: Documentation: Haskell Store Layer Not Fully Specified + +**Severity:** Medium +**Category:** Documentation / Architecture +**Platforms:** Android, Desktop + +### Description + +The Kotlin client communicates with the Haskell core via a text-based command protocol (`CC.cmdString` -> FFI -> Haskell). The Haskell store layer (SQLite operations, migration logic, and the exact semantics of `StoreError` variants) is not documented from the Kotlin side. The `ChatErrorStore` error type wraps a `StoreError` whose variants are defined in Haskell and deserialized by the Kotlin client, but the conditions under which each error occurs are not specified. + +### Affected Locations + +- `SimpleXAPI.kt:6986` -- `ChatErrorStore(storeError: StoreError)` +- `SimpleXAPI.kt` -- `StoreError` sealed class (deserialized from Haskell responses) +- `SimpleXAPI.kt` -- `ChatErrorDatabase(databaseError: DatabaseError)` for migration errors + +### Impact + +- Developers cannot predict which `StoreError` will occur for a given operation without reading the Haskell source. +- Error handling in the Kotlin layer is necessarily generic since the error semantics are not specified. +- Migration failures (`ChatErrorDatabase`) are particularly opaque. + +### Recommendation + +1. Create a specification document mapping each `CC` command to its possible `StoreError` / `DatabaseError` responses. +2. Document the database migration versioning scheme and the conditions under which `confirmDBUpgrades` is triggered. +3. Add inline documentation to the `StoreError` sealed class variants explaining their trigger conditions. + +--- + +## GAP-06: Desktop: Recording Not Implemented + +**Severity:** High +**Category:** Feature / Platform +**Platform:** Desktop only + +### Description + +The `RecorderNative` class on Desktop is a placeholder. Both `start()` and `stop()` are stubbed with `/*LALAL*/` comments and return dummy values (empty string and 0, respectively). Users cannot record voice messages on Desktop. + +```kotlin +// common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +actual class RecorderNative: RecorderInterface { + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { + /*LALAL*/ + return "" + } + + override fun stop(): Int { + /*LALAL*/ + return 0 + } +} +``` + +Audio playback IS implemented on Desktop (via VLC/`vlcj` library), so received voice messages can be played. Only recording is missing. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt:15-25` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt` -- `RecorderInterface` + +### Impact + +Desktop users cannot send voice messages. The record button either does nothing or produces a zero-length file. + +### Recommendation + +1. Implement `RecorderNative` using a JVM audio capture library (e.g., `javax.sound.sampled`, or integrate with the existing `vlcj` dependency for capture). +2. The output format should match the mobile app's voice message format (likely Opus in an OGG container) for cross-platform compatibility. +3. Until implemented, the record button should be hidden or disabled on Desktop with a tooltip explaining the limitation. + +### Additional Desktop LALAL Placeholders + +Several other Desktop features are also marked with `LALAL` placeholders: +- **QR Code Scanner** (`QRCodeScanner.desktop.kt:12`) -- scanning QR codes is not implemented on Desktop +- **Animated Drawables** (`Utils.desktop.kt:179`) -- animated image support (e.g., GIF in-line rendering) is not implemented +- **Animated Chat Images** (`CIImageView.desktop.kt:19`) -- animated image rendering in chat items +- **isImage detection** (`Images.desktop.kt:168`) -- image type detection (implemented but marked as incomplete) + +--- + +## GAP-07: Desktop: Cryptor Not Implemented + +**Severity:** Critical +**Category:** Security / Platform +**Platform:** Desktop only + +### Description + +The `CryptorInterface` implementation on Desktop is a non-functional placeholder. All three methods are stubbed: + +```kotlin +// common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? { + return String(data) // LALAL + } + + override fun encryptText(text: String, alias: String): Pair { + return text.toByteArray() to text.toByteArray() // LALAL + } + + override fun deleteKey(alias: String) { + // LALAL + } +} +``` + +- `decryptData` returns the data as-is (no decryption) +- `encryptText` returns the plaintext as both "encrypted data" and "IV" +- `deleteKey` is a no-op + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` -- `CryptorInterface` +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` -- uses `cryptor` for passphrase encryption + +### Impact + +**This is a critical security gap.** On Desktop: +- The database passphrase is stored **in plaintext** in the preferences file. Anyone with read access to the user's home directory can extract the passphrase and decrypt the database. +- The self-destruct passphrase is similarly stored in plaintext. +- The app passphrase (for local authentication) provides no real protection. +- Key deletion is a no-op, so "deleting" a key has no effect. + +This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self-Destruct Profile) on the Desktop platform. + +### Recommendation + +1. **Priority: Critical.** Implement proper key storage on Desktop using one of: + - **OS Keychain integration:** macOS Keychain, Windows Credential Manager, Linux Secret Service (via `libsecret`/GNOME Keyring/KWallet) + - **Java Cryptography Architecture (JCA)** with a PKCS#12 keystore file protected by a master password + - **Bouncy Castle** library for platform-independent key management +2. Until a real implementation exists, display a prominent warning to Desktop users that their database passphrase is not securely stored. +3. Consider requiring the user to enter their passphrase on each app launch (do not store it) as an interim measure. + +### Related + +- GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop. +- The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed. diff --git a/apps/multiplatform/product/glossary.md b/apps/multiplatform/product/glossary.md new file mode 100644 index 0000000000..10203d8a2a --- /dev/null +++ b/apps/multiplatform/product/glossary.md @@ -0,0 +1,561 @@ +# Domain Term Glossary -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This glossary is self-contained and covers the Android and Desktop (Kotlin/Compose Multiplatform) codebase only. + +--- + +## Table of Contents + +1. [Protocols & Cryptography](#1-protocols--cryptography) +2. [Core Data Types](#2-core-data-types) +3. [Commands & Events](#3-commands--events) +4. [Connection & Identity](#4-connection--identity) +5. [Messaging Features](#5-messaging-features) +6. [Calling & Media](#6-calling--media) +7. [Notifications & Background](#7-notifications--background) +8. [Application Architecture](#8-application-architecture) +9. [Configuration & Preferences](#9-configuration--preferences) + +--- + +## 1. Protocols & Cryptography + +### SMP (SimpleX Messaging Protocol) +The core message-relay protocol. Clients send and receive messages through SMP relay servers without exposing sender/receiver identity correlation. The protocol uses unidirectional queues -- each contact pair maintains separate send and receive queues on potentially different servers. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `SMPErrorType`, `SMPProxyMode`, `SMPProxyFallback`, `SMPWebPortServers` + +### XFTP (SimpleX File Transfer Protocol) +Protocol for transferring files through relay servers. Files are chunked, encrypted, and uploaded to XFTP relays. Recipients download chunks and reassemble locally. Supports inline transfer for small files. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `CC.ApiUploadStandaloneFile`, `CC.ApiDownloadStandaloneFile`, `CC.ApiStandaloneFileInfo` + +### E2E Encryption (End-to-End Encryption) +All messages are encrypted end-to-end. The app never transmits plaintext to relay servers. Encryption keys are negotiated during connection establishment using X3DH-like key agreement and then maintained via the double-ratchet algorithm. + +### Double Ratchet +The core key-management algorithm. After initial key agreement, each message derives a new symmetric key, providing forward secrecy per message. Ratchet state can be re-synchronized via `APISyncContactRatchet` / `APISyncGroupMemberRatchet` commands. + +*See:* `SimpleXAPI.kt` -- `CC.APISyncContactRatchet(contactId, force)`, `CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)`, `CR.ContactRatchetSync`, `CR.GroupMemberRatchetSync` + +### PQ (Post-Quantum) +Post-quantum key exchange support. Connections track PQ state via `Connection.pqSupport`, `Connection.pqEncryption`, `Connection.pqSndEnabled`, and `Connection.pqRcvEnabled` fields. When both peers support PQ, the key exchange incorporates a post-quantum KEM to resist future quantum attacks. + +*See:* `ChatModel.kt` -- `Connection.pqSupport`, `Connection.pqEncryption`; `SimpleXAPI.kt` -- `SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED` (legacy, no longer used) + +### SMP Proxy / Private Routing +Messages can be sent through an intermediate SMP proxy relay to hide the sender's IP from the destination relay. Controlled by `SMPProxyMode` (Always, Unknown, Unprotected, Never) and `SMPProxyFallback` (Allow, AllowProtected, Prohibit). + +*See:* `SimpleXAPI.kt` -- `AppPreferences.networkSMPProxyMode`, `AppPreferences.networkSMPProxyFallback` + +### Transport Session Mode +Controls how TCP sessions to SMP relays are multiplexed. Options: `User` (one session per user profile), `Session` (single shared session), `Server` (one per server), `Entity` (one per queue/entity -- maximum metadata protection). + +*See:* `SimpleXAPI.kt` -- `AppPreferences.networkSessionMode`, `TransportSessionMode` + +--- + +## 2. Core Data Types + +### ChatItem +A single item in a conversation -- a sent or received message, call event, group event, connection event, feature change, or moderation action. Contains direction (`CIDirection`), metadata (`CIMeta`), content (`CIContent`), optional formatted text, mentions, quoted item, reactions, and file attachment. + +*See:* `ChatModel.kt:2720` -- `data class ChatItem` + +### ChatInfo +The top-level discriminated union representing a conversation. Variants: +- `ChatInfo.Direct` -- wraps a `Contact` +- `ChatInfo.Group` -- wraps a `GroupInfo` +- `ChatInfo.Local` -- wraps a `NoteFolder` (saved messages / notes to self) +- `ChatInfo.ContactRequest` -- wraps a `UserContactRequest` +- `ChatInfo.ContactConnection` -- wraps a `PendingContactConnection` +- `ChatInfo.InvalidJSON` -- fallback for unrecognized data + +*See:* `ChatModel.kt:1391` -- `sealed class ChatInfo` + +### CIContent (Chat Item Content) +The content payload of a `ChatItem`. Over 30 variants including: +- `SndMsgContent` / `RcvMsgContent` -- regular message with `MsgContent` +- `SndCall` / `RcvCall` -- call event with status and duration +- `RcvIntegrityError` -- message integrity violation +- `RcvDecryptionError` -- decryption failure with error type and count +- `RcvGroupInvitation` / `SndGroupInvitation` -- group invite +- `RcvGroupEventContent` / `SndGroupEventContent` -- group lifecycle events +- `RcvChatFeature` / `SndChatFeature` -- per-chat feature toggle notifications +- `SndModerated` / `RcvModerated` / `RcvBlocked` -- moderation events +- `RcvDirectEventContent` -- direct chat lifecycle events + +*See:* `ChatModel.kt:3554` -- `sealed class CIContent` + +### MsgContent +The wire-format message body. Variants: `MCText`, `MCLink`, `MCImage`, `MCVideo`, `MCVoice`, `MCFile`, `MCReport`, `MCUnknown`. Each carries text plus optional media/file metadata. + +*See:* `ChatModel.kt` -- `sealed class MsgContent` + +### User +The local user profile. Fields: `userId`, `userContactId`, `localDisplayName`, `profile` (LocalProfile), `fullPreferences` (FullChatPreferences), `activeUser`, `activeOrder`, `showNtfs`, `sendRcptsContacts`, `sendRcptsSmallGroups`, `viewPwdHash` (for hidden profiles), `uiThemes`, `remoteHostId` (Long?), `autoAcceptMemberContacts` (Boolean). + +*See:* `ChatModel.kt:1208` -- `data class User` + +### Contact +A remote contact. Fields: `contactId`, `localDisplayName`, `profile` (LocalProfile), `activeConn` (Connection?), `viaGroup`, `contactUsed`, `contactStatus`, `chatSettings`, `userPreferences`, `mergedPreferences`, `preparedContact`, `contactRequestId`, `contactGroupMemberId`, `chatTags`, `chatItemTTL`. + +*See:* `ChatModel.kt:1711` -- `data class Contact` + +### GroupInfo +Metadata for a group conversation. Fields: `groupId`, `localDisplayName`, `groupProfile` (GroupProfile), `businessChat` (BusinessChatInfo?), `fullGroupPreferences`, `membership` (GroupMember -- the local user's membership), `chatSettings`, `preparedGroup`, `membersRequireAttention`, `chatTags`, `chatItemTTL`. + +*See:* `ChatModel.kt:2004` -- `data class GroupInfo` + +### GroupMember +A member of a group. Fields: `groupMemberId`, `groupId`, `memberId`, `memberRole` (GroupMemberRole), `memberCategory` (GroupMemberCategory), `memberStatus` (GroupMemberStatus), `memberSettings` (GroupMemberSettings), `blockedByAdmin`, `invitedBy`, `localDisplayName`, `memberProfile`, `memberContactId`, `memberContactProfileId`, `activeConn` (Connection?), `supportChat` (GroupSupportChat?). + +*See:* `ChatModel.kt:2177` -- `data class GroupMember` + +### GroupMemberRole +Enumeration of group roles, ordered for comparison: `Observer` < `Author` < `Member` < `Moderator` < `Admin` < `Owner`. Selectable roles for assignment: Observer, Member, Moderator, Admin, Owner. + +*See:* `ChatModel.kt:2369` -- `enum class GroupMemberRole` + +### Connection +An active or pending cryptographic connection to a peer. Fields: `connId`, `agentConnId`, `peerChatVRange` (VersionRange), `connStatus` (ConnStatus), `connLevel`, `viaGroupLink`, `customUserProfileId`, `connectionCode` (SecurityCode?), `pqSupport`, `pqEncryption`, `pqSndEnabled`, `pqRcvEnabled`, `connectionStats`, `authErrCounter`, `quotaErrCounter`. + +*See:* `ChatModel.kt:1882` -- `data class Connection` + +### Chat +A composite type holding `chatInfo` (ChatInfo), `chatItems` (list of ChatItem), and `chatStats` (ChatStats -- unread count, min unread item ID, etc.). Represents a full conversation for the chat list. + +*See:* `ChatModel.kt` -- `data class Chat` + +### PendingContactConnection +Represents an in-progress connection that has not yet been established. Contains the connection link and state but no contact profile yet. + +*See:* `ChatModel.kt` -- referenced in `ChatInfo.ContactConnection` + +### CryptoFile +A file reference that optionally carries `CryptoFileArgs` (key + nonce) for local encryption. `CryptoFile.plain(path)` creates an unencrypted reference. + +*See:* `ChatModel.kt` -- `data class CryptoFile` + +--- + +## 3. Commands & Events + +The codebase uses short type names for the command/event protocol: `CC` (Chat Command), `CR` (Chat Response -- also carries asynchronous events), `API` (top-level response wrapper), and `ChatError` (error hierarchy). There is no separate "ChatEvent" class; asynchronous events from the core (new messages, connection changes, call signaling) are all `CR` subclasses received via the `recvMsg` loop. + +### CC (Chat Command) +The sealed class representing all commands the app can send to the Haskell core library. Over 140 command variants organized by domain: + +**User management:** `ShowActiveUser`, `CreateActiveUser`, `ListUsers`, `ApiSetActiveUser`, `ApiHideUser`, `ApiUnhideUser`, `ApiMuteUser`, `ApiUnmuteUser`, `ApiDeleteUser` + +**Chat lifecycle:** `StartChat`, `CheckChatRunning`, `ApiStopChat`, `ApiSetAppFilePaths`, `ApiSetEncryptLocalFiles` + +**Database:** `ApiExportArchive`, `ApiImportArchive`, `ApiDeleteStorage`, `ApiStorageEncryption`, `TestStorageEncryption` + +**Messaging:** `ApiSendMessages`, `ApiUpdateChatItem`, `ApiDeleteChatItem`, `ApiDeleteMemberChatItem`, `ApiChatItemReaction`, `ApiForwardChatItems`, `ApiPlanForwardChatItems`, `ApiReportMessage` + +**Groups:** `ApiNewGroup`, `ApiAddMember`, `ApiJoinGroup`, `ApiAcceptMember`, `ApiMembersRole`, `ApiBlockMembersForAll`, `ApiRemoveMembers`, `ApiLeaveGroup`, `ApiListMembers`, `ApiUpdateGroupProfile`, `APICreateGroupLink`, `APIDeleteGroupLink`, `APIGetGroupLink`, `ApiAddGroupShortLink` + +**Connections:** `APIAddContact`, `APIConnect`, `APIConnectPlan`, `APIPrepareContact`, `APIPrepareGroup`, `APIConnectPreparedContact`, `APIConnectPreparedGroup`, `ApiConnectContactViaAddress` + +**Contacts:** `ApiDeleteChat`, `ApiClearChat`, `ApiListContacts`, `ApiUpdateProfile`, `ApiSetContactPrefs`, `ApiSetContactAlias` + +**Address:** `ApiCreateMyAddress`, `ApiDeleteMyAddress`, `ApiShowMyAddress`, `ApiAddMyAddressShortLink`, `ApiSetProfileAddress`, `ApiSetAddressSettings` + +**Calls:** `ApiGetCallInvitations`, `ApiSendCallInvitation`, `ApiRejectCall`, `ApiSendCallOffer`, `ApiSendCallAnswer`, `ApiSendCallExtraInfo`, `ApiEndCall`, `ApiCallStatus` + +**Server config:** `ApiGetServerOperators`, `ApiSetServerOperators`, `ApiGetUserServers`, `ApiSetUserServers`, `ApiValidateServers`, `APITestProtoServer` + +**Network:** `APISetNetworkConfig`, `APIGetNetworkConfig`, `APISetNetworkInfo`, `ReconnectServer`, `ReconnectAllServers` + +**Files:** `ReceiveFile`, `CancelFile`, `ApiUploadStandaloneFile`, `ApiDownloadStandaloneFile`, `ApiStandaloneFileInfo` + +**Remote access:** `SetLocalDeviceName`, `ListRemoteHosts`, `StartRemoteHost`, `SwitchRemoteHost`, `StopRemoteHost`, `DeleteRemoteHost`, `StoreRemoteFile`, `GetRemoteFile`, `ConnectRemoteCtrl`, `FindKnownRemoteCtrl`, `ConfirmRemoteCtrl`, `VerifyRemoteCtrlSession`, `ListRemoteCtrls`, `StopRemoteCtrl`, `DeleteRemoteCtrl` + +**Read status:** `ApiChatRead`, `ApiChatItemsRead`, `ApiChatUnread` + +**Settings:** `APISetChatSettings`, `ApiSetMemberSettings`, `APISetChatItemTTL`, `APIGetChatItemTTL`, `APISetChatTTL`, `ApiSaveSettings`, `ApiGetSettings` + +**Ratchet & verification:** `APISwitchContact`, `APISwitchGroupMember`, `APIAbortSwitchContact`, `APIAbortSwitchGroupMember`, `APISyncContactRatchet`, `APISyncGroupMemberRatchet`, `APIGetContactCode`, `APIGetGroupMemberCode`, `APIVerifyContact`, `APIVerifyGroupMember` + +Each command variant has a `cmdString` property that serializes it to the text protocol consumed by the Haskell FFI. + +*See:* `SimpleXAPI.kt:3529` -- `sealed class CC` + +### CR (Chat Response) +The sealed class representing all responses / events received from the Haskell core. Over 130 response types. Examples: + +- `ActiveUser`, `UsersList` -- user management results +- `ChatStarted`, `ChatRunning`, `ChatStopped` -- lifecycle +- `ApiChats`, `ApiChat` -- chat list data +- `NewChatItems`, `ChatItemUpdated`, `ChatItemsDeleted` -- message events +- `ContactConnected`, `ContactConnecting`, `ContactSndReady` -- connection lifecycle +- `GroupCreated`, `ReceivedGroupInvitation`, `JoinedGroupMemberConnecting`, `MemberAccepted` -- group events +- `RcvFileStart`, `RcvFileComplete`, `SndFileComplete` -- file transfer progress +- `CallInvitation`, `CallOffer`, `CallAnswer`, `CallExtraInfo`, `CallEnded` -- call signaling +- `ChatError` -- error wrapper + +*See:* `SimpleXAPI.kt:6114` -- `sealed class CR` + +### API +The top-level response wrapper. Two variants: +- `API.Result(remoteHostId, res: CR)` -- successful response +- `API.Error(remoteHostId, err: ChatError)` -- error response + +Properties: `ok` (Boolean -- true if `CR.CmdOk`), `result` (CR?), `rhId` (Long? -- remote host ID). + +*See:* `SimpleXAPI.kt:5975` -- `sealed class API` + +### ChatError +The error hierarchy returned from the Haskell core: +- `ChatErrorChat(errorType: ChatErrorType)` -- application-level errors (NoActiveUser, UserUnknown, DifferentActiveUser, etc.) +- `ChatErrorAgent(agentError: AgentErrorType)` -- SMP agent errors (BROKER, SMP, PROXY, etc.) +- `ChatErrorStore(storeError: StoreError)` -- database/store errors +- `ChatErrorDatabase(databaseError: DatabaseError)` -- database migration/encryption errors +- `ChatErrorRemoteHost(remoteHostError)` -- remote host control errors +- `ChatErrorRemoteCtrl(remoteCtrlError)` -- remote controller errors +- `ChatErrorInvalidJSON(json)` -- parse failure + +*See:* `SimpleXAPI.kt:6974` -- `sealed class ChatError` + +### sendCmd / recvMsg +The core FFI bridge. `sendCmd(rhId, cmd)` serializes a `CC` command and sends it to the Haskell backend via `chatSendCmd`. `recvMsg(ctrl)` blocks on `chatRecvMsg` to receive the next `API` response/event. The receiver loop runs in `ChatController.startReceiver()` on `Dispatchers.IO`. + +*See:* `SimpleXAPI.kt` -- `ChatController.sendCmd()`, `ChatController.startReceiver()` + +--- + +## 4. Connection & Identity + +### SimpleX Address (User Address) +A long-lived contact address that others can use to send connection requests. Created via `ApiCreateMyAddress`, retrieved via `ApiShowMyAddress`, deleted via `ApiDeleteMyAddress`. Can optionally include a short link (`ApiAddMyAddressShortLink`). Stored as `ChatModel.userAddress` (`UserContactLinkRec`). + +### Contact Link / Connection Link +A one-time or reusable invitation link. The `CreatedConnLink` type wraps the link string. Contact links can be one-time (single use) or long-lived (user address). Created via `APIAddContact` (one-time) or `ApiCreateMyAddress` (reusable). + +### Group Link +A reusable invitation link for joining a group. Created via `APICreateGroupLink(groupId, memberRole)`. The default role for new members joining via the link is configurable. Can also have a short link variant via `ApiAddGroupShortLink`. + +### Short Link +A compact form of a contact or group link. Created via `ApiAddMyAddressShortLink` (for user addresses) or `ApiAddGroupShortLink` (for groups). Short links resolve to the full connection link data including `ContactShortLinkData` or `GroupShortLinkData`. + +### Incognito Mode +When enabled (`AppPreferences.incognito`), the app generates a random profile name for new connections instead of using the user's real profile. Each connection gets a unique random identity. The `customUserProfileId` on a `Connection` tracks which incognito profile is used for that connection. + +*See:* `SimpleXAPI.kt` -- `AppPreferences.incognito`; `ChatModel.kt` -- `Connection.customUserProfileId` + +### Hidden Profile +A user profile protected by a password (`viewPwdHash`). Hidden profiles do not appear in the profile list unless unlocked with the password. Created via `ApiHideUser(userId, viewPwd)`, revealed via `ApiUnhideUser(userId, viewPwd)`. When switching away from a hidden profile, its notifications are cancelled. + +*See:* `SimpleXAPI.kt` -- `CC.ApiHideUser`, `CC.ApiUnhideUser`; `ChatModel.kt` -- `User.viewPwdHash` + +### Connection Verification (Security Code) +Each connection has an optional `SecurityCode` (`Connection.connectionCode`). Users can verify connections out-of-band by comparing security codes displayed via `APIGetContactCode` / `APIGetGroupMemberCode` and confirming via `APIVerifyContact` / `APIVerifyGroupMember`. + +### Connection Plan +Before connecting via a link, `APIConnectPlan` analyzes the link and returns a `ConnectionPlan` indicating whether the link leads to an existing contact, a new contact, a group join, etc. This prevents duplicate connections. + +*See:* `SimpleXAPI.kt` -- `CC.APIConnectPlan`, `CR.CRConnectionPlan` + +### Prepared Contact / Prepared Group +An intermediate state in the connection flow. `APIPrepareContact` / `APIPrepareGroup` creates the local record and displays the contact/group preview before the user confirms the connection. The user can then change the active profile (`APIChangePreparedContactUser` / `APIChangePreparedGroupUser`) and finally confirm via `APIConnectPreparedContact` / `APIConnectPreparedGroup`. + +--- + +## 5. Messaging Features + +### Delivery Receipt +Confirmation that a message was delivered to the recipient's device. Controlled per-user via `sendRcptsContacts` and `sendRcptsSmallGroups` on `User`. The global setting flow is triggered by `ChatModel.setDeliveryReceipts`. Individual overrides per-contact are managed via `ApiSetUserContactReceipts` / `ApiSetUserGroupReceipts`. + +*See:* `SimpleXAPI.kt` -- `CC.SetAllContactReceipts`, `CC.ApiSetUserContactReceipts`, `CC.ApiSetUserGroupReceipts`; `AppPreferences.privacyDeliveryReceiptsSet` + +### Timed Message (Disappearing Message) +Messages with a time-to-live after which they are automatically deleted. Configured as a `ChatFeature` / `GroupFeature` with a TTL parameter in seconds. The `customDisappearingMessageTime` preference stores the last custom duration used. Per-chat TTL can be set via `APISetChatTTL`. Global TTL via `APISetChatItemTTL`. + +*See:* `SimpleXAPI.kt` -- `CC.APISetChatItemTTL`, `CC.APISetChatTTL`; `AppPreferences.customDisappearingMessageTime` + +### Live Message +A message that updates in real-time as the sender types. Controlled by `CC.ApiSendMessages` with `live=true`. The `ComposeState.liveMessage` tracks the current live message being composed. An alert is shown on first use (`AppPreferences.liveMessageAlertShown`). + +### Message Reactions +Emoji reactions on messages. Added/removed via `ApiChatItemReaction(type, id, scope, itemId, add, reaction)`. Reaction members in groups can be queried via `ApiGetReactionMembers`. Each `ChatItem` carries a `reactions: List`. + +### Message Forwarding +Messages can be forwarded between chats. `ApiPlanForwardChatItems` checks feasibility (e.g., file availability), and `ApiForwardChatItems` performs the forward. A `ForwardConfirmation` may be required if files need downloading first. + +### Message Reports +Users can report messages in groups via `ApiReportMessage(groupId, chatItemId, reportReason, reportText)`. Admins can archive (`ApiArchiveReceivedReports`) or delete (`ApiDeleteReceivedReports`) reports. + +### Mentions +In-message mentions of group members. Stored as `mentions: Map` on `ChatItem` and `mentions: MentionedMembers` on `ComposeState`. + +### Link Previews +Automatic preview generation for URLs in messages. Controlled by `AppPreferences.privacyLinkPreviews`. An alert is shown on first use (`privacyLinkPreviewsShowAlert`). + +### Local File Encryption +Files stored on device can be encrypted. Controlled by `AppPreferences.privacyEncryptLocalFiles` and toggled via `CC.ApiSetEncryptLocalFiles(enable)`. + +### Chat Tags +User-defined tags for organizing conversations. CRUD via `ApiCreateChatTag`, `ApiUpdateChatTag`, `ApiDeleteChatTag`, `ApiReorderChatTags`. Assignment via `ApiSetChatTags`. The model tracks `userTags`, `presetTags` (system-defined categories), `unreadTags`, and the active filter (`activeChatTagFilter`). + +--- + +## 6. Calling & Media + +### WebRTC +The real-time communication framework used for audio and video calls. The app uses WebRTC for peer-to-peer media streams, with SMP used only for call signaling (offer/answer/ICE candidates). + +### Call (data class) +Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `sharedKey` (for E2E call encryption), `connectionInfo`, `connectedAt`. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt:14` + +### CallState +Enum tracking call progression: `WaitCapabilities` -> `InvitationSent` / `InvitationAccepted` -> `OfferSent` / `OfferReceived` -> `Negotiated` -> `Connected` -> `Ended`. + +### WCallCommand / WCallResponse +The command/response protocol between the Kotlin app and the WebRTC JavaScript layer: +- **Commands:** `Capabilities`, `Permission`, `Start`, `Offer`, `Answer`, `Ice`, `Media`, `Camera`, `Description`, `Layout`, `End` +- **Responses:** `Capabilities`, `Offer`, `Answer`, `Ice`, `Connection`, `Connected`, `PeerMedia`, `End`, `Ended`, `Ok`, `Error` + +*See:* `WebRTC.kt:88` -- `sealed class WCallCommand`; `WebRTC.kt:103` -- `sealed class WCallResponse` + +### CallManager +Manages incoming call invitations and the active call lifecycle. Handles reporting new incoming calls, accepting calls, switching between calls, and ending calls. Interacts with `ChatModel.callInvitations`, `ChatModel.activeCall`, and the platform notification manager. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` + +### Android: CallActivity +A dedicated Android `Activity` that displays the call UI. Launched when accepting an incoming call or initiating an outgoing call. Uses an Android `WebView` to host the WebRTC JavaScript. + +*See:* `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` + +### Android: CallService +An Android foreground `Service` that keeps the call alive when the app is in the background. Holds a `WakeLock`, displays an ongoing call notification, and manages the call lifecycle. Uses notification channel `CALL_SERVICE_NOTIFICATION`. + +*See:* `android/src/main/java/chat/simplex/app/CallService.kt` + +### Desktop: Browser-based WebRTC via NanoWSD +On Desktop, calls are implemented by opening the system browser to a locally-hosted WebSocket server. A `NanoHTTPD`/`NanoWSD` server runs on `localhost:50395`, serving the WebRTC call page and communicating with the Kotlin app via WebSocket messages. Commands are sent as JSON-serialized `WVAPICall` objects; responses are parsed as `WVAPIMessage` objects. + +*See:* `common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt` + +### ICE Servers +STUN/TURN servers used for WebRTC NAT traversal. Configurable via `AppPreferences.webrtcIceServers`. The relay policy (`AppPreferences.webrtcPolicyRelay`) controls whether calls must use TURN relays (for IP privacy) or can attempt direct connections. + +### CallMediaType +Enum: `Video`, `Audio`. Determines the initial media type of the call. + +### CallMediaSource +Enum: `Mic`, `Camera`, `ScreenAudio`, `ScreenVideo`. Used in `WCallCommand.Media` to toggle individual media streams. + +--- + +## 7. Notifications & Background + +### Android: SimplexService +A foreground `Service` that keeps the chat backend running in the background. Uses a `WakeLock` and displays a persistent notification ("SimpleX Chat service" channel). Started with `START_STICKY` for automatic restart. Manages the `chatRecvMsg` loop indirectly by keeping the process alive. + +Notification channel: `chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION` ("SimpleX Chat service") + +*See:* `android/src/main/java/chat/simplex/app/SimplexService.kt` + +### Android: MessagesFetcherWorker +A `WorkManager` periodic worker that wakes the app to fetch new messages when the foreground service is not running (i.e., when `NotificationsMode` is `PERIODIC`). Provides a battery-friendly alternative to the always-on service. + +*See:* `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` + +### Android: NotificationsMode +Enum controlling background message fetching: +- `OFF` -- no background activity; messages received only when app is open +- `PERIODIC` -- uses `MessagesFetcherWorker` for periodic fetches +- `SERVICE` -- uses `SimplexService` foreground service (default) + +*See:* `SimpleXAPI.kt:7739` -- `enum class NotificationsMode` + +### Android: Notification Channels +Android notification channels registered by the app: +- **Messages:** `chat.simplex.app.MESSAGE_NOTIFICATION` -- high importance, for incoming messages +- **Calls:** `chat.simplex.app.CALL_NOTIFICATION_2` -- high importance, for incoming call alerts with custom sound +- **Service:** `chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION` -- low importance, persistent foreground service indicator +- **Call Service:** `chat.simplex.app.CALL_SERVICE_NOTIFICATION` -- default importance, ongoing call indicator + +*See:* `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt`, `SimplexService.kt`, `CallService.kt` + +### Android: NtfManager +The Android-specific notification manager. Handles creating notification channels, displaying message notifications (with grouping via `MessageGroup`), displaying incoming call notifications (with full-screen intent for lock-screen calls), and managing notification actions (accept/reject call, open chat). + +*See:* `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` + +### Desktop: System Notifications +On Desktop, notifications use the system notification mechanism (typically via the JVM's `SystemTray` or platform-specific notification APIs). The notification manager interface is shared (`ntfManager`) but the implementation is platform-specific. + +### NotificationPreviewMode +Controls what information appears in notifications: +- `HIDDEN` -- no message content +- `CONTACT` -- shows sender name only +- `MESSAGE` -- shows sender name and message preview (default) + +*See:* `ChatModel.kt:4823` -- `enum class NotificationPreviewMode` + +### Wake Lock Management +In `ChatController.startReceiver()`, each received message acquires a wake lock (via `getWakeLock(timeout=60000)`) that is released after 30 seconds. This ensures the device stays awake long enough to process incoming messages and display notifications, particularly for incoming calls. + +--- + +## 8. Application Architecture + +### ChatController +The singleton controller that bridges the Kotlin UI layer and the Haskell core library. Responsibilities: +- Manages the `chatCtrl` (FFI handle to the Haskell runtime) +- Sends commands via `sendCmd()` and receives events via the `startReceiver()` coroutine loop +- Processes received messages in `processReceivedMsg()` +- Holds a reference to `AppPreferences` and `ChatModel` +- Provides the `messagesChannel` (Kotlin coroutine `Channel`) for consumers to observe events +- Manages retry logic for transient network errors (`sendCmdWithRetry`) + +*See:* `SimpleXAPI.kt:493` -- `object ChatController` + +### ChatModel +The singleton reactive state container for the entire app. Uses Compose `mutableStateOf` and `mutableStateListOf` for reactive UI updates. Key state: +- `currentUser` -- the active user profile +- `users` -- list of all user profiles (`UserInfo`) +- `chatsContext` / `secondaryChatsContext` -- `ChatsContext` holding the chat list +- `chatId` -- currently open chat +- `groupMembers` -- members of the currently viewed group +- `callInvitations` -- pending incoming call invitations +- `activeCall` -- the currently active call +- `userAddress` -- the user's SimpleX address +- `chatItemTTL` -- global message TTL setting +- `userTags` -- chat tags +- `terminalItems` -- debug terminal log items +- Various UI state flags (`showCallView`, `switchingUsersAndHosts`, `clearOverlays`, etc.) + +*See:* `ChatModel.kt:86` -- `object ChatModel` + +### AppPreferences +A class wrapping platform-specific key-value storage (`Settings` from `com.russhwolf.settings`). On Android, backed by `SharedPreferences`. On Desktop, backed by Java `Properties` files. Provides type-safe accessors for all user preferences. + +*See:* `SimpleXAPI.kt:94` -- `class AppPreferences` + +### ComposeState +Data class holding the state of the message composition area. Fields: `message` (ComposeMessage), `parsedMessage` (formatted text), `liveMessage`, `preview` (ComposePreview), `contextItem` (ComposeContextItem -- reply/edit context), `inProgress`, `progressByTimeout`, `useLinkPreviews`, `mentions`. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:98` + +### ModalManager +Manages the modal/sheet presentation stack. Supports multiple placements (default, center, fullscreen, end). Holds an ordered list of `ModalViewHolder` items and exposes `showModal`, `showCustomModal`, `showModalCloseable`, `closeModal`. Uses Compose state (`modalCount`) to trigger recomposition. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt:92` + +### AlertManager +Singleton for displaying alert dialogs. Provides `showAlertMsg`, `showAlertDialog`, `showAlertDialogButtons`, etc. Works with `AlertManager.shared` for the default instance. + +### ChatsContext +Holds the chat list state for a particular scope (main or secondary). Manages `chats` (State>), provides `updateChats()` to refresh, and supports filtering/keeping specific chats during updates. + +### ConnectProgressManager +Tracks and displays connection progress in the UI. Methods: `startConnectProgress(text, onCancel)`, `stopConnectProgress()`, `cancelConnectProgress()`. Exposes `showConnectProgress` (nullable string indicating active progress text). + +*See:* `ChatModel.kt:48` -- `object ConnectProgressManager` + +### withBGApi / withLongRunningApi +Utility functions for launching coroutines on background threads. Used throughout the codebase to perform API calls without blocking the UI thread. + +--- + +## 9. Configuration & Preferences + +### AppPreferences (Storage) +All preferences are accessed through `ChatController.appPrefs`, which is a lazy-initialized `AppPreferences` instance. The underlying storage is: +- **Android:** `SharedPreferences` with ID `chat.simplex.app.SIMPLEX_APP_PREFS` +- **Desktop:** Java `Properties` files via `com.russhwolf.settings` + +Theme overrides have separate storage (`SHARED_PREFS_THEMES_ID`). + +### SharedPreference +A generic wrapper providing `get()` and `set(value)` for a single preference. All `AppPreferences` fields are `SharedPreference` instances created by factory methods (`mkBoolPreference`, `mkStrPreference`, `mkIntPreference`, `mkLongPreference`, `mkFloatPreference`, `mkEnumPreference`, `mkSafeEnumPreference`, `mkDatePreference`, `mkMapPreference`, `mkTimeoutPreference`). + +### Key Preference Categories + +**Notifications:** +- `notificationsMode` -- OFF / PERIODIC / SERVICE +- `notificationPreviewMode` -- HIDDEN / CONTACT / MESSAGE +- `canAskToEnableNotifications` -- gate for the notification prompt + +**Privacy:** +- `privacyProtectScreen` -- prevents screenshots (Android FLAG_SECURE) +- `privacyAcceptImages` -- auto-accept inline images +- `privacyLinkPreviews` -- generate URL previews +- `privacySanitizeLinks` -- strip tracking parameters from URLs +- `privacyShowChatPreviews` -- show message preview in chat list +- `privacySaveLastDraft` -- persist draft messages +- `privacyEncryptLocalFiles` -- encrypt files at rest +- `privacyAskToApproveRelays` -- prompt before using relays suggested by contacts +- `privacyMediaBlurRadius` -- blur radius for media in notifications/previews + +**Security:** +- `performLA` -- require local authentication (biometric/PIN) +- `laMode` -- local authentication mode +- `laLockDelay` -- seconds before re-locking +- `storeDBPassphrase` -- whether to persist the DB passphrase +- `initialRandomDBPassphrase` -- indicates the DB uses a random (non-user-chosen) passphrase +- `selfDestruct` -- enable self-destruct profile +- `selfDestructDisplayName` -- display name for the self-destruct profile + +**Network:** +- `networkUseSocksProxy` -- route traffic through SOCKS proxy +- `networkProxy` -- SOCKS proxy host/port configuration +- `networkSessionMode` -- transport session multiplexing mode +- `networkSMPProxyMode` -- SMP proxy / private routing mode +- `networkSMPProxyFallback` -- fallback behavior when proxy fails +- `networkHostMode` -- onion/public host preference +- `networkRequiredHostMode` -- enforce host mode strictly +- Various TCP timeout settings (background, interactive, per-KB) +- Keep-alive settings (idle, interval, count) + +**Calls:** +- `webrtcPolicyRelay` -- force TURN relay usage +- `callOnLockScreen` -- DISABLE / SHOW / ACCEPT calls on lock screen +- `webrtcIceServers` -- custom ICE server configuration +- `experimentalCalls` -- enable experimental call features + +**Appearance:** +- `currentTheme` -- active theme name +- `systemDarkTheme` -- theme for system dark mode +- `themeOverrides` -- per-theme customizations +- `profileImageCornerRadius` -- avatar rounding +- `chatItemRoundness` -- message bubble rounding +- `chatItemTail` -- show/hide message bubble tail +- `fontScale` -- text size scaling +- `densityScale` -- UI density scaling +- `inAppBarsAlpha` -- toolbar transparency +- `appearanceBarsBlurRadius` -- toolbar blur effect + +**UI:** +- `oneHandUI` -- one-handed UI mode (bottom-aligned navigation) +- `chatBottomBar` -- show bottom bar in chat view +- `simplexLinkMode` -- how SimpleX links are displayed (DESCRIPTION / FULL / BROWSER) +- `showUnreadAndFavorites` -- filter chat list to unread/favorites +- `developerTools` -- enable developer tools (terminal, etc.) + +**Database:** +- `encryptedDBPassphrase` -- encrypted form of the DB passphrase +- `initializationVectorDBPassphrase` -- IV for DB passphrase encryption +- `encryptionStartedAt` -- timestamp of encryption operation start (for crash recovery) +- `confirmDBUpgrades` -- prompt before database migrations +- `newDatabaseInitialized` -- flag for incomplete initialization recovery + +**Remote Access:** +- `deviceNameForRemoteAccess` -- device display name for remote control +- `confirmRemoteSessions` -- require confirmation for remote sessions +- `connectRemoteViaMulticast` -- use multicast discovery +- `connectRemoteViaMulticastAuto` -- auto-connect via multicast +- `desktopWindowState` -- persisted window position/size (Desktop only) + +**Migration:** +- `migrationToStage` / `migrationFromStage` -- track migration progress +- `onboardingStage` -- current onboarding step +- `lastMigratedVersionCode` -- last app version that ran migrations + +*See:* `SimpleXAPI.kt:94-489` -- `class AppPreferences` with all `SHARED_PREFS_*` constants diff --git a/apps/multiplatform/product/rules.md b/apps/multiplatform/product/rules.md new file mode 100644 index 0000000000..90a2dadada --- /dev/null +++ b/apps/multiplatform/product/rules.md @@ -0,0 +1,253 @@ +# Business Rules -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This document specifies invariants enforced by the Android and Desktop (Kotlin/Compose Multiplatform) clients. + +--- + +## Table of Contents + +1. [Security (RULE-01 through RULE-05)](#1-security) +2. [Message Integrity (RULE-06 through RULE-09)](#2-message-integrity) +3. [Group Integrity (RULE-10 through RULE-13)](#3-group-integrity) +4. [File Transfer (RULE-14 through RULE-15)](#4-file-transfer) +5. [Notification Delivery (RULE-16 through RULE-17)](#5-notification-delivery) +6. [Call Integrity (RULE-18)](#6-call-integrity) + +--- + +## 1. Security + +### RULE-01: End-to-End Encryption is Mandatory + +**Invariant:** Every message, file chunk, and call signaling payload MUST be encrypted end-to-end before transmission. The app MUST NOT transmit plaintext content to any relay server. + +**Enforcement:** The Haskell core library handles all encryption. The Kotlin layer never constructs raw SMP messages. All communication flows through `ChatController.sendCmd()` which delegates to the FFI, ensuring the encryption layer cannot be bypassed. + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `ChatController.sendCmd()`, `chatSendCmd()` FFI call + +--- + +### RULE-02: Database Encryption at Rest + +**Invariant:** The local SQLite database MUST be encrypted. A passphrase (either user-chosen or randomly generated) MUST be set before the database is operational. + +**Enforcement:** On first launch, a random passphrase is generated and stored encrypted via the platform keystore (`CryptorInterface.encryptText`). The `initialRandomDBPassphrase` preference tracks whether the user has set a custom passphrase. Database encryption state is tracked in `ChatModel.chatDbEncrypted`. Encryption/re-encryption is performed via `CC.ApiStorageEncryption(config: DBEncryptionConfig)`. + +**Caveat:** The user is not forced to set a custom passphrase -- the random passphrase is stored in app-accessible encrypted preferences. See GAP: "Database passphrase not enforced." + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` -- `CryptorInterface` +- Android: `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` -- Android Keystore +- Desktop: `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` -- **placeholder, not implemented** + +--- + +### RULE-03: Local Authentication Gating + +**Invariant:** When local authentication is enabled (`AppPreferences.performLA == true`), the app MUST require biometric/PIN authentication before displaying any chat content. The lock engages after `laLockDelay` seconds of inactivity. + +**Enforcement:** `AppLock.setPerformLA` controls the lock state. The lock delay is configurable via `AppPreferences.laLockDelay` (default 30 seconds). Authentication mode is set via `AppPreferences.laMode` (system biometric or passcode). + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt` +- `SimpleXAPI.kt` -- `AppPreferences.performLA`, `AppPreferences.laMode`, `AppPreferences.laLockDelay` + +--- + +### RULE-04: Self-Destruct Profile + +**Invariant:** When self-destruct is enabled (`AppPreferences.selfDestruct == true`), entering the self-destruct passphrase instead of the real passphrase MUST wipe the database and present a clean profile with `selfDestructDisplayName`. + +**Enforcement:** The self-destruct passphrase is stored separately (`encryptedSelfDestructPassphrase` / `initializationVectorSelfDestructPassphrase`). On Android, `SimplexService` checks for self-destruct on initialization. The comparison happens during the local authentication flow. + +**Location:** +- `SimpleXAPI.kt` -- `AppPreferences.selfDestruct`, `AppPreferences.selfDestructDisplayName` +- `android/src/main/java/chat/simplex/app/SimplexService.kt` -- initialization check + +--- + +### RULE-05: Screen Protection + +**Invariant:** When `AppPreferences.privacyProtectScreen == true` (default), the app MUST prevent screenshots and screen recording. On Android this uses `FLAG_SECURE`; on Desktop this is advisory only. + +**Enforcement:** The preference defaults to `true`. The Android activity applies `FLAG_SECURE` to its window based on this preference. The Desktop app cannot enforce this at the OS level. + +**Location:** `SimpleXAPI.kt` -- `AppPreferences.privacyProtectScreen` + +--- + +## 2. Message Integrity + +### RULE-06: Message Ordering Verification + +**Invariant:** The app MUST detect and surface message integrity violations (gaps, duplicates, out-of-order delivery) to the user. + +**Enforcement:** The Haskell core tracks message sequence numbers per connection. When a gap or integrity error is detected, a `CIContent.RcvIntegrityError(msgError: MsgErrorType)` chat item is inserted into the conversation. The UI renders these as system messages indicating the integrity issue. + +**Location:** `ChatModel.kt:3565` -- `CIContent.RcvIntegrityError` + +--- + +### RULE-07: Decryption Error Surfacing + +**Invariant:** When a message cannot be decrypted, the app MUST display a `RcvDecryptionError` item showing the error type and count of affected messages. The app MUST NOT silently drop undecryptable messages. + +**Enforcement:** The Haskell core emits `CIContent.RcvDecryptionError(msgDecryptError, msgCount)` which the UI renders with an explanation and count. Ratchet re-synchronization can be triggered via `APISyncContactRatchet` / `APISyncGroupMemberRatchet`. + +**Location:** `ChatModel.kt:3566` -- `CIContent.RcvDecryptionError` + +--- + +### RULE-08: Delivery Receipt Consistency + +**Invariant:** Delivery receipt settings MUST be consistent: when a user enables/disables receipts globally, the change MUST propagate to all contacts/groups (optionally clearing per-chat overrides via `clearOverrides`). + +**Enforcement:** Global receipt toggle triggers `CC.SetAllContactReceipts(enable)`. Per-type settings use `CC.ApiSetUserContactReceipts` / `CC.ApiSetUserGroupReceipts` with `UserMsgReceiptSettings(enable, clearOverrides)`. The `privacyDeliveryReceiptsSet` preference gates the initial setup prompt shown during onboarding. + +**Location:** +- `SimpleXAPI.kt` -- `CC.SetAllContactReceipts`, `CC.ApiSetUserContactReceipts`, `CC.ApiSetUserGroupReceipts` +- `SimpleXAPI.kt` -- `ChatController.startChat()` -- triggers `setDeliveryReceipts` prompt + +--- + +### RULE-09: Chat Item TTL Enforcement + +**Invariant:** When a chat item TTL (time-to-live) is set globally or per-chat, expired messages MUST be deleted by the core. The app MUST NOT display expired items. + +**Enforcement:** Global TTL set via `CC.APISetChatItemTTL(userId, seconds)`. Per-chat TTL set via `CC.APISetChatTTL(userId, chatType, id, seconds)`. The Haskell core performs periodic cleanup. The current global TTL is stored in `ChatModel.chatItemTTL`. + +**Location:** `SimpleXAPI.kt` -- `CC.APISetChatItemTTL`, `CC.APISetChatTTL` + +--- + +## 3. Group Integrity + +### RULE-10: Role-Based Access Control + +**Invariant:** Group operations MUST respect the member's role. Only members with sufficient role level can perform privileged operations: +- **Owner:** can delete group, change any member's role, transfer ownership +- **Admin:** can add/remove members, change roles (up to Admin), create/delete group links +- **Moderator:** can delete other members' messages, block members +- **Member / Author / Observer:** cannot perform administrative actions + +**Enforcement:** The Haskell core validates role permissions server-side. The Kotlin UI layer uses `GroupMemberRole` comparisons (the enum is ordered: Observer < Author < Member < Moderator < Admin < Owner) to show/hide action buttons. + +**Location:** `ChatModel.kt:2369` -- `enum class GroupMemberRole`; various group management views + +--- + +### RULE-11: Group Member Removal Atomicity + +**Invariant:** When removing members from a group, the removal command MUST specify all member IDs atomically. Partial removal MUST NOT leave the group in an inconsistent state. + +**Enforcement:** `CC.ApiRemoveMembers(groupId, memberIds: List, withMessages: Boolean)` sends all member IDs in a single command. The `withMessages` flag controls whether the removed members' messages are also deleted. + +**Location:** `SimpleXAPI.kt` -- `CC.ApiRemoveMembers` + +--- + +### RULE-12: Group Link Role Default + +**Invariant:** When creating a group link, the default member role for joiners MUST be explicitly specified. The role can be updated after creation without regenerating the link. + +**Enforcement:** `CC.APICreateGroupLink(groupId, memberRole)` requires a role. `CC.APIGroupLinkMemberRole(groupId, memberRole)` updates it. The link itself remains stable. + +**Location:** `SimpleXAPI.kt` -- `CC.APICreateGroupLink`, `CC.APIGroupLinkMemberRole` + +--- + +### RULE-13: Member Blocking Scope + +**Invariant:** Blocking a member (`ApiBlockMembersForAll`) MUST apply the block for all group members (not just the requester). The `blocked` flag is visible to all members. Only roles >= Moderator can block. + +**Enforcement:** `CC.ApiBlockMembersForAll(groupId, memberIds, blocked)` sends the block/unblock to the core, which propagates it to all group members. + +**Location:** `SimpleXAPI.kt` -- `CC.ApiBlockMembersForAll`; `ChatModel.kt` -- `GroupMember.blockedByAdmin` + +--- + +## 4. File Transfer + +### RULE-14: File Encryption in Transit and at Rest + +**Invariant:** Files sent via XFTP MUST be encrypted before upload. Files received MUST be decrypted only after download. When `privacyEncryptLocalFiles` is enabled (default `true`), files stored locally MUST be encrypted with per-file keys (`CryptoFile.cryptoArgs`). + +**Enforcement:** The Haskell core handles XFTP encryption. Local file encryption is toggled via `CC.ApiSetEncryptLocalFiles(enable)`. The `CryptoFile` type carries optional `CryptoFileArgs` (key + nonce) for local decryption. Files are decrypted on-demand for display via `decryptCryptoFile()`. + +**Location:** +- `SimpleXAPI.kt` -- `CC.ApiSetEncryptLocalFiles`, `AppPreferences.privacyEncryptLocalFiles` +- `ChatModel.kt` -- `CryptoFile`, `CryptoFileArgs` +- `RecAndPlay.desktop.kt` -- `decryptCryptoFile()` usage in audio playback + +--- + +### RULE-15: Relay Approval for File Transfer + +**Invariant:** When `privacyAskToApproveRelays` is enabled (default `true`), the app MUST prompt the user before using XFTP relay servers suggested by contacts (as opposed to the user's own configured servers). The `userApprovedRelays` flag on `CC.ReceiveFile` records the user's consent. + +**Enforcement:** `CC.ReceiveFile(fileId, userApprovedRelays, encrypt, inline)` passes the approval flag. The UI prompts the user when the file is from an unapproved relay. + +**Location:** `SimpleXAPI.kt` -- `CC.ReceiveFile`, `AppPreferences.privacyAskToApproveRelays` + +--- + +## 5. Notification Delivery + +### RULE-16: Background Message Delivery (Android) + +**Invariant:** On Android, when `NotificationsMode.SERVICE` is selected (default), the app MUST maintain a foreground service (`SimplexService`) to ensure continuous message delivery. The service MUST survive app backgrounding and device sleep. When `NotificationsMode.PERIODIC` is selected, `MessagesFetcherWorker` MUST periodically wake and fetch messages. When `NotificationsMode.OFF`, no background delivery occurs. + +**Enforcement:** +- `SimplexService` runs as a foreground service with `START_STICKY` and a `WakeLock`. It displays a persistent notification on the `SIMPLEX_SERVICE_NOTIFICATION` channel. +- `MessagesFetcherWorker` is a `PeriodicWorkRequest` scheduled via `WorkManager`. +- The mode is stored in `AppPreferences.notificationsMode` and checked at app startup. + +**Location:** +- `android/src/main/java/chat/simplex/app/SimplexService.kt` +- `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` +- `SimpleXAPI.kt:7739` -- `enum class NotificationsMode` + +--- + +### RULE-17: Notification Preview Privacy + +**Invariant:** Notification content MUST respect `notificationPreviewMode`: +- `HIDDEN` -- notification shows no sender or message content +- `CONTACT` -- notification shows sender name only +- `MESSAGE` -- notification shows sender name and message preview + +**Enforcement:** `NtfManager` (Android) reads the preview mode from `AppPreferences.notificationPreviewMode` and constructs notifications accordingly. The `CallService` also respects this mode for call notifications (showing or hiding caller identity). + +**Location:** +- `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` -- `displayNotification()`, `notifyCallInvitation()` +- `android/src/main/java/chat/simplex/app/CallService.kt` -- `updateNotification()` +- `SimpleXAPI.kt` -- `AppPreferences.notificationPreviewMode` + +--- + +## 6. Call Integrity + +### RULE-18: Call Lifecycle Management + +**Invariant:** An active call MUST be properly managed across the full lifecycle: +1. **Incoming calls** MUST be reported via `CallManager.reportNewIncomingCall()` which triggers a notification (and on Android, a full-screen intent for lock-screen display). +2. **Only one call** can be active at a time. Accepting a new call MUST end any existing call first (`CallManager.acceptIncomingCall` checks `activeCall` and calls `endCall` if needed, guarded by `switchingCall` flag). +3. **Call state** MUST progress through defined states: `WaitCapabilities` -> `InvitationSent`/`InvitationAccepted` -> `OfferSent`/`OfferReceived` -> `Negotiated` -> `Connected` -> `Ended`. +4. **Call end** MUST clean up all resources: send `WCallCommand.End`, call `apiEndCall`, clear `activeCall`, cancel call notifications, and release platform resources. + +**Android enforcement:** +- `CallService` (foreground service) keeps the call alive in background with a `WakeLock` and ongoing notification on `CALL_SERVICE_NOTIFICATION` channel. +- `CallActivity` hosts the WebRTC WebView. +- Lock-screen behavior controlled by `AppPreferences.callOnLockScreen` (DISABLE / SHOW / ACCEPT). + +**Desktop enforcement:** +- Calls run in the system browser via the NanoWSD WebSocket server on `localhost:50395`. +- The `WebRTCController` composable manages the WebSocket lifecycle. +- On dispose, `WCallCommand.End` is sent and the server is stopped. + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt` +- Android: `android/src/main/java/chat/simplex/app/CallService.kt`, `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` +- Desktop: `common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt` diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md new file mode 100644 index 0000000000..51d323874c --- /dev/null +++ b/apps/multiplatform/product/views/call.md @@ -0,0 +1,115 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. The implementation differs significantly between Android (WebView-based with `CallActivity` and PiP support) and Desktop (browser-based WebRTC via NanoHTTPD server on localhost). + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallAlertView` banner appears at top of screen +- **Presented by**: `ActiveCallView()` (expect/actual composable) is shown when `chatModel.showCallView == true` +- **Dismiss**: Call ends when user taps end button or remote party disconnects; `callManager.endCall()` handles cleanup +- **Android PiP**: Call view supports picture-in-picture mode via `CallActivity` + +## Platform Differences + +| Aspect | Android | Desktop | +|---|---|---| +| WebRTC host | `WebView` with `WebViewAssetLoader` serving local assets | NanoHTTPD server on `localhost:50395` opened in system browser | +| Call activity | `CallActivity` (separate Android Activity) with lifecycle management | Inline composable with `WebRTCController` | +| PiP support | Native Android PiP via `CallActivity` | Not supported | +| Audio management | `CallAudioDeviceManager` with Android `AudioManager`, proximity wake lock | System browser audio routing | +| WebSocket | N/A | `NanoWSD` WebSocket server for bidirectional WebRTC signaling | + +## Page Sections + +### Incoming Call Banner (`IncomingCallAlertView`) + +Displayed as an overlay banner when `chatModel.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| User profile image | Shown when multiple profiles exist (32dp `ProfileImage`) | +| Call type icon | `ic_videocam_filled` (green) for video, `ic_call_filled` (green) for audio | +| Call type text | `invitation.callTypeText` with caller info | +| Caller profile | `ProfilePreview` showing caller name and avatar (64dp) | +| Reject button | Red `ic_call_end_filled` icon -- ends the invitation via `callManager.endCall(invitation)` | +| Ignore button | Blue `ic_close` icon -- dismisses banner, cancels notification | +| Accept button | Green `ic_check_filled` icon -- accepts via `callManager.acceptIncomingCall(invitation)` | + +Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call view is already showing). + +### Active Call View + +#### Android (`CallView.android.kt`) + +| Element | Description | +|---|---| +| WebView | `AndroidView` wrapping a `WebView` that loads `call.html` via `WebViewAssetLoader`; handles WebRTC JS bridge | +| `ActiveCallState` | Manages proximity lock (`PROXIMITY_SCREEN_OFF_WAKE_LOCK`), audio device manager, call sounds | +| Call controls overlay | Mic toggle, speaker toggle, camera switch, video toggle, end call button | +| Audio device selection | `CallAudioDeviceManager` with device enumeration (earpiece, speaker, Bluetooth, wired headset) | +| Permissions | Runtime permission checks for `CAMERA` and `RECORD_AUDIO` via Accompanist permissions library | + +#### Desktop (`CallView.desktop.kt`) + +| Element | Description | +|---|---| +| NanoHTTPD server | HTTP server on `localhost:50395` serving `call.html` and assets | +| NanoWSD WebSocket | WebSocket endpoint for bidirectional signaling between Kotlin and browser JS | +| `WebRTCController` | Processes `WCallCommand`/`WCallResponse` messages via `chatModel.callCommand` channel | +| Browser launch | `LocalUriHandler.openUri("http://localhost:50395/call.html")` opens system browser | +| Connection list | `connections: ArrayList` tracks active WebSocket connections | + +### WebRTC Signaling Flow + +| Step | Command/Response | Description | +|---|---|---| +| 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | +| 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | +| 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | +| 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | +| 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | +| 9. Camera switch | `WCallCommand.Camera` | Switch between front/back camera | +| 10. End | `WCallResponse.End` / `WCallResponse.Ended` | Call termination; cleanup and UI dismissal | + +### Call States (`CallState`) + +| State | Description | +|---|---| +| `WaitCapabilities` | Waiting for WebRTC capabilities | +| `InvitationSent` | Call invitation sent to remote party | +| `InvitationAccepted` | Callee accepted, starting WebRTC | +| `OfferSent` | SDP offer sent | +| `OfferReceived` | Callee received SDP offer | +| `AnswerReceived` | Caller received SDP answer | +| `Negotiated` | ICE negotiation complete | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Ended` | Call terminated | + +### Call Sounds + +| Sound | Trigger | +|---|---| +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | +| Ringtone | `SoundPlayer.start()` for incoming calls | +| End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | + +## Source Files + +| File | Path | +|---|---| +| `CallView.kt` | `views/call/CallView.kt` (common expect declarations) | +| `CallView.android.kt` | `androidMain/.../views/call/CallView.android.kt` | +| `CallView.desktop.kt` | `desktopMain/.../views/call/CallView.desktop.kt` | +| `IncomingCallAlertView.kt` | `views/call/IncomingCallAlertView.kt` | +| `CallManager.kt` | `views/call/CallManager.kt` | +| `WebRTC.kt` | `views/call/WebRTC.kt` | +| `CallAudioDeviceManager.kt` | `androidMain/.../views/call/CallAudioDeviceManager.kt` | diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md new file mode 100644 index 0000000000..daa7907c5d --- /dev/null +++ b/apps/multiplatform/product/views/chat-list.md @@ -0,0 +1,136 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat Android and Desktop apps. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ChatListView` composable as the default view when `chatModel.chatId == null` +- **Navigation**: `ChatListNavLinkView` handles click routing to `ChatView` for each chat type +- **UserPicker**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet (Android: bottom sheet overlay; Desktop: sidebar panel) + +## Platform Layout + +| Platform | Layout | +|---|---| +| Android | Single-column list; toolbar at top or bottom (one-hand UI); FAB for new chat | +| Desktop | 3-column layout: chat list (left), chat view (center), info/detail panel (right via `ModalManager.end`) | + +## Page Sections + +### Toolbar (`ChatListToolbar`) + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop/mobile) | +| "Your chats" title | Center | Tappable to scroll list to top | +| Connection status indicator (`SubscriptionStatusIndicator`) | Adjacent to title | Shows SMP server subscription status; taps open `ServersSummaryView` | +| New chat button (pencil icon) | Trailing (one-hand UI) or FAB (standard) | Opens `NewChatSheet` modal via `showNewChatSheet()` | +| Active call indicator | Trailing (Desktop, one-hand UI) | `ActiveCallInteractiveArea` shown when a call is active | +| Updating progress | Trailing | Shows progress circle/indicator during database updates | +| Stopped indicator | Trailing | Red warning icon when chat engine is stopped | + +The toolbar supports two layout modes controlled by `appPrefs.oneHandUI`: +- **Standard (top)**: `DefaultAppBar` at top with `NavigationButtonMenu` leading, title center, buttons trailing. FAB at bottom-right for new chat. +- **One-hand UI (bottom)**: Toolbar at bottom of screen with `Column(Modifier.align(Alignment.BottomCenter))`; list rendered with `reverseLayout = true`; no FAB (new chat button is inline in toolbar). + +### Search Bar (`ChatListSearchBar`) + +| Element | Description | +|---|---| +| Search icon | Magnifying glass icon at leading edge | +| Text field | `SearchTextField` with placeholder "Search or paste SimpleX link" | +| Filter button | `ToggleFilterEnabledButton` (filter icon) toggles unread-only filter; shown when search text is empty | +| Clear button | Appears when text is entered; `BackHandler` clears search on back | + +Behavior: +- Filters chat list in real-time by contact/group name via `filteredChats()` +- Detects pasted SimpleX links (`strHasSingleSimplexLink`) and triggers `planAndConnect()` connection dialogue +- In one-hand UI mode, search bar appears below tag filters with IME spacer; in standard mode, above tag filters + +### Chat Filter Tags (`TagsView`) + +Managed by `chatModel.userTags`, `chatModel.presetTags`, and `chatModel.activeChatTagFilter`: + +| Filter | `PresetTagKind` | Icon | Description | +|---|---|---|---| +| Group Reports | `GROUP_REPORTS` | Flag | Chats with moderation reports (non-collapsible) | +| Favorites | `FAVORITES` | Star | User-favorited chats | +| Contacts | `CONTACTS` | Person | Direct contacts and contact requests | +| Groups | `GROUPS` | Group | Group conversations (non-business) | +| Business | `BUSINESS` | Work | Business chat conversations | +| Notes | `NOTES` | Folder | Notes to self | +| Custom tags | `UserTag(ChatTag)` | Label/emoji | User-created tags with custom emoji and name | +| Unread | `ActiveFilter.Unread` | Filter list icon | Chats with unread messages (toggle via filter button) | + +Display logic: +- When collapsible preset tags exceed 3 total (with user tags), they collapse into a `CollapsedTagsFilterView` dropdown menu +- Non-collapsible tags (`GROUP_REPORTS`) always show expanded +- User tags show with emoji or label icon; long-press opens `TagsDropdownMenu` (edit, delete, change order) +- "+" button at end opens `TagListEditor` for creating new tags + +### Chat Preview Rows (`ChatPreviewView`) + +Each row rendered by `ChatPreviewView` inside `ChatListNavLinkView`: + +| Element | Description | +|---|---| +| Avatar | `ProfileImage` with overlay icons (inactive contact, left/removed group member) | +| Chat name | Display name with verified icon for verified contacts; colored for pending/connecting states | +| Last message preview | Truncated text of most recent message; draft indicator with edit icon; attachment icons | +| Timestamp | Relative time of last activity | +| Unread badge | Numeric count badge; distinct styling for mentions | +| Muted indicator | Bell-off icon when notifications are muted | +| Favorite indicator | Star icon for favorited chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +Chat types handled by `ChatListNavLinkView`: +- `ChatInfo.Direct` -- direct contact chat +- `ChatInfo.Group` -- group chat (with in-progress indicator for joining) +- `ChatInfo.Local` -- note-to-self folder +- `ChatInfo.ContactRequest` -- incoming contact request (tap shows accept/reject alert) +- `ChatInfo.ContactConnection` -- pending connection (tap opens `ContactConnectionView`) + +### Context Menu (Long Press / Right Click) + +Each chat type provides specific dropdown menu items: + +| Chat Type | Menu Items | +|---|---| +| Direct contact | Mark read/unread, toggle favorite, toggle notify, tag list, clear chat, delete contact | +| Group | Mark read/unread, toggle favorite, toggle notify, tag list, clear chat, archive all reports (moderator, when reports exist), leave group, delete group | +| Note folder | Mark read/unread, clear notes | +| Contact request | Accept, reject | +| Contact connection | Set name/alias, delete | + +### Floating Elements + +| Element | Condition | Description | +|---|---|---| +| One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch | +| Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` | +| FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` | + +### Empty States + +| State | Display | +|---|---| +| Loading | "Loading chats..." centered text | +| No chats | "You have no chats" centered text | +| No filtered chats | "No chats in list [tag name]" or "No unread chats" with clickable filter reset | +| No search results | "No chats found" centered text | + +## Source Files + +| File | Path | +|---|---| +| `ChatListView.kt` | `views/chatlist/ChatListView.kt` | +| `ChatListNavLinkView.kt` | `views/chatlist/ChatListNavLinkView.kt` | +| `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` | +| `UserPicker.kt` | `views/chatlist/UserPicker.kt` | +| `TagListView.kt` | `views/chatlist/TagListView.kt` | diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md new file mode 100644 index 0000000000..64abda7ee6 --- /dev/null +++ b/apps/multiplatform/product/views/chat.md @@ -0,0 +1,135 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, reporting, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` (routed by `ChatListNavLinkView`) +- **Presented by**: `ChatView` composable bound to `chatModel.chatId`; on Desktop, shown in the center column +- **Back navigation**: Sets `chatModel.chatId = null`, stops `AudioPlayer`, clears group members, returns to chat list +- **Sub-navigation**: + - Info button opens `ChatInfoView` (contact) or `GroupChatInfoView` (group) via `ModalManager.end` + - Member avatars in group chats navigate to `GroupMemberInfoView` + - Reports button opens `GroupReportsView` for groups with moderation reports + - Support chats button opens `MemberSupportView` (moderators) or member support chat (regular members) + +## Page Sections + +### Navigation Bar (`ChatLayout`) + +Custom toolbar with themed background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list; stops audio/video playback | +| Contact/Group avatar | Small profile image in toolbar | +| Chat name | Display name; tappable to open info view | +| Verified shield | Shows verified contact checkmark (direct chats with verified contacts only) | +| More menu button | Opens overflow menu containing search and audio/video call buttons (call buttons shown in direct chats only) | +| Info button | Opens `ChatInfoView` (direct) or `GroupChatInfoView` (group) | +| Reports count | Badge for group reports count; taps open reports view | +| Support chats | Badge for member support; taps open support chat view | + +### Message List + +Rendered by `LazyColumnWithScrollBar` with pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | `apiLoadMessages` called on scroll to load more; supports `.before`, `.after`, `.around`, `.initial` | +| Merged items | Adjacent messages grouped with `ItemSeparation` (timestamp, large gap, date separators) | +| Floating buttons | Scroll-to-bottom button with unread count | +| Date separators | Date headers between messages from different days | +| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes` | +| Content filter | Filter messages by type via `ContentFilter` (images, files, links, etc.) | + +### Message Types + +Each type has a dedicated composable in `views/chat/item/`: + +| Type | Composable | Description | +|---|---|---| +| Text | `FramedItemView` | Rendered with markdown (bold, italic, code, links, `@mentions`) via `CIMarkdownText` | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback via `VideoPlayerHolder` | +| Voice | `CIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions with progress indicator | +| Link preview | `ChatItemLinkView` | URL preview card with title, description, image (defined in `LinkPreviews.kt`) | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message (`ComposeContextItem.QuotedItem`) | +| Forward | Opens destination picker; uses `apiPlanForwardChatItems` with confirmation for partial forwards | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode (`ComposeContextItem.EditingItem`); own messages within edit window | +| Delete | Delete for self or delete for everyone (with confirmation via `deleteMessagesAlertDialog`) | +| Moderate | Group moderators can delete messages for all members (`moderateMessagesAlertDialog`) | +| React | Emoji reaction picker | +| Report | Report message to group moderators (`ComposeContextItem.ReportedItem` with `ReportReason`) | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward/archive toolbar | +| Archive | Archive selected reports (moderators) | + +### Compose Bar (`ComposeView` + `SendMsgView`) + +Bottom input area for composing messages: + +| Element | Description | +|---|---| +| Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support | +| Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file | +| Send button | Sends message; changes to checkmark for reports; animated size/alpha | +| Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview | +| Live message button | Start/update live typing message (if `liveMessageAlertShown`) | +| Context preview | Shows quoted message, editing indicator, or forwarding source above text field | +| Media preview | Thumbnail row of selected images/videos before sending | +| Link preview | Auto-generated link preview card (`ComposePreview.CLinkPreview`) | +| Connecting status | "Connecting..." text shown when contact is not yet ready | +| Commands menu | Developer commands (`showCommandsMenu`) | + +Compose states (`ComposeState`): +- `NoContextItem` -- normal new message +- `QuotedItem` -- replying to a message +- `EditingItem` -- editing own message +- `ForwardingItems` -- forwarding from another chat +- `ReportedItem` -- reporting a message with reason + +### Multi-Select Toolbar (`SelectedItemsButtonsToolbar`) + +Shown when `selectedChatItems != null`: + +| Button | Description | +|---|---| +| Delete / Archive | Delete selected messages (for self, or for everyone if allowed by `fullDeleteAllowed`); shown as Archive for report items (group moderators only) | +| Forward | Forward selected messages to another chat | +| Moderate | Delete selected messages for all members (group moderators only) | + +### Timed/Disappearing Messages + +When `timedMessageAllowed` is true, compose bar includes a timer icon for setting message disappear time via `customDisappearingMessageTimePref`. + +## Source Files + +| File | Path | +|---|---| +| `ChatView.kt` | `views/chat/ChatView.kt` | +| `ComposeView.kt` | `views/chat/ComposeView.kt` | +| `SendMsgView.kt` | `views/chat/SendMsgView.kt` | +| Chat item views | `views/chat/item/*.kt` | diff --git a/apps/multiplatform/product/views/contact-info.md b/apps/multiplatform/product/views/contact-info.md new file mode 100644 index 0000000000..32793a3b70 --- /dev/null +++ b/apps/multiplatform/product/views/contact-info.md @@ -0,0 +1,104 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings (switch address, sync ratchet), and perform destructive actions like clearing or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `ChatInfoView` composable shown via `ModalManager.end` from `ChatView` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` (via `ModalManager.end`) + - Security code verification -> `VerifyCodeView` (via `ModalManager.end`) + - Chat wallpaper -> wallpaper editor + - Group profile view (for group-direct contacts) + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar (tappable) | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field for setting a local-only name visible only on this device. Not shared with the contact. Changes saved via `setContactAlias()`. + +### Action Buttons + +Horizontal row of quick-action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearchClicked` to search messages in chat | +| Audio call | Initiate audio call | +| Video call | Initiate video call | +| Mute/Unmute | Toggle notification mode | + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito profile): + +| Element | Description | +|---|---| +| Incognito icon | Indicates incognito connection | +| Profile name | The random profile name used for this connection | + +### Chat Preferences + +| Setting | Description | +|---|---| +| Send receipts | Per-contact delivery receipt setting (`SendReceipts` tristate: default/on/off) | +| Chat item TTL | Per-contact message retention setting (`ChatItemTTL` with alert confirmation) | +| Contact preferences | Opens `ContactPreferencesView` for feature toggles (timed messages, full delete, reactions, voice, calls) | + +### Connection Details + +Shown when `connectionStats` is available: + +| Element | Description | +|---|---| +| Connection stats | Server information, agent connection ID | +| Switch address | Initiates SMP server address switch (`apiSwitchContact`) with confirmation alert | +| Abort switch | Cancels an in-progress address switch (`apiAbortSwitchContact`) | +| Sync connection | Fixes encryption ratchet synchronization (`apiSyncContactRatchet`) | +| Force sync | Force ratchet re-synchronization with confirmation alert | + +### Security Code Verification + +| Element | Description | +|---|---| +| Verify button | Opens `VerifyCodeView` showing the connection security code | +| Verified badge | Shows checkmark when contact is verified | +| Code comparison | Side-by-side code display for out-of-band verification via `apiVerifyContact` | + +### Developer Tools Section + +Shown when `developerTools` preference is enabled: + +| Element | Description | +|---|---| +| Database ID | Contact's internal database identifier | +| Agent connection ID | Underlying SMP agent connection ID | + +### Destructive Actions + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages in chat (with confirmation via `clearChatDialog`) | +| Delete contact | Removes the contact and all associated data (with confirmation via `deleteContactDialog`) | + +## Source Files + +| File | Path | +|---|---| +| `ChatInfoView.kt` | `views/chat/ChatInfoView.kt` | +| `ContactPreferences.kt` | `views/chat/ContactPreferences.kt` | +| `VerifyCodeView.kt` | `views/chat/VerifyCodeView.kt` | diff --git a/apps/multiplatform/product/views/group-info.md b/apps/multiplatform/product/views/group-info.md new file mode 100644 index 0000000000..65b068adc8 --- /dev/null +++ b/apps/multiplatform/product/views/group-info.md @@ -0,0 +1,145 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `GroupChatInfoView` composable shown via `ModalManager.end` from `ChatView` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` (via `ModalManager.end`) + - Add members -> `AddGroupMembersView` (via `ModalManager.end`) + - Group link -> `GroupLinkView` (via `ModalManager.end`) + - Group preferences -> `GroupPreferencesView` (via `ModalManager.end`) + - Welcome message -> `GroupWelcomeView` (via `ModalManager.end`) + - Member info -> `GroupMemberInfoView` (via `ModalManager.end`) + - Chat wallpaper -> wallpaper editor + - Member support -> `MemberSupportView` (via `ModalManager.end`) + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners via `GroupProfileView`) | +| Member count | "N members" label from `activeSortedMembers` | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Changes saved via `setGroupAlias()`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearchClicked` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode | +| Add members | Opens `AddGroupMembersView` (shown when user has admin+ role and `groupInfo.canAddMembers`) | + +### Group Management Section + +Available actions depend on role (`GroupMemberRole`): + +| Action | Minimum Role | Description | +|---|---|---| +| Edit group profile | Owner | Opens `GroupProfileView` to edit name, image, description | +| Add members | Admin | Opens `AddGroupMembersView` to invite contacts | +| Manage group link | Admin | Opens `GroupLinkView` to create/share/delete group link | +| Member support | Moderator | Opens `MemberSupportView` to manage member support chats | +| Edit welcome message | Owner | Opens `GroupWelcomeView` to set the auto-sent welcome text | +| Group preferences | Any | Opens `GroupPreferencesView` (read-only; only owners can change settings) | + +### Chat Preferences + +| Setting | Description | +|---|---| +| Send receipts | Per-group delivery receipt setting (`SendReceipts`); limited to groups under `SMALL_GROUPS_RCPS_MEM_LIMIT` (20 members) | +| Chat item TTL | Per-group message retention setting with confirmation alert via `setChatTTLAlert` | + +### Member List + +Displays `activeSortedMembers` (excluding left/removed members, sorted by role descending): + +| Element | Description | +|---|---| +| Member avatar | `MEMBER_ROW_AVATAR_SIZE` (42dp) profile image | +| Member name | Display name with role badge | +| Member role | Owner, Admin, Moderator, Member, Observer | +| Member status | Active, connecting, pending, left, removed | +| Tap action | Opens `GroupMemberInfoView` with connection stats and verification code | + +### Group Link (`GroupLinkView`) + +| Element | Description | +|---|---| +| Create link button | `apiCreateGroupLink` generates a shareable group invitation link | +| QR code display | QR code rendering of the group link | +| Short link toggle | Switch between short and full link display | +| Share button | System share for the link | +| Copy button | Copy link to clipboard | +| Member role selector | Set the default role for members joining via link (`acceptMemberRole`) | +| Add short link | `apiAddGroupShortLink` creates a short link that includes group profile | +| Delete link | Remove the group link with confirmation | + +### Add Members (`AddGroupMembersView`) + +| Element | Description | +|---|---| +| Contact list | Filterable list of contacts to invite | +| Role selector | Set the role for invited members | +| Invite button | Sends group invitations to selected contacts | +| Group link option | Alternative to direct invitation | + +### Group Member Info (`GroupMemberInfoView`) + +| Element | Description | +|---|---| +| Member profile | Avatar, name, role | +| Connection stats | Server information, connection status | +| Security code | Verification code for the member connection | +| Role change | Change member role (admin+ only) | +| Remove member | Remove from group (admin+ only) | +| Block member | Block member for self | +| Direct message | Open direct chat with member | + +### Developer Tools Section + +Shown when `developerTools` preference is enabled: + +| Element | Description | +|---|---| +| Database ID | Group's internal database identifier | + +### Destructive Actions + +| Action | Condition | Description | +|---|---|---| +| Clear chat | Any member | Deletes all messages locally (`clearChatDialog`) | +| Leave group | Non-owner | Leave the group (`leaveGroupDialog`) | +| Delete group | Owner or non-current member | Delete group for all (owner) or for self (`deleteGroupDialog`) | + +Business chats use alternative labels: "Delete chat" instead of "Delete group". + +## Source Files + +| File | Path | +|---|---| +| `GroupChatInfoView.kt` | `views/chat/group/GroupChatInfoView.kt` | +| `GroupMemberInfoView.kt` | `views/chat/group/GroupMemberInfoView.kt` | +| `AddGroupMembersView.kt` | `views/chat/group/AddGroupMembersView.kt` | +| `GroupLinkView.kt` | `views/chat/group/GroupLinkView.kt` | +| `GroupProfileView.kt` | `views/chat/group/GroupProfileView.kt` | +| `GroupPreferences.kt` | `views/chat/group/GroupPreferences.kt` | +| `WelcomeMessageView.kt` | `views/chat/group/WelcomeMessageView.kt` | +| `MemberAdmission.kt` | `views/chat/group/MemberAdmission.kt` | +| `MemberSupportView.kt` | `views/chat/group/MemberSupportView.kt` | diff --git a/apps/multiplatform/product/views/new-chat.md b/apps/multiplatform/product/views/new-chat.md new file mode 100644 index 0000000000..b664fda67f --- /dev/null +++ b/apps/multiplatform/product/views/new-chat.md @@ -0,0 +1,96 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary entry point for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar or FAB +- **Presented by**: `NewChatSheet` modal from `ChatListView` via `showNewChatSheet()`; wraps `NewChatView` and group creation in `ModalManager.start` +- **Internal navigation**: `NewChatSheet` provides 3 action buttons: + - "Create 1-time link" -- opens `NewChatView` with `INVITE` tab (generate and share a one-time invitation link) + - "Scan / paste link" -- opens `NewChatView` with `CONNECT` tab (scan QR code or paste a received link) + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: `HorizontalPager` with `TabRow` toggles between `NewChatOption.INVITE` (1-time link) and `NewChatOption.CONNECT` (connect via link) +- **Swipe gesture**: Left/right swipe switches between tabs (Android only; `userScrollEnabled = appPlatform.isAndroid`) +- **Dismiss behavior**: On dispose, a `DisposableEffect` shows an alert dialog (via `AlertManager.shared.showAlertDialog`) asking whether to keep an unused invitation link or delete it via `controller.deleteChat()` + +## Page Sections + +### Tab Selector + +| Tab | Icon | Label | Description | +|---|---|---|---| +| 1-time link | `ic_repeat_one` | "1-time link" | Generate and share a one-time invitation link | +| Connect via link | `ic_qr_code` | "Connect via link" | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) -- `PrepareAndInviteView` + +Displayed when `selection == INVITE`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `CreatingLinkProgressView` with "Creating link" text while `creatingConnReq` is true | +| Retry button | `RetryButton` shown if link creation fails; calls `createInvitation()` | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. The invitation is tracked via `chatModel.showingInvitation`. + +### Connect Tab -- `ConnectView` + +Displayed when `selection == CONNECT`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based QR code scanner (`showQRCodeScanner` state) | +| Paste link field | Text field for pasting a SimpleX link (`pastedLink`) | +| Connect button | Initiates connection via `planAndConnect()` | + +When a valid SimpleX link is detected: +1. `planAndConnect()` is called with the link URI +2. If the link matches a known contact, filters to that chat +3. If the link matches a known group, filters to that group +4. Otherwise, creates a new connection + +### Create Group (`AddGroupView`) + +| Element | Description | +|---|---| +| Group name field | Required display name input with `FocusRequester` | +| Profile image picker | `GetImageBottomSheet` for selecting/cropping a group avatar | +| Incognito toggle | Option to create group with random profile (`incognitoPref`) | +| Create button | Calls `apiNewGroup()`, then opens `AddGroupMembersView` (normal) or `GroupLinkView` (incognito) | + +Group creation flow: +1. User enters group name and optionally selects an image +2. `apiNewGroup()` creates the group and returns `GroupInfo` +3. `openGroupChat()` navigates to the new group chat +4. `setGroupMembers()` preloads member data +5. `AddGroupMembersView` opens for inviting contacts (or `GroupLinkView` for incognito groups) + +### QR Code Components (`QRCode.kt`) + +| Component | Description | +|---|---| +| `SimpleXLinkQRCode` | Renders a QR code for a SimpleX connection link | +| QR scanner | Platform camera scanner for reading QR codes | +| Short link display | Compact link text with copy/share actions | + +## Source Files + +| File | Path | +|---|---| +| `NewChatView.kt` | `views/newchat/NewChatView.kt` | +| `AddGroupView.kt` | `views/newchat/AddGroupView.kt` | +| `QRCode.kt` | `views/newchat/QRCode.kt` | +| `NewChatSheet.kt` | `views/newchat/NewChatSheet.kt` | +| `ConnectPlan.kt` | `views/newchat/ConnectPlan.kt` | +| `QRCodeScanner.kt` | `views/newchat/QRCodeScanner.kt` (expect/actual) | +| `ContactConnectionInfoView.kt` | `views/newchat/ContactConnectionInfoView.kt` | diff --git a/apps/multiplatform/product/views/onboarding.md b/apps/multiplatform/product/views/onboarding.md new file mode 100644 index 0000000000..4127ac65f7 --- /dev/null +++ b/apps/multiplatform/product/views/onboarding.md @@ -0,0 +1,139 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, database passphrase setup (Desktop), server operator conditions acceptance, SimpleX address creation, and notification configuration (Android). Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStage` is not `OnboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression controlled by `appPrefs.onboardingStage` +- **Completion**: Sets `onboardingStage` to `OnboardingComplete` + +## Onboarding Stages + +The `OnboardingStage` enum defines the flow: + +| Stage | Description | +|---|---| +| `Step1_SimpleXInfo` | Welcome screen with app introduction | +| `Step2_CreateProfile` | Create first user profile | +| `LinkAMobile` | Desktop-only: link a mobile device | +| `Step2_5_SetupDatabasePassphrase` | Desktop-only: set database encryption passphrase | +| `Step3_ChooseServerOperators` | Accept server operator conditions | +| `Step3_CreateSimpleXAddress` | Create a SimpleX contact address | +| `Step4_SetNotificationsMode` | Android-only: configure notification mode | +| `OnboardingComplete` | Onboarding finished | + +## Page Sections + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `Step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | `SimpleXLogo` -- SimpleX Chat logo (light/dark variant based on `isInDarkTheme()`) | +| Info button | `OnboardingInformationButton` -- "The next generation of private messaging"; taps open `HowItWorks` fullscreen modal | +| Privacy redefined | `InfoRow` with privacy icon: "No user identifiers" | +| Immune to spam | `InfoRow` with shield icon: "You decide who can connect" | +| Decentralized | `InfoRow` with decentralized icon: "Anybody can host servers" | +| **Create your profile** button | `OnboardingActionButton` -- primary action; advances to profile creation | +| **Migrate from another device** button | `TextButtonBelowOnboardingButton` -- opens `MigrateToDeviceView` fullscreen modal | + +Layout: `ColumnWithScrollBar` with `DEFAULT_ONBOARDING_HORIZONTAL_PADDING`, max width constrained (250dp Android, 500dp Desktop). + +### Step 2: Create Profile + +**Stage**: `Step2_CreateProfile` + +| Element | Description | +|---|---| +| Display name field | Required text input; auto-focused | +| Validation | Name validation with `mkValidName` check | +| Create button | Creates profile via API; advances to next step | + +Profile is stored locally and only shared with contacts. + +### Step 2.5: Setup Database Passphrase (Desktop only) + +**Stage**: `Step2_5_SetupDatabasePassphrase` + +| Element | Description | +|---|---| +| Passphrase field | Secure text input for database encryption key | +| Confirm field | Passphrase confirmation | +| Set button | Encrypts database with passphrase | + +### Link a Mobile (Desktop only) + +**Stage**: `LinkAMobile` + +| Element | Description | +|---|---| +| Instructions | How to connect mobile device to desktop | +| QR code | Connection QR code for mobile scanning | +| Skip button | Skip this step | + +### Step 3: Choose Server Operators + +**Stage**: `Step3_ChooseServerOperators` + +| Element | Description | +|---|---| +| Operator list | Available server operators with conditions | +| Conditions text | Terms of service for selected operators | +| Accept button | Accept conditions and continue | + +Managed by `ChooseServerOperators.kt`. + +### Step 3b: Create SimpleX Address + +**Stage**: `Step3_CreateSimpleXAddress` + +| Element | Description | +|---|---| +| Address creation | Auto-creates a SimpleX contact address | +| QR code | Displays the created address as QR code | +| Share button | Share address link | +| Skip button | Skip address creation | + +### Step 4: Set Notifications Mode (Android only) + +**Stage**: `Step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| Notification options | Instant (background service) / Periodic (every 10 min) / Off | +| Description | Explains battery impact and notification behavior for each mode | +| Continue button | Saves selection and completes onboarding | + +Managed by `SetNotificationsMode.kt`. + +### What's New (`WhatsNewView`) + +Shown after onboarding or when triggered from Settings: + +| Element | Description | +|---|---| +| Version highlights | New features and changes in the current version | +| Updated conditions | Notice about updated server operator conditions (if applicable) | +| Close button | Dismisses the view | + +Triggered in `ChatListView` via `shouldShowWhatsNew()` with a 1-second delay. + +## Source Files + +| File | Path | +|---|---| +| `OnboardingView.kt` | `views/onboarding/OnboardingView.kt` | +| `SimpleXInfo.kt` | `views/onboarding/SimpleXInfo.kt` | +| `HowItWorks.kt` | `views/onboarding/HowItWorks.kt` | +| `SetupDatabasePassphrase.kt` | `views/onboarding/SetupDatabasePassphrase.kt` | +| `SetNotificationsMode.kt` | `views/onboarding/SetNotificationsMode.kt` | +| `ChooseServerOperators.kt` | `views/onboarding/ChooseServerOperators.kt` | +| `WhatsNewView.kt` | `views/onboarding/WhatsNewView.kt` | +| `LinkAMobileView.kt` | `views/onboarding/LinkAMobileView.kt` | diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md new file mode 100644 index 0000000000..e668bf2d04 --- /dev/null +++ b/apps/multiplatform/product/views/settings.md @@ -0,0 +1,159 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker or directly from the chat list toolbar. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option; or directly via `NavigationButtonMenu` when no users exist +- **Presented by**: `SettingsView` composable via `ModalManager.start.showModalCloseable` +- **Navigation title**: "Your settings" (`AppBarTitle`) +- **Sub-navigation**: Each settings row opens a dedicated view via `showSettingsModal` or `showCustomModal` + +## Platform Differences + +| Aspect | Android | Desktop | +|---|---|---| +| App section | Device settings, app version | App updates (`AppUpdater`), device settings, app version | +| Notifications | Full notification mode selection (instant/periodic/off) | Notification settings | +| Use from desktop/mobile | "Use from desktop" option in UserPicker | "Link a mobile" / "Linked mobiles" option in UserPicker | +| Database migration | "Migrate to another device" with auth | Same | + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `ic_bolt` / `ic_bolt_off` | `NotificationsSettingsView` | Push notification mode and preview settings | +| Network & servers | `ic_wifi_tethering` | `NetworkAndServersView` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `ic_videocam` | `CallSettingsView` | WebRTC relay policy, ICE servers | +| Privacy & security | `ic_lock` | `PrivacySettingsView` | SimpleX Lock, delivery receipts, link previews, auto-accept | +| Appearance | `ic_light_mode` | `AppearanceView` | Theme, language, profile images, chat bubbles | + +All rows disabled when `chatModel.chatRunning != true` (except Appearance). + +#### Notifications (`NotificationsSettingsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background service) / Periodic (every 10 min) / Off | +| Notification preview | Configuration for notification content visibility | + +#### Network & Servers (`NetworkAndServersView`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | + +Sub-files: `NetworkAndServers.kt`, `ProtocolServersView.kt`, `ProtocolServerView.kt`, `NewServerView.kt`, `ScanProtocolServer.kt`, `AdvancedNetworkSettings.kt`, `OperatorView.kt` + +#### Audio & Video Calls (`CallSettingsView`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / relay when needed / never relay | +| ICE servers | Custom STUN/TURN server configuration | + +#### Privacy & Security (`PrivacySettingsView`) + +Organized in sections: + +**Device Section** (`PrivacyDeviceSection`): + +| Setting | Description | +|---|---| +| SimpleX Lock | `SimplexLockView` -- app lock with system auth or passcode (`LAMode.SYSTEM` / `LAMode.PASSCODE`) | + +**Chats Section**: + +| Setting | Preference Key | Description | +|---|---|---| +| Send link previews | `privacyLinkPreviews` | Auto-generate link preview cards | +| Sanitize links | `privacySanitizeLinks` | Strip tracking parameters from URLs | +| Show last messages | `privacyShowChatPreviews` | Show message previews in chat list | +| Message draft | `privacySaveLastDraft` | Save unsent message draft for each chat | + +**Files Section**: + +| Setting | Preference Key | Description | +|---|---|---| +| Encrypt local files | `privacyEncryptLocalFiles` | Encrypt files stored on device | +| Auto-accept images | `privacyAcceptImages` | Automatically download received images | +| Blur media radius | `privacyMediaBlurRadius` | Blur radius for media previews | +| Protect IP address | `privacyAskToApproveRelays` | Prompt before connecting to unknown file relays to protect IP address | + +#### Appearance (`AppearanceView`) + +Platform-specific composable (`expect fun AppearanceView`): + +| Setting | Description | +|---|---| +| Profile images | `ProfileImageSection` -- slider for profile image corner radius | +| Theme selection | Color scheme / theme picker | +| Language | App language selection | +| Chat wallpaper | Background image settings | +| Chat bubbles | Message bubble appearance configuration | +| Toolbar opacity | App bar transparency settings (`inAppBarsAlpha`) | +| Color picker | `ClassicColorPicker` for custom theme colors | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `ic_database` | `DatabaseView` | Manage encryption, export/import database | +| Migrate to another device | `ic_ios_share` | `MigrateFromDeviceView` | Device migration (requires auth) | + +Database icon shows warning color (`WarningOrange`) when database is not encrypted or passphrase is not saved. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use SimpleX Chat | `ic_help` | `HelpView` | Usage guide | +| What's new | `ic_add` | `WhatsNewView` | Version changelog | +| About SimpleX Chat | `ic_info` | `SimpleXInfo` (non-onboarding mode) | App information | +| Chat with the founder | `ic_tag` | Opens SimpleX link | Direct chat with SimpleX team | +| Send us an email | `ic_mail` | Opens mailto: | Email support | + +### Support Section + +| Row | Icon | Description | +|---|---|---| +| Contribute | `ic_keyboard` | Opens GitHub contribution page (hidden for Android Bundle) | +| Rate the app | `ic_star` | Opens Google Play / app store listing | +| Star on GitHub | `ic_github` | Opens GitHub repository | + +### App Section (`SettingsSectionApp`) + +Platform-specific section (expect/actual composable): + +| Row | Description | +|---|---| +| App updates (Desktop) | App update checker and installer | +| Developer tools | Toggle developer mode | +| Chat console | Opens `ChatConsoleView` terminal | +| Terminal always visible (Desktop) | Keep terminal window open | +| Install terminal app | Link to CLI app on GitHub | +| Reset all hints | Reset dismissed hint/card preferences | +| App version | Version string with build info; taps open `VersionInfoView` | + +## Source Files + +| File | Path | +|---|---| +| `SettingsView.kt` | `views/usersettings/SettingsView.kt` | +| `Appearance.kt` | `views/usersettings/Appearance.kt` | +| `PrivacySettings.kt` | `views/usersettings/PrivacySettings.kt` | +| `NetworkAndServers.kt` | `views/usersettings/networkAndServers/NetworkAndServers.kt` | +| `AdvancedNetworkSettings.kt` | `views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| `OperatorView.kt` | `views/usersettings/networkAndServers/OperatorView.kt` | +| `ProtocolServersView.kt` | `views/usersettings/networkAndServers/ProtocolServersView.kt` | +| `NewServerView.kt` | `views/usersettings/networkAndServers/NewServerView.kt` | diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md new file mode 100644 index 0000000000..dfc37a5e8d --- /dev/null +++ b/apps/multiplatform/product/views/user-profiles.md @@ -0,0 +1,122 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password. The UserPicker provides quick profile switching from the chat list, while UserProfilesView offers full profile management. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserProfilesView` composable via `ModalManager.start.showCustomModal` with search bar +- **Navigation title**: "Your chat profiles" (`AppBarTitle`) +- **Sub-navigation**: + - Create profile -> `CreateProfile` (via `ModalManager.center`) + - Edit active profile -> `UserProfileView` (via UserPicker tap on active user) + - User address -> `UserAddressView` (via UserPicker) + - Chat preferences -> `PreferencesView` (via UserPicker) + +## Page Sections + +### UserPicker (`UserPicker.kt`) + +Overlay panel triggered from `ChatListView` toolbar: + +| Section | Description | +|---|---| +| Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | +| Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | +| User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | +| SimpleX address | Row to open `UserAddressView` (create or view address) | +| Chat preferences | Row to open `PreferencesView` | +| Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | +| Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | +| Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | + +Platform behavior: +- **Android**: `PlatformUserPicker` renders as bottom sheet with `AnimatedViewState` transitions; shows all users inline +- **Desktop**: Sidebar panel; shows only active user in header, inactive users in separate section below divider + +### UserProfilesView + +Full profile management screen with search/password field: + +#### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against `user.anyNameContains()` and `correctPassword()` + +#### Profile List + +Each row rendered by `UserView` -> `UserProfilePickerItem`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark icon (`ic_done_filled`) for the current active profile | +| Profile image | 54dp avatar with `fontSizeSqrtMultiplier` scaling | +| Display name | Profile's display name; bold for active, normal for inactive | +| Unread count | Badge showing unread message count (`unreadCountStr`) with primary/secondary color based on mute state | +| Muted indicator | `ic_notifications_off` icon when profile notifications are muted | +| Hidden indicator | `ic_lock` icon for hidden profiles (only shown when revealed via password) | + +#### Profile Row Tap Action + +| Action | Description | +|---|---| +| Switch active | Tapping a profile row calls `changeActiveUser()` to activate the selected profile; all chats switch context | + +#### Profile Actions (Context Menu) + +Available via long-press / right-click on a profile row (`DefaultDropdownMenu`): + +| Action | Condition | Description | +|---|---|---| +| Mute | Visible, notifications on | `apiMuteUser()` mutes notifications; shows `showMuteProfileAlert` on first use | +| Unmute | Visible, notifications off | `apiUnmuteUser()` restores notifications | +| Hide | Visible, multiple visible users | Opens `HiddenProfileView` to set password | +| Unhide | Hidden profile | `apiUnhideUser()` with password entry (`ProfileActionView` with `UserProfileAction.UNHIDE`) | +| Delete | Any non-sole profile | Delete with confirmation dialog; options: "Delete with connections" (removes SMP queues) or "Delete data only" | + +#### Add Profile + +| Element | Description | +|---|---| +| Add button | "+" icon with "Add profile" text at bottom of list (hidden when searching) | +| Auth required | Profile creation requires authentication via `withAuth` | +| Create view | Opens `CreateProfile` in `ModalManager.center` | + +#### Profile Deletion (`removeUser`) + +Deletion flow: +1. If hidden profile requiring password: opens `ProfileActionView` with `UserProfileAction.DELETE` +2. If active profile: switches to another visible user first via `changeActiveUser_`, then deletes +3. If last visible profile with hidden profiles: deletes user, then changes active to null; on Android, stops chat and resets to onboarding +4. Cleans up wallpaper files and cancels notifications for the deleted user + +#### Hidden Profile Notice + +Shown once via `showHiddenProfilesNotice` preference: + +| Element | Description | +|---|---| +| Alert title | "Make profile private" | +| Alert text | "You can hide or mute user profile" | +| "Don't show again" | Disables the notice permanently | + +### Profile Password Validation + +| Function | Description | +|---|---| +| `correctPassword()` | Validates password against `user.viewPwdHash` using `chatPasswordHash(pwd, salt)` | +| `passwordEntryRequired()` | Returns true if user is hidden, active, and password does not match current search text | +| `userViewPassword()` | Extracts view password from search text for hidden user operations | + +## Source Files + +| File | Path | +|---|---| +| `UserProfilesView.kt` | `views/usersettings/UserProfilesView.kt` | +| `UserPicker.kt` | `views/chatlist/UserPicker.kt` | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md new file mode 100644 index 0000000000..c5d9a3b4f7 --- /dev/null +++ b/apps/multiplatform/spec/README.md @@ -0,0 +1,137 @@ +# SimpleX Chat -- Kotlin Multiplatform Specification + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Dependency Graph](#dependency-graph) +3. [Specification Documents](#specification-documents) +4. [Product Documents](#product-documents) +5. [Source Entry Points](#source-entry-points) + +--- + +## Executive Summary + +SimpleX Chat is a Kotlin Multiplatform application targeting **Android** and **Desktop** (JVM) platforms. The UI layer is built entirely with Jetpack Compose. The application communicates with a Haskell-based cryptographic core (`simplex-chat`) through a **JNI bridge** -- native functions declared in Kotlin and linked at runtime to a shared library (`libapp-lib`). Platform-specific behavior (notifications, file system paths, services, audio/video) is abstracted using the `expect`/`actual` pattern and a runtime-assignable `PlatformInterface` callback object. + +The Gradle project is structured as three modules: + +| Module | Purpose | +|---|---| +| `:common` | Shared Compose UI, models, platform abstractions (`commonMain`, `androidMain`, `desktopMain`) | +| `:android` | Android application entry point (`SimplexApp`, `MainActivity`) | +| `:desktop` | Desktop application entry point (`Main.kt`, `showApp()`) | + +All meaningful application logic resides in `:common/commonMain`. Platform source sets (`androidMain`, `desktopMain`) provide `actual` implementations for `expect` declarations and host platform-specific integration code. + +--- + +## Dependency Graph + +``` +App Entry Points ++-- Android: SimplexApp.onCreate -> initHaskell -> initMultiplatform -> initChatControllerOnStart +| MainActivity.onCreate -> setContent { AppScreen() } ++-- Desktop: main() -> initHaskell -> runMigrations -> initApp -> showApp -> AppWindow -> AppScreen() + | + v +Common Module (commonMain) ++-- ChatModel (Compose state singleton) <-> ChatController/SimpleXAPI (JNI bridge) <-> Haskell Core (chat_ctrl) ++-- Views (Compose) +| +-- App.kt: AppScreen -> MainScreen +| +-- ChatListView -> ChatView -> ComposeView -> SendMsgView +| +-- ChatItemView (message rendering: text, image, video, voice, file, call, events) +| +-- Settings: SettingsView, UserProfileView, UserProfilesView +| +-- Onboarding: OnboardingView, WhatsNewView, CreateFirstProfile +| +-- Call: CallView, IncomingCallAlertView +| +-- Database: DatabaseView, DatabaseEncryptionView, DatabaseErrorView +| +-- Groups: GroupChatInfoView, AddGroupMembersView, GroupMemberInfoView +| +-- Contacts: ContactListNavView +| +-- Remote: ConnectDesktopView, ConnectMobileView +| +-- Terminal: TerminalView ++-- Models +| +-- ChatModel -- global app state (Compose MutableState singleton) +| +-- ChatsContext -- per-context chat list state (primary + optional secondary) +| +-- Chat -- per-conversation state (chatInfo, chatItems, chatStats) +| +-- ChatController -- API command dispatch, event receiver, preferences +| +-- AppPreferences -- 150+ SharedPreferences keys ++-- Services +| +-- NtfManager -- abstract notification coordinator (Android/Desktop implementations) +| +-- SimplexService -- Android foreground service for background messaging +| +-- ThemeManager -- theme resolution (system/light/dark/simplex/black + per-user overrides) +| +-- CallManager -- WebRTC call lifecycle ++-- Platform (expect/actual) + +-- Core.kt -- JNI declarations (external fun), initChatController, chatInitTemporaryDatabase + +-- AppCommon.kt -- runMigrations, AppPlatform enum + +-- Files.kt -- dataDir, tmpDir, filesDir, dbAbsolutePrefixPath (expect) + +-- Share.kt -- shareText, shareFile, openFile (expect) + +-- VideoPlayer.kt -- VideoPlayerInterface, VideoPlayer (expect class) + +-- RecAndPlay.kt -- RecorderInterface, AudioPlayerInterface (expect) + +-- UI.kt -- showToast, hideKeyboard, getKeyboardState (expect) + +-- Notifications.kt -- allowedToShowNotification (expect) + +-- NtfManager.kt -- abstract NtfManager class + +-- Platform.kt -- PlatformInterface (runtime callback object) + +-- Cryptor.kt -- CryptorInterface (expect) + +-- Images.kt -- bitmap utilities (expect) + +-- SimplexService.kt-- getWakeLock (expect) + +-- Log.kt, Modifier.kt, Back.kt, ScrollableColumn.kt, PlatformTextField.kt, Resources.kt +``` + +--- + +## Specification Documents + +| Document | Path | Description | +|---|---|---| +| Architecture | [spec/architecture.md](architecture.md) | System layers, module structure, JNI bridge, app lifecycle, event streaming, platform abstraction | +| State Management | [spec/state.md](state.md) | ChatModel singleton, ChatsContext, Chat data class, AppPreferences, ActiveChatState | +| API | [spec/api.md](api.md) | ChatController command dispatch, ~150 API functions in 11 categories, CC/CR/API types | +| Database | [spec/database.md](database.md) | SQLite database files, migrations, encryption, backup/restore | +| Impact | [spec/impact.md](impact.md) | Source file → product concept mapping for change impact analysis | +| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions | +| Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags | +| Compose | [spec/client/compose.md](client/compose.md) | ComposeView, SendMsgView, ComposeState, attachments, mentions | +| Navigation | [spec/client/navigation.md](client/navigation.md) | App screen routing, onboarding, settings, new chat flows | +| Calls | [spec/services/calls.md](services/calls.md) | WebRTC call lifecycle, signaling, platform-specific call views | +| Files | [spec/services/files.md](services/files.md) | File transfer (SMP inline / XFTP), CryptoFile encryption, platform file paths | +| Notifications | [spec/services/notifications.md](services/notifications.md) | NtfManager, SimplexService, notification channels, background delivery | +| Theme | [spec/services/theme.md](services/theme.md) | ThemeManager, color system, wallpapers, per-user overrides | + +--- + +## Product Documents + +| Category | Path | Topic | +|---|---|---| +| Overview | [product/README.md](../product/README.md) | Product overview, capability map, navigation map | +| Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | +| Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | +| Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | +| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations | +| Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle | +| Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles | + +--- + +## Source Entry Points + +| Component | File | Key Symbol | Line | +|---|---|---|---| +| Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L41) | `class SimplexApp` | 41 | +| Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L27) | `class MainActivity` | 27 | +| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) | `fun main()` | 21 | +| Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) | `fun showApp()` | 33 | +| Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | +| Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | +| JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | +| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | `object ChatController` | 493 | +| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | +| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) | `class AppPreferences` | 94 | +| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | +| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 | +| Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | +| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 | +| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 | +| Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 | +| Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L22) | `include(":android", ":desktop", ":common")` | 22 | +| Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L14) | `kotlin { androidTarget(); jvm("desktop") }` | 14 | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md new file mode 100644 index 0000000000..15d5e141a0 --- /dev/null +++ b/apps/multiplatform/spec/api.md @@ -0,0 +1,435 @@ +# Chat API Reference + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories](#2-command-categories) + - 2.1 [User Management](#21-user-management) + - 2.2 [Chat Lifecycle](#22-chat-lifecycle) + - 2.3 [Message Operations](#23-message-operations) + - 2.4 [Group Operations](#24-group-operations) + - 2.5 [Contact Operations](#25-contact-operations) + - 2.6 [File Operations](#26-file-operations) + - 2.7 [Call Operations](#27-call-operations) + - 2.8 [Settings & Network](#28-settings--network) + - 2.9 [Chat Tags](#29-chat-tags) + - 2.10 [Server Operators](#210-server-operators) + - 2.11 [Archive](#211-archive) +3. [Response Types](#3-response-types) +4. [Event Types](#4-event-types) +5. [Error Types](#5-error-types) +6. [Source Files](#6-source-files) + +--- + +## 1. Overview + +The SimpleX Chat API bridge connects Kotlin/Compose UI code to the Haskell core via JNI. All communication follows a **command/response JSON protocol**: + +``` +Kotlin suspend fun api*() + -> ChatController.sendCmd(rhId, CC.*, ctrl) + -> serialize CC to cmdString (JSON) + -> chatSendCmdRetry(ctrl, cmdString, retryNum) [JNI / external fun] + -> Haskell core processes command + -> returns JSON response string + -> json.decodeFromString(responseString) + -> API.Result(rhId, CR.*) or API.Error(rhId, ChatError) + -> pattern-match on CR subclass -> update ChatModel / return data to UI +``` + +**Key types in the pipeline:** + +| Type | Role | Location | +|------|------|----------| +| `CC` (sealed class) | Command definitions (~165 subclasses) | [SimpleXAPI.kt#L3529](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L3529) | +| `API` (sealed class) | Top-level response wrapper (`Result` / `Error`) | [SimpleXAPI.kt#L5975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L5975) | +| `CR` (sealed class) | Chat response variants (~180 subclasses) | [SimpleXAPI.kt#L6114](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6114) | +| `ChatError` (sealed class) | Error hierarchy | [SimpleXAPI.kt#L6974](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6974) | +| `ChatController` (object) | Singleton hosting all `api*` functions | [SimpleXAPI.kt#L493](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | + +**JNI bridge functions** (declared in [Core.kt#L25](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25)): + +```kotlin +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +external fun chatCloseStore(ctrl: ChatCtrl): String +external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String +external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String +external fun chatRecvMsg(ctrl: ChatCtrl): String +external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String +``` + + + +**`sendCmd` flow** ([SimpleXAPI.kt#L804](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804)): + +1. Obtains the `ChatCtrl` handle (or uses the provided `otherCtrl`). +2. Serializes the `CC` command to its `cmdString`. +3. Dispatches to `Dispatchers.IO`; calls `chatSendCmdRetry` (local) or `chatSendRemoteCmdRetry` (remote host). +4. Decodes the returned JSON string into `API`. +5. Logs the result to the terminal item list. + + + + + +**Asynchronous event receiver** (`startReceiver`, [SimpleXAPI.kt#L660](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660)): + +A long-running coroutine on `Dispatchers.IO` repeatedly calls `chatRecvMsgWait` (blocking JNI). Each received `API` message is dispatched to `processReceivedMsg` ([SimpleXAPI.kt#L2568](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568)), which pattern-matches on `CR` subclasses to update `ChatModel` state and trigger notifications. + +--- + + + +## 2. Command Categories + +All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.kt#L493](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493)). The `rh` / `rhId` parameter is `Long?` identifying a remote host (`null` = local device). + +### 2.1 User Management + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiGetActiveUser` | `rh: Long?, ctrl: ChatCtrl?` | Fetch the currently active user profile | [L841](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L841) | +| `apiCreateActiveUser` | `rh: Long?, p: Profile?, pastTimestamp: Boolean, ctrl: ChatCtrl?` | Create a new user profile and set it as active | [L851](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L851) | +| `listUsers` | `rh: Long?` | List all user profiles sorted by display name | [L871](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L871) | +| `apiSetActiveUser` | `rh: Long?, userId: Long, viewPwd: String?` | Switch the active user to a different profile | [L881](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L881) | +| `apiSetAllContactReceipts` | `rh: Long?, enable: Boolean` | Enable/disable delivery receipts for all contacts globally | [L888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L888) | +| `apiSetUserContactReceipts` | `u: User, userMsgReceiptSettings: UserMsgReceiptSettings` | Set delivery receipt settings for user contacts | [L894](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L894) | +| `apiSetUserGroupReceipts` | `u: User, userMsgReceiptSettings: UserMsgReceiptSettings` | Set delivery receipt settings for user groups | [L900](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L900) | +| `apiSetUserAutoAcceptMemberContacts` | `u: User, enable: Boolean` | Toggle auto-accept for member contact requests | [L906](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L906) | +| `apiHideUser` | `u: User, viewPwd: String` | Hide a user profile behind a password | [L912](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L912) | +| `apiUnhideUser` | `u: User, viewPwd: String` | Unhide a previously hidden user profile | [L915](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L915) | +| `apiMuteUser` | `u: User` | Mute all notifications for a user profile | [L918](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L918) | +| `apiUnmuteUser` | `u: User` | Unmute notifications for a user profile | [L921](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L921) | +| `apiDeleteUser` | `u: User, delSMPQueues: Boolean, viewPwd: String?` | Delete a user profile and optionally its SMP queues | [L930](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L930) | +| `apiUpdateProfile` | `rh: Long?, profile: Profile` | Update the active user's display profile | [L1682](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1682) | +| `apiSetProfileAddress` | `rh: Long?, on: Boolean` | Enable/disable including address in user profile | [L1694](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1694) | +| `apiSetUserUIThemes` | `rh: Long?, userId: Long, themes: ThemeModeOverrides?` | Set UI theme overrides for a user | [L1732](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1732) | + +### 2.2 Chat Lifecycle + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiStartChat` | `ctrl: ChatCtrl?` | Start the chat engine (returns `true` if newly started) | [L937](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L937) | +| `apiStopChat` | _(none)_ | Stop the chat engine | [L955](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L955) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder, remoteHostsFolder: String, ctrl: ChatCtrl?` | Configure file-system paths for the Haskell core | [L961](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L961) | +| `apiSetEncryptLocalFiles` | `enable: Boolean` | Enable/disable encryption of locally stored files | [L967](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L967) | +| `apiSaveAppSettings` | `settings: AppSettings` | Persist application settings to the core | [L969](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L969) | +| `apiGetAppSettings` | `settings: AppSettings` | Retrieve application settings from the core | [L975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L975) | +| `apiGetChats` | `rh: Long?` | Fetch the list of all chats for the active user | [L1013](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1013) | +| `apiGetChat` | `rh, type, id, scope, contentTag, pagination, search` | Fetch a single chat with paginated messages | [L1031](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1031) | +| `apiGetChatContentTypes` | `rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?` | Get available content type filters for a chat | [L1044](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1044) | +| `apiClearChat` | `rh: Long?, type: ChatType, id: Long` | Delete all messages in a chat | [L1675](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1675) | +| `apiDeleteChat` | `rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode` | Delete a chat (contact, group, connection, etc.) | [L1620](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1620) | +| `apiChatRead` | `rh: Long?, type: ChatType, id: Long` | Mark a chat as read | [L1888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1888) | +| `apiChatItemsRead` | `rh, type, id, scope, itemIds` | Mark specific chat items as read | [L1902](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1902) | +| `apiChatUnread` | `rh: Long?, type: ChatType, id: Long, unreadChat: Boolean` | Toggle a chat's unread flag | [L1909](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1909) | +| `getChatItemTTL` | `rh: Long?` | Get the auto-delete TTL for chat items | [L1286](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1286) | +| `setChatItemTTL` | `rh: Long?, chatItemTTL: ChatItemTTL` | Set the auto-delete TTL for chat items | [L1299](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1299) | +| `setChatTTL` | `rh: Long?, chatType, id, chatItemTTL` | Set TTL for a specific chat | [L1306](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1306) | + +### 2.3 Message Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiSendMessages` | `rh, type, id, scope, live, ttl, composedMessages` | Send one or more messages to a chat | [L1074](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1074) | +| `apiCreateChatItems` | `rh: Long?, noteFolderId: Long, composedMessages: List` | Create items in a private notes folder | [L1111](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1111) | +| `apiReportMessage` | `rh, groupId, chatItemId, reportReason, reportText` | Report a message in a group | [L1119](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1119) | +| `apiGetChatItemInfo` | `rh, type, id, scope, itemId` | Get delivery info for a specific chat item | [L1126](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1126) | +| `apiForwardChatItems` | `rh, toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl` | Forward messages between chats | [L1133](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1133) | +| `apiPlanForwardChatItems` | `rh, fromChatType, fromChatId, fromScope, chatItemIds` | Check forward feasibility before forwarding | [L1138](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1138) | +| `apiUpdateChatItem` | `rh, type, id, scope, itemId, updatedMessage, live` | Edit an existing message | [L1145](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1145) | +| `apiChatItemReaction` | `rh, type, id, scope, itemId, add, reaction` | Add or remove a reaction to a message | [L1168](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1168) | +| `apiGetReactionMembers` | `rh: Long?, groupId: Long, itemId: Long, reaction: MsgReaction` | List members who reacted with a specific emoji | [L1175](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1175) | +| `apiDeleteChatItems` | `rh, type, id, scope, itemIds, mode` | Delete messages (for self or for everyone) | [L1183](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1183) | +| `apiDeleteMemberChatItems` | `rh: Long?, groupId: Long, itemIds: List` | Moderate: delete another member's messages | [L1190](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1190) | +| `apiArchiveReceivedReports` | `rh: Long?, groupId: Long` | Archive all received reports in a group | [L1197](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1197) | +| `apiDeleteReceivedReports` | `rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode` | Delete specific received reports | [L1204](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1204) | + +### 2.4 Group Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiNewGroup` | `rh: Long?, incognito: Boolean, groupProfile: GroupProfile` | Create a new group | [L2092](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2092) | +| `apiAddMember` | `rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole` | Invite a contact to a group | [L2100](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2100) | +| `apiJoinGroup` | `rh: Long?, groupId: Long` | Accept a group invitation | [L2109](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2109) | +| `apiAcceptMember` | `rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole` | Accept a member joining via group link | [L2135](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2135) | +| `apiDeleteMemberSupportChat` | `rh: Long?, groupId: Long, groupMemberId: Long` | Delete a member's support chat | [L2144](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2144) | +| `apiRemoveMembers` | `rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean` | Remove members from a group | [L2151](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2151) | +| `apiMembersRole` | `rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole` | Change the role of group members | [L2160](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2160) | +| `apiBlockMembersForAll` | `rh: Long?, groupId: Long, memberIds: List, blocked: Boolean` | Block/unblock members for all group participants | [L2169](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2169) | +| `apiLeaveGroup` | `rh: Long?, groupId: Long` | Leave a group | [L2178](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2178) | +| `apiListMembers` | `rh: Long?, groupId: Long` | List all members of a group | [L2185](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2185) | +| `apiUpdateGroup` | `rh: Long?, groupId: Long, groupProfile: GroupProfile` | Update group profile (name, image, etc.) | [L2192](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2192) | +| `apiCreateGroupLink` | `rh: Long?, groupId: Long, memberRole: GroupMemberRole` | Create a group invitation link | [L2211](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2211) | +| `apiGroupLinkMemberRole` | `rh: Long?, groupId: Long, memberRole: GroupMemberRole` | Update the default role for group link joins | [L2226](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2226) | +| `apiDeleteGroupLink` | `rh: Long?, groupId: Long` | Delete the group invitation link | [L2235](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2235) | +| `apiGetGroupLink` | `rh: Long?, groupId: Long` | Retrieve the current group invitation link | [L2245](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2245) | +| `apiAddGroupShortLink` | `rh: Long?, groupId: Long` | Create a short link for the group | [L2252](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2252) | +| `apiCreateMemberContact` | `rh: Long?, groupId: Long, groupMemberId: Long` | Create a direct contact from a group member | [L2262](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2262) | +| `apiSendMemberContactInvitation` | `rh: Long?, contactId: Long, mc: MsgContent` | Send a direct message invitation to a group member | [L2271](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2271) | +| `apiAcceptMemberContact` | `rh: Long?, contactId: Long` | Accept a member's direct contact invitation | [L2280](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2280) | +| `apiSetMemberSettings` | `rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings` | Configure per-member settings (e.g., mentions) | [L1343](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1343) | +| `apiGroupMemberInfo` | `rh: Long?, groupId: Long, groupMemberId: Long` | Get a group member's info and connection stats | [L1353](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1353) | +| `apiSetGroupAlias` | `rh: Long?, groupId: Long, localAlias: String` | Set a local alias for a group | [L1718](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1718) | + +### 2.5 Contact Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiAddContact` | `rh: Long?, incognito: Boolean` | Create a one-time invitation link for a new contact | [L1444](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1444) | +| `apiSetConnectionIncognito` | `rh: Long?, connId: Long, incognito: Boolean` | Toggle incognito on a pending connection | [L1455](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1455) | +| `apiChangeConnectionUser` | `rh: Long?, connId: Long, userId: Long` | Change the user profile on a pending connection | [L1464](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1464) | +| `apiConnectPlan` | `rh: Long?, connLink: String, inProgress: MutableState` | Analyze a connection link before connecting | [L1474](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1474) | +| `apiConnect` | `rh: Long?, incognito: Boolean, connLink: CreatedConnLink` | Connect via an invitation or address link | [L1482](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1482) | +| `apiPrepareContact` | `rh, connLink, contactShortLinkData` | Prepare a contact chat from a short link (before connecting) | [L1546](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1546) | +| `apiPrepareGroup` | `rh, connLink, groupShortLinkData` | Prepare a group chat from a short link (before connecting) | [L1555](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1555) | +| `apiConnectPreparedContact` | `rh, contactId, incognito, msg` | Connect to a previously prepared contact | [L1580](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1580) | +| `apiConnectPreparedGroup` | `rh, groupId, incognito, msg` | Join a previously prepared group | [L1590](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1590) | +| `apiConnectContactViaAddress` | `rh: Long?, incognito: Boolean, contactId: Long` | Connect to a contact using their public address | [L1600](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1600) | +| `apiDeleteContact` | `rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode` | Delete a contact and return the deleted Contact | [L1644](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1644) | +| `apiContactInfo` | `rh: Long?, contactId: Long` | Get a contact's connection stats and custom profile | [L1346](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1346) | +| `apiSetContactAlias` | `rh: Long?, contactId: Long, localAlias: String` | Set a local display alias for a contact | [L1711](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1711) | +| `apiSetConnectionAlias` | `rh: Long?, connId: Long, localAlias: String` | Set a local display alias for a pending connection | [L1725](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1725) | +| `apiSetContactPrefs` | `rh: Long?, contactId: Long, prefs: ChatPreferences` | Update feature preferences for a contact | [L1704](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1704) | +| `apiCreateUserAddress` | `rh: Long?` | Create a long-term public contact address | [L1746](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1746) | +| `apiDeleteUserAddress` | `rh: Long?` | Delete the user's public contact address | [L1762](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1762) | +| `apiAddMyAddressShortLink` | `rh: Long?` | Create a short link for the user's address | [L1784](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1784) | +| `apiSetUserAddressSettings` | `rh: Long?, settings: AddressSettings` | Configure auto-accept for incoming contact requests | [L1795](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1795) | +| `apiAcceptContactRequest` | `rh: Long?, incognito: Boolean, contactReqId: Long` | Accept an incoming contact request | [L1809](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1809) | +| `apiRejectContactRequest` | `rh: Long?, contactReqId: Long` | Reject an incoming contact request | [L1832](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1832) | +| `apiSwitchContact` | `rh: Long?, contactId: Long` | Initiate SMP server switch for a contact | [L1374](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1374) | +| `apiAbortSwitchContact` | `rh: Long?, contactId: Long` | Abort an in-progress server switch | [L1388](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1388) | +| `apiSyncContactRatchet` | `rh: Long?, contactId: Long, force: Boolean` | Force ratchet synchronization with a contact | [L1402](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1402) | +| `apiGetContactCode` | `rh: Long?, contactId: Long` | Get the security verification code for a contact | [L1416](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1416) | +| `apiVerifyContact` | `rh: Long?, contactId: Long, connectionCode: String?` | Verify a contact's security code | [L1430](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1430) | + +### 2.6 File Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `receiveFiles` | `rhId, user, fileIds, userApprovedRelays, auto` | Accept and download one or more files (handles relay approval) | [L1946](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1946) | +| `receiveFile` | `rhId, user, fileId, userApprovedRelays, auto` | Accept and download a single file (convenience wrapper) | [L2062](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2062) | +| `cancelFile` | `rh: Long?, user: User, fileId: Long` | Cancel an in-progress file transfer and clean up | [L2072](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2072) | +| `apiCancelFile` | `rh: Long?, fileId: Long, ctrl: ChatCtrl?` | Cancel a file transfer (low-level, returns updated chat item) | [L2080](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2080) | +| `uploadStandaloneFile` | `user: UserLike, file: CryptoFile, ctrl: ChatCtrl?` | Upload a standalone file (used for migration) | [L1916](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1916) | +| `downloadStandaloneFile` | `user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl?` | Download a standalone file by URL | [L1926](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1926) | +| `standaloneFileInfo` | `url: String, ctrl: ChatCtrl?` | Retrieve metadata for a standalone file link | [L1936](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1936) | + +### 2.7 Call Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiGetCallInvitations` | `rh: Long?` | Retrieve pending call invitations | [L1842](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1842) | +| `apiSendCallInvitation` | `rh: Long?, contact: Contact, callType: CallType` | Initiate a call by sending an invitation | [L1849](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1849) | +| `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | +| `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | +| `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | +| `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | + +### 2.8 Settings & Network + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiSetNetworkConfig` | `cfg: NetCfg, showAlertOnError: Boolean, ctrl: ChatCtrl?` | Apply network configuration (SOCKS proxy, timeouts, etc.) | [L1313](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1313) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Update network reachability information | [L1340](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1340) | +| `apiSetSettings` | `rh: Long?, type: ChatType, id: Long, settings: ChatSettings` | Update per-chat settings (notifications, favorites) | [L1333](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1333) | +| `apiStorageEncryption` | `currentKey: String, newKey: String` | Change the database encryption passphrase | [L999](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L999) | +| `testStorageEncryption` | `key: String, ctrl: ChatCtrl?` | Verify a database encryption key is correct | [L1006](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1006) | +| `testProtoServer` | `rh: Long?, server: String` | Test connectivity to a protocol server | [L1211](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1211) | +| `reconnectServer` | `rh: Long?, server: String` | Reconnect to a specific server | [L1326](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1326) | +| `reconnectAllServers` | `rh: Long?` | Reconnect to all servers | [L1331](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1331) | +| `apiSetChatUIThemes` | `rh: Long?, chatId: ChatId, themes: ThemeModeOverrides?` | Set per-chat UI theme overrides | [L1739](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1739) | +| `apiContactQueueInfo` | `rh: Long?, contactId: Long` | Get server queue diagnostics for a contact | [L1360](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1360) | +| `apiGroupMemberQueueInfo` | `rh: Long?, groupId: Long, groupMemberId: Long` | Get server queue diagnostics for a group member | [L1367](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1367) | + +### 2.9 Chat Tags + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiCreateChatTag` | `rh: Long?, tag: ChatTagData` | Create a new chat tag (folder/label) | [L1052](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1052) | +| `apiSetChatTags` | `rh: Long?, type: ChatType, id: Long, tagIds: List` | Assign tags to a chat | [L1060](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1060) | +| `apiDeleteChatTag` | `rh: Long?, tagId: Long` | Delete a chat tag | [L1068](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1068) | +| `apiUpdateChatTag` | `rh: Long?, tagId: Long, tag: ChatTagData` | Update a chat tag's name or emoji | [L1070](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1070) | +| `apiReorderChatTags` | `rh: Long?, tagIds: List` | Set the display order of chat tags | [L1072](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1072) | + +### 2.10 Server Operators + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `getServerOperators` | `rh: Long?` | Get server operator conditions detail | [L1219](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1219) | +| `setServerOperators` | `rh: Long?, operators: List` | Update the list of server operators | [L1226](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1226) | +| `getUserServers` | `rh: Long?` | Get the user's configured servers per operator | [L1233](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1233) | +| `setUserServers` | `rh: Long?, userServers: List` | Save user's configured servers per operator | [L1241](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1241) | +| `validateServers` | `rh: Long?, userServers: List` | Validate server configuration for errors | [L1253](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1253) | +| `getUsageConditions` | `rh: Long?` | Get current and accepted usage conditions | [L1261](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1261) | +| `setConditionsNotified` | `rh: Long?, conditionsId: Long` | Mark conditions as shown to user | [L1268](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1268) | +| `acceptConditions` | `rh: Long?, conditionsId: Long, operatorIds: List` | Accept usage conditions for operators | [L1275](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1275) | + +### 2.11 Archive + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiExportArchive` | `config: ArchiveConfig` | Export chat database to a ZIP archive | [L981](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L981) | +| `apiImportArchive` | `config: ArchiveConfig` | Import chat database from a ZIP archive | [L987](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L987) | +| `apiDeleteStorage` | _(none)_ | Delete all chat database storage | [L993](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L993) | + + + +`ArchiveConfig` ([SimpleXAPI.kt#L4162](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4162)): + +```kotlin +class ArchiveConfig( + val archivePath: String, + val disableCompression: Boolean? = null, + val parentTempDirectory: String? = null +) +``` + +--- + + + +## 3. Response Types + +All command responses are deserialized into the `API` sealed class ([SimpleXAPI.kt#L5975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L5975)): + +```kotlin +sealed class API { + class Result(val remoteHostId: Long?, val res: CR) : API() + class Error(val remoteHostId: Long?, val err: ChatError) : API() +} +``` + + + +The `CR` sealed class ([SimpleXAPI.kt#L6114](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6114)) contains approximately 180 response variants. Key categories: + +| Category | Examples | Lines | +|----------|---------|-------| +| User | `ActiveUser`, `UsersList`, `UserPrivacy`, `UserProfileUpdated` | L6104-L6157 | +| Chat state | `ChatStarted`, `ChatRunning`, `ChatStopped`, `ApiChats`, `ApiChat` | L6106-L6110 | +| Tags | `ChatTags`, `TagsUpdated` | L6112, L6137 | +| Contacts | `Invitation`, `SentConfirmation`, `SentInvitation`, `ContactConnected`, `ContactDeleted` | L6138-L6165 | +| Messages | `NewChatItems`, `ChatItemUpdated`, `ChatItemsDeleted`, `ChatItemReaction`, `ForwardPlan` | L6176-L6184 | +| Groups | `GroupCreated`, `SentGroupInvitation`, `UserAcceptedGroupSent`, `GroupUpdated`, `GroupMembers` | L6186-L6219 | +| Files (receive) | `RcvFileAccepted`, `RcvFileStart`, `RcvFileComplete`, `RcvFileCancelled`, `RcvFileError` | L6221-L6232 | +| Files (send) | `SndFileStart`, `SndFileComplete`, `SndFileCancelled`, `SndFileCompleteXFTP` | L6234-L6244 | +| Calls | `CallInvitation`, `CallOffer`, `CallAnswer`, `CallExtraInfo`, `CallEnded` | L6246-L6251 | +| Remote host | `RemoteHostList`, `RemoteHostStarted`, `RemoteHostConnected`, `RemoteHostStopped` | L6255-L6262 | +| Remote ctrl | `RemoteCtrlList`, `RemoteCtrlFound`, `RemoteCtrlConnected`, `RemoteCtrlStopped` | L6264-L6269 | +| Encryption | `ContactPQAllowed`, `ContactPQEnabled` | L6271-L6272 | +| Misc | `CmdOk`, `ArchiveExported`, `ArchiveImported`, `AppSettingsR`, `VersionInfo` | L6274-L6283 | +| Fallback | `Response` (unknown type + raw JSON), `Invalid` (unparseable) | L6282-L6283 | + +Each `CR` subclass is annotated with `@Serializable @SerialName("jsonTag")` for polymorphic JSON deserialization. + +--- + +## 4. Event Types + +The chat core pushes asynchronous events through the same `CR` type hierarchy. The `startReceiver` coroutine ([SimpleXAPI.kt#L660](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660)) continuously calls `chatRecvMsgWait` (blocking JNI), then dispatches each message to `processReceivedMsg` ([SimpleXAPI.kt#L2568](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568)). + +Events handled in `processReceivedMsg` include: + +| Event | Description | +|-------|-------------| +| `ContactConnected` | A contact has completed the connection handshake | +| `ContactConnecting` | A contact connection is in progress | +| `ContactSndReady` | Contact's sending channel is ready | +| `ContactDeletedByContact` | A contact deleted their side of the conversation | +| `ReceivedContactRequest` | An incoming contact request arrived | +| `NewChatItems` | New messages received | +| `ChatItemUpdated` | A message was edited | +| `ChatItemsDeleted` | Messages were deleted | +| `ChatItemReaction` | A reaction was added/removed | +| `ChatItemsStatusesUpdated` | Delivery statuses updated | +| `GroupCreated` | A new group was created | +| `ReceivedGroupInvitation` | An invitation to join a group | +| `JoinedGroupMember` | A new member joined | +| `DeletedMember` / `DeletedMemberUser` | A member was removed | +| `LeftMember` | A member left voluntarily | +| `GroupUpdated` | Group profile changed | +| `MemberRole` | A member's role changed | +| `MemberBlockedForAll` | A member was blocked for all | +| `RcvFileStart` / `RcvFileComplete` / `RcvFileError` | File receive progress | +| `SndFileStart` / `SndFileComplete` / `SndFileError` | File send progress | +| `CallInvitation` / `CallOffer` / `CallAnswer` / `CallEnded` | Call signaling events | +| `ContactPQEnabled` | Post-quantum encryption status changed | +| `RemoteHostStopped` / `RemoteCtrlStopped` | Remote access session ended | +| `SubscriptionStatusEvt` | Connection subscription status changed | + +Each event triggers updates to `ChatModel` (reactive Compose state) and optionally fires platform notifications via `ntfManager`. + +--- + + + +## 5. Error Types + +### ChatError ([SimpleXAPI.kt#L6974](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6974)) + +```kotlin +sealed class ChatError { + class ChatErrorChat(val errorType: ChatErrorType) // Application-level errors + class ChatErrorAgent(val agentError: AgentErrorType) // SMP/XFTP agent errors + class ChatErrorStore(val storeError: StoreError) // Database store errors + class ChatErrorDatabase(val databaseError: DatabaseError)// Database engine errors + class ChatErrorRemoteHost(val remoteHostError: ...) // Remote host errors + class ChatErrorRemoteCtrl(val remoteCtrlError: ...) // Remote controller errors + class ChatErrorInvalidJSON(val json: String) // JSON parsing failure +} +``` + +### ChatErrorType ([SimpleXAPI.kt#L7004](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7004)) + +Common application error codes (~70 variants): + +| Error | Meaning | +|-------|---------| +| `NoActiveUser` | No user profile is set as active | +| `UserExists` | Attempted to create a duplicate user | +| `InvalidDisplayName` | Display name contains invalid characters | +| `ChatNotStarted` / `ChatNotStopped` | Chat engine in wrong state | +| `InvalidConnReq` / `UnsupportedConnReq` | Bad or incompatible connection link | +| `ContactNotReady` / `ContactDisabled` | Contact in unusable state | +| `GroupUserRole` | Insufficient group permissions | +| `GroupNotJoined` | User has not joined the group | +| `FileNotFound` / `FileCancelled` / `FileAlreadyReceiving` | File transfer errors | +| `FileNotApproved` | File from unapproved relay server | +| `HasCurrentCall` / `NoCurrentCall` | Call state conflicts | +| `CommandError` / `InternalError` / `CEException` | Generic/internal errors | + +### StoreError ([SimpleXAPI.kt#L7168](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7168)) + +Database-level errors: `DuplicateName`, `UserNotFound`, `GroupNotFound`, `ChatItemNotFound`, `LargeMsg`, `UserContactLinkNotFound`, etc. + +### ArchiveError ([SimpleXAPI.kt#L7658](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7658)) + +```kotlin +sealed class ArchiveError { + class ArchiveErrorImport(val importError: String) + class ArchiveErrorFile(val file: String, val fileError: String) +} +``` + +--- + +## 6. Source Files + +| File | Purpose | Path | +|------|---------|------| +| SimpleXAPI.kt | API bridge: all `api*` functions, `CC`, `CR`, `ChatError` | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Core.kt | JNI externals, `initChatController`, `chatMigrateInit` | `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` | +| ChatModel.kt | Reactive UI state (`ChatModel` object) | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| DatabaseUtils.kt | `DBMigrationResult`, `MigrationError`, DB password helpers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | +| Files.kt | Platform-expect file path declarations | `common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt` | +| Files.android.kt | Android actual file paths | `common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt` | +| Files.desktop.kt | Desktop actual file paths | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt` | +| Cryptor.kt | Platform-expect encryption interface | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` | +| Cryptor.android.kt | Android: AndroidKeyStore AES-GCM encryption | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` | +| Cryptor.desktop.kt | Desktop: placeholder (no-op) encryption | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` | + +All paths are relative to `apps/multiplatform/`. diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md new file mode 100644 index 0000000000..cfef4d06c2 --- /dev/null +++ b/apps/multiplatform/spec/architecture.md @@ -0,0 +1,423 @@ +# System Architecture + +## Table of Contents + +1. [Overview](#1-overview) +2. [Module Structure](#2-module-structure) +3. [JNI Bridge](#3-jni-bridge) +4. [App Lifecycle](#4-app-lifecycle) +5. [Event Streaming](#5-event-streaming) +6. [Platform Abstraction](#6-platform-abstraction) +7. [Source Files](#7-source-files) + +--- + +## 1. Overview + +The application is a three-layer system: + +``` ++------------------------------------------------------------------+ +| Compose UI (Views) | +| ChatListView, ChatView, ComposeView, SettingsView, CallView | ++------------------------------------------------------------------+ + | ^ + | user actions | Compose MutableState recomposition + v | ++------------------------------------------------------------------+ +| Application Logic Layer | +| ChatModel (state) ChatController (command dispatch) | +| AppPreferences NtfManager ThemeManager | ++------------------------------------------------------------------+ + | ^ + | sendCmd() | recvMsg() / processReceivedMsg() + v | ++------------------------------------------------------------------+ +| JNI Bridge (Core.kt) | +| external fun chatSendCmdRetry() external fun chatRecvMsgWait()| ++------------------------------------------------------------------+ + | ^ + | C FFI | C FFI + v | ++------------------------------------------------------------------+ +| Haskell Core (libsimplex / libapp-lib) | +| chat_ctrl handle SMP/XFTP protocols SQLite/PostgreSQL | ++------------------------------------------------------------------+ +``` + +**Data flow summary:** +1. User interacts with Compose UI. +2. View calls a `suspend fun api*()` method on `ChatController`. +3. `ChatController.sendCmd()` serializes the command to a JSON string and calls `chatSendCmdRetry()` (JNI). +4. The Haskell core processes the command and returns a JSON response string. +5. The response is deserialized to an `API` sealed class and returned to the caller. +6. Asynchronous events from the core (incoming messages, connection updates, call invitations) are delivered via a receiver coroutine that calls `chatRecvMsgWait()` in a loop and dispatches each event through `processReceivedMsg()`. + +--- + +## 2. Module Structure + +### Gradle Configuration + +Root: [`settings.gradle.kts`](../settings.gradle.kts#L22) +``` +include(":android", ":desktop", ":common") +``` + +### `:common` Module + +Build file: [`common/build.gradle.kts`](../common/build.gradle.kts#L14) + +``` +kotlin { + androidTarget() + jvm("desktop") +} +``` + +Source sets: + +| Source Set | Path | Purpose | +|---|---|---| +| `commonMain` | `common/src/commonMain/kotlin/` | All shared UI, models, platform abstractions | +| `androidMain` | `common/src/androidMain/kotlin/` | Android `actual` implementations | +| `desktopMain` | `common/src/desktopMain/kotlin/` | Desktop `actual` implementations | + +Key dependencies (from `commonMain`): +- `kotlinx-serialization-json` -- JSON codec for Haskell core communication +- `kotlinx-datetime` -- cross-platform date/time +- `multiplatform-settings` (russhwolf) -- `SharedPreferences` abstraction +- `kaml` -- YAML parsing (theme import/export) +- `boofcv-core` -- QR code scanning +- `jsoup` -- HTML parsing for link previews +- `moko-resources` -- cross-platform string/image resources +- `multiplatform-markdown-renderer` -- Markdown rendering in chat + +### `:android` Module + +Build file: [`android/build.gradle.kts`](../android/build.gradle.kts) + +Contains: +- `SimplexApp` (Application subclass) +- `MainActivity` (FragmentActivity) +- `SimplexService` (foreground Service) +- `NtfManager` (Android NotificationManager wrapper) +- `CallActivity` (dedicated activity for calls) + +### `:desktop` Module + +Build file: [`desktop/build.gradle.kts`](../desktop/build.gradle.kts) + +Contains: +- `main()` entry point +- `initHaskell()` -- loads native library and calls `initHS()` +- Window management (VLC library loading on Windows) + +--- + +## 3. JNI Bridge + +All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt). + + + + +### External Native Functions + +| # | Function | Signature | Line | Purpose | +|---|---|---|---|---| +| 1 | [`initHS()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | Initialize GHC runtime system | +| 2 | [`pipeStdOutToSocket()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L20) | `external fun pipeStdOutToSocket(socketName: String): Int` | 20 | Redirect Haskell stdout to Android local socket for logging | +| 3 | [`chatMigrateInit()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25) | `external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array` | 25 | Initialize database with migration; returns `[jsonResult, chatCtrl]` | +| 4 | [`chatCloseStore()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L26) | `external fun chatCloseStore(ctrl: ChatCtrl): String` | 26 | Close database store | +| 5 | [`chatSendCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L27) | `external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String` | 27 | Send command to core with retry count | +| 6 | [`chatSendRemoteCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L28) | `external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String` | 28 | Send command to remote host | +| 7 | [`chatRecvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L29) | `external fun chatRecvMsg(ctrl: ChatCtrl): String` | 29 | Receive message (non-blocking) | +| 8 | [`chatRecvMsgWait()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L30) | `external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String` | 30 | Receive message with timeout (blocking up to `timeout` microseconds) | +| 9 | [`chatParseMarkdown()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L31) | `external fun chatParseMarkdown(str: String): String` | 31 | Parse markdown formatting | +| 10 | [`chatParseServer()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L32) | `external fun chatParseServer(str: String): String` | 32 | Parse SMP/XFTP server address | +| 11 | [`chatParseUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L33) | `external fun chatParseUri(str: String, safe: Int): String` | 33 | Parse SimpleX connection URI | +| 12 | [`chatPasswordHash()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L34) | `external fun chatPasswordHash(pwd: String, salt: String): String` | 34 | Hash password with salt | +| 13 | [`chatValidName()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L35) | `external fun chatValidName(name: String): String` | 35 | Validate/sanitize display name | +| 14 | [`chatJsonLength()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L36) | `external fun chatJsonLength(str: String): Int` | 36 | Get JSON-encoded string length | +| 15 | [`chatWriteFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L37) | `external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String` | 37 | Write encrypted file via core | +| 16 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatReadFile(path: String, key: String, nonce: String): Array` | 38 | Read and decrypt file | +| 17 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 39 | Encrypt file on disk | +| 18 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 40 | Decrypt file on disk | + +**Total: 18 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). + + + + + +### Key Kotlin Functions in Core.kt + +| Function | Line | Purpose | +|---|---|---| +| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L51) | 51 | Entry point called during app startup; launches `initChatController` in a long-running coroutine | +| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62) | 62 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat | +| [`chatInitTemporaryDatabase()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L190) | 190 | Creates a temporary database for migration scenarios | +| [`chatInitControllerRemovingDatabases()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L202) | 202 | Removes existing DBs and creates fresh controller (used during re-initialization) | +| [`showStartChatAfterRestartAlert()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L222) | 222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored | + + + +### initChatController Flow + +``` +initChatController(useKey, confirmMigrations, startChat) + | + +-- chatMigrateInit(dbPath, dbKey, confirm) // JNI -> Haskell + | returns [jsonResult, chatCtrl] + | + +-- if migration error and rerunnable: + | chatMigrateInit(dbPath, dbKey, confirm) // retry with user confirmation + | + +-- setChatCtrl(ctrl) // store controller handle + +-- apiSetAppFilePaths(...) // tell core about file dirs + +-- apiSetEncryptLocalFiles(...) + +-- apiGetActiveUser() -> currentUser + +-- getServerOperators() -> conditions + +-- if shouldImportAppSettings: apiGetAppSettings + importIntoApp + +-- if user exists and startChat confirmed: + | startChat(user) // starts receiver, API commands + +-- else if no user: + set onboarding stage, optionally startChatWithoutUser() +``` + +--- + +## 4. App Lifecycle + +### Android + +Entry: [`SimplexApp.onCreate()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L47) + +``` +SimplexApp.onCreate() + +-- initHaskell(packageName) // Load native lib, pipe stdout, call initHS() + | +-- System.loadLibrary("app-lib") + | +-- pipeStdOutToSocket(packageName) + | +-- initHS() + +-- initMultiplatform() // Set up ntfManager, platform callbacks + +-- reconfigureBroadcastReceivers() + +-- runMigrations() // Theme migration, version code tracking + +-- initChatControllerOnStart() // -> initChatController() -> chatMigrateInit -> startChat +``` + +Activity: [`MainActivity.onCreate()`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L32) + +``` +MainActivity.onCreate() + +-- processNotificationIntent(intent) // Handle OpenChat/AcceptCall from notifications + +-- processIntent(intent) // Handle VIEW intents (deep links) + +-- processExternalIntent(intent) // Handle SEND/SEND_MULTIPLE (share sheet) + +-- setContent { AppScreen() } // Compose UI entry point +``` + +Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`): +- `ON_START`: refresh chat list from API if chat is running +- `ON_RESUME`: show background service notice, start `SimplexService` if configured + +### Desktop + +Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) + +``` +main() + +-- initHaskell() // Load native lib from resources dir, call initHS() + | +-- System.load(libapp-lib.so/dll/dylib) + | +-- initHS() + +-- runMigrations() + +-- setupUpdateChecker() + +-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart + +-- showApp() // Compose window with AppScreen() +``` + +[`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window. + +[`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`. + +--- + +## 5. Event Streaming + +### Receiver Coroutine + +[`ChatController.startReceiver()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660) launches a coroutine on `Dispatchers.IO` that continuously polls for events from the Haskell core: + +```kotlin +// SimpleXAPI.kt line 660 +private fun startReceiver() { + if (receiverJob != null || chatCtrl == null) return // guard against double-start + receiverJob = CoroutineScope(Dispatchers.IO).launch { + var releaseLock: (() -> Unit) = {} + while (isActive) { + val ctrl = chatCtrl + if (ctrl == null) { stopReceiver(); break } // chatCtrl became null + try { + val release = releaseLock + launch { delay(30000); release() } // release previous wake lock after 30s + val msg = recvMsg(ctrl) // calls chatRecvMsgWait with 300s timeout + releaseLock = getWakeLock(timeout = 60000) // acquire wake lock (60s timeout) + if (msg != null) { + val finished = withTimeoutOrNull(60_000L) { + processReceivedMsg(msg) + messagesChannel.trySend(msg) + } + if (finished == null) { + Log.e(TAG, "Timeout processing: " + msg.responseType) + } + } + } catch (e: Exception) { + Log.e(TAG, "recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + } + } + } +} +``` + +### Message Reception + +[`recvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L829) calls `chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)` where `MESSAGE_TIMEOUT = 300_000_000` microseconds (300 seconds). Returns `null` on timeout (empty string from Haskell), otherwise deserializes the JSON response to an `API` instance. + +### Command Sending + +[`sendCmd()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804) runs on `Dispatchers.IO`, serializes the command via `CC.cmdString`, calls `chatSendCmdRetry()` (or `chatSendRemoteCmdRetry()` for remote hosts), deserializes the response, and logs terminal items. + +### Event Processing + +[`processReceivedMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568) is a large `when` block that dispatches on the `CR` (ChatResponse) type: + +- `CR.ContactConnected` -- update contact in `ChatModel` +- `CR.NewChatItems` -- add items to chat, trigger notifications +- `CR.RcvCallInvitation` -- add to `callInvitations`, trigger call UI +- `CR.ChatStopped` -- set `chatRunning = false` +- `CR.GroupMemberConnected`, `CR.GroupMemberUpdated`, etc. -- update group state +- Many more event types for connection status, file transfers, SMP relay events, etc. + +### Wake Lock + +On Android, the receiver acquires a wake lock via [`getWakeLock(timeout)`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) (expect function) after each received message with a 60-second timeout. The previous iteration's wake lock is released after a 30-second delay, ensuring overlap so the CPU does not sleep between messages. + +--- + +## 6. Platform Abstraction + +### expect/actual Pattern + +The `commonMain` source set declares `expect` functions and classes. Each platform source set provides `actual` implementations. + +Examples from platform files: + +| expect Declaration | File | Line | +|---|---|---| +| `expect val appPlatform: AppPlatform` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L20) | 20 | +| `expect val deviceName: String` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L22) | 22 | +| `expect fun isAppVisibleAndFocused(): Boolean` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L24) | 24 | +| `expect val dataDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | 18 | +| `expect val tmpDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | 19 | +| `expect val filesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | 20 | +| `expect val appFilesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | 21 | +| `expect val dbAbsolutePrefixPath: String` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | 24 | +| `expect fun showToast(text: String, timeout: Long)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L6) | 6 | +| `expect fun hideKeyboard(view: Any?, clearFocus: Boolean)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L16) | 16 | +| `expect fun getKeyboardState(): State` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L15) | 15 | +| `expect fun allowedToShowNotification(): Boolean` | [`Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt#L3) | 3 | +| `expect class VideoPlayer` | [`VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt#L25) | 25 | +| `expect class RecorderNative` | [`RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt#L17) | 17 | +| `expect val cryptor: CryptorInterface` | [`Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt#L9) | 9 | +| `expect fun base64ToBitmap(base64ImageString: String): ImageBitmap` | [`Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt#L17) | 17 | +| `expect fun getWakeLock(timeout: Long): (() -> Unit)` | [`SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) | 3 | +| `expect class GlobalExceptionsHandler` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L24) | 24 | +| `expect fun UriHandler.sendEmail(subject: String, body: CharSequence)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L7) | 7 | +| `expect fun ClipboardManager.shareText(text: String)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L9) | 9 | +| `expect fun shareFile(text: String, fileSource: CryptoFile)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L10) | 10 | + +### PlatformInterface Callback Object + +[`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point: + +- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187) +- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50) + +The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50): +```kotlin +var platform: PlatformInterface = object : PlatformInterface {} +``` + +#### PlatformInterface Callbacks + +| Callback | Default | Android Implementation | +|---|---|---| +| `androidServiceStart()` | no-op | Start `SimplexService` foreground service | +| `androidServiceSafeStop()` | no-op | Stop `SimplexService` | +| `androidCallServiceSafeStop()` | no-op | Stop `CallService` | +| `androidNotificationsModeChanged(mode)` | no-op | Toggle receivers, start/stop service | +| `androidChatStartedAfterBeingOff()` | no-op | Start service or schedule periodic worker | +| `androidChatStopped()` | no-op | Cancel workers, stop service | +| `androidChatInitializedAndStarted()` | no-op | Show background service notice, start service | +| `androidIsBackgroundCallAllowed()` | `true` | Check battery restriction | +| `androidSetNightModeIfSupported()` | no-op | Set `UiModeManager` night mode | +| `androidSetStatusAndNavigationBarAppearance(...)` | no-op | Configure system bar colors/appearance | +| `androidStartCallActivity(acceptCall, rhId, chatId)` | no-op | Launch `CallActivity` | +| `androidPictureInPictureAllowed()` | `true` | Check PiP permission via AppOps | +| `androidCallEnded()` | no-op | Destroy call WebView | +| `androidRestartNetworkObserver()` | no-op | Restart `NetworkObserver` | +| `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | +| `androidIsXiaomiDevice()` | `false` | Check device brand | +| `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | +| `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | +| `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | + +--- + +## 7. Source Files + +### Core Infrastructure + +| File | Path | Key Contents | +|---|---|---| +| Core.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt) | JNI externals, `initChatController`, `chatInitTemporaryDatabase` | +| SimpleXAPI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | `ChatController`, `AppPreferences`, `startReceiver`, `sendCmd`, `recvMsg`, `processReceivedMsg`, all `api*` functions | +| ChatModel.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | `ChatModel` singleton, `ChatsContext`, `Chat`, `ChatInfo`, `ChatItem` and all domain types | +| App.kt | [`common/src/commonMain/kotlin/chat/simplex/common/App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen()`, `MainScreen()` | + +### Platform Layer + +| File | Path | Key Contents | +|---|---|---| +| Platform.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt) | `PlatformInterface`, global `platform` var | +| AppCommon.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt) | `AppPlatform`, `runMigrations()` | +| AppCommon.android.kt | [`common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt) | `initHaskell()`, `androidAppContext` | +| AppCommon.desktop.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt) | `initApp()`, desktop NtfManager setup | +| Files.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | `expect val dataDir/tmpDir/filesDir/dbAbsolutePrefixPath` | +| NtfManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | `abstract class NtfManager` | +| Notifications.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt) | `expect fun allowedToShowNotification()` | +| UI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt) | `showToast`, `hideKeyboard`, `GlobalExceptionsHandler` | +| Share.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt) | `shareText`, `shareFile`, `openFile` | +| VideoPlayer.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt) | `VideoPlayerInterface`, `expect class VideoPlayer` | +| RecAndPlay.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt) | `RecorderInterface`, `AudioPlayerInterface` | +| Cryptor.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt) | `CryptorInterface` | +| Images.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt) | `base64ToBitmap`, `resizeImageToStrSize` | +| SimplexService.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt) | `expect fun getWakeLock()` | + +### Entry Points + +| File | Path | Key Contents | +|---|---|---| +| SimplexApp.kt | [`android/src/main/java/chat/simplex/app/SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt) | Android Application class, lifecycle observer | +| MainActivity.kt | [`android/src/main/java/chat/simplex/app/MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt) | Android main activity | +| SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service | +| Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` | +| DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` | + +### Theme + +| File | Path | Key Contents | +|---|---|---| +| ThemeManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | Theme resolution, system/light/dark/custom, per-user overrides | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md new file mode 100644 index 0000000000..b0f3750659 --- /dev/null +++ b/apps/multiplatform/spec/client/chat-list.md @@ -0,0 +1,314 @@ +# Chat List Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView Composable](#2-chatlistview-composable) +3. [Data Sources](#3-data-sources) +4. [Filter System](#4-filter-system) +5. [Chat Preview](#5-chat-preview) +6. [ChatListNavLinkView](#6-chatlistnavlinkview) +7. [Tag System](#7-tag-system) +8. [UserPicker](#8-userpicker) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. + +--- + +## 1. Overview + +``` +ChatListView +|-- ChatListToolbar (top or bottom app bar) +| |-- UserProfileButton (opens UserPicker) +| |-- Title ("Your chats") +| |-- SubscriptionStatusIndicator +| +-- NewChatButton / StoppedIndicator +|-- ChatListWithLoadingScreen +| |-- ChatList (LazyColumnWithScrollBar) +| | |-- Spacer (top/bottom padding) +| | |-- stickyHeader +| | | |-- ChatListSearchBar (search input + filter toggle) +| | | +-- TagsView (preset + custom tag chips) +| | |-- ChatListNavLinkView[] (per-chat row items) +| | +-- ChatListFeatureCards (one-hand UI card, address card) +| +-- EmptyState text +|-- NewChatSheetFloatingButton (FAB, standard mode only) +|-- UserPicker (slide-in panel, Android) ++-- ActiveCallInteractiveArea (desktop, in-call banner) +``` + +--- + + + +## 2. ChatListView Composable + +**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127) + +```kotlin +fun ChatListView( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit, + stopped: Boolean +) +``` + +### Initialization + +- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay. +- On desktop, closing a chat resets audio/video players (line ~138). + +### Layout Modes + +The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: + +| Mode | Toolbar Position | List Direction | FAB | Search/Tags Position | +|---|---|---|---|---| +| **Standard** (`oneHandUI = false`) | Top | Top-to-bottom | Bottom-right FAB | Below toolbar | +| **One-hand** (`oneHandUI = true`) | Bottom | Bottom-to-top (reversed) | Integrated in toolbar | Above toolbar | + +### State + +| State | Type | Purpose | +|---|---|---| +| `searchText` | `MutableState` | Search query (saved across recomposition) | +| `listState` | `LazyListState` | Scroll position (persisted in `lazyListState` var) | +| `oneHandUI` | `State` | One-hand UI mode toggle | + +### Android-specific + +- `SetNotificationsModeAdditions`: Notification permission setup (line ~184). +- `UserPicker`: Overlay side panel for user switching (line ~192). + +--- + +## 3. Data Sources + +| Source | Location | Description | +|---|---|---| +| `chatModel.chats` | `ChatModel.chatsContext.chats` | Full list of `Chat` objects for the active user | +| `chatModel.activeChatTagFilter` | `ChatModel.activeChatTagFilter` | Currently active filter (`PresetTag`, `UserTag`, or `Unread`) | +| `chatModel.userTags` | `ChatModel.userTags` | User-created custom tags | +| `chatModel.presetTags` | `ChatModel.presetTags` | Map of `PresetTagKind` to count | +| `chatModel.unreadTags` | `ChatModel.unreadTags` | Map of tag ID to unread count | +| `chatModel.chatId` | `ChatModel.chatId` | Currently selected chat ID (highlights row) | +| `chatModel.currentUser` | `ChatModel.currentUser` | Active user profile | +| `chatModel.users` | `ChatModel.users` | All user profiles (for UserPicker) | +| `chatModel.showChatPreviews` | `ChatModel.showChatPreviews` | Privacy toggle for message previews | + +--- + +## 4. Filter System + +### Active Filter Types + +Defined as sealed class `ActiveFilter` (line ~51): + +```kotlin +sealed class ActiveFilter { + data class PresetTag(val tag: PresetTagKind) : ActiveFilter() + data class UserTag(val tag: ChatTag) : ActiveFilter() + data object Unread : ActiveFilter() +} +``` + +### PresetTagKind Enum + +| Value | Description | +|---|---| +| `GROUP_REPORTS` | Groups with active reports (moderator-visible) | +| `FAVORITES` | Chats marked as favorite | +| `CONTACTS` | Direct (1:1) chats | +| `GROUPS` | Group chats | +| `BUSINESS` | Business-type chats | +| `NOTES` | Local note folders | + +### Search Filtering + +The `filteredChats` function (line ~1188) applies filters in this order: + +1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat. +2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`. +3. **Active filter:** + - `PresetTag`: Matches chat type and characteristics (e.g., `CONTACTS` filters `ChatInfo.Direct`, `GROUPS` filters `ChatInfo.Group`). + - `UserTag`: Matches chats whose `chatTags` contain the tag ID. + - `Unread`: Matches chats with `unreadCount > 0` or `unreadChat == true`. + +### Search Bar + +`ChatListSearchBar` (line ~611) provides: +- Text input with search icon. +- SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search. +- Unread filter toggle button (right side, when search is empty). + +--- + + + +## 5. Chat Preview + +**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40) + +```kotlin +fun ChatPreviewView( + chat: Chat, + showChatPreviews: Boolean, + chatModelDraft: ComposeState?, + chatModelDraftChatId: ChatId?, + currentUserProfileDisplayName: String?, + disabled: Boolean, + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean, + defaultClickAction: () -> Unit +) +``` + +### Layout + +Each chat preview row contains: + +| Element | Position | Content | +|---|---|---| +| Profile image | Left | `ChatInfoImage` with overlay icons for inactive contacts/groups | +| Title row | Top-right of image | Chat name (bold), verified shield (direct), timestamp | +| Preview row | Below title | Last message preview or draft indicator, unread badge | +| Unread badge | Right | Circular badge with count, or dot for muted chats | + +### Draft Display + +When `chatModelDraftChatId` matches the chat ID, the preview shows a draft indicator (pencil icon) with the draft message text instead of the last chat item. + +### Inactive Indicators + +- Inactive contacts: cancel icon overlay on profile image. +- Left/removed/deleted groups: cancel icon overlay. + +--- + + + +## 6. ChatListNavLinkView + +**Location:** [`ChatListNavLinkView.kt#L37`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt#L37) + +Routes each chat to the appropriate click action and context menu based on `chat.chatInfo`: + +| ChatInfo Type | Click Action | Context Menu | +|---|---|---| +| `ChatInfo.Direct` | `directChatAction` (opens chat) | `ContactMenuItems`: mark read/unread, mute, favorite, tag, clear, delete | +| `ChatInfo.Group` | `groupChatAction` (opens chat or joins) | `GroupMenuItems`: mark read/unread, mute, favorite, tag, clear, leave, delete | +| `ChatInfo.Local` | `noteFolderChatAction` (opens notes) | `NoteFolderMenuItems`: mark read, clear, delete | +| `ChatInfo.ContactRequest` | `contactRequestAlertDialog` (accept/reject) | `ContactRequestMenuItems`: reject | +| `ChatInfo.ContactConnection` | Sets `chatModel.chatId` (opens connection info) | `ContactConnectionMenuItems`: delete | +| `ChatInfo.InvalidJSON` | Sets `chatModel.chatId` | No menu | + +### Selection Highlight + +On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) receives a highlight background. `nextChatSelected` state is used to suppress the bottom divider when the next chat in the list is selected. + +--- + +## 7. Tag System + +### TagsView + +**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929) + +Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable). + +Layout logic: +- If there are more than 1 collapsible preset tags and the total tag count exceeds 3, preset tags collapse into a `CollapsedTagsFilterView` dropdown. +- Otherwise, each preset tag renders as an `ExpandedTagFilterView` chip. +- User tags render as individual chips with emoji or label icon, bold when active. +- A "+" button at the end opens `TagListEditor` for creating new tags. + +### Tag Interactions + +- **Single tap:** Toggles the tag filter on `chatModel.activeChatTagFilter`. +- **Long press / right-click (user tags):** Opens dropdown menu with edit/delete/reorder options. +- **Unread dot:** Shown on tags that have chats with unread messages. + + + +### TagListView + +**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48) + +Full-screen tag management view opened from the "+" button or long-press menu. + +```kotlin +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) +``` + +- Displays all user tags in a `LazyColumnWithScrollBar`. +- Supports drag-and-drop reordering via `rememberDragDropState` (calls `apiReorderChatTags`). +- Each tag row shows emoji/icon, name, chat count, and a checkbox if opened for a specific chat (to assign/unassign tags). +- "Create list" button opens `TagListEditor` modal. + +--- + + + +## 8. UserPicker + +**Location:** [`UserPicker.kt#L46`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt#L46) + +```kotlin +fun UserPicker( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit +) +``` + +### Behavior + +- **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. +- **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. +- Closes automatically when any `ModalManager.start` modal opens. + +### Content + +| Section | Content | +|---|---| +| **Active user** | Profile image, display name, "active" indicator | +| **Other users** | List of non-hidden user profiles sorted by `activeOrder`; tapping switches user | +| **Remote hosts** | Connected remote devices (desktop linking) | +| **Settings** | Opens `SettingsView` modal | +| **Color mode** | `ColorModeSwitcher` for theme toggle | +| **Add profile** | Opens `CreateProfile` flow | +| **Lock** | Locks app (calls `AppLock.setPerformLA`) | + +### State Machine + +Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow` to coordinate animation between the parent screen and the picker overlay. + +--- + +## 9. Source Files + +| File | Description | +|---|---| +| `ChatListView.kt` | Main chat list view, toolbar, search, tags, filtering | +| `ChatListNavLinkView.kt` | Per-chat row routing and context menus | +| `ChatPreviewView.kt` | Chat preview row layout (image, title, last message) | +| `ChatHelpView.kt` | Empty-state help content | +| `ContactConnectionView.kt` | Pending connection preview row | +| `ContactRequestView.kt` | Contact request preview row | +| `ServersSummaryView.kt` | Server connection status summary | +| `ShareListNavLinkView.kt` | Share target list row (forwarding) | +| `ShareListView.kt` | Share target list (forwarding flow) | +| `TagListView.kt` | Tag management and assignment view | +| `UserPicker.kt` | User switching side panel | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md new file mode 100644 index 0000000000..2819b1e751 --- /dev/null +++ b/apps/multiplatform/spec/client/chat-view.md @@ -0,0 +1,324 @@ +# Chat View Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView Composable](#2-chatview-composable) +3. [Message List](#3-message-list) +4. [ChatItemView](#4-chatitemview) +5. [Message Types](#5-message-types) +6. [Context Menu Actions](#6-context-menu-actions) +7. [ChatInfoView](#7-chatinfoview) +8. [GroupChatInfoView](#8-groupchatinfoview) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the `ChatView` composable (line ~96 in `ChatView.kt`), which orchestrates a `ChatLayout` containing a reverse-scrolling `LazyColumn` of `ChatItemView` items and a `ComposeView` for message input. The view supports direct chats, group chats, local notes, and contact connections, with per-chat theming, search/filter, multi-select, and side-panel info modals. Message rendering is delegated to type-specific composables in the `views/chat/item/` package. + +--- + +## 1. Overview + +``` +ChatView +|-- ChatLayout +| |-- ChatInfoToolbar (top/bottom app bar with back, title, call, search, menu) +| |-- SupportChatsCountToolbar (reports/support banner, group only) +| |-- ChatItemsList (LazyColumnWithScrollBar, reverse layout) +| | |-- ChatViewListItem +| | | |-- DateSeparator +| | | |-- MemberNameAndRole (group received messages) +| | | |-- MemberImage (group received messages) +| | | +-- ChatItemView (message type routing) +| | |-- ChatBannerView (first item: chat profile banner) +| | +-- FloatingButtons (scroll-to-bottom, unread counter) +| |-- ComposeView (message composition area) +| | |-- ContextItemView (reply/edit/forward/report indicator) +| | |-- previewView (link/media/voice/file preview) +| | +-- SendMsgView (text input + send/voice/timed buttons) +| |-- GroupMentions (mention autocomplete popup) +| |-- CommandsMenuView (bot commands popup) +| +-- ChooseAttachmentView (bottom sheet for attachment type) +|-- ChatInfoView (contact info, end modal) ++-- GroupChatInfoView (group management, end modal) +``` + +--- + + + +## 2. ChatView Composable + +**Location:** [`ChatView.kt#L97`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L97) + +```kotlin +fun ChatView( + chatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState, + onComposed: suspend (chatId: String) -> Unit +) +``` + +### State Management + +| State Variable | Type | Purpose | +|---|---|---| +| `showSearch` | `MutableState` | Controls search bar visibility | +| `searchText` | `MutableState` | Current search query text | +| `composeState` | `MutableState` | Full compose area state (message, preview, context, mentions) | +| `attachmentOption` | `MutableState` | Selected attachment type from bottom sheet | +| `selectedChatItems` | `MutableState?>` | Multi-select mode item IDs; `null` = selection off | +| `showCommandsMenu` | `MutableState` | Bot commands menu visibility | +| `contentFilter` | `MutableState` | Active content type filter (images, videos, etc.) | +| `availableContent` | `MutableState>` | Content types available in this chat | +| `activeChat` | `State` | Derived from `chatModel.chats` matching `staleChatId` | +| `unreadCount` | `State` | Unread message count derived from chat stats | + +### Chat Loading + +On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): + +1. Marks unread chat as read (`markUnreadChatAsRead`) +2. Clears group members state +3. Resets search, content filter, and selection +4. Fetches available content types (`updateAvailableContent`) +5. For direct chats, loads contact info and connection stats +6. For groups with pending membership, opens member support chat + +### Chat Type Routing + +The outer `when (chatInfo)` (line ~229) branches: + +| ChatInfo Type | Behavior | +|---|---| +| `ChatInfo.Direct`, `ChatInfo.Group`, `ChatInfo.Local` | Full `ChatLayout` with compose, search, reactions, per-chat theme | +| `ChatInfo.ContactConnection` | `ModalView` wrapping `ContactConnectionInfoView` | +| `ChatInfo.InvalidJSON` | `ModalView` with raw JSON display and share button | + +--- + +## 3. Message List + +**Location:** [`ChatView.kt#L1592`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1592) (`ChatItemsList` composable) + +The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, meaning index 0 is the newest message at the bottom of the screen. + +### Key Behaviors + +- **Merged Items:** Messages are grouped via `MergedItems.create()` (line ~1653), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in `revealedItems`. +- **Pagination:** `PreloadItems` triggers `loadMessages` with `ChatPagination.Before` (older) or `ChatPagination.Last` (newer) when the user scrolls near list boundaries. +- **Scroll To Item:** `scrollToItem` lambda supports animated scrolling to a specific item ID, used by search result taps and quoted message navigation. +- **Unread Marking:** `MarkItemsReadAfterDelay` composable marks newly visible received items as read after a brief delay. +- **Date Separators:** `DateSeparator` composable renders between messages when the date changes (via `ItemSeparation.date`). +- **Swipe to Reply:** `SwipeToDismiss` modifier on each item (EndToStart direction, 30dp threshold) sets `ComposeContextItem.QuotedItem`. +- **Selection Mode:** When `selectedChatItems` is non-null, a checkbox overlay appears on each item; a full-width clickable overlay toggles selection. + +### Item Layout (ChatViewListItem) + +- **Group received messages** with `showAvatar = true`: Column layout with `MemberNameAndRole` header, `MemberImage` (clickable to `showMemberInfo`), and message bubble. +- **Group received without avatar:** Indented to align with avatar-bearing messages. +- **Sent messages (group or direct):** Right-aligned with larger start padding. +- **Direct messages:** Symmetric padding (76dp opposite side). + +--- + + + +## 4. ChatItemView + +**Location:** [`item/ChatItemView.kt#L66`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L66) + +```kotlin +fun ChatItemView( + chatsCtx, rhId, chat, cItem, composeState, imageProvider, + useLinkPreviews, linkMode, revealed, highlighted, hoveredItemId, + range, selectedChatItems, searchIsNotBlank, fillMaxWidth, + selectChatItem, deleteMessage, deleteMessages, archiveReports, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, + openDirectChat, forwardItem, scrollToItem, scrollToItemId, + scrollToQuotedItemFromItem, setReaction, showItemDetails, + reveal, showMemberInfo, showChatInfo, developerTools, showViaProxy, + showTimestamp, itemSeparation, ... +) +``` + +The composable routes based on `cItem.content` and `cItem.meta.itemDeleted`: + +- **Deleted items** -> `DeletedItemView` or `MarkedDeletedItemView` +- **Message content** (`SndMsgContent`, `RcvMsgContent`) -> `FramedItemView` or specialized views depending on `msgContent` type +- **Call items** -> `CICallItemView` +- **Integrity/decryption errors** -> `IntegrityErrorItemView`, `CIRcvDecryptionError` +- **Group invitations** -> `CIGroupInvitationView` +- **Events** (group/direct/connection events) -> `CIEventView` +- **Feature changes** -> `CIChatFeatureView`, `CIFeaturePreferenceView` +- **E2EE info** -> `CIEventView` +- **Chat banner** -> handled at list level, not in `ChatItemView` +- **Invalid JSON** -> `CIInvalidJSONView` + +### Reactions + +`ChatItemReactions` row renders below each message bubble, showing emoji reaction counts. Tapping own reactions removes them; tapping others' opens a member list dropdown. + +### Context Menu + +Long-press or right-click opens a dropdown menu with context-sensitive actions (see section 6). + +--- + +## 5. Message Types + +| CIContent Variant | MsgContent Type | View Composable | Source File | +|---|---|---|---| +| `SndMsgContent` / `RcvMsgContent` | `MCText` | `FramedItemView` -> `TextItemView` or `EmojiItemView` | `TextItemView.kt`, `EmojiItemView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCLink` | `FramedItemView` (with link preview) | `FramedItemView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCImage` | `CIImageView` (inside `FramedItemView`) | `CIImageView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCVideo` | `CIVideoView` (inside `FramedItemView`) | `CIVideoView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCVoice` | `CIVoiceView` | `CIVoiceView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCFile` | `CIFileView` | `CIFileView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCReport` | `FramedItemView` (with report styling) | `FramedItemView.kt` | +| `SndCall` / `RcvCall` | -- | `CICallItemView` | `CICallItemView.kt` | +| `RcvIntegrityError` | -- | `IntegrityErrorItemView` | `IntegrityErrorItemView.kt` | +| `RcvDecryptionError` | -- | `CIRcvDecryptionError` | `CIRcvDecryptionError.kt` | +| `RcvGroupInvitation` / `SndGroupInvitation` | -- | `CIGroupInvitationView` | `CIGroupInvitationView.kt` | +| `RcvDirectEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvGroupEventContent` / `SndGroupEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvConnEventContent` / `SndConnEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvChatFeature` / `SndChatFeature` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `RcvChatPreference` / `SndChatPreference` | -- | `CIFeaturePreferenceView` | `CIFeaturePreferenceView.kt` | +| `RcvGroupFeature` / `SndGroupFeature` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `SndModerated` / `RcvModerated` / `RcvBlocked` | -- | `MarkedDeletedItemView` | `MarkedDeletedItemView.kt` | +| `SndDirectE2EEInfo` / `RcvDirectE2EEInfo` | -- | `CIEventView` | `CIEventView.kt` | +| `SndGroupE2EEInfo` / `RcvGroupE2EEInfo` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvChatFeatureRejected` / `RcvGroupFeatureRejected` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `ChatBanner` | -- | `ChatBannerView` (inline in `ChatItemsList`) | `ChatView.kt` | +| `InvalidJSON` | -- | `CIInvalidJSONView` | `CIInvalidJSONView.kt` | +| `CIMemberCreatedContact` | -- | `CIMemberCreatedContactView` | `CIMemberCreatedContactView.kt` | + +--- + +## 6. Context Menu Actions + +Context menu actions are built dynamically in `ChatItemView` based on message type, direction, chat type, and feature flags. + +| Action | Condition | Effect | +|---|---|---| +| **Reply** | Message content (not event/deleted), not local notes | Sets `ComposeContextItem.QuotedItem` | +| **Edit** | Sent message, editable (`meta.editable`), text/link content | Sets `ComposeContextItem.EditingItem` | +| **Delete for me** | Any deletable item | `apiDeleteChatItems` with `cidmInternal` mode | +| **Delete for everyone** | Sent + within time window, or moderator privilege | `apiDeleteChatItems` with `cidmBroadcast` mode | +| **Moderate** | Group moderator + received message | `apiDeleteMemberChatItems` | +| **Forward** | Message content, not live message | Opens share sheet via `SharedContent.Forward` | +| **Select** | Any selectable item | Enters multi-select mode (`selectedChatItems`) | +| **React** | Message content, reactions enabled | Opens emoji picker; calls `apiChatItemReaction` | +| **Report** | Received group message, reports enabled | Sets `ComposeContextItem.ReportedItem` with reason | +| **Info** | Any message | Opens `ChatItemInfoView` in end modal | +| **Copy** | Text content present | Copies text to clipboard | +| **Save** | Image/video/file with completed download | Saves media to device | +| **Open** | File with completed download | Opens file with system handler | +| **Reveal / Hide** | Part of a merged group; expanded or collapsed | Toggles `revealedItems` state | + +--- + +## 7. ChatInfoView + +**Location:** [`ChatInfoView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt) + +Opened via the `info` callback when the user taps the toolbar title in a direct chat. Displayed in `ModalManager.end`. + +Preloads `apiContactInfo` (connection stats, server profile) and `apiGetContactCode` (verification code) before showing the modal. + +Key sections: contact profile, local alias, connection stats, shared media, disappearing messages preference, voice/call/file feature toggles, encryption verification, and contact deletion. + +--- + +## 8. GroupChatInfoView + +**Location:** [`group/GroupChatInfoView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt) + +Opened via the `info` callback for group chats. Displayed in `ModalManager.end`. + +Preloads group members (`setGroupMembers`) and group link (`apiGetGroupLink`). + +Key sections: group profile, group link, member list with roles, group preferences (disappearing messages, direct messages, full deletion, voice, files, SimpleX links, history), member admission, welcome message, reports view, and group deletion/leave. + +--- + +## 9. Source Files + +### `views/chat/` + +| File | Description | +|---|---| +| `ChatView.kt` | Main chat view, ChatLayout, ChatItemsList, ChatInfoToolbar | +| `ChatInfoView.kt` | Contact info modal | +| `ChatItemInfoView.kt` | Individual message delivery/read info | +| `ChatItemsLoader.kt` | Pagination and message loading logic | +| `ChatItemsMerger.kt` | MergedItems grouping of consecutive events | +| `CommandsMenuView.kt` | Bot `/command` menu popup | +| `ComposeContextContactRequestActionsView.kt` | Contact request action buttons in compose area | +| `ComposeContextGroupDirectInvitationActionsView.kt` | Group direct invitation compose actions | +| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | +| `ComposeContextProfilePickerView.kt` | Profile picker in compose context | +| `ComposeFileView.kt` | File attachment preview in compose | +| `ComposeImageView.kt` | Image/video attachment preview in compose | +| `ComposeView.kt` | Main compose area (ComposeState, send logic) | +| `ComposeVoiceView.kt` | Voice recording preview in compose | +| `ContactPreferences.kt` | Per-contact feature preferences | +| `ContextItemView.kt` | Reply/edit/forward context indicator | +| `ScanCodeView.kt` | QR code scanner | +| `SelectableChatItemToolbars.kt` | Multi-select toolbar (delete, forward, moderate) | +| `SendMsgView.kt` | Text input field, send button, voice record button | +| `VerifyCodeView.kt` | Contact/member encryption verification | + +### `views/chat/item/` + +| File | Description | +|---|---| +| `ChatItemView.kt` | Message type routing, context menu, reactions | +| `CIBrokenComposableView.kt` | Fallback for rendering errors | +| `CICallItemView.kt` | Call event display (incoming/outgoing/missed) | +| `CIChatFeatureView.kt` | Chat feature change event | +| `CIEventView.kt` | Generic event display (group/direct/connection) | +| `CIFeaturePreferenceView.kt` | Feature preference change event | +| `CIFileView.kt` | File message (download/upload progress) | +| `CIGroupInvitationView.kt` | Group invitation card | +| `CIImageView.kt` | Image message (thumbnail + fullscreen) | +| `CIInvalidJSONView.kt` | Invalid JSON fallback display | +| `CIMemberCreatedContactView.kt` | Member-created contact event | +| `CIMetaView.kt` | Message metadata (time, status indicators) | +| `CIRcvDecryptionError.kt` | Decryption error display | +| `CIVideoView.kt` | Video message (thumbnail + player) | +| `CIVoiceView.kt` | Voice message (waveform + player) | +| `DeletedItemView.kt` | Deleted message placeholder | +| `EmojiItemView.kt` | Large emoji-only message | +| `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | +| `ImageFullScreenView.kt` | Fullscreen image gallery | +| `IntegrityErrorItemView.kt` | Message integrity error | +| `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | +| `TextItemView.kt` | Plain text message with markdown | + +### `views/chat/group/` + +| File | Description | +|---|---| +| `AddGroupMembersView.kt` | Add members to group | +| `GroupChatInfoView.kt` | Group info and management | +| `GroupLinkView.kt` | Group link display and management | +| `GroupMemberInfoView.kt` | Individual member info | +| `GroupMembersToolbar.kt` | Members toolbar in group info | +| `GroupMentions.kt` | @mention autocomplete | +| `GroupPreferences.kt` | Group feature preferences | +| `GroupProfileView.kt` | Group profile editor | +| `GroupReportsView.kt` | Group reports list view | +| `MemberAdmission.kt` | Member admission settings | +| `MemberSupportChatView.kt` | Member support chat (scoped context) | +| `MemberSupportView.kt` | Support chat list for moderators | +| `WelcomeMessageView.kt` | Group welcome message editor | diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md new file mode 100644 index 0000000000..241dcf667b --- /dev/null +++ b/apps/multiplatform/spec/client/compose.md @@ -0,0 +1,399 @@ +# Message Composition Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt`, `SendMsgView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeState Data Class](#2-composestate-data-class) +3. [ComposePreview Sealed Class](#3-composepreview-sealed-class) +4. [ComposeContextItem Sealed Class](#4-composecontextitem-sealed-class) +5. [SendMsgView](#5-sendmsgview) +6. [Attachment Handling](#6-attachment-handling) +7. [Draft Persistence](#7-draft-persistence) +8. [Source Files](#8-source-files) + +--- + +## Executive Summary + +Message composition in SimpleX Chat is managed by `ComposeView` (line ~345 in `ComposeView.kt`) backed by the serializable `ComposeState` data class. The compose area supports text input, link previews, media/file/voice attachments, reply/edit/forward contexts, live (streaming) messages, member @mentions, message reports, and timed (disappearing) messages. The `SendMsgView` composable (in `SendMsgView.kt`) provides the text field and action buttons. Draft state persists across chat switches when the privacy preference is enabled. + +--- + + + +## 1. Overview + +``` +ComposeView +|-- contextItemView() +| |-- ContextItemView (QuotedItem) [reply indicator] +| |-- ContextItemView (EditingItem) [edit indicator] +| |-- ContextItemView (ForwardingItems) [forward indicator] +| +-- ContextItemView (ReportedItem) [report indicator] +|-- ReportReasonView [report reason header] +|-- MsgNotAllowedView [disabled send reason] +|-- previewView() +| |-- ComposeLinkView [link preview card] +| |-- ComposeImageView [media thumbnails] +| |-- ComposeVoiceView [voice recording waveform] +| +-- ComposeFileView [file name display] +|-- AttachmentAndCommandsButtons +| |-- CommandsButton [bot commands "//"] +| +-- AttachmentButton [paperclip icon] ++-- SendMsgView + |-- PlatformTextField [multiline text input] + |-- DeleteTextButton [clear text, shown on long text] + |-- SendMsgButton [arrow/check icon] + |-- RecordVoiceView [microphone + hold-to-record] + |-- StartLiveMessageButton [bolt icon] + |-- CancelLiveMessageButton [cancel live] + +-- TimedMessageDropdown [disappearing message timer] +``` + +--- + + + +## 2. ComposeState Data Class + +**Location:** [`ComposeView.kt#L98`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L98) + +```kotlin +@Serializable +data class ComposeState( + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), + val liveMessage: LiveMessage? = null, + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false, + val progressByTimeout: Boolean = false, + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() +) +``` + +### Fields + +| Field | Type | Description | +|---|---|---| +| `message` | `ComposeMessage` | Current text and cursor selection (`TextRange`) | +| `parsedMessage` | `List` | Markdown-parsed representation of message text | +| `liveMessage` | `LiveMessage?` | Active live (streaming) message state | +| `preview` | `ComposePreview` | Attachment preview (link, media, voice, file) | +| `contextItem` | `ComposeContextItem` | Reply/edit/forward/report context | +| `inProgress` | `Boolean` | Send operation in flight | +| `progressByTimeout` | `Boolean` | Show spinner after 1-second send delay | +| `useLinkPreviews` | `Boolean` | Link preview feature flag | +| `mentions` | `MentionedMembers` | Map of mention display name to `CIMention` | + +### Computed Properties + +| Property | Type | Description | +|---|---|---| +| `editing` | `Boolean` | True when `contextItem` is `EditingItem` | +| `forwarding` | `Boolean` | True when `contextItem` is `ForwardingItems` | +| `reporting` | `Boolean` | True when `contextItem` is `ReportedItem` | +| `sendEnabled` | `() -> Boolean` | True when there is content to send and not in progress | +| `linkPreviewAllowed` | `Boolean` | True when no media/voice/file preview is active | +| `linkPreview` | `LinkPreview?` | Extracts link preview from `CLinkPreview` | +| `attachmentDisabled` | `Boolean` | True when editing, forwarding, live, in-progress, or reporting | +| `attachmentPreview` | `Boolean` | True when a file or media preview is showing | +| `empty` | `Boolean` | True when no text, no preview, and no context item | +| `whitespaceOnly` | `Boolean` | True when message text contains only whitespace | +| `placeholder` | `String` | Input placeholder text (report reason text or default) | +| `memberMentions` | `Map` | Extracted member ID map for API calls | + +### ComposeMessage + +```kotlin +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) +``` + +### LiveMessage + +```kotlin +@Serializable +data class LiveMessage( + val chatItem: ChatItem, + val typedMsg: String, + val sentMsg: String, + val sent: Boolean +) +``` + +Tracks a live (streaming) message: the associated `ChatItem`, the currently typed text, the last sent text, and whether the initial send has occurred. + +### Serialization + +`ComposeState` is fully `@Serializable` with a custom `Saver` (line ~214) that uses `json.encodeToString`/`decodeFromString` for `rememberSaveable` persistence across configuration changes. + +--- + + + +## 3. ComposePreview Sealed Class + +**Location:** [`ComposeView.kt#L52`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L52) + +```kotlin +sealed class ComposePreview { + object NoPreview : ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview?) : ComposePreview() + class MediaPreview(val images: List, val content: List) : ComposePreview() + data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean) : ComposePreview() + class FilePreview(val fileName: String, val uri: URI) : ComposePreview() +} +``` + +| Variant | Fields | View | +|---|---|---| +| `NoPreview` | -- | Nothing shown | +| `CLinkPreview` | `linkPreview: LinkPreview?` (null = loading) | `ComposeLinkView`: title, description, image thumbnail, cancel button | +| `MediaPreview` | `images: List` (base64 thumbnails), `content: List` | `ComposeImageView`: horizontal thumbnail strip, cancel button | +| `VoicePreview` | `voice: String` (file path), `durationMs: Int`, `finished: Boolean` | `ComposeVoiceView`: waveform visualization, duration, play/pause | +| `FilePreview` | `fileName: String`, `uri: URI` | `ComposeFileView`: file icon, file name, cancel button | + +### UploadContent + +Used within `MediaPreview` to track the source type: + +- `SimpleImage(uri: URI)` -- still image +- `AnimatedImage(uri: URI)` -- GIF or animated WebP +- `Video(uri: URI, duration: Int)` -- video with duration in seconds + +--- + +## 4. ComposeContextItem Sealed Class + +**Location:** [`ComposeView.kt#L61`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L61) + +```kotlin +sealed class ComposeContextItem { + object NoContextItem : ComposeContextItem() + class QuotedItem(val chatItem: ChatItem) : ComposeContextItem() + class EditingItem(val chatItem: ChatItem) : ComposeContextItem() + class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo) : ComposeContextItem() + class ReportedItem(val chatItem: ChatItem, val reason: ReportReason) : ComposeContextItem() +} +``` + +| Variant | Trigger | Compose Behavior | +|---|---|---| +| `NoContextItem` | Default state | Normal message composition | +| `QuotedItem` | Swipe-to-reply or reply menu action | Shows quoted message indicator; sends with `quoted` parameter | +| `EditingItem` | Edit menu action | Populates text field with existing message; send button becomes checkmark; calls `apiUpdateChatItem` | +| `ForwardingItems` | Forward action from another chat | Shows forwarded items indicator; calls `apiForwardChatItems`; can include optional text message | +| `ReportedItem` | Report menu action | Shows report indicator with reason; placeholder changes to reason text; calls `apiReportMessage` | + +### Context Item View + +`contextItemView()` (line ~1098 in `ComposeView.kt`) renders the active context as a dismissible bar above the text input: + +- Icon: reply (ic_reply), edit (ic_edit_filled), forward (ic_forward), report (ic_flag) +- Content: quoted message preview text with sender name +- Close button: resets `contextItem` to `NoContextItem` (or `clearState()` for editing) + +--- + + + +## 5. SendMsgView + +**Location:** [`SendMsgView.kt#L36`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt#L36) + +```kotlin +fun SendMsgView( + composeState: MutableState, + showVoiceRecordIcon: Boolean, + recState: MutableState, + isDirectChat: Boolean, + liveMessageAlertShown: SharedPreference, + sendMsgEnabled: Boolean, + userCantSendReason: Pair?, + sendButtonEnabled: Boolean, + sendToConnect: (() -> Unit)?, + hideSendButton: Boolean, + nextConnect: Boolean, + needToAllowVoiceToContact: Boolean, + allowedVoiceByPrefs: Boolean, + sendButtonColor: Color, + allowVoiceToContact: () -> Unit, + timedMessageAllowed: Boolean, + customDisappearingMessageTimePref: SharedPreference?, + placeholder: String, + sendMessage: (Int?) -> Unit, + sendLiveMessage: (suspend () -> Unit)?, + updateLiveMessage: (suspend () -> Unit)?, + cancelLiveMessage: (() -> Unit)?, + editPrevMessage: () -> Unit, + onFilesPasted: (List) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? +) +``` + +### Layout + +The view is a `Box` containing: + +1. **PlatformTextField:** Multiline text input (platform-specific `expect`). Handles text changes via `onMessageChange`, up-arrow to `editPrevMessage`, file paste via `onFilesPasted`, and Enter to send. +2. **DeleteTextButton:** Shown when text is long; clears the field. +3. **Action area** (bottom-right, stacked): + - **Progress indicator:** Shown when `progressByTimeout` is true. + - **Report confirm button:** Checkmark icon when context is `ReportedItem`. + - **Voice record button:** Shown when message is empty, not editing/forwarding, no preview active. + - `RecordVoiceView`: Hold-to-record with waveform display. + - `DisallowedVoiceButton`: Shown when voice is disabled by preferences. + - `VoiceButtonWithoutPermissionByPlatform`: Shown when microphone permission is not granted. + - **Live message button:** Bolt icon, starts streaming message (calls `sendLiveMessage`). + - **Send button:** Arrow icon (new message) or checkmark (editing/live). Long-press opens dropdown: + - "Send live message" option + - Timed message options (1min, 5min, 1hr, 8hr, 1day, 1week, 1month, custom) + +### RecordingState + +```kotlin +sealed class RecordingState { + object NotStarted : RecordingState() + class Started(val filePath: String, val progressMs: Int) : RecordingState() + class Finished(val filePath: String, val durationMs: Int) : RecordingState() +} +``` + +Voice recording of 300ms or less is auto-cancelled. + +### Disabled State + +When `sendMsgEnabled` is false (e.g., contact not ready, group permissions), an overlay covers the text field. If `userCantSendReason` is provided, tapping the overlay shows an alert explaining why sending is disabled. + +--- + +## 6. Attachment Handling + + + +### Attachment Selection + +The `AttachmentSelection` composable (line ~263 in `ComposeView.kt`) is an `expect` function with platform-specific implementations: + +**Android:** +- Camera launcher (image capture) +- Gallery launcher (image/video picker, multi-select) +- File picker (any file type) + +**Desktop:** +- File chooser dialog (filters for images or all files) + +### ChooseAttachmentView + +Bottom sheet (`ModalBottomSheetLayout`) presenting attachment type options: + +| Option | Result | +|---|---| +| Camera (Android) | Launches camera intent; result processed as `SimpleImage` | +| Gallery | Launches media picker; results processed via `processPickedMedia` | +| File | Launches file picker; result processed via `processPickedFile` | + +### File Processing + +**`processPickedFile`** (line ~281): +1. Checks file size against `maxFileSize` (XFTP limit). +2. Extracts file name from URI. +3. Sets `ComposePreview.FilePreview` on compose state. + +**`processPickedMedia`** (line ~300): +1. For each URI, determines type (image, animated image, video). +2. Images: Gets bitmap, creates `SimpleImage` or `AnimatedImage` upload content. +3. Videos: Extracts thumbnail and duration, creates `Video` upload content. +4. Generates base64 preview thumbnails (max 14KB). +5. Sets `ComposePreview.MediaPreview` with thumbnails and content list. + +**`onFilesAttached`** (line ~270): +Groups dropped/pasted files into images and non-images; routes to `processPickedMedia` or `processPickedFile`. + +### Send Flow + +On send (line ~603, `sendMessageAsync`): + +1. **Forwarding:** Calls `apiForwardChatItems`, then optionally sends a text message quoting the last forwarded item. +2. **Editing:** Calls `apiUpdateChatItem` with updated `MsgContent`. +3. **Reporting:** Calls `apiReportMessage` with reason and text. +4. **New message:** Iterates over `msgs` (one per media item or single for text/file/voice): + - Saves file to app storage (or remote host). + - For voice: encrypts if `privacyEncryptLocalFiles` is enabled. + - Calls `apiSendMessages` or `apiCreateChatItems` (local notes). +5. On failure of the last message, restores compose state for retry. + +### Link Preview + +When `privacyLinkPreviews` is enabled and the message contains a URL: + +1. `showLinkPreview` extracts first non-SimpleX, non-cancelled link from parsed markdown. +2. Sets `ComposePreview.CLinkPreview(null)` (loading state). +3. After 1.5s debounce, calls `getLinkPreview(url)`. +4. On success, updates to `CLinkPreview(linkPreview)`. +5. Cancel button adds the URL to `cancelledLinks` set. + +--- + +## 7. Draft Persistence + +**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`) + +Controlled by the `privacySaveLastDraft` preference. + +### Save Behavior + +When the user navigates away from a chat (`chatModel.chatId.value` changes): + +| Compose State | Action | +|---|---| +| Live message active (text present or already sent) | Sends the live message immediately, clears draft | +| In progress | Clears in-progress flag, clears previous draft | +| Non-empty (text, preview, or context) | If `saveLastDraft` is true: saves `composeState.value` to `chatModel.draft.value` and `chatModel.draftChatId.value` | +| Empty but draft exists for current chat | Restores draft from `chatModel.draft` | +| Empty, no draft | Clears previous draft, deletes unused files | + +### Restore Behavior + +When entering a chat (line ~132 in `ChatView.kt`): + +1. Checks if `chatModel.draftChatId.value` matches the chat ID. +2. If match and draft is not null (and not a cross-chat forward), initializes `composeState` from the draft. +3. Otherwise, creates a fresh `ComposeState`. + +### Desktop-specific + +On desktop, a `DisposableEffect` (line ~1256) saves the draft on dispose when forwarding content, since the `KeyChangeEffect` mechanism is Android-specific. + +### Draft Display in Chat List + +When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the draft text instead of the last message preview. + +--- + +## 8. Source Files + +| File | Description | +|---|---| +| `ComposeView.kt` | ComposeState, ComposePreview, ComposeContextItem, ComposeView composable, send logic, link preview, draft persistence | +| `SendMsgView.kt` | Text input field, send/voice/live/timed buttons, recording state | +| `ComposeFileView.kt` | File attachment preview (name, cancel) | +| `ComposeImageView.kt` | Media attachment preview (thumbnails, cancel) | +| `ComposeVoiceView.kt` | Voice recording preview (waveform, duration, play) | +| `ContextItemView.kt` | Reply/edit/forward/report context bar | +| `ComposeContextContactRequestActionsView.kt` | Contact request action buttons in compose area | +| `ComposeContextGroupDirectInvitationActionsView.kt` | Group direct invitation compose actions | +| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | +| `ComposeContextProfilePickerView.kt` | Profile picker in compose context | +| `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md new file mode 100644 index 0000000000..c9939ea3c0 --- /dev/null +++ b/apps/multiplatform/spec/client/navigation.md @@ -0,0 +1,379 @@ +# Navigation Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/App.kt` (470 lines) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [AppScreen Composable](#2-appscreen-composable) +3. [MainScreen](#3-mainscreen) +4. [Android Layout](#4-android-layout) +5. [Desktop Layout](#5-desktop-layout) +6. [ModalManager](#6-modalmanager) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +SimpleX Chat navigation is a platform-adaptive system implemented in `App.kt`. The root `AppScreen` composable applies theming and safe-area insets, delegating to `MainScreen` which acts as a state machine routing between onboarding, authentication, database error, and the main chat interface. Android uses a 2-column sliding layout (`AndroidScreen`), while desktop uses a fixed 3-column layout (`DesktopScreen`). Modal presentation is managed by `ModalManager`, which provides named zones (start, center, end, fullscreen) for layered content. Authentication is gated by `AppLock`, and onboarding follows a linear `OnboardingStage` enum. + +--- + +## 1. Overview + +``` +AppScreen (line 46) ++-- SimpleXTheme + +-- Surface + +-- MainScreen (line 82) + |-- [Migration in progress] -> DefaultProgressView + |-- [Database opening] -> DefaultProgressView + |-- [Database error] -> DatabaseErrorView + |-- [Encryption check pending] -> SplashView + |-- [Onboarding incomplete] -> AnimatedContent { OnboardingStage views } + |-- [Onboarding complete] + | |-- [Android] + | | +-- AndroidWrapInCallLayout + | | +-- AndroidScreen (line 293) + | | |-- StartPartOfScreen (ChatListView) + | | +-- ChatView (slide-in panel) + | +-- [Desktop] + | +-- DesktopScreen (line 406) + | |-- StartPartOfScreen + UserPicker (left column) + | |-- ModalManager.start (overlay on left) + | |-- CenterPartOfScreen / ChatView (center column) + | +-- ModalManager.end (right column) + |-- [Unauthorized] -> AuthView / SplashView / PasscodeView + |-- [Active call] -> ActiveCallView (desktop) / startCallActivity (Android) + +-- [Incoming call] -> IncomingCallAlertView +``` + +--- + + + +## 2. AppScreen Composable + +**Location:** [`App.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) + +```kotlin +@Composable +fun AppScreen() +``` + +### Responsibilities + +1. **Theme application:** Wraps content in `SimpleXTheme` with `Surface` using `MaterialTheme.colors.background`. +2. **Window insets:** Computes safe padding for landscape mode, accounting for display cutouts on both sides. Uses `WindowInsets.safeDrawing` and `WindowInsets.displayCutout` to calculate symmetric padding. +3. **Fullscreen gallery overlay:** When `chatModel.fullscreenGalleryVisible` is true, draws a black rectangle behind content extending into the cutout areas to provide an immersive gallery background. +4. **Delegates to `MainScreen()`.** + +--- + + + +## 3. MainScreen + +**Location:** [`App.kt#L84`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L84) + +```kotlin +@Composable +fun MainScreen() +``` + +### State Machine + +`MainScreen` evaluates a series of conditions in priority order: + +| Priority | Condition | View | +|---|---|---| +| 1 | `onboarding == Step1_SimpleXInfo && migrationState != null` | `SimpleXInfo` (migration in progress) | +| 2 | `dbMigrationInProgress` | `DefaultProgressView("Database migration...")` | +| 3 | `chatDbStatus == null && showInitializationView` | `DefaultProgressView("Opening database...")` | +| 4 | `showChatDatabaseError` | `DatabaseErrorView` | +| 5 | `chatDbEncrypted == null \|\| localUserCreated == null` | `SplashView` | +| 6 | `onboarding == OnboardingComplete` | Platform-specific main screen | +| 7 | Other onboarding stages | `AnimatedContent` with stage-specific views | + +### Onboarding Complete Branch (line ~156) + +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. + +### Overlay Layers (bottom of MainScreen) + +| Layer | Condition | Content | +|---|---|---| +| `ModalManager.fullscreen` | Android + migration/onboarding | Fullscreen modals | +| `SwitchingUsersView` | User switch in progress | Loading overlay | +| Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode | +| Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) | +| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` | +| Privacy alerts | Always | `AlertManager.privacySensitive` | +| Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` | +| Shared alerts | Always | `AlertManager.shared` | + +--- + + + +## 4. Android Layout + +**Location:** [`App.kt#L296`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L296) + +```kotlin +@Composable +fun AndroidScreen(userPickerState: MutableStateFlow) +``` + +### 2-Column Slide Animation + +Uses `BoxWithConstraints` to get `maxWidth`, then two `Box` containers: + +1. **Left panel (StartPartOfScreen):** Chat list, positioned at `translationX = -offset`. +2. **Right panel (ChatView):** Chat view, positioned at `translationX = maxWidth - offset`. + +The `offset` is an `Animatable`: +- `0f` when no chat is selected (chat list visible). +- `maxWidth.value` when a chat is open (chat view visible). + +### Animation Flow + +1. `snapshotFlow { chatModel.chatId.value }` detects chat ID changes. +2. When `chatId` becomes null, `onComposed(null)` animates offset to 0. +3. When `ChatView` finishes composing (calls `onComposed(chatId)`), offset animates to `maxWidth`. +4. Animation uses `chatListAnimationSpec()` (standard spring or tween). + +### Display Cutout Handling + +If the device has a display cutout on horizontal sides (detected via `WindowInsets.displayCutout`), the panels are clipped with `RectangleShape` to prevent the chat list from showing through during transition. + +### Call Layout Wrapper + +`AndroidWrapInCallLayout` (line ~279) adds a 40dp top padding when an active call is in progress (not in `WaitCapabilities` or `InvitationAccepted` state), with an `ActiveCallInteractiveArea` banner above. + +--- + + + +## 5. Desktop Layout + +**Location:** [`App.kt#L410`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L410) + +```kotlin +@Composable +fun DesktopScreen(userPickerState: MutableStateFlow) +``` + +### 3-Column Layout + +| Column | Width | Content | +|---|---|---| +| **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | +| **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | + +### Column Separators + +- `VerticalDivider` between left and center columns (always visible). +- `VerticalDivider` between center and right columns (visible when `ModalManager.end.hasModalsOpen()`). + +### Click-to-Dismiss Overlay + +When the UserPicker is visible or a start modal is open (but no center modal), a full-size clickable overlay covers the center+right area (line ~428). Clicking it closes start modals and hides the UserPicker. + +### CenterPartOfScreen + +**Location:** [`App.kt#L373`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L373) + +- When `chatId` is null and no center modals: shows "No selected chat" placeholder. +- When `chatId` is null and center modals open: shows `ModalManager.center`. +- When `chatId` is set: shows `ChatView`. +- Automatically closes center modals when a chat is selected. + +### StartPartOfScreen + +**Location:** [`App.kt#L352`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L352) + +Routes between: +- `SetDeliveryReceiptsView` (if `chatModel.setDeliveryReceipts` is true) +- `ChatListView` (normal operation) +- `ShareListView` (when `chatModel.sharedContent` is non-null, i.e., forwarding) + +--- + +## 6. ModalManager + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt` (line 92) + +```kotlin +class ModalManager(private val placement: ModalPlacement?) +``` + +### Zones + +| Zone | Android Behavior | Desktop Behavior | +|---|---|---| +| `start` | Shared (same as all others) | Left column overlay, slides from start | +| `center` | Shared | Center column overlay, replaces ChatView | +| `end` | Shared | Right column, slides from end | +| `fullscreen` | Shared | Fullscreen overlay | + +On Android, all four zones point to the same `shared` instance, meaning modals stack in a single overlay. On desktop, each zone is independent with its own `ModalPlacement`. + +```kotlin +companion object { + val start = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.START) + val center = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.CENTER) + val end = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.END) + val fullscreen = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.FULLSCREEN) +} +``` + +### Modal Stack + +Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: +- `id: ModalViewId?` -- optional identifier for deduplication +- `animated: Boolean` -- whether to use enter/exit transitions +- `data: ModalData` -- scoped data for the modal +- `modal: @Composable ModalData.(close: () -> Unit) -> Unit` -- the modal content + +### Key Methods + +| Method | Description | +|---|---| +| `showModal` | Push a simple modal onto the stack | +| `showModalCloseable` | Push a modal with a close callback | +| `showCustomModal` | Push a modal with full control over `ModalView` wrapper | +| `closeModals` | Pop all modals from the stack | +| `closeModalsExceptFirst` | Pop all but the bottom modal | +| `hasModalsOpen()` | Check if any modals are on the stack | +| `showInView` | Render the current modal stack into the composable tree | + +### Usage Pattern + +| Action | Zone Used | +|---|---| +| Settings, New Chat, User Address | `ModalManager.start` | +| Onboarding conditions, What's New | `ModalManager.center` | +| ChatInfoView, GroupChatInfoView, ChatItemInfoView, GroupMemberInfoView | `ModalManager.end` | +| Passcode entry, Call view, Migration | `ModalManager.fullscreen` | + +--- + + + +## 7. Authentication Gate + +**Location:** [`AppLock.kt#L17`](../../common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt#L17) + +```kotlin +object AppLock { + val userAuthorized = mutableStateOf(null) + val enteredBackground = mutableStateOf(null) + val laFailed = mutableStateOf(false) +} +``` + +### State + +| Field | Type | Description | +|---|---|---| +| `userAuthorized` | `MutableState` | `null` = not yet determined, `true` = authenticated, `false` = locked | +| `enteredBackground` | `MutableState` | Timestamp when app entered background (for lock delay) | +| `laFailed` | `MutableState` | True if last authentication attempt failed | + +### Authentication Flow + +1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135. +2. If unauthorized and not in an active call: + - Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt. + - On Android with system auth finishing during activity destruction, authentication is skipped. +3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button. +4. If `performLA` is set and `laFailed` is false: shows `SplashView` with passcode overlay. + +### Lock Delay + +The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. + +### Lock Modes + +- `LAMode.SYSTEM`: Uses Android biometric/system lock screen. +- `LAMode.PASSCODE`: Uses in-app passcode (`SetAppPasscodeView`). + +### First-Time Lock Notice + +`showLANotice` (line ~33 in `AppLock.kt`) prompts users to enable SimpleX Lock when they have more than 3 chats, have not yet been shown the notice, and have not enabled lock. On Android, it offers a choice between system auth and passcode. + +--- + +## 8. Onboarding Flow + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt` (line 3) + +```kotlin +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + LinkAMobile, + Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, + Step3_CreateSimpleXAddress, + Step4_SetNotificationsMode, + OnboardingComplete +} +``` + +### Stage Progression + +| Stage | View | Next Stage | +|---|---|---| +| `Step1_SimpleXInfo` | `SimpleXInfo` -- app introduction, privacy features | `Step2_CreateProfile` or `LinkAMobile` (desktop) | +| `Step2_CreateProfile` | `CreateFirstProfile` -- display name, optional image | `Step2_5_SetupDatabasePassphrase` or `Step3_ChooseServerOperators` | +| `LinkAMobile` | `LinkAMobile` -- desktop linking to mobile device | `Step2_CreateProfile` | +| `Step2_5_SetupDatabasePassphrase` | `SetupDatabasePassphrase` -- optional DB encryption | `Step3_ChooseServerOperators` | +| `Step3_ChooseServerOperators` | `OnboardingConditionsView` -- server operator selection, T&C | `Step3_CreateSimpleXAddress` or `Step4_SetNotificationsMode` | +| `Step3_CreateSimpleXAddress` | `SetNotificationsMode` (legacy backcompat) | `Step4_SetNotificationsMode` | +| `Step4_SetNotificationsMode` | `SetNotificationsMode` -- notification permission setup | `OnboardingComplete` | +| `OnboardingComplete` | Main app screen | -- | + +### Animated Transitions + +Onboarding uses `AnimatedContent` with directional transitions: +- Forward: `fromEndToStartTransition` (slide left). +- Backward: `fromStartToEndTransition` (slide right). + +The stage value is stored in `appPrefs.onboardingStage` and persisted across app restarts. + +--- + +## 9. Source Files + +| File | Description | +|---|---| +| `App.kt` | AppScreen, MainScreen, AndroidScreen, DesktopScreen, StartPartOfScreen, CenterPartOfScreen, EndPartOfScreen | +| `AppLock.kt` | AppLock object, authentication state, lock notice, LA mode selection | +| `views/helpers/ModalView.kt` | ModalManager class, ModalPlacement enum, modal stack management | +| `views/onboarding/OnboardingView.kt` | OnboardingStage enum | +| `views/onboarding/SimpleXInfo.kt` | Step 1: App introduction | +| `views/WelcomeView.kt` | Step 2: Profile creation (CreateFirstProfile) | +| `views/onboarding/LinkAMobileView.kt` | Desktop: Link a mobile device | +| `views/onboarding/SetupDatabasePassphrase.kt` | Step 2.5: Database passphrase | +| `views/onboarding/ChooseServerOperators.kt` | Step 3: Server operators and conditions | +| `views/onboarding/SetNotificationsMode.kt` | Step 4: Notification setup | +| `views/chatlist/ChatListView.kt` | Chat list (StartPartOfScreen content) | +| `views/chatlist/UserPicker.kt` | User switching panel | +| `views/chat/ChatView.kt` | Chat view (CenterPartOfScreen content) | +| `views/database/DatabaseErrorView.kt` | Database error recovery | +| `views/SplashView.kt` | Splash / loading screen | +| `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) | +| `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) | diff --git a/apps/multiplatform/spec/database.md b/apps/multiplatform/spec/database.md new file mode 100644 index 0000000000..f6ecedb721 --- /dev/null +++ b/apps/multiplatform/spec/database.md @@ -0,0 +1,393 @@ +# Database & Storage + +## Table of Contents + +1. [Overview](#1-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [Source Files](#8-source-files) + +--- + +## 1. Overview + +SimpleX Chat uses **two SQLite databases** managed entirely by the Haskell core. Kotlin code **never reads or writes the databases directly** -- all data access goes through the JNI command/response protocol defined in [SimpleXAPI.kt](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt). + +The two databases are: + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat database | `_chat.db` | Users, contacts, groups, messages, files metadata, settings | +| Agent database | `_agent.db` | SMP/XFTP agent state: connections, queues, encryption keys, delivery tracking | + +Both databases are created and migrated by the `chatMigrateInit` JNI function. The Kotlin layer handles: +- Providing the correct file path prefix (`dbAbsolutePrefixPath`) +- Providing the encryption key +- Interpreting migration results (`DBMigrationResult`) +- Exposing API functions that proxy to Haskell store operations + +--- + +## 2. Database Files & Paths + +### Expect Declarations + +The common module declares platform-dependent paths as `expect` values in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt): + +```kotlin +expect val dataDir: File // L18 +expect val tmpDir: File // L19 +expect val filesDir: File // L20 +expect val appFilesDir: File // L21 +expect val wallpapersDir: File // L22 +expect val coreTmpDir: File // L23 +expect val dbAbsolutePrefixPath: String // L24 +expect val preferencesDir: File // L25 +expect val preferencesTmpDir: File // L26 + +expect val chatDatabaseFileName: String // L28 +expect val agentDatabaseFileName: String // L29 + +expect val databaseExportDir: File // L35 +expect val remoteHostsDir: File // L37 +``` + +### Android Actual Values + +From [Files.android.kt](../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt): + +| Variable | Value | Notes | +|----------|-------|-------| +| `dataDir` | `androidAppContext.dataDir` | `/data/data//` | +| `tmpDir` | `getDir("temp", MODE_PRIVATE)` | Private temp directory | +| `filesDir` | `dataDir/files` | Parent for all file storage | +| `appFilesDir` | `filesDir/app_files` | User-visible chat file attachments | +| `wallpapersDir` | `filesDir/assets/wallpapers` | Custom wallpaper images | +| `coreTmpDir` | `filesDir/temp_files` | Haskell core temp directory | +| `dbAbsolutePrefixPath` | `dataDir/files` | Prefix: core appends `_chat.db` / `_agent.db` | +| `chatDatabaseFileName` | `"files_chat.db"` | Full filename: `files_chat.db` | +| `agentDatabaseFileName` | `"files_agent.db"` | Full filename: `files_agent.db` | +| `databaseExportDir` | `androidAppContext.cacheDir` | Temp location for archive export | +| `remoteHostsDir` | `tmpDir/remote_hosts` | Remote host file staging | +| `preferencesDir` | `dataDir/shared_prefs` | Android SharedPreferences directory | + +### Desktop Actual Values + +From [Files.desktop.kt](../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt): + +| Variable | Value | Notes | +|----------|-------|-------| +| `dataDir` | `desktopPlatform.dataPath` | XDG_DATA_HOME (Linux), AppData (Windows), Application Support (macOS) | +| `tmpDir` | `java.io.tmpdir/simplex` | System temp with `deleteOnExit` | +| `filesDir` | `dataDir/simplex_v1_files` | Flat file storage | +| `appFilesDir` | Same as `filesDir` | No subdirectory on desktop | +| `wallpapersDir` | `dataDir/simplex_v1_assets/wallpapers` | Custom wallpaper images | +| `coreTmpDir` | `dataDir/tmp` | Haskell core temp directory | +| `dbAbsolutePrefixPath` | `dataDir/simplex_v1` | Prefix: core appends `_chat.db` / `_agent.db` | +| `chatDatabaseFileName` | `"simplex_v1_chat.db"` | Full filename: `simplex_v1_chat.db` | +| `agentDatabaseFileName` | `"simplex_v1_agent.db"` | Full filename: `simplex_v1_agent.db` | +| `databaseExportDir` | Same as `tmpDir` | Temp location for archive export | +| `remoteHostsDir` | `dataDir/remote_hosts` | Remote host file staging | +| `preferencesDir` | `desktopPlatform.configPath` | Platform config directory | + +### Resulting Database Paths + +| Platform | Chat DB | Agent DB | +|----------|---------|----------| +| Android | `/data/data//files_chat.db` | `/data/data//files_agent.db` | +| Desktop (Linux) | `~/.local/share/simplex/simplex_v1_chat.db` | `~/.local/share/simplex/simplex_v1_agent.db` | +| Desktop (macOS) | `~/Library/Application Support/simplex/simplex_v1_chat.db` | ... | +| Desktop (Windows) | `%APPDATA%/simplex/simplex_v1_chat.db` | ... | + +--- + +## 3. Haskell Store Modules + +The Haskell core organizes database access into store modules. Kotlin code invokes these indirectly through `CC` commands. The store modules are: + +| Module | Path | Responsibilities | +|--------|------|-----------------| +| `Messages.hs` | `src/Simplex/Chat/Store/Messages.hs` | Message CRUD, chat items, reactions, delivery statuses, TTL cleanup | +| `Groups.hs` | `src/Simplex/Chat/Store/Groups.hs` | Group profiles, membership, roles, invitations, group links | +| `Direct.hs` | `src/Simplex/Chat/Store/Direct.hs` | Contact management, direct connections, contact requests | +| `Files.hs` | `src/Simplex/Chat/Store/Files.hs` | File transfer metadata, XFTP state, standalone files | +| `Profiles.hs` | `src/Simplex/Chat/Store/Profiles.hs` | User profiles, display names, address book | +| `Connections.hs` | `src/Simplex/Chat/Store/Connections.hs` | SMP agent connections, pending connections, server switches | + +All store operations execute within SQLite transactions managed by the Haskell core. The Kotlin layer has no direct knowledge of table schemas or SQL queries. + +--- + +## 4. Migrations + +### JNI Entry Point + +Database migration is triggered by the `chatMigrateInit` external function ([Core.kt#L25](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25)): + +```kotlin +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +``` + +**Parameters:** +- `dbPath` -- the `dbAbsolutePrefixPath` (core appends `_chat.db` and `_agent.db`) +- `dbKey` -- encryption passphrase (empty string = unencrypted) +- `confirm` -- migration confirmation mode: `"error"`, `"yesUp"`, or `"yesUpDown"` + +**Returns:** `Array` where: +- `[0]` -- JSON string encoding a `DBMigrationResult` +- `[1]` -- `ChatCtrl` handle (Long) if migration succeeded + +### Migration Flow in `initChatController` + +The full initialization sequence is in [Core.kt#L62](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62): + +1. Obtain the DB encryption key from `DatabaseUtils.useDatabaseKey()`. +2. Determine the confirmation mode (default: `YesUp`; developer mode with confirm upgrades: `Error`). +3. Call `chatMigrateInit(dbAbsolutePrefixPath, dbKey, "error")` -- first attempt with `Error` to detect pending migrations. +4. Parse the result as `DBMigrationResult`. +5. If the result is `ErrorMigration` with an `Upgrade` error and confirmation allows it, re-run `chatMigrateInit` with the appropriate confirmation (`"yesUp"`). +6. If `OK`, store the `ChatCtrl` handle, set `chatDbEncrypted`, and proceed to start the chat. +7. If not `OK`, handle special case: if the `newDatabaseInitialized` preference is not set AND the database was only partially initialized (single DB file exists), remove both files and retry once. + + + +### DBMigrationResult + +Defined in [DatabaseUtils.kt#L79](../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt#L79): + +```kotlin +sealed class DBMigrationResult { + object OK // Migration succeeded + object InvalidConfirmation // Invalid confirmation parameter + data class ErrorNotADatabase(val dbFile: String) // File exists but is not a valid database + data class ErrorMigration(val dbFile: String, // Migration error with details + val migrationError: MigrationError) + data class ErrorSQL(val dbFile: String, // SQL error during migration + val migrationSQLError: String) + object ErrorKeychain // Keychain/keystore error + data class Unknown(val json: String) // Unparseable response +} +``` + +### MigrationError + +```kotlin +sealed class MigrationError { + class Upgrade(val upMigrations: List) // Pending forward migrations + class Downgrade(val downMigrations: List) // Database is newer than app + class Error(val mtrError: MTRError) // Conflict or missing migrations +} +``` + +### MigrationConfirmation + +```kotlin +enum class MigrationConfirmation(val value: String) { + YesUp("yesUp"), // Auto-confirm forward migrations + YesUpDown("yesUpDown"), // Auto-confirm both directions (not used in UI) + Error("error") // Report errors without running migrations +} +``` + +--- + +## 5. Database Encryption + +### Encryption API + +Two API functions manage database encryption, both in [SimpleXAPI.kt](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Parameters | Description | Line | +|----------|-----------|-------------|------| +| `apiStorageEncryption` | `currentKey: String, newKey: String` | Change or set the database encryption passphrase | [L999](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L999) | +| `testStorageEncryption` | `key: String, ctrl: ChatCtrl?` | Test whether a given key can decrypt the database | [L1006](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1006) | + +Both delegate to the Haskell core via `CC.ApiStorageEncryption(DBEncryptionConfig)` and `CC.TestStorageEncryption(key)` respectively. + + + +`DBEncryptionConfig` ([SimpleXAPI.kt#L4166](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4166)): + +```kotlin +class DBEncryptionConfig(val currentKey: String, val newKey: String) +``` + +### Passphrase Storage -- CryptorInterface + +The `CryptorInterface` ([Cryptor.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt)) provides platform-specific key encryption for storing the DB passphrase at rest: + +```kotlin +interface CryptorInterface { + fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? + fun encryptText(text: String, alias: String): Pair + fun deleteKey(alias: String) +} + +expect val cryptor: CryptorInterface +``` + +### Android Implementation + +[Cryptor.android.kt](../common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt): + +- Uses **Android KeyStore** (`"AndroidKeyStore"` provider) +- Algorithm: **AES/GCM/NoPadding** (128-bit authentication tag) +- Keys are hardware-backed when available +- On decryption failure with a random initial passphrase, throws to prevent overwriting +- Shows user alerts for keychain errors + +```kotlin +internal class Cryptor: CryptorInterface { + private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + // AES-GCM encryption/decryption using AndroidKeyStore-managed keys +} +``` + +### Desktop Implementation + +[Cryptor.desktop.kt](../common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt): + +- **Placeholder/no-op implementation** -- data is returned as-is +- No actual encryption of the stored passphrase on desktop +- `decryptData` returns `String(data)` without decryption +- `encryptText` returns the raw bytes without encryption + +```kotlin +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? = String(data) + override fun encryptText(text: String, alias: String) = text.toByteArray() to text.toByteArray() + override fun deleteKey(alias: String) {} +} +``` + +### Passphrase Management + +`DatabaseUtils` ([DatabaseUtils.kt](../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt)) provides: + +- `ksDatabasePassword` -- encrypted passphrase stored in platform preferences (SharedPreferences on Android, file-based on desktop) +- `useDatabaseKey()` -- retrieves the passphrase, decrypting it via `CryptorInterface` +- `randomDatabasePassword()` -- generates a 32-byte random passphrase (Base64-encoded) for initial database creation + +The flow: +1. On first launch, `randomDatabasePassword()` generates a key. +2. `CryptorInterface.encryptText()` encrypts the key for storage. +3. The encrypted (data, IV) pair is saved to preferences via `ksDatabasePassword`. +4. On subsequent launches, `ksDatabasePassword.get()` retrieves the encrypted pair, and `CryptorInterface.decryptData()` recovers the plaintext key. +5. The key is passed to `chatMigrateInit` to open the encrypted SQLite databases. + +--- + +## 6. File Storage + +### Directory Layout + +Declared in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) with platform-specific implementations: + +| Directory | Variable | Android Path | Desktop Path | Purpose | +|-----------|----------|-------------|--------------|---------| +| App files | `appFilesDir` | `dataDir/files/app_files` | `dataDir/simplex_v1_files` | Chat file attachments (images, videos, documents) | +| Wallpapers | `wallpapersDir` | `dataDir/files/assets/wallpapers` | `dataDir/simplex_v1_assets/wallpapers` | Custom chat wallpaper images | +| Core temp | `coreTmpDir` | `dataDir/files/temp_files` | `dataDir/tmp` | Haskell core temporary files (in-progress transfers) | +| App temp | `tmpDir` | `getDir("temp", MODE_PRIVATE)` | `java.io.tmpdir/simplex` | Application-level temporary files | +| Remote hosts | `remoteHostsDir` | `tmpDir/remote_hosts` | `dataDir/remote_hosts` | Files staged for remote host sessions | +| DB export | `databaseExportDir` | `androidAppContext.cacheDir` | Same as `tmpDir` | Temporary storage for database archive ZIP | +| Preferences | `preferencesDir` | `dataDir/shared_prefs` | `desktopPlatform.configPath` | User preferences, theme YAML | +| Migration temp | `getMigrationTempFilesDirectory()` | `dataDir/migration_temp_files` | `dataDir/migration_temp_files` | Temporary files during database migration | + +### File Path Resolution + +Files referenced by chat items use `CryptoFile` (optional encryption metadata + relative path). Path resolution is handled by helper functions in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt): + +- `getAppFilePath(fileName)` ([L81](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L81)) -- resolves to `appFilesDir/fileName` for local, or `remoteHostsDir//simplex_v1_files/fileName` for remote hosts +- `getWallpaperFilePath(fileName)` ([L91](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L91)) -- resolves wallpaper paths similarly +- `getLoadedFilePath(file)` ([L105](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L105)) -- returns the full path if the file is downloaded and ready + +### Local File Encryption + +The `apiSetEncryptLocalFiles(enable)` command ([SimpleXAPI.kt#L967](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L967)) tells the Haskell core to encrypt files stored in `appFilesDir`. When enabled, files are written as `CryptoFile` with a random AES key and nonce. The JNI functions `chatEncryptFile` and `chatDecryptFile` ([Core.kt#L39-L40](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39)) handle the actual crypto operations. + +--- + +## 7. Export & Import + +### API Functions + +| Function | CC Command | CR Response | Line | +|----------|-----------|-------------|------| +| `apiExportArchive(config)` | `CC.ApiExportArchive(config)` | `CR.ArchiveExported(archiveErrors)` | [SimpleXAPI.kt#L981](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L981) | +| `apiImportArchive(config)` | `CC.ApiImportArchive(config)` | `CR.ArchiveImported(archiveErrors)` | [SimpleXAPI.kt#L987](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L987) | +| `apiDeleteStorage()` | `CC.ApiDeleteStorage()` | `CR.CmdOk` | [SimpleXAPI.kt#L993](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L993) | + +### ArchiveConfig + +Defined at [SimpleXAPI.kt#L4162](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4162): + +```kotlin +class ArchiveConfig( + val archivePath: String, // Full path to the ZIP archive + val disableCompression: Boolean?, // Skip compression for speed + val parentTempDirectory: String? // Temp directory for extraction +) +``` + +### Export Flow + +1. UI constructs an `ArchiveConfig` with a path under `databaseExportDir`. +2. Calls `apiExportArchive(config)` which sends `CC.ApiExportArchive` to the Haskell core. +3. The core creates a ZIP containing both `_chat.db` and `_agent.db` (and optionally files). +4. Returns `CR.ArchiveExported` with a list of `ArchiveError` (non-fatal issues during export). +5. UI offers the archive file for sharing/saving. + +### Import Flow + +1. User selects an archive file. +2. UI copies it to a temp location and constructs an `ArchiveConfig`. +3. Calls `apiImportArchive(config)` which sends `CC.ApiImportArchive` to the Haskell core. +4. The core extracts and replaces both databases. +5. Returns `CR.ArchiveImported` with a list of `ArchiveError` (non-fatal issues during import). +6. UI triggers re-initialization via `initChatController`. + + + +### ArchiveError + +Defined at [SimpleXAPI.kt#L7658](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7658): + +```kotlin +sealed class ArchiveError { + class ArchiveErrorImport(val importError: String) // General import error + class ArchiveErrorFile(val file: String, val fileError: String) // Per-file error +} +``` + +### Delete Storage + +`apiDeleteStorage()` removes both database files entirely. This is used during account deletion or database reset operations. After calling this, `initChatController` must be called to create fresh databases. + +--- + +## 8. Source Files + +| File | Purpose | Path | +|------|---------|------| +| SimpleXAPI.kt | API functions: encryption, export/import, storage commands | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Core.kt | JNI externals (`chatMigrateInit`, `chatEncryptFile`, etc.), `initChatController` | `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` | +| Files.kt | Platform-expect file/directory path declarations | `common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt` | +| Files.android.kt | Android actual paths (dataDir, appFilesDir, etc.) | `common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt` | +| Files.desktop.kt | Desktop actual paths (XDG/AppData, etc.) | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt` | +| Cryptor.kt | Platform-expect encryption interface for passphrase storage | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` | +| Cryptor.android.kt | Android: AES-GCM via AndroidKeyStore | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` | +| Cryptor.desktop.kt | Desktop: placeholder (no-op) implementation | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` | +| DatabaseUtils.kt | `DBMigrationResult`, `MigrationError`, `MigrationConfirmation`, passphrase helpers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | +| Messages.hs | Haskell store: message CRUD, reactions, delivery | `src/Simplex/Chat/Store/Messages.hs` | +| Groups.hs | Haskell store: groups, membership, roles | `src/Simplex/Chat/Store/Groups.hs` | +| Direct.hs | Haskell store: contacts, direct connections | `src/Simplex/Chat/Store/Direct.hs` | +| Files.hs | Haskell store: file transfer metadata | `src/Simplex/Chat/Store/Files.hs` | +| Profiles.hs | Haskell store: user profiles | `src/Simplex/Chat/Store/Profiles.hs` | +| Connections.hs | Haskell store: SMP agent connections | `src/Simplex/Chat/Store/Connections.hs` | + +All Kotlin paths are relative to `apps/multiplatform/`. All Haskell paths are relative to the repository root. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md new file mode 100644 index 0000000000..cd0f836585 --- /dev/null +++ b/apps/multiplatform/spec/impact.md @@ -0,0 +1,532 @@ +# SimpleX Chat Android & Desktop -- Impact Graph + +> Source file to product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Covers Kotlin Multiplatform (Compose) sources: commonMain, androidMain, desktopMain, and the Android and Desktop app modules. Also covers the shared Haskell core. + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | + +--- + +## 1. Common Sources (commonMain) + +Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` + +### 1.1 Core Model & Platform + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `App.kt` | PC1 through PC30 | High | Root composable — navigation scaffold for all features | +| `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle | +| `model/ChatModel.kt` | PC1 through PC30 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC30 | High | FFI bridge to Haskell core — all commands and responses | +| `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers | +| `platform/Core.kt` | PC1 through PC30 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC30 | Medium | Shared app initialization logic | +| `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities | +| `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations | +| `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions | +| `platform/SimplexService.kt` | PC18 | Medium | Background service expect declarations | +| `platform/RecAndPlay.kt` | PC9 | Medium | Audio recording and playback abstractions | +| `platform/VideoPlayer.kt` | PC10, PC17 | Low | Video playback abstractions | +| `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations | +| `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions | +| `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities | +| `platform/Platform.kt` | PC1 through PC30 | Low | Platform detection and capability flags | +| `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations | +| `platform/Back.kt` | PC1 | Low | Back navigation handling | +| `platform/UI.kt` | PC24 | Low | UI density and locale helpers | +| `platform/ScrollableColumn.kt` | PC1 | Low | Scrollable list abstractions | +| `platform/Log.kt` | — | Low | Logging utility — no direct product impact | +| `platform/Modifier.kt` | PC24 | Low | Compose modifier extensions | +| `platform/Resources.kt` | PC24 | Low | Resource loading helpers | + +### 1.2 Theme + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `ui/theme/ThemeManager.kt` | PC24 | Medium | Theme resolution engine — all color and wallpaper logic | +| `ui/theme/Theme.kt` | PC24 | Medium | Theme composables and `SimpleXTheme` | +| `ui/theme/Color.kt` | PC24 | Low | Color palette definitions | +| `ui/theme/Shape.kt` | PC24 | Low | Shape token definitions | +| `ui/theme/Type.kt` | PC24 | Low | Typography definitions | + +### 1.3 Views — Chat List + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chatlist/ChatListView.kt` | PC1, PC28 | High | Main screen — chat list rendering and search | +| `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat | +| `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering | +| `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI | +| `views/chatlist/UserPicker.kt` | PC19, PC21 | Medium | Multi-profile switcher overlay | +| `views/chatlist/ShareListView.kt` | PC10 | Low | Share target list | +| `views/chatlist/ShareListNavLinkView.kt` | PC10 | Low | Share target navigation | +| `views/chatlist/ChatHelpView.kt` | PC1 | Low | Empty-state help content | +| `views/chatlist/ContactRequestView.kt` | PC12 | Medium | Incoming contact request row | +| `views/chatlist/ContactConnectionView.kt` | PC12 | Low | Pending connection row | +| `views/chatlist/ServersSummaryView.kt` | PC25 | Low | Server status summary | + +### 1.4 Views — Chat & Messaging + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages | +| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | +| `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI | +| `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area | +| `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | +| `views/chat/ContextItemView.kt` | PC6 | Low | Reply/edit quote preview | +| `views/chat/SelectableChatItemToolbars.kt` | PC7, PC10 | Medium | Multi-select toolbar (delete, forward) | +| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20 | Medium | Contact details and verification | +| `views/chat/ContactPreferences.kt` | PC2, PC8 | Medium | Per-contact feature preferences | +| `views/chat/ChatItemInfoView.kt` | PC2, PC3 | Low | Message delivery detail | +| `views/chat/ChatItemsLoader.kt` | PC2, PC3 | Medium | Pagination and message loading logic | +| `views/chat/ChatItemsMerger.kt` | PC2, PC3 | Medium | Merges incremental message updates | +| `views/chat/VerifyCodeView.kt` | PC13 | Medium | Contact security code verification | +| `views/chat/ScanCodeView.kt` | PC13 | Low | QR code scanning for verification | +| `views/chat/CommandsMenuView.kt` | PC4 | Low | Slash-command menu | +| `views/chat/ComposeContextProfilePickerView.kt` | PC20 | Low | Incognito profile picker in compose | +| `views/chat/ComposeContextPendingMemberActionsView.kt` | PC14, PC30 | Low | Pending member action buttons in compose | +| `views/chat/ComposeContextGroupDirectInvitationActionsView.kt` | PC14 | Low | Direct invitation action buttons in compose | +| `views/chat/ComposeContextContactRequestActionsView.kt` | PC12 | Low | Contact request action buttons in compose | + +### 1.5 Views — Chat Items + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8 | High | Root chat item renderer with context menus | +| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4 | Medium | Text message bubble rendering | +| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/CIImageView.kt` | PC10 | Medium | Image message rendering | +| `views/chat/item/CIVideoView.kt` | PC10 | Medium | Video message rendering | +| `views/chat/item/CIFileView.kt` | PC10 | Medium | File message rendering | +| `views/chat/item/CIVoiceView.kt` | PC9 | Medium | Voice message rendering and playback | +| `views/chat/item/EmojiItemView.kt` | PC5 | Low | Emoji reaction display | +| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8 | Low | Timestamp, delivery status, timed message indicator | +| `views/chat/item/CICallItemView.kt` | PC17 | Low | Call event item rendering | +| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16 | Low | Group event item rendering | +| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14 | Low | Group invitation item rendering | +| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12 | Low | Member-created contact event | +| `views/chat/item/CIChatFeatureView.kt` | PC8 | Low | Feature change event rendering | +| `views/chat/item/CIFeaturePreferenceView.kt` | PC8 | Low | Feature preference change rendering | +| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3 | Low | Decryption error display | +| `views/chat/item/DeletedItemView.kt` | PC7 | Low | Deleted message placeholder | +| `views/chat/item/MarkedDeletedItemView.kt` | PC7 | Low | Moderated/marked-deleted placeholder | +| `views/chat/item/ImageFullScreenView.kt` | PC10 | Low | Full-screen image viewer | +| `views/chat/item/CIBrokenComposableView.kt` | — | Low | Fallback for render failures | +| `views/chat/item/CIInvalidJSONView.kt` | — | Low | Fallback for malformed items | +| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3 | Low | Message integrity error display | + +### 1.6 Views — Groups + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/group/GroupChatInfoView.kt` | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | +| `views/chat/group/AddGroupMembersView.kt` | PC14, PC16 | Medium | Member invitation flow | +| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| `views/chat/group/GroupProfileView.kt` | PC3, PC14 | Medium | Group profile editing | +| `views/chat/group/GroupLinkView.kt` | PC15 | Low | Group link creation and sharing | +| `views/chat/group/GroupPreferences.kt` | PC3, PC8, PC14 | Medium | Group feature toggles | +| `views/chat/group/GroupMentions.kt` | PC3, PC4 | Medium | @mention resolution and display | +| `views/chat/group/GroupMembersToolbar.kt` | PC3, PC14 | Low | Member list toolbar | +| `views/chat/group/GroupReportsView.kt` | PC3, PC14 | Low | Group content reports | +| `views/chat/group/MemberAdmission.kt` | PC14, PC16 | Medium | Member admission settings | +| `views/chat/group/MemberSupportView.kt` | PC30 | Medium | Member support chat toggle | +| `views/chat/group/MemberSupportChatView.kt` | PC30 | Medium | Member support chat conversation | +| `views/chat/group/WelcomeMessageView.kt` | PC3, PC14 | Low | Group welcome message editor | + +### 1.7 Views — Calls + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/call/CallView.kt` | PC17 | High | Call UI and WebRTC composable | +| `views/call/CallManager.kt` | PC17 | High | Call lifecycle management | +| `views/call/WebRTC.kt` | PC17 | High | WebRTC types and signaling | +| `views/call/IncomingCallAlertView.kt` | PC17, PC18 | Medium | Incoming call overlay | + +### 1.8 Views — New Chat & Contacts + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/newchat/NewChatView.kt` | PC12, PC29 | High | New connection creation — onramp for all contacts | +| `views/newchat/NewChatSheet.kt` | PC12 | Medium | Bottom sheet with connection options | +| `views/newchat/ConnectPlan.kt` | PC12, PC15 | Medium | Link parsing and connection plan resolution | +| `views/newchat/AddGroupView.kt` | PC3, PC14 | Medium | New group creation flow | +| `views/newchat/ContactConnectionInfoView.kt` | PC12 | Low | Pending connection details | +| `views/newchat/AddContactLearnMore.kt` | PC12 | Low | Educational content | +| `views/newchat/QRCode.kt` | PC12 | Low | QR code display | +| `views/newchat/QRCodeScanner.kt` | PC12 | Low | QR code camera scanner | +| `views/contacts/ContactListNavView.kt` | PC1, PC12 | Medium | Contact list navigation | +| `views/contacts/ContactPreviewView.kt` | PC12 | Low | Contact row preview | + +### 1.9 Views — User Settings + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/usersettings/SettingsView.kt` | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| `views/usersettings/Appearance.kt` | PC24 | Low | Theme and appearance customization | +| `views/usersettings/PrivacySettings.kt` | PC20, PC22 | Medium | Privacy and lock settings | +| `views/usersettings/UserProfileView.kt` | PC19 | Medium | Profile display name and image editing | +| `views/usersettings/UserProfilesView.kt` | PC19, PC21 | Medium | Multi-profile management | +| `views/usersettings/HiddenProfileView.kt` | PC21 | Medium | Hidden profile access | +| `views/usersettings/IncognitoView.kt` | PC20 | Low | Incognito mode explanation | +| `views/usersettings/UserAddressView.kt` | PC29 | Medium | User SimpleX address management | +| `views/usersettings/UserAddressLearnMore.kt` | PC29 | Low | Address educational content | +| `views/usersettings/NotificationsSettingsView.kt` | PC18 | Medium | Notification mode configuration | +| `views/usersettings/CallSettings.kt` | PC17 | Low | Call-related settings | +| `views/usersettings/Preferences.kt` | PC2, PC3, PC8 | Medium | Chat feature preferences UI | +| `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle | +| `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration | +| `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings | +| `views/usersettings/HelpView.kt` | — | Low | Help and support links | +| `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide | +| `views/usersettings/VersionInfoView.kt` | — | Low | Version display | +| `views/usersettings/networkAndServers/NetworkAndServers.kt` | PC25 | High | Server and network configuration hub | +| `views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | PC25 | Medium | SOCKS proxy, timeouts, etc. | +| `views/usersettings/networkAndServers/OperatorView.kt` | PC25 | Medium | Server operator management | +| `views/usersettings/networkAndServers/ProtocolServersView.kt` | PC25 | Medium | SMP/XFTP server list | +| `views/usersettings/networkAndServers/ProtocolServerView.kt` | PC25 | Low | Individual server editing | +| `views/usersettings/networkAndServers/NewServerView.kt` | PC25 | Low | Add new server | +| `views/usersettings/networkAndServers/ScanProtocolServer.kt` | PC25 | Low | QR scan for server address | + +### 1.10 Views — Database & Migration + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/database/DatabaseView.kt` | PC23, PC26 | High | Database management — export, import, passphrase | +| `views/database/DatabaseEncryptionView.kt` | PC23 | High | Database encryption passphrase change | +| `views/database/DatabaseErrorView.kt` | PC23 | Medium | Database open error recovery | +| `views/migration/MigrateFromDevice.kt` | PC26 | High | Outbound device migration | +| `views/migration/MigrateToDevice.kt` | PC26 | High | Inbound device migration | + +### 1.11 Views — Local Auth & Onboarding + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/localauth/LocalAuthView.kt` | PC22 | Medium | App lock authentication flow | +| `views/localauth/SetAppPasscodeView.kt` | PC22 | Medium | Passcode creation and change | +| `views/localauth/PasscodeView.kt` | PC22 | Medium | Passcode entry UI | +| `views/localauth/PasswordEntry.kt` | PC22 | Low | Password input field | +| `views/onboarding/OnboardingView.kt` | PC1 | Medium | Onboarding flow navigation | +| `views/onboarding/SimpleXInfo.kt` | PC1 | Low | Welcome screen | +| `views/onboarding/SetNotificationsMode.kt` | PC18 | Medium | Notification permission and mode setup | +| `views/onboarding/SetupDatabasePassphrase.kt` | PC23 | Medium | Initial database passphrase setup | +| `views/onboarding/ChooseServerOperators.kt` | PC25 | Medium | Initial server operator selection | +| `views/onboarding/WhatsNewView.kt` | — | Low | Release notes display | +| `views/onboarding/HowItWorks.kt` | — | Low | Educational content | +| `views/onboarding/LinkAMobileView.kt` | PC27 | Low | Mobile linking onboarding | + +### 1.12 Views — Remote Desktop + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/remote/ConnectDesktopView.kt` | PC27 | Medium | Connect-to-desktop flow (from mobile) | +| `views/remote/ConnectMobileView.kt` | PC27 | Medium | Connect-to-mobile flow (from desktop) | + +### 1.13 Views — Helpers + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/helpers/AlertManager.kt` | PC1 through PC30 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC30 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC30 | Low | Shared formatting, clipboard, and utility functions | +| `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers | +| `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering | +| `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect | +| `views/helpers/ChatWallpaper.kt` | PC24 | Low | Chat wallpaper rendering | +| `views/helpers/ChatInfoImage.kt` | PC19 | Low | Profile image composable | +| `views/helpers/ThemeModeEditor.kt` | PC24 | Low | Theme mode toggle | +| `views/helpers/ChooseAttachmentView.kt` | PC10 | Low | Attachment picker | +| `views/helpers/GetImageView.kt` | PC10, PC19 | Low | Image capture and crop | +| `views/helpers/TextEditor.kt` | PC4 | Low | Rich text editor helpers | +| `views/helpers/SearchTextField.kt` | PC1 | Low | Search bar composable | +| `views/helpers/CustomTimePicker.kt` | PC8 | Low | Time picker for timed messages | +| `views/helpers/DragAndDrop.kt` | PC10 | Low | Drag-and-drop file handling | +| `views/helpers/ProcessedErrors.kt` | — | Low | Error aggregation | +| `views/helpers/AnimationUtils.kt` | PC24 | Low | Animation helpers | +| `views/helpers/DefaultDialog.kt` | — | Low | Dialog composable primitives | +| `views/helpers/DefaultDropdownMenu.kt` | — | Low | Dropdown menu composable | +| `views/helpers/Section.kt` | — | Low | Settings section composable | +| `views/helpers/SimpleButton.kt` | — | Low | Button composable | +| `views/helpers/DefaultTopAppBar.kt` | — | Low | App bar composable | +| `views/helpers/DefaultBasicTextField.kt` | PC4 | Low | Text field composable | +| `views/helpers/AppBarTitle.kt` | — | Low | App bar title composable | +| `views/helpers/BlurModifier.kt` | PC22 | Low | Blur modifier for app lock | +| `views/helpers/CollapsingAppBar.kt` | — | Low | Collapsing toolbar composable | +| `views/helpers/CustomIcons.kt` | — | Low | Custom icon definitions | +| `views/helpers/DataClasses.kt` | — | Low | Shared data class utilities | +| `views/helpers/DefaultProgressBar.kt` | — | Low | Progress bar composable | +| `views/helpers/DefaultSwitch.kt` | — | Low | Switch composable | +| `views/helpers/Enums.kt` | — | Low | Enum utility extensions | +| `views/helpers/ExposedDropDownSettingRow.kt` | — | Low | Dropdown setting row composable | +| `views/helpers/GestureDetector.kt` | — | Low | Touch gesture utilities | +| `views/helpers/Modifiers.kt` | — | Low | Compose modifier extensions | +| `views/helpers/SubscriptionStatusIcon.kt` | PC25 | Low | Server connection status icon | + +### 1.14 Views — Other + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/TerminalView.kt` | — | Low | Developer chat console | +| `views/SplashView.kt` | — | Low | Splash screen | +| `views/WelcomeView.kt` | PC1 | Low | Empty-state welcome | +| `views/Preview.kt` | — | Low | Compose preview utilities | + +--- + +## 2. Android Sources + +### 2.1 Android App Module + +Path prefix: `android/src/main/java/chat/simplex/app/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `SimplexApp.kt` | PC1 through PC30 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC30 | High | Single-activity host — intent handling, lifecycle, deep links | +| `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive | +| `CallService.kt` | PC17 | Medium | Foreground service for active calls | +| `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch | +| `model/NtfManager.android.kt` | PC18 | High | Android notification channels, display, and actions | +| `views/call/CallActivity.kt` | PC17 | Medium | Dedicated activity for full-screen call UI | +| `views/helpers/Util.kt` | — | Low | Android-specific utility extensions | + +### 2.2 Android Platform Implementations (androidMain) + +Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `platform/AppCommon.android.kt` | PC1 through PC30 | Medium | Android app initialization actual declarations | +| `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation | +| `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution | +| `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation | +| `platform/RecAndPlay.android.kt` | PC9 | Medium | Android MediaRecorder/MediaPlayer actual implementation | +| `platform/VideoPlayer.android.kt` | PC10 | Low | Android ExoPlayer actual implementation | +| `platform/Notifications.android.kt` | PC18 | Medium | Android notification channel creation | +| `platform/Images.android.kt` | PC10, PC19 | Low | Android bitmap processing | +| `platform/PlatformTextField.android.kt` | PC4 | Low | Android native text field actual implementation | +| `platform/Share.android.kt` | PC10 | Low | Android share intent actual implementation | +| `platform/Back.android.kt` | PC1 | Low | Android back press handler | +| `platform/UI.android.kt` | PC24 | Low | Android density and locale | +| `platform/ScrollableColumn.android.kt` | PC1 | Low | Android lazy list actual implementation | +| `platform/Log.android.kt` | — | Low | Android Log wrapper | +| `platform/Modifier.android.kt` | — | Low | Android modifier extensions | +| `platform/Resources.android.kt` | — | Low | Android resource loading | +| `helpers/NetworkObserver.kt` | PC25 | Medium | Android ConnectivityManager observer | +| `helpers/Permissions.kt` | PC9, PC10, PC17, PC18 | Medium | Android runtime permission requests | +| `helpers/SoundPlayer.kt` | PC17, PC18 | Low | Android sound playback for calls and notifications | +| `helpers/Extensions.kt` | — | Low | Kotlin extension utilities | +| `helpers/Locale.kt` | — | Low | Locale helpers | +| `views/call/CallView.android.kt` | PC17 | Medium | Android WebView-based WebRTC call | +| `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) | +| `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions | +| `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions | +| `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions | +| `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions | +| `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions | +| `views/chat/item/CIFileView.android.kt` | PC10 | Low | Android file view extensions | +| `views/chat/item/EmojiItemView.android.kt` | PC5 | Low | Android emoji rendering extensions | +| `views/chat/item/ImageFullScreenView.android.kt` | PC10 | Low | Android full-screen image viewer | +| `views/chatlist/ChatListView.android.kt` | PC1 | Low | Android chat list extensions | +| `views/chatlist/ChatListNavLinkView.android.kt` | PC1 | Low | Android chat list navigation extensions | +| `views/chatlist/TagListView.android.kt` | PC28 | Low | Android tag list extensions | +| `views/chatlist/UserPicker.android.kt` | PC19 | Low | Android profile picker extensions | +| `views/database/DatabaseView.android.kt` | PC23, PC26 | Low | Android database view extensions | +| `views/database/DatabaseEncryptionView.android.kt` | PC23 | Low | Android encryption view extensions | +| `views/helpers/LocalAuthentication.android.kt` | PC22 | Medium | Android BiometricPrompt actual implementation | +| `views/helpers/ChooseAttachmentView.android.kt` | PC10 | Low | Android file/camera chooser | +| `views/helpers/GetImageView.android.kt` | PC10, PC19 | Low | Android image capture | +| `views/helpers/CustomTimePicker.android.kt` | PC8 | Low | Android time picker | +| `views/helpers/Utils.android.kt` | — | Low | Android utility extensions | +| `views/helpers/DefaultDialog.android.kt` | — | Low | Android dialog extensions | +| `views/helpers/WorkaroundFocusSearchLayout.kt` | — | Low | Android focus workaround | +| `views/newchat/QRCode.android.kt` | PC12 | Low | Android QR code rendering | +| `views/newchat/QRCodeScanner.android.kt` | PC12 | Low | Android camera QR scanner | +| `views/onboarding/SimpleXInfo.android.kt` | PC1 | Low | Android onboarding extensions | +| `views/onboarding/SetNotificationsMode.android.kt` | PC18 | Low | Android notification mode extensions | +| `views/usersettings/Appearance.android.kt` | PC24 | Low | Android appearance extensions | +| `views/usersettings/PrivacySettings.android.kt` | PC20, PC22 | Low | Android privacy settings extensions | +| `views/usersettings/SettingsView.android.kt` | — | Low | Android settings extensions | +| `views/usersettings/networkAndServers/OperatorView.android.kt` | PC25 | Low | Android operator view extensions | +| `views/usersettings/networkAndServers/ScanProtocolServer.android.kt` | PC25 | Low | Android server QR scan | +| `ui/theme/Theme.android.kt` | PC24 | Low | Android dynamic color / system theme | +| `ui/theme/Type.android.kt` | PC24 | Low | Android typography | + +--- + +## 3. Desktop Sources + +### 3.1 Desktop App Module + +Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `Main.kt` | PC1 through PC30 | High | JVM entry point — Haskell init, migrations, app launch | + +### 3.2 Desktop Platform Implementations (desktopMain) + +Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | +| `StoreWindowState.kt` | — | Low | Window position/size persistence | +| `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | +| `platform/AppCommon.desktop.kt` | PC1 through PC30 | Medium | Desktop app initialization actual declarations | +| `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | +| `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution | +| `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation | +| `platform/RecAndPlay.desktop.kt` | PC9 | Medium | Desktop audio recording/playback actual implementation | +| `platform/VideoPlayer.desktop.kt` | PC10 | Low | Desktop VLC-based video player | +| `platform/Videos.desktop.kt` | PC10 | Low | Desktop video utilities | +| `platform/Notifications.desktop.kt` | PC18 | Low | Desktop notification setup | +| `platform/Images.desktop.kt` | PC10 | Low | Desktop image processing | +| `platform/PlatformTextField.desktop.kt` | PC4 | Low | Desktop text field actual implementation | +| `platform/Share.desktop.kt` | PC10 | Low | Desktop clipboard/share | +| `platform/Back.desktop.kt` | PC1 | Low | Desktop back navigation | +| `platform/UI.desktop.kt` | PC24 | Low | Desktop density and locale | +| `platform/ScrollableColumn.desktop.kt` | PC1 | Low | Desktop lazy list | +| `platform/Platform.desktop.kt` | — | Low | Platform detection | +| `platform/Log.desktop.kt` | — | Low | Desktop log output | +| `platform/Modifier.desktop.kt` | — | Low | Desktop modifier extensions | +| `platform/Resources.desktop.kt` | — | Low | Desktop resource loading | +| `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call | +| `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) | +| `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) | +| `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions | +| `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering | +| `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering | +| `views/chat/item/CIFileView.desktop.kt` | PC10 | Low | Desktop file open/save | +| `views/chat/item/EmojiItemView.desktop.kt` | PC5 | Low | Desktop emoji rendering | +| `views/chat/item/ImageFullScreenView.desktop.kt` | PC10 | Low | Desktop full-screen image | +| `views/chatlist/ChatListView.desktop.kt` | PC1 | Low | Desktop chat list extensions | +| `views/chatlist/ChatListNavLinkView.desktop.kt` | PC1 | Low | Desktop chat list navigation | +| `views/chatlist/TagListView.desktop.kt` | PC28 | Low | Desktop tag list extensions | +| `views/chatlist/UserPicker.desktop.kt` | PC19 | Low | Desktop profile picker | +| `views/database/DatabaseView.desktop.kt` | PC23, PC26 | Low | Desktop database view extensions | +| `views/database/DatabaseEncryptionView.desktop.kt` | PC23 | Low | Desktop encryption view extensions | +| `views/helpers/AppUpdater.kt` | — | Low | Desktop auto-update checker and installer | +| `views/helpers/OkHttpProgressListener.kt` | — | Low | Download progress tracking for updates | +| `views/helpers/LocalAuthentication.desktop.kt` | PC22 | Low | Desktop passcode-only auth (no biometrics) | +| `views/helpers/ChooseAttachmentView.desktop.kt` | PC10 | Low | Desktop file chooser dialog | +| `views/helpers/GetImageView.desktop.kt` | PC10, PC19 | Low | Desktop image file picker | +| `views/helpers/CustomTimePicker.desktop.kt` | PC8 | Low | Desktop time picker | +| `views/helpers/Utils.desktop.kt` | — | Low | Desktop utility extensions | +| `views/helpers/DefaultDialog.desktop.kt` | — | Low | Desktop dialog extensions | +| `views/newchat/QRCode.desktop.kt` | PC12 | Low | Desktop QR code rendering | +| `views/newchat/QRCodeScanner.desktop.kt` | PC12 | Low | Desktop QR code scanner (screen/clipboard) | +| `views/onboarding/SimpleXInfo.desktop.kt` | PC1 | Low | Desktop onboarding extensions | +| `views/onboarding/SetNotificationsMode.desktop.kt` | PC18 | Low | Desktop notification mode extensions | +| `views/usersettings/Appearance.desktop.kt` | PC24 | Low | Desktop appearance extensions | +| `views/usersettings/PrivacySettings.desktop.kt` | PC20, PC22 | Low | Desktop privacy settings extensions | +| `views/usersettings/SettingsView.desktop.kt` | — | Low | Desktop settings extensions | +| `views/usersettings/networkAndServers/OperatorView.desktop.kt` | PC25 | Low | Desktop operator view extensions | +| `views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt` | PC25 | Low | Desktop server address scan | +| `ui/theme/Theme.desktop.kt` | PC24 | Low | Desktop system theme detection | +| `ui/theme/Type.desktop.kt` | PC24 | Low | Desktop typography | +| `other/videoplayer/SkiaBitmapVideoSurface.kt` | PC10 | Low | Desktop Skia video surface for VLC | + +--- + +## 4. Haskell Core Impact + +The Haskell core is compiled as a shared native library (`libsimplex.so` / `libsimplex.dylib`) and linked via JNI through `Core.kt`. Changes here affect both Android and Desktop identically. + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `src/Simplex/Chat.hs` | PC1 through PC30 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC30 | High | Command processor — all API commands dispatched here | +| `src/Simplex/Chat/Types.hs` | PC1 through PC30 | High | Core data types shared across all features | +| `src/Simplex/Chat/Core.hs` | PC1 through PC30 | High | Chat engine lifecycle (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC30 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC30 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC30 | High | Event subscriber — incoming message routing | +| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| `src/Simplex/Chat/Messages/CIContent/Events.hs` | PC3, PC14, PC16 | Medium | Group event content types | +| `src/Simplex/Chat/Messages/Batch.hs` | PC2, PC3, PC4 | Medium | Message batching for efficient delivery | +| `src/Simplex/Chat/Call.hs` | PC17 | Medium | Call signaling types | +| `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration | +| `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine | +| `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting | +| `src/Simplex/Chat/Store.hs` | PC1 through PC30 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC30 | Medium | Shared store utilities | +| `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence | +| `src/Simplex/Chat/Store/Files.hs` | PC10 | Medium | File transfer persistence | +| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21 | Medium | User profile persistence | +| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12 | High | Connection persistence and entity resolution | +| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12 | Medium | Contact request persistence | +| `src/Simplex/Chat/Store/NoteFolders.hs` | PC1 | Low | Note folder (self-chat) persistence | +| `src/Simplex/Chat/Store/Delivery.hs` | PC2, PC3 | Medium | Delivery task persistence | +| `src/Simplex/Chat/Store/AppSettings.hs` | PC25 | Low | App settings persistence | +| `src/Simplex/Chat/Store/Remote.hs` | PC27 | Low | Remote desktop session persistence | +| `src/Simplex/Chat/Archive.hs` | PC26 | Medium | Database export/import for migration | +| `src/Simplex/Chat/Options.hs` | PC23, PC25 | Low | Startup options (DB path, key, etc.) | +| `src/Simplex/Chat/Remote.hs` | PC27 | Medium | Remote desktop protocol handler | +| `src/Simplex/Chat/Remote/Types.hs` | PC27 | Low | Remote desktop data types | +| `src/Simplex/Chat/Remote/Protocol.hs` | PC27 | Medium | Remote desktop wire protocol | +| `src/Simplex/Chat/Remote/Transport.hs` | PC27 | Low | Remote desktop transport layer | +| `src/Simplex/Chat/Remote/RevHTTP.hs` | PC27 | Low | Reverse HTTP for remote desktop | +| `src/Simplex/Chat/Remote/AppVersion.hs` | PC27 | Low | Remote version negotiation | +| `src/Simplex/Chat/ProfileGenerator.hs` | PC20 | Low | Random profile generation for incognito | +| `src/Simplex/Chat/Types/UITheme.hs` | PC24 | Low | Theme data types for UI customization | +| `src/Simplex/Chat/Types/Preferences.hs` | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| `src/Simplex/Chat/Types/Shared.hs` | PC3, PC16 | Medium | Shared types including GroupMemberRole | +| `src/Simplex/Chat/Types/MemberRelations.hs` | PC3, PC16, PC30 | Medium | Member relationship state machine | +| `src/Simplex/Chat/Operators.hs` | PC25 | Medium | Server operator management | +| `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators | +| `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions | +| `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types | +| `src/Simplex/Chat/Mobile.hs` | PC1 through PC30 | High | C FFI exports — JNI bridge target | +| `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI | +| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC30 | Medium | Shared FFI helpers | +| `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers | +| `src/Simplex/Chat/View.hs` | PC1 through PC30 | Low | Terminal view rendering (not used by mobile/desktop UI) | +| `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking | +| `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities | +| `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) | +| `src/Simplex/Chat/Help.hs` | — | Low | Terminal help text | +| `src/Simplex/Chat/Bot.hs` | — | Low | Chat bot framework | +| `src/Simplex/Chat/Bot/KnownContacts.hs` | — | Low | Bot known contacts | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md new file mode 100644 index 0000000000..a8d056ebea --- /dev/null +++ b/apps/multiplatform/spec/services/calls.md @@ -0,0 +1,175 @@ +# WebRTC Calling Service + +## Table of Contents + +1. [Overview](#1-overview) +2. [Call State Machine](#2-call-state-machine) +3. [Android Implementation](#3-android-implementation) +4. [Desktop Implementation](#4-desktop-implementation) +5. [Common Call API](#5-common-call-api) +6. [IncomingCallAlertView](#6-incomingcallalertview) +7. [Source Files](#7-source-files) + +## Executive Summary + +WebRTC calling in SimpleX Chat operates over SMP (SimpleX Messaging Protocol) for signaling, with platform-specific WebRTC media implementations. Android uses a WebView-based approach with a dedicated `CallActivity` and foreground `CallService`, while Desktop opens the system browser and communicates via a NanoWSD WebSocket server on localhost. Both platforms share a common `CallManager` for call lifecycle and a `CallState` enum for state tracking. Call commands and responses are serialized as JSON and exchanged between the native layer and the WebRTC JavaScript layer. + +--- + +## 1. Overview + +Call signaling uses the same SMP protocol on all platforms -- call invitations, offers, answers, ICE candidates, and status updates flow through the chat backend via API commands. The WebRTC media plane, however, is implemented differently per platform: + +- **Android**: WebView loads `call.html` from bundled assets; a `@JavascriptInterface` bridge (`WebRTCInterface`) forwards JSON messages between Kotlin and JavaScript. +- **Desktop**: The system browser opens `http://localhost:50395/simplex/call/`; a NanoWSD HTTP+WebSocket server serves `call.html` from classpath resources and relays JSON commands/responses over WebSocket. + +Both platforms share the [`CallManager`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) class (119 lines), which orchestrates incoming call acceptance, call ending, and notification management. + +--- + + + +## 2. Call State Machine + +Defined in [`WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt#L50): + +``` +enum class CallState { + WaitCapabilities, // Call initiated, waiting for local WebRTC capabilities + InvitationSent, // Invitation sent to peer via SMP + InvitationAccepted, // Peer's invitation accepted locally + OfferSent, // SDP offer sent to peer + OfferReceived, // SDP offer received from peer + AnswerReceived, // SDP answer received from peer + Negotiated, // ICE negotiation in progress + Connected, // Media flowing + Ended; // Call terminated +} +``` + +**Outgoing call flow**: `WaitCapabilities` -> `InvitationSent` -> `OfferSent` -> `AnswerReceived` -> `Negotiated` -> `Connected` -> `Ended` + +**Incoming call flow**: `InvitationAccepted` -> `OfferReceived` -> `Negotiated` -> `Connected` -> `Ended` + +State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). + +--- + + + +## 3. Android Implementation + +### 3.1 CallActivity.kt (464 lines) + +[`CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) + +A dedicated `ComponentActivity` that hosts the call UI. Key responsibilities: + +- **Intent handling** ([line 64](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L64)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. +- **Lock screen support** ([line 160](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L160)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. +- **Picture-in-Picture** ([line 99](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L99)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. +- **Permission checks** ([line 122](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L122)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. +- **Service binding** ([line 181](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L181)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. +- **CallActivityView composable** ([line 208](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L208)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. + +### 3.2 CallService.kt (207 lines) + +[`CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) + +An Android foreground `Service` that keeps the call alive when the app is backgrounded: + +- **Foreground notification** ([line 131](../../android/src/main/java/chat/simplex/app/CallService.kt#L131)): Shows contact name (respecting `NotificationPreviewMode`), call type (audio/video), a chronometer when connected, and an "End call" action button. +- **WakeLock** ([line 66](../../android/src/main/java/chat/simplex/app/CallService.kt#L66)): Acquires `PARTIAL_WAKE_LOCK` to prevent CPU sleep during calls. +- **Notification channel** ([line 121](../../android/src/main/java/chat/simplex/app/CallService.kt#L121)): Creates `CALL_NOTIFICATION_CHANNEL_ID` with `IMPORTANCE_DEFAULT`. +- **Foreground service type** ([line 100](../../android/src/main/java/chat/simplex/app/CallService.kt#L100)): Uses `MEDIA_PLAYBACK | MICROPHONE` (+ `CAMERA` for video) on API 30+, `REMOTE_MESSAGING` on API 34+ when no active call. +- **Binder** ([line 158](../../android/src/main/java/chat/simplex/app/CallService.kt#L158)): `CallServiceBinder` allows `CallActivity` to call `updateNotification()` when call state changes. +- **CallActionReceiver** ([line 170](../../android/src/main/java/chat/simplex/app/CallService.kt#L170)): `BroadcastReceiver` that handles the `EndCallAction` from the notification. + +### 3.3 CallView.android.kt (891 lines) + +[`CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) + +The `actual` platform implementation of `ActiveCallView()` and supporting composables: + +- **ActiveCallState** ([line 74](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L74)): Manages proximity lock (screen-off wake lock), `CallAudioDeviceManager` for audio routing (earpiece/speaker/bluetooth), `CallSoundsPlayer` for ringtones and vibration. Implements `Closeable` to clean up resources on call end. +- **ActiveCallView** ([line 114](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L114)): Renders `WebRTCView` plus `ActiveCallOverlay`. Handles `WCallResponse` messages and dispatches corresponding API calls. Manages volume control stream (`STREAM_VOICE_CALL`), screen keep-on, and call command lifecycle. +- **WebRTCView** ([line 691](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L691)): Creates/reuses a static `WebView` via `AndroidView`. Configures `WebViewAssetLoader` for local asset loading. Sets up `WebRTCInterface` JavaScript bridge. Loads `file:android_asset/www/android/call.html`. Processes `WCallCommand` queue by evaluating `processCommand()` JavaScript. +- **ActiveCallOverlayLayout** ([line 329](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L329)): Full overlay with mic toggle, speaker/device selector, end call, video toggle, and camera flip buttons. Adapts layout for video vs audio calls. +- **CallPermissionsView** ([line 569](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L569)): Handles runtime permission requests for microphone and camera with a fallback to settings if the system dialog is not shown. + +### 3.4 ActiveCallState + +[`ActiveCallState`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L74) (line 74 of `CallView.android.kt`): + +| Component | Purpose | +|---|---| +| `proximityLock` | `PROXIMITY_SCREEN_OFF_WAKE_LOCK` -- turns screen off when phone is held to ear | +| `callAudioDeviceManager` | Manages audio routing between earpiece, speaker, Bluetooth, wired headset | +| `CallSoundsPlayer` | Plays connecting/ringing sounds and vibration patterns | +| `wasConnected` | Tracks if call ever connected (for end-of-call vibration) | +| `close()` | Stops sounds, vibrates on disconnect, releases proximity lock, clears audio manager overrides | + +--- + +## 4. Desktop Implementation + +### 4.1 CallView.desktop.kt (263 lines) + +[`CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) + +Desktop calls run WebRTC in the system browser, not an embedded WebView: + +- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`. +- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`. +- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Opens `http://localhost:50395/simplex/call/` via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server. +- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display. +- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance. + +--- + +## 5. Common Call API + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Line | Description | +|---|---|---| +| `apiGetCallInvitations` | [L1842](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1842) | Retrieve pending call invitations from the backend | +| `apiSendCallInvitation` | [L1849](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1849) | Send call invitation to a contact with `CallType` | +| `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | +| `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | +| `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | +| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | +| `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | + +All functions send commands via `sendCmd()` to the chat core and return `Boolean` success status (except `apiGetCallInvitations` which returns `List`). + +--- + + + +## 6. IncomingCallAlertView + +[`IncomingCallAlertView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt) (128 lines) + +An in-app notification banner shown when a call invitation arrives while the app is in the foreground: + +- **IncomingCallAlertView** ([line 27](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L27)): Starts `SoundPlayer` for the ringtone (suppressed if already in a call view). Shows `IncomingCallAlertLayout`. +- **IncomingCallAlertLayout** ([line 49](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L49)): Colored banner with `ProfilePreview` of the caller, call type icon (audio/video), and three action buttons: Reject (red), Ignore (primary), Accept (green). +- **IncomingCallInfo** ([line 74](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L74)): Shows the user profile image (for multi-user), call media type icon, and call type text (encrypted/unencrypted audio/video). + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting | +| `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions | +| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 263 | Desktop browser WebRTC via NanoWSD | +| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen | +| `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls | +| `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management | +| `WebRTC.kt` | [`common/src/commonMain/.../views/call/WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt) | -- | `CallState` enum, `WCallCommand`, `WCallResponse` types | +| `IncomingCallAlertView.kt` | [`common/src/commonMain/.../views/call/IncomingCallAlertView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt) | 128 | In-app incoming call notification banner | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | Call API commands (L1837--L1881) | diff --git a/apps/multiplatform/spec/services/files.md b/apps/multiplatform/spec/services/files.md new file mode 100644 index 0000000000..329e37dbb1 --- /dev/null +++ b/apps/multiplatform/spec/services/files.md @@ -0,0 +1,213 @@ +# File Transfer Service + +## Table of Contents + +1. [Overview](#1-overview) +2. [File Size Constants](#2-file-size-constants) +3. [CryptoFile](#3-cryptofile) +4. [File Storage Paths](#4-file-storage-paths) +5. [API Commands](#5-api-commands) +6. [Auto-Receive Logic](#6-auto-receive-logic) +7. [Source Files](#7-source-files) + +## Executive Summary + +SimpleX Chat uses two file transfer mechanisms: inline SMP transfers for small files (embedded in message bodies) and XFTP (eXtended File Transfer Protocol) for larger files up to 1 GB. Files are optionally encrypted at rest using `CryptoFile` functions backed by the chat core's native crypto library. File storage paths are platform-specific: Android uses `Context.dataDir`-based directories while Desktop uses platform-appropriate data directories (XDG on Linux, AppData on Windows, Application Support on macOS). Auto-receive logic automatically accepts images, voice messages, and videos below configurable size thresholds. + +--- + +## 1. Overview + +File transfer decision logic: + +- **Inline (SMP)**: Files small enough to be base64-encoded and embedded directly in an SMP message body. The practical limit is defined by `MAX_IMAGE_SIZE` (255 KB) for compressed images. The maximum SMP inline size is `MAX_FILE_SIZE_SMP` (~7.6 MB). +- **XFTP**: For files exceeding the inline threshold, up to `MAX_FILE_SIZE_XFTP` (1 GB). XFTP uses dedicated file relay servers with chunked, encrypted transfers. + +The `receiveFile` / `receiveFiles` API commands handle both protocols transparently -- the chat core selects the appropriate transfer mechanism based on file metadata received from the sender. + +--- + + + +## 2. File Size Constants + +Defined in [`Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L118): + +| Constant | Value | Human-Readable | Line | Purpose | +|---|---|---|---|---| +| `MAX_IMAGE_SIZE` | 261,120 | 255 KB | [L118](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L118) | Inline image compression target | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 | 510 KB | [L119](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L119) | Auto-receive threshold for images (`2 * MAX_IMAGE_SIZE`) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 | 510 KB | [L120](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L120) | Auto-receive threshold for voice messages (`2 * MAX_IMAGE_SIZE`) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 | 1023 KB | [L121](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L121) | Auto-receive threshold for video | +| `MAX_VOICE_MILLIS_FOR_SENDING` | 300,000 | 5 min | [L123](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L123) | Maximum voice message duration | +| `MAX_FILE_SIZE_SMP` | 8,000,000 | ~7.6 MB | [L125](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L125) | Maximum SMP inline file size | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 | 1 GB | [L127](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L127) | Maximum XFTP transfer size | +| `MAX_FILE_SIZE_LOCAL` | `Long.MAX_VALUE` | Unlimited | [L129](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L129) | Local file protocol (no size limit) | + +The `getMaxFileSize()` function ([`Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L442)) selects the limit based on `FileProtocol`: + +```kotlin +FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP +FileProtocol.SMP -> MAX_FILE_SIZE_SMP +FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +``` + +--- + +## 3. CryptoFile + +[`CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) (62 lines) + +Provides encrypted file I/O backed by the chat core's native cryptography (via JNI/JNA calls to `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`). + +### Data types + +```kotlin +@Serializable +sealed class WriteFileResult { + @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult() + @SerialName("error") data class Error(val writeError: String): WriteFileResult() +} +``` + +`CryptoFileArgs` contains `fileKey` and `fileNonce` -- the symmetric encryption key and nonce for AES-GCM encryption. + + + +### Functions + +| Function | Line | Signature | Description | +|---|---|---|---| +| `writeCryptoFile` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L24) | `(path: String, data: ByteArray): CryptoFileArgs` | Writes data to an encrypted file via a direct `ByteBuffer`. Returns the generated key and nonce. Requires initialized `ChatController`. | +| `readCryptoFile` | [L36](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L36) | `(path: String, cryptoArgs: CryptoFileArgs): ByteArray` | Reads and decrypts a file given its key and nonce. Returns the plaintext bytes. Throws on error (status != 0). | +| `encryptCryptoFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L47) | `(fromPath: String, toPath: String): CryptoFileArgs` | Encrypts an existing plaintext file to a new encrypted file. Returns the generated key and nonce. | +| `decryptCryptoFile` | [L57](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L57) | `(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String)` | Decrypts an encrypted file to a plaintext output file. Throws on non-empty error string. | + +All functions delegate to native C library functions through the chat core JNI bridge. + +--- + + + +## 4. File Storage Paths + +### Common expect declarations + +[`Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) (191 lines, commonMain) + +| Property | Line | Description | +|---|---|---| +| `dataDir` | [L18](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | Root application data directory | +| `tmpDir` | [L19](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | Temporary files directory | +| `filesDir` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | Base files directory | +| `appFilesDir` | [L21](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | Application files (chat attachments) | +| `wallpapersDir` | [L22](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L22) | Theme wallpaper images | +| `coreTmpDir` | [L23](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L23) | Temporary files for the chat core | +| `dbAbsolutePrefixPath` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | Database file path prefix | +| `preferencesDir` | [L25](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L25) | Preferences/config directory | +| `databaseExportDir` | [L35](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L35) | Temporary DB archive storage for export | +| `remoteHostsDir` | [L37](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L37) | Remote host connection data | + +### Android implementation + +[`Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) (79 lines) + +| Property | Value | +|---|---| +| `dataDir` | `androidAppContext.dataDir` | +| `tmpDir` | `androidAppContext.getDir("temp", MODE_PRIVATE)` | +| `filesDir` | `dataDir/files` | +| `appFilesDir` | `dataDir/files/app_files` | +| `wallpapersDir` | `dataDir/files/assets/wallpapers` | +| `coreTmpDir` | `dataDir/files/temp_files` | +| `dbAbsolutePrefixPath` | `dataDir/files` | +| `preferencesDir` | `dataDir/shared_prefs` | +| `databaseExportDir` | `androidAppContext.cacheDir` | +| `remoteHostsDir` | `tmpDir/remote_hosts` | + +### Desktop implementation + +[`Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) (116 lines) + +| Property | Value | +|---|---| +| `dataDir` | `desktopPlatform.dataPath` (XDG_DATA_HOME on Linux, AppData on Windows, Application Support on macOS) | +| `tmpDir` | `java.io.tmpdir/simplex` (deleted on exit) | +| `filesDir` | `dataDir/simplex_v1_files` | +| `appFilesDir` | Same as `filesDir` | +| `wallpapersDir` | `dataDir/simplex_v1_assets/wallpapers` | +| `coreTmpDir` | `dataDir/tmp` | +| `dbAbsolutePrefixPath` | `dataDir/simplex_v1` | +| `preferencesDir` | `desktopPlatform.configPath` | +| `databaseExportDir` | Same as `tmpDir` | +| `remoteHostsDir` | `dataDir/remote_hosts` | + +### Helper functions (common) + +| Function | Line | Description | +|---|---|---| +| `getAppFilePath` | [L81](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L81) | Resolves file path considering remote hosts | +| `getWallpaperFilePath` | [L91](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L91) | Resolves wallpaper image path, creates parent directories | +| `getLoadedFilePath` | [L105](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L105) | Returns path if file exists and is fully loaded | +| `getLoadedFileSource` | [L115](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L115) | Returns `CryptoFile` source if file is loaded | +| `readThemeOverrides` | [L125](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L125) | Reads theme overrides from `themes.yaml` | +| `writeThemeOverrides` | [L151](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L151) | Atomically writes theme overrides to `themes.yaml` | +| `copyFileToFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L47) | Copies a `File` to a `URI` destination with toast feedback | +| `copyBytesToFile` | [L63](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L63) | Copies a `ByteArrayInputStream` to a `URI` destination | + +--- + +## 5. API Commands + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Line | Signature | Description | +|---|---|---|---| +| `receiveFiles` | [L1946](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1946) | `(rhId, user, fileIds, userApprovedRelays, auto)` | Receive multiple files. Sends `CC.ReceiveFile` for each ID. Handles relay approval workflow: collects unapproved files, shows alert, re-calls with `userApprovedRelays=true`. Respects `privacyEncryptLocalFiles` preference. | +| `receiveFile` | [L2062](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2062) | `(rhId, user, fileId, userApprovedRelays, auto)` | Delegates to `receiveFiles` with a single-element list. | +| `cancelFile` | [L2072](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2072) | `(rh, user, fileId)` | Cancels an in-progress file transfer (send or receive). Cleans up the local file. | +| `apiCancelFile` | [L2080](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2080) | `(rh, fileId, ctrl?)` | Low-level cancel. Returns `AChatItem?` on success (`SndFileCancelled` or `RcvFileCancelled`). | +| `uploadStandaloneFile` | [L1916](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1916) | `(user, file, ctrl?)` | Upload a standalone file (for database migration). Returns `FileTransferMeta?` with XFTP link. | +| `downloadStandaloneFile` | [L1926](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1926) | `(user, url, file, ctrl?)` | Download a standalone file from an XFTP URL. Returns `RcvFileTransfer?`. | + +--- + +## 6. Auto-Receive Logic + +Located in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2696) within the `CR.NewChatItems` handler: + +```kotlin +if (file != null && + appPrefs.privacyAcceptImages.get() && + ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV + && file.fileStatus !is CIFileStatus.RcvAccepted)) +) { + receiveFile(rhId, r.user, file.fileId, auto = true) +} +``` + +**Conditions for auto-receive:** + +1. The `privacyAcceptImages` preference is enabled (user opt-in). +2. The content type and size match one of: + - **Images** (`MCImage`): file size <= 510 KB (`MAX_IMAGE_SIZE_AUTO_RCV`) + - **Video** (`MCVideo`): file size <= 1023 KB (`MAX_VIDEO_SIZE_AUTO_RCV`) + - **Voice** (`MCVoice`): file size <= 510 KB (`MAX_VOICE_SIZE_AUTO_RCV`) AND file is not already accepted +3. The file has a non-null `file` attachment. + +When `auto = true`, relay approval alerts are suppressed (the file is silently received). + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `CryptoFile.kt` | [`common/src/commonMain/.../model/CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) | 62 | Encrypted file read/write via native crypto | +| `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | Common file path declarations, theme I/O, file helpers | +| `Files.android.kt` | [`common/src/androidMain/.../platform/Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) | 79 | Android file path implementations | +| `Files.desktop.kt` | [`common/src/desktopMain/.../platform/Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) | 116 | Desktop file path implementations | +| `Utils.kt` | [`common/src/commonMain/.../views/helpers/Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt) | -- | File size constants (L117--L128), `getMaxFileSize()` (L442) | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | File transfer API commands (L1911--L2085), auto-receive (L2690) | diff --git a/apps/multiplatform/spec/services/notifications.md b/apps/multiplatform/spec/services/notifications.md new file mode 100644 index 0000000000..6ce4bc9dc1 --- /dev/null +++ b/apps/multiplatform/spec/services/notifications.md @@ -0,0 +1,261 @@ +# Notification System + +## Table of Contents + +1. [Overview](#1-overview) +2. [NtfManager Abstract Class](#2-ntfmanager-abstract-class) +3. [Android Notification Manager](#3-android-notification-manager) +4. [Desktop Notification Manager](#4-desktop-notification-manager) +5. [Android Background Messaging](#5-android-background-messaging) +6. [Notification Privacy](#6-notification-privacy) +7. [Source Files](#7-source-files) + +## Executive Summary + +SimpleX Chat uses platform-specific notification strategies. The common `NtfManager` abstract class defines the notification contract with shared helper methods for message, contact, and call notifications. Android implements a full notification system with channels, grouped summaries, full-screen call intents, and a foreground service (`SimplexService`) or periodic `WorkManager` tasks for background message fetching. Desktop uses the TwoSlices library (with OS-native fallbacks) for system notifications. Notification privacy is controlled via `NotificationPreviewMode` (MESSAGE, CONTACT, HIDDEN). + +--- + +## 1. Overview + +Notifications serve three purposes in SimpleX Chat: + +1. **Message notifications** -- alert users to new messages when the app is not focused on the relevant chat. +2. **Call notifications** -- high-priority alerts for incoming WebRTC calls, with full-screen intent support on Android for lock-screen scenarios. +3. **Contact events** -- notifications for contact connection and contact request events. + +The architecture uses an abstract `NtfManager` in common code with platform-specific `actual` implementations. On Android, background message delivery requires a foreground service or periodic WorkManager tasks since SimpleX does not use push notifications (no Firebase/APNs dependency for privacy). + +--- + + + + +## 2. NtfManager Abstract Class + +[`NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) (139 lines, commonMain) + +The global `ntfManager` instance is declared at [line 17](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L17) and initialized by each platform at startup. + +### Concrete methods + +| Method | Line | Description | +|---|---|---| +| `notifyContactConnected` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | Displays "contact connected" notification for a `Contact` | +| `notifyContactRequestReceived` | [L27](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L27) | Shows contact request notification with an "Accept" action button | +| `notifyMessageReceived` | [L38](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L38) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | +| `acceptContactRequestAction` | [L51](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L51) | Accepts a contact request from a notification action | +| `openChatAction` | [L59](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L59) | Opens a specific chat from a notification tap, switching user if needed | +| `showChatsAction` | [L74](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L74) | Opens the chat list, switching user if needed | +| `acceptCallAction` | [L88](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L88) | Accepts a call invitation from a notification action | + +### Abstract methods + +| Method | Line | Description | +|---|---|---| +| `notifyCallInvitation` | [L98](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L98) | Show call notification; returns `true` if notification was shown | +| `displayNotification` | [L102](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L102) | Display a message notification with optional image and action buttons | +| `cancelCallNotification` | [L103](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L103) | Cancel the active call notification | +| `hasNotificationsForChat` | [L99](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L99) | Check if notifications exist for a given chat | +| `cancelNotificationsForChat` | [L100](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L100) | Cancel all notifications for a specific chat | +| `cancelNotificationsForUser` | [L101](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L101) | Cancel all notifications for a user profile | +| `cancelAllNotifications` | [L104](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L104) | Cancel all notifications | +| `showMessage` | [L105](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L105) | Show a simple title+text notification | +| `androidCreateNtfChannelsMaybeShowAlert` | [L107](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L107) | Android-only: create notification channels (triggers permission prompt on Android 13+) | + +### Private helpers + +- `awaitChatStartedIfNeeded` ([line 109](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L109)): Waits up to 30 seconds for chat initialization (handles database decryption delay). +- `hideSecrets` ([line 122](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L122)): Replaces `Format.Secret` formatted text with `"..."` in notification previews. + +--- + +## 3. Android Notification Manager + +[`NtfManager.android.kt`](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt) (331 lines) + +Implemented as a Kotlin `object` (singleton) in the Android module. + +### Notification channels + +| Channel | Constant | Importance | Purpose | +|---|---|---|---| +| Messages | `MessageChannel` (`chat.simplex.app.MESSAGE_NOTIFICATION`) | HIGH | All chat message notifications | +| Calls | `CallChannel` (`chat.simplex.app.CALL_NOTIFICATION_2`) | HIGH | Incoming call alerts with custom ringtone and vibration | + +Channel creation happens in `createNtfChannelsMaybeShowAlert()` ([line 298](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L298)). Old channel IDs (`CALL_NOTIFICATION`, `CALL_NOTIFICATION_1`, `LOCK_SCREEN_CALL_NOTIFICATION`) are explicitly deleted. + +### displayNotification (messages) + +[Line 102](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L102): + +- Uses `NotificationCompat.Builder` with `MessageChannel`. +- Groups notifications using `MessageGroup` with `GROUP_ALERT_CHILDREN` behavior. +- Applies rate limiting: silent mode if notification for the same `(userId, chatId)` was shown within 30 seconds (`msgNtfTimeoutMs`). +- Creates a group summary notification ([line 142](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L142)) with `setGroupSummary(true)`. +- Content intent uses `TaskStackBuilder` for proper back stack. +- Supports `NotificationAction.ACCEPT_CONTACT_REQUEST` action buttons via `NtfActionReceiver` broadcast receiver. + +### notifyCallInvitation + +[Line 160](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L160): + +- Returns `false` (no notification) if app is in foreground -- in-app alert is used instead. +- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`, plus `VISIBILITY_PUBLIC`. +- **Foreground / unlocked**: Uses regular notification with Accept/Reject action buttons and a custom ringtone (`ring_once` raw resource). +- Notification flags include `FLAG_INSISTENT` for repeating sound and vibration. +- Call notification channel vibration pattern: `[250, 250, 0, 2600]` ms. + +### Cancel operations + +| Method | Line | Description | +|---|---|---| +| `cancelNotificationsForChat` | [L75](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L75) | Cancels by `chatId.hashCode()`, cleans up group summary if no children remain | +| `cancelNotificationsForUser` | [L88](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L88) | Iterates and cancels all notifications for a given `userId` | +| `cancelCallNotification` | [L261](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L261) | Cancels the singleton call notification (`CallNotificationId = -1`) | +| `cancelAllNotifications` | [L265](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L265) | Cancels all via `NotificationManager.cancelAll()` | + +### NtfActionReceiver + +[Line 311](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L311): A `BroadcastReceiver` that handles notification action intents: +- `ACCEPT_CONTACT_REQUEST` -- calls `ntfManager.acceptContactRequestAction()` +- `RejectCallAction` -- calls `callManager.endCall()` on the invitation + +--- + +## 4. Desktop Notification Manager + +[`NtfManager.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt) (193 lines) + +Implemented as a Kotlin `object` using the [TwoSlices](https://github.com/sshtools/two-slices) library (`Toast` builder API) for cross-platform desktop notifications. + +### displayNotification + +[Line 97](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L97): + +- Suppresses if `!user.showNotifications`. +- Respects `NotificationPreviewMode` for title and content. +- Calls `displayNotificationViaLib()` ([line 114](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L114)) which builds a `Toast` with title, content, icon, action buttons, and default action. +- Icon images are written to a temporary PNG file via `prepareIconPath()` ([line 150](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L150)). +- Default action on click opens the relevant chat via `openChatAction()`. + +### notifyCallInvitation + +[Line 22](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L22): + +- Returns `false` if the SimpleX window is focused (in-app alert used instead). +- Creates a notification with Accept and Reject action buttons. +- Default click action opens the chat. + +### OS-native fallbacks + +[Line 162](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L162): The `displayNotification` private method dispatches based on `desktopPlatform`: + +| Platform | Method | +|---|---| +| Linux | `notify-send` command with optional `-i` icon | +| Windows | `SystemTray` with `TrayIcon.displayMessage()` | +| macOS | `osascript -e 'display notification ...'` | + +### Notification tracking + +Previous notifications are tracked in `prevNtfs: ArrayList, Slice>>` with a `Mutex` for thread safety. Cancel operations remove entries from this list. + +--- + +## 5. Android Background Messaging + +### 5.1 SimplexService.kt (734 lines) + +[`SimplexService.kt`](../../android/src/main/java/chat/simplex/app/SimplexService.kt) + +A foreground `Service` that keeps the app process alive for continuous message receiving. This is SimpleX's privacy-preserving alternative to push notifications. + +**Service lifecycle:** + +- `startService()` ([line 128](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L128)): Waits for database migration, validates DB status, saves service state as STARTED. WakeLock acquisition is commented out -- the app relies on battery optimization whitelisting instead. +- `onDestroy()` ([line 87](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L87)): Releases wakelocks, saves state as STOPPED, sends broadcast to `AutoRestartReceiver` if allowed. +- `onTaskRemoved()` ([line 211](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L211)): Schedules restart via `AlarmManager` when the app is swiped from recents. + +**Notification:** + +- Channel: `SIMPLEX_SERVICE_NOTIFICATION` with `IMPORTANCE_LOW` and badge disabled ([line 165](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L165)). +- Shows a persistent notification with a "Hide notification" action that opens channel settings. +- Service ID: `6789`. + +**Restart mechanisms:** + +| Receiver | Line | Trigger | +|---|---|---| +| `StartReceiver` | [L234](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L234) | Device boot (`BOOT_COMPLETED`) | +| `AutoRestartReceiver` | [L253](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L253) | Service destruction | +| `AppUpdateReceiver` | [L261](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L261) | App update (`MY_PACKAGE_REPLACED`) | +| `ServiceStartWorker` | [L283](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L283) | WorkManager one-time task | + +**Battery optimization:** + +- `isBackgroundAllowed()` ([line 681](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L681)): Checks both `isIgnoringBatteryOptimizations` and `!isBackgroundRestricted`. +- `showBackgroundServiceNoticeIfNeeded()` ([line 430](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L430)): Shows alerts guiding users to disable battery optimization or background restriction. Includes Xiaomi-specific guidance. +- `disableNotifications()` ([line 722](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L722)): Switches mode to OFF, disables receivers, cancels workers. + +### 5.2 MessagesFetcherWorker.kt (100 lines) + +[`MessagesFetcherWorker.kt`](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt) + +A `CoroutineWorker` used in `PERIODIC` notification mode as an alternative to the persistent foreground service: + +- `scheduleWork()` ([line 18](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt#L18)): Schedules a `OneTimeWorkRequest` with a default 600-second (10 minute) initial delay and 60-second duration. Requires `NetworkType.CONNECTED` constraint. +- `doWork()` ([line 53](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt#L53)): Skips if `SimplexService` is already running. Initializes chat controller if needed (self-destruct mode). Waits for DB migration. Runs for up to `durationSec` seconds, polling every 5 seconds until no messages have been received for 10 seconds (`WAIT_AFTER_LAST_MESSAGE`). +- Self-rescheduling: Always calls `reschedule()` at the end (creating a chain of one-time tasks that simulate periodic execution). + + + +### 5.3 Notification modes + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7739): + +```kotlin +enum class NotificationsMode { + OFF, // No background message fetching + PERIODIC, // WorkManager periodic tasks (MessagesFetcherWorker) + SERVICE; // Persistent foreground service (SimplexService) +} +``` + +Default is `SERVICE`. The `requiresIgnoringBattery` property is an Android extension property (defined in `Extensions.kt`, not on the enum itself) whose value depends on the SDK version: `SERVICE` requires ignoring battery optimizations since SDK S (API 31), `PERIODIC` since SDK M (API 23). + +--- + +## 6. Notification Privacy + +Defined in [`ChatModel.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L4823): + +```kotlin +enum class NotificationPreviewMode { + MESSAGE, // Show sender name and message text + CONTACT, // Show sender name, generic "new message" text + HIDDEN; // Show "Somebody" as sender, generic "new message" text +} +``` + +Privacy mode affects: +- **Notification title**: `HIDDEN` uses `"Somebody"` instead of contact name. +- **Notification content**: Only `MESSAGE` mode shows actual message text. +- **Large icon**: `HIDDEN` uses the app icon instead of the contact's profile image. +- **Call notifications**: `HIDDEN` hides the caller's name and profile image. + +Both Android and Desktop implementations check `appPreferences.notificationPreviewMode.get()` before constructing notification content. + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `NtfManager.kt` | [`common/src/commonMain/.../platform/NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | 139 | Abstract notification manager with shared logic | +| `NtfManager.android.kt` | [`android/src/main/java/.../model/NtfManager.android.kt`](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt) | 331 | Android notification channels, groups, call intents | +| `NtfManager.desktop.kt` | [`common/src/desktopMain/.../model/NtfManager.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt) | 193 | Desktop notifications via TwoSlices/OS-native | +| `SimplexService.kt` | [`android/src/main/java/.../SimplexService.kt`](../../android/src/main/java/chat/simplex/app/SimplexService.kt) | 734 | Android foreground service for background messaging | +| `MessagesFetcherWorker.kt` | [`android/src/main/java/.../MessagesFetcherWorker.kt`](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt) | 100 | WorkManager periodic message fetcher | +| `ChatModel.kt` | [`common/src/commonMain/.../model/ChatModel.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | -- | `NotificationPreviewMode` enum (L4823) | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | `NotificationsMode` enum (L7739) | diff --git a/apps/multiplatform/spec/services/theme.md b/apps/multiplatform/spec/services/theme.md new file mode 100644 index 0000000000..e5839fc193 --- /dev/null +++ b/apps/multiplatform/spec/services/theme.md @@ -0,0 +1,498 @@ +# Theme Engine + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Theme Types](#4-theme-types) +5. [Color System](#5-color-system) +6. [SimpleXTheme Composable](#6-simplextheme-composable) +7. [Platform Theme](#7-platform-theme) +8. [YAML Import/Export](#8-yaml-importexport) +9. [Source Files](#9-source-files) + +## Executive Summary + +The SimpleX Chat theme engine implements a four-level cascade: per-chat theme overrides take precedence over per-user overrides, which take precedence over global (app-settings) overrides, which take precedence over built-in presets. Four preset themes exist (LIGHT, DARK, SIMPLEX, BLACK), each defining a Material `Colors` palette and custom `AppColors` for chat-specific elements. Themes support wallpaper customization (preset patterns or custom images) with background and tint color overrides. Theme configuration is persisted as YAML and can be imported/exported. The `SimpleXTheme` composable wraps `MaterialTheme` with additional `CompositionLocal` providers for app colors and wallpaper. + +--- + +## 1. Overview + +Theme resolution follows a priority chain: + +``` +per-chat override > per-user override > global override > preset default +``` + +At each level, individual color properties can be overridden. Unspecified properties fall through to the next level. The resolution is performed by `ThemeManager.currentColors()`, which merges all levels into a single `ActiveTheme` containing Material `Colors`, `AppColors`, and `AppWallpaper`. + +Wallpapers follow the same cascade, with additional support for preset wallpapers (built-in patterns like `SCHOOL`) and custom images. Wallpaper presets can define their own color overrides that sit between the global override and the base preset. + +--- + +## 2. ThemeManager + +[`ThemeManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) (241 lines) + +A singleton `object` that manages theme state, persistence, and resolution. + +### Core resolution + + + +**`currentColors()`** ([line 57](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L57)): + +```kotlin +fun currentColors( + themeOverridesForType: WallpaperType?, + perChatTheme: ThemeModeOverride?, + perUserTheme: ThemeModeOverrides?, + appSettingsTheme: List +): ActiveTheme +``` + +This is the core resolution function. It: +1. Determines the non-system theme name (resolving `SYSTEM` to light or dark based on `systemInDarkThemeCurrently`). +2. Selects the base theme palette (LIGHT/DARK/SIMPLEX/BLACK). +3. Finds the matching `ThemeOverrides` from `appSettingsTheme` based on wallpaper type and theme name. +4. Selects the `perUserTheme` for the current light/dark mode. +5. Resolves wallpaper preset colors if applicable. +6. Merges all color layers via `toColors()`, `toAppColors()`, and `toAppWallpaper()`. + +Returns `ActiveTheme(name, base, colors, appColors, wallpaper)`. + +### Theme application + +**`applyTheme()`** ([line 105](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L105)): + +Persists the theme name, recalculates `CurrentColors`, and updates Android system bar appearance: + +```kotlin +fun applyTheme(theme: String) { + if (appPrefs.currentTheme.get() != theme) { + appPrefs.currentTheme.set(theme) + } + CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + platform.androidSetNightModeIfSupported() + val c = CurrentColors.value.colors + platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight) +} +``` + +**`changeDarkTheme()`** ([line 115](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L115)): + +Sets the dark mode variant (DARK, SIMPLEX, or BLACK) and recalculates colors. + +### Color and wallpaper modification + +**`saveAndApplyThemeColor()`** ([line 120](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L120)): + +Persists a single color change to the global theme overrides: +1. Gets or creates `ThemeOverrides` for the current base theme. +2. Calls `withUpdatedColor()` to update the specific `ThemeColor`. +3. Updates `currentThemeIds` mapping. +4. Recalculates `CurrentColors`. + +**`applyThemeColor()`** ([line 132](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L132)): + +In-memory-only color change (for per-chat/per-user theme editing before save). + +**`saveAndApplyWallpaper()`** ([line 136](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L136)): + +Persists wallpaper type change. Finds or creates matching `ThemeOverrides` (matching by wallpaper type + theme name), updates the wallpaper, and persists. + +### Reset + +**`resetAllThemeColors()` (global)** ([line 204](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L204)): + +Resets all custom colors in the current global theme override to defaults. Preserves wallpaper but clears its background and tint overrides. + +**`resetAllThemeColors()` (per-chat/per-user)** ([line 213](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L213)): + +In-memory reset of a `ThemeModeOverride` state. + +### Import/Export + +**`saveAndApplyThemeOverrides()`** ([line 188](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L188)): + +Imports a complete `ThemeOverrides` (from YAML). Handles wallpaper image import (base64 to file), replaces existing override for the same type, and applies. + +**`currentThemeOverridesForExport()`** ([line 92](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L92)): + +Exports the fully resolved current theme as a `ThemeOverrides` with all colors filled and wallpaper image embedded as base64. + +### Utility + +**`colorFromReadableHex()`** ([line 224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L224)): + +Parses `#AARRGGBB` hex string to `Color`. + +**`toReadableHex()`** ([line 227](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L227)): + +Converts `Color` to `#AARRGGBB` hex string with intelligent alpha handling. + +--- + + + +## 3. Default Themes + +[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L26): + +```kotlin +enum class DefaultTheme { + LIGHT, DARK, SIMPLEX, BLACK; + + companion object { + const val SYSTEM_THEME_NAME: String = "SYSTEM" + } +} +``` + +| Theme | `mode` | Description | +|---|---|---| +| `LIGHT` | LIGHT | Standard light theme with white/light gray surfaces | +| `DARK` | DARK | Standard dark theme with dark gray surfaces | +| `SIMPLEX` | DARK | SimpleX branded dark theme with deep blue background and cyan accent | +| `BLACK` | DARK | AMOLED-optimized pure black theme | + +`SYSTEM` is a virtual theme name that resolves to LIGHT or the configured dark variant at runtime. + +`DefaultThemeMode` ([line 46](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L46)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. + +--- + +## 4. Theme Types + + + +### AppColors (line 53) + +[`Theme.kt` L53](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L53): + +```kotlin +@Stable +class AppColors( + title: Color, + primaryVariant2: Color, + sentMessage: Color, + sentQuote: Color, + receivedMessage: Color, + receivedQuote: Color, +) +``` + +Mutable state properties (for efficient recomposition) representing chat-specific colors not covered by Material's `Colors`. + + + +### AppWallpaper (line 106) + +[`Theme.kt` L106](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L106): + +```kotlin +@Stable +class AppWallpaper( + background: Color? = null, + tint: Color? = null, + type: WallpaperType = WallpaperType.Empty, +) +``` + +Represents the active wallpaper state with optional background color, tint overlay, and wallpaper type (Empty, Preset, or Image). + + + +### ThemeColor (line 140) + +Enum of all customizable color slots: + +`PRIMARY`, `PRIMARY_VARIANT`, `SECONDARY`, `SECONDARY_VARIANT`, `BACKGROUND`, `SURFACE`, `TITLE`, `SENT_MESSAGE`, `SENT_QUOTE`, `RECEIVED_MESSAGE`, `RECEIVED_QUOTE`, `PRIMARY_VARIANT2`, `WALLPAPER_BACKGROUND`, `WALLPAPER_TINT` + +Each has a `fromColors()` method to extract the current value and a `text` property for UI display. + + + +### ThemeColors (line 183) + +[`Theme.kt` L183](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L183): + +Serializable data class with optional hex color strings for each slot. Uses `@SerialName` annotations for YAML compatibility (`accent` for `primary`, `accentVariant` for `primaryVariant`, `menus` for `surface`, etc.). + + + +### ThemeWallpaper (line 224) + +[`Theme.kt` L224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L224): + +```kotlin +@Serializable +data class ThemeWallpaper( + val preset: String? = null, // Preset wallpaper name + val scale: Float? = null, // Wallpaper scale factor + val scaleType: WallpaperScaleType? = null, // Fill/fit mode + val background: String? = null, // Background color hex + val tint: String? = null, // Tint overlay color hex + val image: String? = null, // Base64-encoded image (for import/export) + val imageFile: String? = null, // Local image file name +) +``` + +Key methods: +- `toAppWallpaper()`: Converts to runtime `AppWallpaper`. +- `withFilledWallpaperBase64()`: Embeds the image as base64 for export. +- `importFromString()`: Saves a base64 image to disk and returns a copy with `imageFile` set. +- `from(type, background, tint)`: Factory from `WallpaperType`. + + + +### ThemeOverrides (line 304) + +[`Theme.kt` L304](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L304): + +```kotlin +@Serializable +data class ThemeOverrides( + val themeId: String = UUID.randomUUID().toString(), + val base: DefaultTheme, + val colors: ThemeColors = ThemeColors(), + val wallpaper: ThemeWallpaper? = null, +) +``` + +A complete theme override entry. Multiple can coexist (one per wallpaper type per base theme). The `themeId` is a UUID for identity tracking. Key methods: +- `isSame(type, themeName)`: Matches by wallpaper type and base theme. +- `withUpdatedColor(name, color)`: Returns a copy with one color changed. +- `toColors()`, `toAppColors()`, `toAppWallpaper()`: Merge with base theme and per-user/per-chat overrides. + + + +### ThemeModeOverrides (line 475) + +[`Theme.kt` L475](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L475): + +```kotlin +@Serializable +data class ThemeModeOverrides( + val light: ThemeModeOverride? = null, + val dark: ThemeModeOverride? = null, +) +``` + +Container for per-user or per-chat overrides, with separate light and dark mode variants. Stored on the `User` model as `uiThemes`. + + + +### ThemeModeOverride (line 487) + +[`Theme.kt` L487](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L487): + +```kotlin +@Serializable +data class ThemeModeOverride( + val mode: DefaultThemeMode = CurrentColors.value.base.mode, + val colors: ThemeColors = ThemeColors(), + val wallpaper: ThemeWallpaper? = null, +) +``` + +A single mode's override with colors and wallpaper. Has `withUpdatedColor()` and `removeSameColors()` (strips colors that match base defaults). + +--- + +## 5. Color System + +Four built-in color palettes, each consisting of a Material `Colors` and an `AppColors`: + +### DarkColorPalette ([line 634](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L634)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `SimplexBlue` | `#0088ff` | +| `surface` | `#222222` | | +| `sentMessage` | `#18262E` | Dark blue-gray | +| `receivedMessage` | `#262627` | Neutral dark | + +### LightColorPalette ([line 656](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L656)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `SimplexBlue` | `#0088ff` | +| `surface` | `White` | | +| `sentMessage` | `#E9F7FF` | Light blue | +| `receivedMessage` | `#F5F5F6` | Near-white | + +### SimplexColorPalette ([line 678](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L678)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `#70F0F9` | Cyan | +| `primaryVariant` | `#1298A5` | Dark cyan | +| `background` | `#111528` | Deep navy | +| `surface` | `#121C37` | Dark navy | +| `title` | `#267BE5` | Blue | + +### BlackColorPalette ([line 701](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L701)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `#0077E0` | Darker blue | +| `background` | `#070707` | Near-black | +| `surface` | `#161617` | Very dark | +| `sentMessage` | `#18262E` | Same as Dark | +| `receivedMessage` | `#1B1B1B` | Very dark | + +--- + + + +## 6. SimpleXTheme Composable + +[`Theme.kt` line 773](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L773): + +```kotlin +@Composable +fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) +``` + +The root theme composable that wraps all app content: + +1. **System dark mode tracking** ([line 781](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L781)): Uses `snapshotFlow` on `isSystemInDarkTheme()` to call `reactOnDarkThemeChanges()` when the system theme changes. This triggers `ThemeManager.applyTheme(SYSTEM)` if the app is in system theme mode. + +2. **User theme tracking** ([line 790](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L790)): Monitors `chatModel.currentUser.value?.uiThemes` and re-applies the theme when the active user changes. + +3. **MaterialTheme wrapping** ([line 797](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L797)): Provides `theme.colors` to `MaterialTheme`, plus custom `CompositionLocal` providers: + - `LocalContentColor` -- set to `MaterialTheme.colors.onBackground` + - `LocalAppColors` -- the `AppColors` instance (remembered and updated) + - `LocalAppWallpaper` -- the `AppWallpaper` instance (remembered and updated) + - `LocalDensity` -- scaled by `desktopDensityScaleMultiplier` and `fontSizeMultiplier` + +4. **`SimpleXThemeOverride`** ([line 825](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L825)): A variant that accepts an explicit `ActiveTheme` for per-chat theme previews and overlays. + +### CompositionLocal access + +```kotlin +val MaterialTheme.appColors: AppColors // via LocalAppColors +val MaterialTheme.wallpaper: AppWallpaper // via LocalAppWallpaper +``` + +### Global state + + + +`CurrentColors` ([line 727](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L727)): A `MutableStateFlow` that holds the current resolved theme. Updated by `ThemeManager.applyTheme()` and collected by `SimpleXTheme`. + +`systemInDarkThemeCurrently` ([line 724](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L724)): Tracks the current system dark mode state. + +--- + +## 7. Platform Theme + +### isSystemInDarkTheme + +**Android** ([`Theme.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt)): + +```kotlin +@Composable +actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme() +``` + +Delegates to the standard Compose function which reads `Configuration.uiMode`. + +**Desktop** ([`Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt)): + +```kotlin +private val detector: OsThemeDetector = OsThemeDetector.getDetector() + .apply { registerListener(::reactOnDarkThemeChanges) } + +@Composable +actual fun isSystemInDarkTheme(): Boolean = try { + detector.isDark +} catch (e: Exception) { + false // Fallback for macOS exceptions +} +``` + +Uses the [jSystemThemeDetector](https://github.com/Dansoftowner/jSystemThemeDetector) library (`OsThemeDetector`). The detector also registers a listener that calls `reactOnDarkThemeChanges()` proactively when the OS theme changes, ensuring the app responds even outside of composition. + +### reactOnDarkThemeChanges + +[`Theme.kt` line 763](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L763): + +```kotlin +fun reactOnDarkThemeChanges(isDark: Boolean) { + systemInDarkThemeCurrently = isDark + if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME + && CurrentColors.value.colors.isLight == isDark) { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) + } +} +``` + +Only triggers a theme switch if the app is in SYSTEM mode and the current light/dark state disagrees with the OS. + +--- + +## 8. YAML Import/Export + +Theme overrides are persisted in `themes.yaml` (located in `preferencesDir`). + +### readThemeOverrides + +[`Files.kt` line 125](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L125): + +```kotlin +fun readThemeOverrides(): List +``` + +1. Reads `themes.yaml` from `preferencesDir`. +2. Parses the YAML node tree. +3. Extracts the `themes` list. +4. Deserializes each entry as `ThemeOverrides`, skipping entries that fail to parse (with error logging). +5. Calls `skipDuplicates()` to remove entries with the same type+base combination. + +### writeThemeOverrides + +[`Files.kt` line 151](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L151): + +```kotlin +fun writeThemeOverrides(overrides: List): Boolean +``` + +1. Serializes `ThemesFile(themes = overrides)` to YAML string. +2. Writes to a temporary file in `preferencesTmpDir`. +3. Atomically moves the temp file to `themes.yaml` using `Files.move` with `REPLACE_EXISTING`. +4. Thread-safe via `synchronized(lock)`. + +### YAML format + +```yaml +themes: + - themeId: "uuid-string" + base: "LIGHT" + colors: + accent: "#ff0088ff" + background: "#ffffffff" + sentMessage: "#ffe9f7ff" + wallpaper: + preset: "school" + scale: 1.0 + background: "#ccffffff" + tint: "#22000000" +``` + +Uses the [kaml](https://github.com/charleskorn/kaml) YAML library for serialization. `ThemeColors` uses `@SerialName` annotations for cross-platform YAML key compatibility (e.g., `accent` for `primary`, `menus` for `surface`). + +--- + +## 9. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `ThemeManager.kt` | [`common/src/commonMain/.../ui/theme/ThemeManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | 241 | Theme resolution, persistence, color/wallpaper management | +| `Theme.kt` | [`common/src/commonMain/.../ui/theme/Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt) | 848 | Type definitions, color palettes, `SimpleXTheme` composable | +| `Theme.android.kt` | [`common/src/androidMain/.../ui/theme/Theme.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt) | 6 | Android `isSystemInDarkTheme` | +| `Theme.desktop.kt` | [`common/src/desktopMain/.../ui/theme/Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt) | 25 | Desktop `isSystemInDarkTheme` via OsThemeDetector | +| `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | `readThemeOverrides()` (L125), `writeThemeOverrides()` (L151) | diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md new file mode 100644 index 0000000000..900d6593ab --- /dev/null +++ b/apps/multiplatform/spec/state.md @@ -0,0 +1,486 @@ +# State Management + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel](#2-chatmodel) +3. [ChatsContext](#3-chatscontext) +4. [Chat](#4-chat) +5. [AppPreferences](#5-apppreferences) +6. [Source Files](#6-source-files) + +--- + +## 1. Overview + +SimpleX Chat uses a **singleton-based, Compose-reactive state model**. The primary state holder is `ChatModel`, a Kotlin `object` annotated with `@Stable`. All mutable fields are Compose `MutableState`, `MutableStateFlow`, or `SnapshotStateList`/`SnapshotStateMap` instances, which trigger Compose recomposition on mutation. + +There is no ViewModel layer, no dependency injection framework, and no Redux/MVI pattern. The architecture is: + +``` +ChatModel (singleton, global Compose state) + | + +-- ChatController (command dispatch + event processing) + | | + | +-- sendCmd() -> chatSendCmdRetry() [JNI] + | +-- recvMsg() -> chatRecvMsgWait() [JNI] + | +-- processReceivedMsg() -> mutates ChatModel fields + | + +-- AppPreferences (150+ SharedPreferences via multiplatform-settings) + | + +-- ChatsContext (primary) -- chat list + current chat items + +-- ChatsContext? (secondary) -- optional second context for dual-pane/support chat +``` + +State mutations originate from two sources: +1. **User actions**: Compose UI handlers call `api*()` suspend functions on `ChatController`, which send commands to the Haskell core, receive responses, and update `ChatModel`. +2. **Core events**: The receiver coroutine (`startReceiver`) calls `processReceivedMsg()`, which updates `ChatModel` fields on `Dispatchers.Main`. + +--- + + + +## 2. ChatModel + +Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) as `@Stable object ChatModel`. + +### Controller Reference + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`controller`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L87) | `ChatController` | 87 | Reference to the `ChatController` singleton | + +### User State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`currentUser`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L89) | `MutableState` | 89 | Currently active user profile | +| [`users`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L90) | `SnapshotStateList` | 90 | All user profiles (multi-account) | +| [`localUserCreated`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L91) | `MutableState` | 91 | Whether a local user has been created (null = unknown during init) | +| [`setDeliveryReceipts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L88) | `MutableState` | 88 | Trigger for delivery receipts setup dialog | +| [`switchingUsersAndHosts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L100) | `MutableState` | 100 | True while switching active user/remote host | +| [`changingActiveUserMutex`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L193) | `Mutex` | 193 | Prevents concurrent user switches | + +### Chat Runtime State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`chatRunning`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L92) | `MutableState` | 92 | `null` = initializing, `true` = running, `false` = stopped | +| [`chatDbChanged`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L93) | `MutableState` | 93 | Database was changed externally (needs restart) | +| [`chatDbEncrypted`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L94) | `MutableState` | 94 | Whether database is encrypted | +| [`chatDbStatus`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L95) | `MutableState` | 95 | Result of database migration attempt | +| [`ctrlInitInProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L96) | `MutableState` | 96 | Controller initialization in progress | +| [`dbMigrationInProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L97) | `MutableState` | 97 | Database migration in progress | +| [`incompleteInitializedDbRemoved`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L98) | `MutableState` | 98 | Tracks if incomplete DB files were removed (prevents infinite retry) | + +### Current Chat State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`chatId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L103) | `MutableState` | 103 | ID of the currently open chat (null = chat list shown) | +| [`chatAgentConnId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L104) | `MutableState` | 104 | Agent connection ID for current chat | +| [`chatSubStatus`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L105) | `MutableState` | 105 | Subscription status for current chat | +| [`openAroundItemId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L106) | `MutableState` | 106 | Item ID to scroll to when opening chat | +| [`chatsContext`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L107) | `ChatsContext` | 107 | Primary chat context (see [ChatsContext](#3-chatscontext)) | +| [`secondaryChatsContext`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L108) | `MutableState` | 108 | Optional secondary context for dual-pane views | +| [`chats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L110) | `State>` | 110 | Derived from `chatsContext.chats` | +| [`deletedChats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L112) | `MutableState>>` | 112 | Recently deleted chats (rhId, chatId) | + +### Group Members + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`groupMembers`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L113) | `MutableState>` | 113 | Members of currently viewed group | +| [`groupMembersIndexes`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L114) | `MutableState>` | 114 | Index lookup by `groupMemberId` | +| [`membersLoaded`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L115) | `MutableState` | 115 | Whether group members have been loaded | + +### Chat Tags and Filters + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`userTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L118) | `MutableState>` | 118 | User-defined chat tags | +| [`activeChatTagFilter`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L119) | `MutableState` | 119 | Currently active filter in chat list | +| [`presetTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L120) | `SnapshotStateMap` | 120 | Counts for preset tag categories (favorites, groups, contacts, etc.) | +| [`unreadTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L121) | `SnapshotStateMap` | 121 | Unread counts per user-defined tag | + +### Terminal and Developer + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`terminalsVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L125) | `Set` | 125 | Tracks which terminal views are visible (default vs floating) | +| [`terminalItems`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L126) | `MutableState>` | 126 | Command/response log for developer terminal | + +### Calls (WebRTC) + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`callManager`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L161) | `CallManager` | 161 | WebRTC call lifecycle manager | +| [`callInvitations`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L162) | `SnapshotStateMap` | 162 | Pending incoming call invitations keyed by chatId | +| [`activeCallInvitation`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L163) | `MutableState` | 163 | Currently displayed incoming call invitation | +| [`activeCall`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L164) | `MutableState` | 164 | Currently active call | +| [`activeCallViewIsVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L165) | `MutableState` | 165 | Whether call UI is showing | +| [`activeCallViewIsCollapsed`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L166) | `MutableState` | 166 | Whether call UI is in PiP/collapsed mode | +| [`callCommand`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L167) | `SnapshotStateList` | 167 | Pending WebRTC commands | +| [`showCallView`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L168) | `MutableState` | 168 | Call view visibility toggle | +| [`switchingCall`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L169) | `MutableState` | 169 | True during call switching | + +### Compose Draft and Sharing + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`draft`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L176) | `MutableState` | 176 | Saved compose draft for current chat | +| [`draftChatId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L177) | `MutableState` | 177 | Chat ID the draft belongs to | +| [`sharedContent`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L180) | `MutableState` | 180 | Content received via share intent or internal forwarding | + +### Remote Hosts + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`remoteHosts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L199) | `SnapshotStateList` | 199 | Connected remote hosts (for desktop-mobile pairing) | +| [`currentRemoteHost`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L200) | `MutableState` | 200 | Currently selected remote host | +| [`remoteHostPairing`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L203) | `MutableState?>` | 203 | Remote host pairing state | +| [`remoteCtrlSession`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L204) | `MutableState` | 204 | Remote controller session | + +### Miscellaneous UI State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`userAddress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L127) | `MutableState` | 127 | User's public contact address | +| [`chatItemTTL`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L128) | `MutableState` | 128 | Chat item time-to-live setting | +| [`clearOverlays`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L131) | `MutableState` | 131 | Signal to close all overlays/modals | +| [`appOpenUrl`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L137) | `MutableState?>` | 137 | URL opened via deep link (rhId, uri) | +| [`appOpenUrlConnecting`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L138) | `MutableState` | 138 | Whether a deep link connection is in progress | +| [`newChatSheetVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L141) | `MutableState` | 141 | Whether new chat bottom sheet is visible | +| [`fullscreenGalleryVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L144) | `MutableState` | 144 | Fullscreen gallery mode | +| [`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 | +| [`simplexLinkMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L183) | `MutableState` | 183 | How SimpleX links are displayed | +| [`migrationState`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L174) | `MutableState` | 174 | Database migration to new device state | +| [`showingInvitation`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L172) | `MutableState` | 172 | Currently displayed invitation | +| [`desktopOnboardingRandomPassword`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L134) | `MutableState` | 134 | Desktop: user skipped password setup | +| [`filesToDelete`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L182) | `MutableSet` | 182 | Temporary files pending cleanup | + +--- + + + +## 3. ChatsContext + +Defined as inner class at [`ChatModel.kt line 339`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L339): + +```kotlin +class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) +``` + +`ChatsContext` holds the chat list and current chat items for a given context. The `ChatModel` maintains a **primary** context (`chatsContext` at line 107) and an optional **secondary** context (`secondaryChatsContext` at line 108). + +The secondary context is used for: +- **Group support chat scope** (`SecondaryContextFilter.GroupChatScopeContext`) -- viewing member support threads alongside the main group chat +- **Message content tag filtering** (`SecondaryContextFilter.MsgContentTagContext`) -- filtering messages by content type + +### Fields + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`secondaryContextFilter`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L339) | `SecondaryContextFilter?` | 339 | Filter type: null = primary, GroupChatScope or MsgContentTag | +| [`chats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L340) | `MutableState>` | 340 | List of all chats in this context | +| [`chatItems`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L345) | `MutableState>` | 345 | Items for the currently open chat in this context | +| [`chatState`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L347) | `ActiveChatState` | 347 | Tracks unread counts, splits, scroll state | + +### Derived Properties + +| Property | Line | Purpose | +|---|---|---| +| [`contentTag`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L353) | 353 | `MsgContentTag?` -- content filter tag if context is MsgContentTag | +| [`groupScopeInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L360) | 360 | `GroupChatScopeInfo?` -- group scope if context is GroupChatScope | +| [`isUserSupportChat`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L367) | 367 | True when viewing own support chat (no specific member) | + +### Key Operations + +- `addChat(chat)` -- adds chat at index 0, triggers pop animation +- `reorderChat(chat, toIndex)` -- reorders chat list (e.g., when a chat receives a new message) +- `updateChatInfo(rhId, cInfo)` -- updates chat metadata while preserving connection stats +- `hasChat(rhId, id)` / `getChat(id)` -- lookup methods + +### ActiveChatState + +Defined at [`ChatItemsMerger.kt line 196`](../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt#L196): + +```kotlin +data class ActiveChatState( + val splits: MutableStateFlow> = MutableStateFlow(emptyList()), + val unreadAfterItemId: MutableStateFlow = MutableStateFlow(-1L), + val totalAfter: MutableStateFlow = MutableStateFlow(0), + val unreadTotal: MutableStateFlow = MutableStateFlow(0), + val unreadAfter: MutableStateFlow = MutableStateFlow(0), + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) +) +``` + +This tracks the scroll position and unread item accounting for the lazy-loaded chat item list: + +| Field | Purpose | +|---|---| +| `splits` | List of item IDs where pagination gaps exist (items not yet loaded) | +| `unreadAfterItemId` | The item ID that marks the boundary of "read" vs "unread after" | +| `totalAfter` | Total items after the unread boundary | +| `unreadTotal` | Total unread items in the chat | +| `unreadAfter` | Unread items after the boundary (exclusive) | +| `unreadAfterNewestLoaded` | Unread items after the newest loaded batch | + +--- + + + +## 4. Chat + +Defined at [`ChatModel.kt line 1328`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1328): + +```kotlin +@Serializable @Stable +data class Chat( + val remoteHostId: Long?, + val chatInfo: ChatInfo, + val chatItems: List, + val chatStats: ChatStats = ChatStats() +) +``` + +### Fields + +| Field | Type | Purpose | +|---|---|---| +| `remoteHostId` | `Long?` | Remote host ID (null = local) | +| `chatInfo` | `ChatInfo` | Sealed class: `Direct`, `Group`, `Local`, `ContactRequest`, `ContactConnection`, `InvalidJSON` | +| `chatItems` | `List` | Latest chat items (summary; full list is in `ChatsContext.chatItems`) | +| `chatStats` | `ChatStats` | Unread counts and stats | + + + +### ChatStats + +Defined at [`ChatModel.kt line 1370`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1370): + +```kotlin +data class ChatStats( + val unreadCount: Int = 0, + val unreadMentions: Int = 0, + val reportsCount: Int = 0, + val minUnreadItemId: Long = 0, + val unreadChat: Boolean = false +) +``` + +### Derived Properties + +| Property | Line | Purpose | +|---|---|---| +| `id` | 1349 | Chat ID derived from `chatInfo.id` | +| `unreadTag` | 1343 | Whether chat counts as "unread" for tag filtering (considers notification settings) | +| `supportUnreadCount` | 1351 | Unread count in support/moderation context | +| `nextSendGrpInv` | 1337 | Whether next message should send group invitation | + + + +### ChatInfo Variants + +`ChatInfo` is a sealed class at [`ChatModel.kt line 1391`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1391): + +| Variant | SerialName | Key Data | +|---|---|---| +| `ChatInfo.Direct` | `"direct"` | `contact: Contact` | +| `ChatInfo.Group` | `"group"` | `groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?` | +| `ChatInfo.Local` | `"local"` | `noteFolder: NoteFolder` | +| `ChatInfo.ContactRequest` | `"contactRequest"` | `contactRequest: UserContactRequest` | +| `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | +| `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | + +--- + + + + +## 5. AppPreferences + +Defined at [`SimpleXAPI.kt line 94`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) as `class AppPreferences`. + +Uses the `multiplatform-settings` library (`com.russhwolf.settings.Settings`) for cross-platform key-value storage (Android `SharedPreferences` / Desktop `java.util.prefs.Preferences`). + +The `AppPreferences` instance is created lazily in `ChatController` at [`SimpleXAPI.kt line 496`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L496): +```kotlin +val appPrefs: AppPreferences by lazy { AppPreferences() } +``` + +### Preference Categories + +#### Notifications (lines 96-103) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `notificationsMode` | `NotificationsMode` | `SERVICE` (if previously enabled) | OFF / SERVICE / PERIODIC | +| `notificationPreviewMode` | `String` | `"message"` | message / contact / hidden | +| `canAskToEnableNotifications` | `Boolean` | `true` | Whether to show notification enable prompt | +| `backgroundServiceNoticeShown` | `Boolean` | `false` | Background service notice already shown | +| `backgroundServiceBatteryNoticeShown` | `Boolean` | `false` | Battery notice already shown | +| `autoRestartWorkerVersion` | `Int` | `0` | Worker version for periodic restart | + +#### Calls (lines 105-111) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `webrtcPolicyRelay` | `Boolean` | `true` | Use TURN relay for WebRTC | +| `callOnLockScreen` | `CallOnLockScreen` | `SHOW` | DISABLE / SHOW / ACCEPT | +| `webrtcIceServers` | `String?` | `null` | Custom ICE servers | +| `experimentalCalls` | `Boolean` | `false` | Enable experimental call features | + +#### Authentication (lines 107-110) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `performLA` | `Boolean` | `false` | Enable local authentication | +| `laMode` | `LAMode` | default | Authentication mode | +| `laLockDelay` | `Int` | `30` | Seconds before re-auth required | +| `laNoticeShown` | `Boolean` | `false` | LA notice shown | + +#### Privacy (lines 112-128) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `privacyProtectScreen` | `Boolean` | `true` | FLAG_SECURE on Android | +| `privacyAcceptImages` | `Boolean` | `true` | Auto-accept images | +| `privacyLinkPreviews` | `Boolean` | `true` | Generate link previews | +| `privacySanitizeLinks` | `Boolean` | `false` | Remove tracking params from links | +| `simplexLinkMode` | `SimplexLinkMode` | `DESCRIPTION` | DESCRIPTION / FULL / BROWSER | +| `privacyShowChatPreviews` | `Boolean` | `true` | Show chat previews in list | +| `privacySaveLastDraft` | `Boolean` | `true` | Save compose draft | +| `privacyDeliveryReceiptsSet` | `Boolean` | `false` | Delivery receipts configured | +| `privacyEncryptLocalFiles` | `Boolean` | `true` | Encrypt local files | +| `privacyAskToApproveRelays` | `Boolean` | `true` | Ask before using relays | +| `privacyMediaBlurRadius` | `Int` | `0` | Blur radius for media | + +#### Network (lines 140-175) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `networkUseSocksProxy` | `Boolean` | `false` | Enable SOCKS proxy | +| `networkProxy` | `NetworkProxy` | localhost:9050 | Proxy host/port | +| `networkSessionMode` | `TransportSessionMode` | default | Session mode | +| `networkSMPProxyMode` | `SMPProxyMode` | default | SMP proxy mode | +| `networkSMPProxyFallback` | `SMPProxyFallback` | default | Proxy fallback policy | +| `networkHostMode` | `HostMode` | default | Host mode (onion routing) | +| `networkRequiredHostMode` | `Boolean` | `false` | Enforce host mode | +| `networkSMPWebPortServers` | `SMPWebPortServers` | default | Web port server config | +| `networkShowSubscriptionPercentage` | `Boolean` | `false` | Show subscription stats | +| `networkTCPConnectTimeout*` | `Long` | varies | TCP connect timeouts (background/interactive) | +| `networkTCPTimeout*` | `Long` | varies | TCP operation timeouts | +| `networkTCPTimeoutPerKb` | `Long` | varies | Per-KB timeout | +| `networkRcvConcurrency` | `Int` | default | Receive concurrency | +| `networkSMPPingInterval` | `Long` | default | SMP ping interval | +| `networkSMPPingCount` | `Int` | default | SMP ping count | +| `networkEnableKeepAlive` | `Boolean` | default | TCP keep-alive | +| `networkTCPKeepIdle` | `Int` | default | Keep-alive idle time | +| `networkTCPKeepIntvl` | `Int` | default | Keep-alive interval | +| `networkTCPKeepCnt` | `Int` | default | Keep-alive count | + +#### Appearance (lines 213-233) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `currentTheme` | `String` | `"SYSTEM"` | Active theme name | +| `systemDarkTheme` | `String` | `"SIMPLEX"` | Theme for system dark mode | +| `currentThemeIds` | `Map` | empty | Theme ID per base theme | +| `themeOverrides` | `List` | empty | Custom theme overrides | +| `profileImageCornerRadius` | `Float` | `22.5f` | Avatar corner radius | +| `chatItemRoundness` | `Float` | `0.75f` | Message bubble roundness | +| `chatItemTail` | `Boolean` | `true` | Show bubble tail | +| `fontScale` | `Float` | `1f` | Font scale factor | +| `densityScale` | `Float` | `1f` | UI density scale | +| `inAppBarsAlpha` | `Float` | varies | Bar transparency | +| `appearanceBarsBlurRadius` | `Int` | 50 or 0 | Bar blur radius (device-dependent) | + +#### Developer (lines 135-139) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `developerTools` | `Boolean` | `false` | Enable developer tools | +| `logLevel` | `LogLevel` | `WARNING` | Log level | +| `showInternalErrors` | `Boolean` | `false` | Show internal errors to user | +| `showSlowApiCalls` | `Boolean` | `false` | Alert on slow API calls | +| `terminalAlwaysVisible` | `Boolean` | `false` | Floating terminal window (desktop) | + +#### Database (lines 188-208) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `onboardingStage` | `OnboardingStage` | `OnboardingComplete` | Current onboarding step | +| `storeDBPassphrase` | `Boolean` | `true` | Store DB passphrase in keystore | +| `initialRandomDBPassphrase` | `Boolean` | `false` | DB was created with random passphrase | +| `encryptedDBPassphrase` | `String?` | null | Encrypted DB passphrase | +| `confirmDBUpgrades` | `Boolean` | `false` | Confirm DB migrations | +| `chatStopped` | `Boolean` | `false` | Chat was explicitly stopped | +| `chatLastStart` | `Instant?` | null | Last chat start timestamp | +| `newDatabaseInitialized` | `Boolean` | `false` | DB successfully initialized at least once | +| `shouldImportAppSettings` | `Boolean` | `false` | Import settings after DB import | +| `selfDestruct` | `Boolean` | `false` | Self-destruct enabled | +| `selfDestructDisplayName` | `String?` | null | Display name for self-destruct profile | + +#### UI Preferences (lines 255-257) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `oneHandUI` | `Boolean` | `true` | One-hand mode | +| `chatBottomBar` | `Boolean` | `true` | Bottom bar in chat | + +#### Remote Access (lines 238-243) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `deviceNameForRemoteAccess` | `String` | device model | Device name shown to paired devices | +| `confirmRemoteSessions` | `Boolean` | `false` | Confirm remote sessions | +| `connectRemoteViaMulticast` | `Boolean` | `false` | Use multicast for discovery | +| `connectRemoteViaMulticastAuto` | `Boolean` | `true` | Auto-connect via multicast | +| `offerRemoteMulticast` | `Boolean` | `true` | Offer multicast connection | + +#### Migration (lines 189-190) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `migrationToStage` | `String?` | null | Migration-to-device progress | +| `migrationFromStage` | `String?` | null | Migration-from-device progress | + +#### Updates and Versioning (lines 184-186, 235-237) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `appUpdateChannel` | `AppUpdatesChannel` | `DISABLED` | DISABLED / STABLE / BETA | +| `appSkippedUpdate` | `String` | `""` | Skipped update version | +| `appUpdateNoticeShown` | `Boolean` | `false` | Update notice shown | +| `whatsNewVersion` | `String?` | null | Last "What's New" version seen | +| `lastMigratedVersionCode` | `Int` | `0` | Last app version code for data migrations | +| `customDisappearingMessageTime` | `Int` | `300` | Custom disappearing message time (seconds) | + +### Preference Utility Types + +The `SharedPreference` wrapper (defined in SimpleXAPI.kt) provides: +- `get(): T` -- read current value +- `set(value: T)` -- write value +- `state: MutableState` -- Compose-observable state (derived lazily) + +Factory methods: `mkBoolPreference`, `mkIntPreference`, `mkLongPreference`, `mkFloatPreference`, `mkStrPreference`, `mkEnumPreference`, `mkSafeEnumPreference`, `mkDatePreference`, `mkMapPreference`, `mkTimeoutPreference`. + +--- + +## 6. Source Files + +| File | Path | Key Contents | +|---|---|---| +| ChatModel.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | `ChatModel` singleton (line 86), `ChatsContext` (line 339), `Chat` (line 1328), `ChatInfo` (line 1391), `ChatStats` (line 1370), helper methods | +| SimpleXAPI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | `AppPreferences` (line 94), `ChatController` (line 493), `startReceiver` (line 660), `sendCmd` (line 804), `recvMsg` (line 829), `processReceivedMsg` (line 2568) | +| ChatItemsMerger.kt | [`common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt`](../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt) | `ActiveChatState` (line 196), chat item merge/diff logic | +| Core.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt) | `initChatController` (line 62), state initialization flow | +| App.kt | [`common/src/commonMain/kotlin/chat/simplex/common/App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen` (line 47), `MainScreen` (line 84), top-level UI state reads | From f23b801523146589514347d0fdc02c0fc8f97255 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 26 Feb 2026 22:01:45 +0000 Subject: [PATCH 016/112] website: better font sizes (#6658) Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- website/src/css/design3.css | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/website/src/css/design3.css b/website/src/css/design3.css index 001e02eedd..d4b5d4d271 100644 --- a/website/src/css/design3.css +++ b/website/src/css/design3.css @@ -279,6 +279,7 @@ p { -webkit-text-fill-color: transparent; background-clip: text; color: transparent; + text-decoration: none; } .dark .gradient-text, @@ -428,7 +429,7 @@ section.cover div.content h2 { section.cover div.content p { font-family: "Manrope", "GT-Walsheim", sans-serif; font-weight: 200; - font-size: calc(var(--sec-vwu) * 2.4); + font-size: calc(var(--sec-vwu) * 2.14); align-items: center; color: #ffffff; max-width: calc(var(--sec-vwu) * 53); @@ -916,7 +917,7 @@ main .section-bg { section.cover div.content h2 { font-weight: 500; - font-size: calc(var(--sec-vwu) * 11.5); + font-size: calc(var(--sec-vwu) * 12.85); } section.cover div.content p { From 3707a419ce772cd77505b589efbc4b00408c19e7 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:47:13 +0400 Subject: [PATCH 017/112] core: update simplexmq (fix build) --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 07a9a0f217..17ddfa74db 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 6be64cd369b548c956204b4ffd12c92d7d3d0618 + tag: 50ae1e1c3e9400e841cc6db455e20cf05bd465b8 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d513ac3b75..fcd05b02d5 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8fdc0703bc9b89dae8b2fe6820b705580a669281" = "1s3ihb8pyhvxbk1f205wmcfr7d0m7slpjq771z3zz6cvg3fyppbg"; + "https://github.com/simplex-chat/simplexmq.git"."50ae1e1c3e9400e841cc6db455e20cf05bd465b8" = "083808v87dqhyp3c101cdv9nvhnpvymiy3vxmxqanrmjwcpm0hjj"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From bf56ed0f56b07c3f4f1cae4d3dd89d7325e6d447 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Sun, 1 Mar 2026 23:24:29 +0000 Subject: [PATCH 018/112] ui: translations (#6648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * website: directory under maintenance (#6557) * Translated using Weblate (Catalan) Currently translated at 100.0% (2506 of 2506 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2506 of 2506 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Russian) Currently translated at 99.8% (2502 of 2506 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2506 of 2506 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Arabic) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Catalan) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/ * Translated using Weblate (German) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Italian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Czech) Currently translated at 99.0% (2486 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Translated using Weblate (Japanese) Currently translated at 78.6% (1976 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Italian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Italian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Danish) Currently translated at 34.1% (857 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/da/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Russian) Currently translated at 99.5% (2500 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Russian) Currently translated at 99.7% (2163 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Polish) Currently translated at 85.6% (2150 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Persian) Currently translated at 99.6% (2503 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fa/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Indonesian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Greek) Currently translated at 30.2% (760 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hebrew) Currently translated at 83.3% (2092 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/ * Translated using Weblate (Greek) Currently translated at 31.3% (788 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Greek) Currently translated at 33.4% (840 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Greek) Currently translated at 36.5% (917 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Greek) Currently translated at 50.0% (1258 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Greek) Currently translated at 67.3% (1690 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Greek) Currently translated at 70.2% (1763 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Greek) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Greek) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Greek) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Russian) Currently translated at 99.8% (2507 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Russian) Currently translated at 99.8% (2507 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Added translation using Weblate (Swedish) * Added translation using Weblate (Kurdish) * Translated using Weblate (Russian) Currently translated at 99.8% (2507 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Japanese) Currently translated at 78.6% (1976 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 99.4% (2156 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Russian) Currently translated at 99.8% (2508 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2511 of 2511 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Italian) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/ * Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (German) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/ * Translated using Weblate (Spanish) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * Translated using Weblate (Czech) Currently translated at 59.4% (1288 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Czech) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/ * Translated using Weblate (Kurdish) Currently translated at 22.6% (571 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ku/ * Translated using Weblate (Greek) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/el/ * Translated using Weblate (Czech) Currently translated at 99.0% (2498 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/ * Update translation files Updated by "Remove blank strings" hook in Weblate. Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ * Translated using Weblate (Kurdish) Currently translated at 28.2% (712 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ku/ * Translated using Weblate (Kurdish) Currently translated at 33.1% (837 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ku/ * Translated using Weblate (Polish) Currently translated at 84.2% (1826 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/pl/ * Translated using Weblate (Polish) Currently translated at 99.9% (2522 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pl/ * Translated using Weblate (Turkish) Currently translated at 97.5% (2461 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/ * Translated using Weblate (Japanese) Currently translated at 78.7% (1986 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/ * Translated using Weblate (Russian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/ * Translated using Weblate (Japanese) Currently translated at 56.5% (1225 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Japanese) Currently translated at 56.5% (1225 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ja/ * Translated using Weblate (Russian) Currently translated at 99.8% (2520 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2523 of 2523 strings) Translation: SimpleX Chat/SimpleX Chat Android Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (2168 of 2168 strings) Translation: SimpleX Chat/SimpleX Chat iOS Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/ * process localizations * revert Czech translations from PR #6648 (#6650) Restores all 7 Czech (cs) translation files to their master branch state, removing the malicious changes introduced in the weblate/translations branch. * process localizations --------- Co-authored-by: fran secs Co-authored-by: summoner001 Co-authored-by: Skyward Copied Co-authored-by: mlanp Co-authored-by: 大王叫我来巡山 Co-authored-by: No name Co-authored-by: jonnysemon Co-authored-by: Random Co-authored-by: zenobit Co-authored-by: Miyu Sakatsuki Co-authored-by: Thomas Christensen Co-authored-by: А О Co-authored-by: sawd Co-authored-by: MatinTaghavi Co-authored-by: Rafi Co-authored-by: chamdim Co-authored-by: מילקי צבעוני Co-authored-by: Andrew <56andrey21@bk.ru> Co-authored-by: Li Dong Co-authored-by: BlacAmDK Co-authored-by: Dima Sivan Co-authored-by: Terciman Co-authored-by: Hosted Weblate Co-authored-by: Wiesław Fijołek Co-authored-by: Abdullah Koyuncu Co-authored-by: proudmuslim-dev Co-authored-by: Paul Co-authored-by: Rei Co-authored-by: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> --- .../bg.xcloc/Localized Contents/bg.xliff | 75 +- .../cs.xcloc/Localized Contents/cs.xliff | 75 +- .../de.xcloc/Localized Contents/de.xliff | 75 +- .../en.xcloc/Localized Contents/en.xliff | 91 +- .../es.xcloc/Localized Contents/es.xliff | 81 +- .../fi.xcloc/Localized Contents/fi.xliff | 75 +- .../fr.xcloc/Localized Contents/fr.xliff | 75 +- .../hu.xcloc/Localized Contents/hu.xliff | 311 ++- .../it.xcloc/Localized Contents/it.xliff | 77 +- .../ja.xcloc/Localized Contents/ja.xliff | 94 +- .../nl.xcloc/Localized Contents/nl.xliff | 75 +- .../pl.xcloc/Localized Contents/pl.xliff | 87 +- .../ru.xcloc/Localized Contents/ru.xliff | 86 +- .../th.xcloc/Localized Contents/th.xliff | 75 +- .../tr.xcloc/Localized Contents/tr.xliff | 75 +- .../uk.xcloc/Localized Contents/uk.xliff | 75 +- .../Localized Contents/zh-Hans.xliff | 307 ++- .../zh-Hans.lproj/Localizable.strings | 20 +- .../SimpleX SE/hu.lproj/Localizable.strings | 6 +- apps/ios/bg.lproj/Localizable.strings | 9 +- apps/ios/cs.lproj/Localizable.strings | 9 +- apps/ios/de.lproj/Localizable.strings | 11 +- apps/ios/es.lproj/Localizable.strings | 17 +- apps/ios/fi.lproj/Localizable.strings | 9 +- apps/ios/fr.lproj/Localizable.strings | 11 +- apps/ios/hu.lproj/Localizable.strings | 241 +- apps/ios/it.lproj/Localizable.strings | 13 +- apps/ios/ja.lproj/Localizable.strings | 66 +- apps/ios/nl.lproj/Localizable.strings | 11 +- apps/ios/pl.lproj/Localizable.strings | 45 +- apps/ios/ru.lproj/Localizable.strings | 32 +- apps/ios/th.lproj/Localizable.strings | 9 +- apps/ios/tr.lproj/Localizable.strings | 11 +- apps/ios/uk.lproj/Localizable.strings | 11 +- apps/ios/zh-Hans.lproj/Localizable.strings | 687 ++++- .../commonMain/resources/MR/ar/strings.xml | 5 + .../commonMain/resources/MR/ca/strings.xml | 11 +- .../commonMain/resources/MR/da/strings.xml | 2 + .../commonMain/resources/MR/de/strings.xml | 17 + .../commonMain/resources/MR/el/strings.xml | 2308 ++++++++++++++++- .../commonMain/resources/MR/es/strings.xml | 25 +- .../commonMain/resources/MR/fa/strings.xml | 1 + .../commonMain/resources/MR/hu/strings.xml | 255 +- .../commonMain/resources/MR/in/strings.xml | 5 + .../commonMain/resources/MR/it/strings.xml | 19 +- .../commonMain/resources/MR/iw/strings.xml | 13 + .../commonMain/resources/MR/ja/strings.xml | 15 +- .../commonMain/resources/MR/ku/strings.xml | 837 ++++++ .../commonMain/resources/MR/pl/strings.xml | 429 ++- .../commonMain/resources/MR/ru/strings.xml | 87 +- .../commonMain/resources/MR/sv/strings.xml | 3 + .../commonMain/resources/MR/tr/strings.xml | 7 +- .../resources/MR/zh-rCN/strings.xml | 17 + website/src/directory.html | 1 + website/src/index.html | 1 + 55 files changed, 6363 insertions(+), 722 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index a59179ddfa..3c21db94b6 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -792,6 +792,10 @@ swipe action Всички членове на групата ще останат свързани. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Всички съобщения и файлове се изпращат с **криптиране от край до край**, с постквантова сигурност в директните съобщения. @@ -1142,6 +1146,10 @@ swipe action Аудио и видео разговори No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудио/видео разговори @@ -2552,6 +2560,14 @@ swipe action Изтрий съобщението на члена? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Изтрий съобщението? @@ -2560,7 +2576,8 @@ swipe action Delete messages Изтрий съобщенията - alert button + alert action +alert button Delete messages after @@ -3741,6 +3758,10 @@ snd error text Файловете и медията са забранени! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Филтрирайте непрочетените и любимите чатове. @@ -4194,6 +4215,10 @@ Error: %2$@ Изображението ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Веднага @@ -4442,6 +4467,10 @@ More improvements are coming soon! Покани приятели No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Покани членове @@ -4658,6 +4687,10 @@ This is your link for group %@! Запомнени настолни устройства No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4773,6 +4806,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4793,12 +4830,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Членът ще бъде премахнат от групата - това не може да бъде отменено! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6296,7 +6333,11 @@ swipe action Remove Премахване - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6318,7 +6359,7 @@ swipe action Remove member? Острани член? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6703,11 +6744,31 @@ chat item action Лентата за търсене приема линк за връзка. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Търсене или поставяне на SimpleX линк No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8432,6 +8493,10 @@ To connect, please ask your contact to create another connection link and check Видеото ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Видео и файлове до 1gb diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index ace3079550..db9c2e1910 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -782,6 +782,10 @@ swipe action Všichni členové skupiny zůstanou připojeni. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -1117,6 +1121,10 @@ swipe action Hlasové a video hovory No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio/video hovory @@ -2438,6 +2446,14 @@ swipe action Smazat zprávu člena? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Smazat zprávu? @@ -2446,7 +2462,8 @@ swipe action Delete messages Smazat zprávy - alert button + alert action +alert button Delete messages after @@ -3596,6 +3613,10 @@ snd error text Soubory a média jsou zakázány! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtrovat nepřečtené a oblíbené chaty. @@ -4037,6 +4058,10 @@ Error: %2$@ Obrázek bude přijat, až bude váš kontakt online, vyčkejte prosím nebo se podívejte později! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Ihned @@ -4272,6 +4297,10 @@ More improvements are coming soon! Pozvat přátele No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Pozvat členy @@ -4479,6 +4508,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4594,6 +4627,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4614,12 +4651,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Člen bude odstraněn ze skupiny - toto nelze vzít zpět! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6074,7 +6111,11 @@ swipe action Remove Odstranit - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6096,7 +6137,7 @@ swipe action Remove member? Odebrat člena? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6471,10 +6512,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8156,6 +8217,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Video obdržíte, až bude váš kontakt online, vyčkejte prosím nebo zkontrolujte později! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videa a soubory až do velikosti 1 gb diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 34bbab2a6a..cec79a1739 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -792,6 +792,10 @@ swipe action Alle Gruppenmitglieder bleiben verbunden. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Alle Nachrichten und Dateien werden **Ende-zu-Ende verschlüsselt** versendet - in Direkt-Nachrichten mit Post-Quantum-Security. @@ -1142,6 +1146,10 @@ swipe action Audio- und Videoanrufe No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio-/Video-Anrufe @@ -2579,6 +2587,14 @@ swipe action Nachricht des Mitglieds löschen? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Die Nachricht löschen? @@ -2587,7 +2603,8 @@ swipe action Delete messages Nachrichten löschen - alert button + alert action +alert button Delete messages after @@ -3851,6 +3868,10 @@ snd error text Dateien und Medien sind nicht erlaubt! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Nach ungelesenen und favorisierten Chats filtern. @@ -4335,6 +4356,10 @@ Fehler: %2$@ Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Sofort @@ -4594,6 +4619,10 @@ Weitere Verbesserungen sind bald verfügbar! Freunde einladen No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Mitglieder einladen @@ -4817,6 +4846,10 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfte Desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List Liste @@ -4942,6 +4975,10 @@ Das ist Ihr Link für die Gruppe %@! Mitglied ist gelöscht - Anfrage kann nicht angenommen werden No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Mitglieder-Meldungen @@ -4965,12 +5002,12 @@ Das ist Ihr Link für die Gruppe %@! Member will be removed from chat - this cannot be undone! Das Mitglied wird aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6595,7 +6632,11 @@ swipe action Remove Entfernen - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6620,7 +6661,7 @@ swipe action Remove member? Das Mitglied entfernen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7079,31 @@ chat item action In der Suchleiste werden nun auch Einladungslinks angenommen. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Suchen oder SimpleX-Link einfügen No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Zweite Farbe @@ -8918,6 +8979,10 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videos und Dateien bis zu 1GB diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 2f5a0acbb1..581cd791a5 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -792,6 +792,11 @@ swipe action All group members will remain connected. No comment provided by engineer. + + All messages + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. @@ -1142,6 +1147,11 @@ swipe action Audio and video calls No comment provided by engineer. + + Audio call + Audio call + No comment provided by engineer. + Audio/video calls Audio/video calls @@ -2579,6 +2589,16 @@ swipe action Delete member message? No comment provided by engineer. + + Delete member messages + Delete member messages + No comment provided by engineer. + + + Delete member messages? + Delete member messages? + alert title + Delete message? Delete message? @@ -2587,7 +2607,8 @@ swipe action Delete messages Delete messages - alert button + alert action +alert button Delete messages after @@ -3851,6 +3872,11 @@ snd error text Files and media prohibited! No comment provided by engineer. + + Filter + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filter unread and favorite chats. @@ -4335,6 +4361,11 @@ Error: %2$@ Image will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Images + Images + No comment provided by engineer. + Immediately Immediately @@ -4594,6 +4625,11 @@ More improvements are coming soon! Invite friends No comment provided by engineer. + + Invite member + Invite member + No comment provided by engineer. + Invite members Invite members @@ -4817,6 +4853,11 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + Links + No comment provided by engineer. + List List @@ -4942,6 +4983,11 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Member messages will be deleted - this cannot be undone! + alert message + Member reports Member reports @@ -4965,12 +5011,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Member will be removed from group - this cannot be undone! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6595,7 +6641,12 @@ swipe action Remove Remove - No comment provided by engineer. + alert action + + + Remove and delete messages + Remove and delete messages + alert action Remove archive? @@ -6620,7 +6671,7 @@ swipe action Remove member? Remove member? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7089,36 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + Search files + No comment provided by engineer. + + + Search images + Search images + No comment provided by engineer. + + + Search links + Search links + No comment provided by engineer. + Search or paste SimpleX link Search or paste SimpleX link No comment provided by engineer. + + Search videos + Search videos + No comment provided by engineer. + + + Search voice messages + Search voice messages + No comment provided by engineer. + Secondary Secondary @@ -8918,6 +8994,11 @@ To connect, please ask your contact to create another connection link and check Video will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Videos + Videos + No comment provided by engineer. + Videos and files up to 1gb Videos and files up to 1gb diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 30c69af755..edacbd8e56 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -792,6 +792,10 @@ swipe action Todos los miembros del grupo permanecerán conectados. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos. @@ -1142,6 +1146,10 @@ swipe action Llamadas y videollamadas No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Llamadas y videollamadas @@ -2579,6 +2587,14 @@ swipe action ¿Eliminar el mensaje de miembro? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? ¿Eliminar mensaje? @@ -2587,7 +2603,8 @@ swipe action Delete messages Activar - alert button + alert action +alert button Delete messages after @@ -3851,6 +3868,10 @@ snd error text ¡Archivos y multimedia no permitidos! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtra chats no leídos y favoritos. @@ -4335,6 +4356,10 @@ Error: %2$@ La imagen se recibirá cuando el contacto esté en línea, ¡por favor espera o revisa más tarde! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Inmediatamente @@ -4594,6 +4619,10 @@ More improvements are coming soon! Invitar amigos No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Invitar miembros @@ -4817,6 +4846,10 @@ This is your link for group %@! Ordenadores enlazados No comment provided by engineer. + + Links + No comment provided by engineer. + List Lista @@ -4942,6 +4975,10 @@ This is your link for group %@! Miembro eliminado, no puede aceptar solicitudes No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Informes de miembros @@ -4965,12 +5002,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! El miembro será eliminado del chat. ¡No puede deshacerse! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! El miembro será expulsado del grupo. ¡No puede deshacerse! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5484,7 +5521,7 @@ This is your link for group %@! No direct connection yet, message is forwarded by admin. - Aún no hay conexión directa con este miembro, el mensaje es reenviado por el administrador. + Aún no hay conexión directa, los mensajes son reenviados por el administrador. item status description @@ -6595,7 +6632,11 @@ swipe action Remove Eliminar - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6620,7 +6661,7 @@ swipe action Remove member? ¿Expulsar miembro? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7079,31 @@ chat item action La barra de búsqueda acepta enlaces de invitación. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Buscar o pegar enlace SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secundario @@ -8918,6 +8979,10 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión El vídeo se recibirá cuando el contacto esté en línea, por favor espera o revisa más tarde. No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Vídeos y archivos de hasta 1Gb @@ -9649,7 +9714,7 @@ Repeat connection request? accepted you - te ha aceptado + te ha admitido rcv group event chat item @@ -10623,7 +10688,7 @@ last received msg: %2$@ you accepted this member - has aceptado al miembro + has admitido al miembro snd group event chat item diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 56fa4a1485..00b4bca1d4 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -726,6 +726,10 @@ swipe action Kaikki ryhmän jäsenet pysyvät yhteydessä. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -1042,6 +1046,10 @@ swipe action Ääni- ja videopuhelut No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Ääni/videopuhelut @@ -2328,6 +2336,14 @@ swipe action Poista jäsenviesti? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Poista viesti? @@ -2336,7 +2352,8 @@ swipe action Delete messages Poista viestit - alert button + alert action +alert button Delete messages after @@ -3483,6 +3500,10 @@ snd error text Tiedostot ja media kielletty! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Suodata lukemattomia- ja suosikkikeskusteluja. @@ -3924,6 +3945,10 @@ Error: %2$@ Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Heti @@ -4159,6 +4184,10 @@ More improvements are coming soon! Kutsu ystäviä No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Kutsu jäseniä @@ -4366,6 +4395,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4481,6 +4514,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4501,12 +4538,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Jäsen poistetaan ryhmästä - tätä ei voi perua! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5959,7 +5996,11 @@ swipe action Remove Poista - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -5981,7 +6022,7 @@ swipe action Remove member? Poista jäsen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6356,10 +6397,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8038,6 +8099,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videot ja tiedostot 1 Gt asti diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 67485353d2..82912c5d44 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -792,6 +792,10 @@ swipe action Tous les membres du groupe resteront connectés. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs. @@ -1141,6 +1145,10 @@ swipe action Appels audio et vidéo No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Appels audio/vidéo @@ -2564,6 +2572,14 @@ swipe action Supprimer le message de ce membre ? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Supprimer le message ? @@ -2572,7 +2588,8 @@ swipe action Delete messages Supprimer les messages - alert button + alert action +alert button Delete messages after @@ -3822,6 +3839,10 @@ snd error text Fichiers et médias interdits ! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtrer les messages non lus et favoris. @@ -4296,6 +4317,10 @@ Erreur : %2$@ L'image sera reçue quand votre contact sera en ligne, merci d'attendre ou de revenir plus tard ! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Immédiatement @@ -4548,6 +4573,10 @@ D'autres améliorations sont à venir ! Inviter des amis No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Inviter des membres @@ -4769,6 +4798,10 @@ Voici votre lien pour le groupe %@ ! Bureaux liés No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4887,6 +4920,10 @@ Voici votre lien pour le groupe %@ ! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4909,12 +4946,12 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! Le membre sera retiré de la discussion - cela ne peut pas être annulé ! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Ce membre sera retiré du groupe - impossible de revenir en arrière ! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6489,7 +6526,11 @@ swipe action Remove Supprimer - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6513,7 +6554,7 @@ swipe action Remove member? Retirer ce membre ? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6912,11 +6953,31 @@ chat item action La barre de recherche accepte les liens d'invitation. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Rechercher ou coller un lien SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secondaire @@ -8743,6 +8804,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien La vidéo ne sera reçue que lorsque votre contact sera en ligne. Veuillez patienter ou vérifier plus tard ! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Vidéos et fichiers jusqu'à 1Go diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 22d223ffd9..99219c1f40 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -92,12 +92,12 @@ %@ is not verified - %@ nincs hitelesítve + %@ nincs ellenőrizve No comment provided by engineer. %@ is verified - %@ hitelesítve + %@ ellenőrizve No comment provided by engineer. @@ -217,7 +217,7 @@ %lld contact(s) selected - %lld partner kijelölve + %lld partner kiválasztva No comment provided by engineer. @@ -367,7 +367,7 @@ **Warning**: Instant push notifications require passphrase saved in Keychain. - **Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. + **Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. No comment provided by engineer. @@ -377,12 +377,12 @@ **e2e encrypted** audio call - **e2e titkosított** hanghívás + **végpontok között titkosított** hanghívás No comment provided by engineer. **e2e encrypted** video call - **e2e titkosított** videóhívás + **végpontok között titkosított** videóhívás No comment provided by engineer. @@ -789,7 +789,11 @@ swipe action All group members will remain connected. - Az összes csoporttag kapcsolatban marad. + Az összes csoporttag továbbra is kapcsolatban marad. + No comment provided by engineer. + + + All messages No comment provided by engineer. @@ -829,12 +833,12 @@ swipe action All your contacts will remain connected. - Az összes partnerével kapcsolatban marad. + Az összes partnerével továbbra is kapcsolatban marad. No comment provided by engineer. All your contacts will remain connected. Profile update will be sent to your contacts. - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. + Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. No comment provided by engineer. @@ -954,7 +958,7 @@ swipe action Allow your contacts to send disappearing messages. - Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. + Az eltűnő üzenetek küldése engedélyezve van a partnerei számára. No comment provided by engineer. @@ -984,12 +988,12 @@ swipe action Always use private routing. - Mindig használjon privát útválasztást. + Mindig legyen használva privát útválasztás. No comment provided by engineer. Always use relay - Mindig használjon továbbítókiszolgálót + Mindig legyen használva továbbítókiszolgáló No comment provided by engineer. @@ -1142,6 +1146,10 @@ swipe action Hang- és videóhívások No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Hang- és videóhívások @@ -1264,12 +1272,12 @@ swipe action Bio - Névjegy + Életrajz No comment provided by engineer. Bio too large - A névjegy túl hosszú + Az életrajz túl hosszú alert title @@ -1398,7 +1406,7 @@ swipe action Call already ended! - A hívás már befejeződött! + A hívás már véget ért! No comment provided by engineer. @@ -1691,7 +1699,7 @@ set passcode view Choose _Migrate from another device_ on the new device and scan QR code. - Válassza az _Átköltöztetés egy másik eszközről_ opciót az új eszközén és olvassa be a QR-kódot. + Válassza az _Átköltöztetés egy másik eszközről_ beállítást az új eszközén és olvassa be a QR-kódot. No comment provided by engineer. @@ -1721,37 +1729,37 @@ set passcode view Clear - Kiürítés + Ürítés swipe action Clear conversation - Üzenetek kiürítése + Üzenetek ürítése No comment provided by engineer. Clear conversation? - Kiüríti az üzeneteket? + Üríti a beszélgetés üzeneteit? No comment provided by engineer. Clear group? - Kiüríti a csoportot? + Üríti a csoport üzeneteit? No comment provided by engineer. Clear or delete group? - Csoport kiürítése vagy törlése? + Csoport ürítése vagy törlése? No comment provided by engineer. Clear private notes? - Kiüríti a privát jegyzeteket? + Üríti a privát jegyzetek tartalmát? No comment provided by engineer. Clear verification - Hitelesítés törlése + Ellenőrzés törlése No comment provided by engineer. @@ -1935,7 +1943,7 @@ Ez a saját egyszer használható meghívója! Connect via one-time link - Kapcsolódás egyszer használható meghívón keresztül + Kapcsolódás az egyszer használható meghívón keresztül new chat sheet title @@ -1960,7 +1968,7 @@ Ez a saját egyszer használható meghívója! Connected to desktop - Kapcsolódva a számítógéphez + Társítva a számítógéppel No comment provided by engineer. @@ -1985,7 +1993,7 @@ Ez a saját egyszer használható meghívója! Connecting to desktop - Kapcsolódás a számítógéphez + Társítás számítógéppel No comment provided by engineer. @@ -2212,7 +2220,7 @@ Ez a saját egyszer használható meghívója! Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 - Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻 + Új profil létrehozása a [számítógépes alkalmazásban](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -2456,7 +2464,7 @@ swipe action Delete all files - Az összes fájl törlése + Összes fájl törlése No comment provided by engineer. @@ -2579,6 +2587,14 @@ swipe action Törli a tag üzenetét? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Törli az üzenetet? @@ -2587,7 +2603,8 @@ swipe action Delete messages Üzenetek törlése - alert button + alert action +alert button Delete messages after @@ -2706,7 +2723,7 @@ swipe action Desktop app version %@ is not compatible with this app. - A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. + A számítógépes alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. No comment provided by engineer. @@ -2881,7 +2898,7 @@ swipe action Do NOT use private routing. - NE használjon privát útválasztást. + NE legyen használva privát útválasztás. No comment provided by engineer. @@ -2911,7 +2928,7 @@ swipe action Don't enable - Ne engedélyezze + Nem engedélyezem No comment provided by engineer. @@ -2921,7 +2938,7 @@ swipe action Don't show again - Ne mutasd újra + Ne jelenjen meg újra alert action @@ -2992,7 +3009,7 @@ chat item action E2E encrypted notifications. - Végpontok közötti titkosított értesítések. + Végpontok között titkosított értesítések. No comment provided by engineer. @@ -3102,7 +3119,7 @@ chat item action Encrypt - Titkosít + Titkosítás No comment provided by engineer. @@ -3632,7 +3649,7 @@ chat item action Error verifying passphrase: - Hiba történt a jelmondat hitelesítésekor: + Hiba történt a jelmondat ellenőrzésekor: No comment provided by engineer. @@ -3851,6 +3868,10 @@ snd error text A fájlok és a médiatartalmak küldése le van tiltva! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Olvasatlan és kedvenc csevegésekre való szűrés. @@ -4272,7 +4293,7 @@ Hiba: %2$@ How to - Hogyan + Útmutató No comment provided by engineer. @@ -4317,7 +4338,7 @@ Hiba: %2$@ If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). + Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). No comment provided by engineer. @@ -4335,6 +4356,10 @@ Hiba: %2$@ A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Azonnal @@ -4500,7 +4525,7 @@ További fejlesztések hamarosan! Instant push notifications will be hidden! - Az azonnali push-értesítések el lesznek rejtve! + Az azonnali leküldéses értesítések el lesznek rejtve! No comment provided by engineer. @@ -4594,6 +4619,10 @@ További fejlesztések hamarosan! Barátok meghívása No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Tagok meghívása @@ -4714,7 +4743,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Keep the app open to use it from desktop - A számítógépről való használathoz tartsd nyitva az alkalmazást + Alkalmazás megnyitva tartása a számítógépről való használathoz No comment provided by engineer. @@ -4804,7 +4833,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Link mobile and desktop apps! 🔗 - Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 + Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗 No comment provided by engineer. @@ -4817,6 +4846,10 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Társított számítógépek No comment provided by engineer. + + Links + No comment provided by engineer. + List Lista @@ -4894,7 +4927,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Mark verified - Hitelesítés + Megjelölés ellenőrzöttként No comment provided by engineer. @@ -4904,7 +4937,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Max 30 seconds, received instantly. - Max. 30 másodperc, azonnal érkezett. + Legfeljebb 30 másodperc, azonnal megérkezik. No comment provided by engineer. @@ -4942,6 +4975,10 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! A tag törölve lett – nem lehet elfogadni a kérést No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Tagok jelentései @@ -4965,12 +5002,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Member will be removed from chat - this cannot be undone! A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5044,7 +5081,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Message draft - Üzenetvázlat + Piszkozatok No comment provided by engineer. @@ -5159,7 +5196,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Messages were deleted after you selected them. - Az üzeneteket törölték miután kijelölte őket. + Az üzeneteket törölték miután kiváasztotta őket. alert message @@ -5219,7 +5256,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). - Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat). + Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. @@ -5374,12 +5411,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! New contact: - Új kapcsolat: + Új partner: notification New desktop app! - Új számítógép-alkalmazás! + Új számítógépes alkalmazás! No comment provided by engineer. @@ -5464,7 +5501,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No contacts selected - Nincs partner kijelölve + Nincs partner kiválasztva No comment provided by engineer. @@ -5549,7 +5586,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No push server - Helyi + Nincs kiszolgáló a leküldéses értesítésekhez No comment provided by engineer. @@ -5604,7 +5641,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Nothing selected - Nincs semmi kijelölve + Nincs semmi kiválasztva No comment provided by engineer. @@ -5739,17 +5776,17 @@ VPN engedélyezése szükséges. Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours) - Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) + Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra) No comment provided by engineer. Only you can make calls. - Csak Ön tud hívásokat indítani. + Csak Ön kezdeményezhet hívásokat. No comment provided by engineer. Only you can send disappearing messages. - Csak Ön tud eltűnő üzeneteket küldeni. + Csak Ön küldhet eltűnő üzeneteket. No comment provided by engineer. @@ -5759,7 +5796,7 @@ VPN engedélyezése szükséges. Only you can send voice messages. - Csak Ön tud hangüzeneteket küldeni. + Csak Ön küldhet hangüzeneteket. No comment provided by engineer. @@ -5769,17 +5806,17 @@ VPN engedélyezése szükséges. Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours) - Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra) No comment provided by engineer. Only your contact can make calls. - Csak a partnere tud hívást indítani. + Csak a partnere kezdeményezhet hívásokat. No comment provided by engineer. Only your contact can send disappearing messages. - Csak a partnere tud eltűnő üzeneteket küldeni. + Csak a partnere küldhet eltűnő üzeneteket. No comment provided by engineer. @@ -5789,7 +5826,7 @@ VPN engedélyezése szükséges. Only your contact can send voice messages. - Csak a partnere tud hangüzeneteket küldeni. + Csak a partnere küldhet hangüzeneteket. No comment provided by engineer. @@ -6100,7 +6137,7 @@ Hiba: %@ Please restart the app and migrate the database to enable push notifications. - Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges push-értesítések engedélyezéséhez. + Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges leküldéses értesítések engedélyezéséhez. No comment provided by engineer. @@ -6285,7 +6322,7 @@ Hiba: %@ Prohibit reporting messages to moderators. - Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az üzenetek jelentése a moderátorok felé le van tiltva. No comment provided by engineer. @@ -6367,12 +6404,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Push notifications - Push-értesítések + Leküldéses értesítések No comment provided by engineer. Push server - Push-kiszolgáló + Leküldéses értesítéskiszolgáló No comment provided by engineer. @@ -6382,7 +6419,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Rate the app - Értékelje az alkalmazást + Alkalmazás értékelése No comment provided by engineer. @@ -6392,7 +6429,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. React… - Reagálj… + Reagálás… chat item menu @@ -6569,7 +6606,7 @@ swipe action Reject (sender NOT notified) - Elutasítás (a kérés küldője NEM fog értesítést kapni) + Elutasítás (a kérés küldője NEM lesz értesítve) No comment provided by engineer. @@ -6595,7 +6632,11 @@ swipe action Remove Eltávolítás - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6620,7 +6661,7 @@ swipe action Remove member? Eltávolítja a tagot? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6739,7 +6780,7 @@ swipe action Reset all statistics - Az összes statisztika visszaállítása + Összes statisztika visszaállítása No comment provided by engineer. @@ -7038,11 +7079,31 @@ chat item action A keresősáv elfogadja a meghívási hivatkozásokat. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Keresés vagy SimpleX-hivatkozás beillesztése No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Másodlagos szín @@ -7060,7 +7121,7 @@ chat item action Security assessment - Biztonsági kiértékelés + Biztonsági felmérés No comment provided by engineer. @@ -7070,22 +7131,22 @@ chat item action Select - Kijelölés + Kiválasztás chat item action Select chat profile - Csevegési profil kijelölése + Csevegési profil kiválasztása No comment provided by engineer. Selected %lld - %lld kijelölve + %lld kiválasztva No comment provided by engineer. Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -7240,7 +7301,7 @@ chat item action Sending receipts is disabled for %lld contacts - A kézbesítési jelentések le vannak tiltva %lld partnernél + A kézbesítési jelentések le vannak tiltva %lld partner számára No comment provided by engineer. @@ -7250,7 +7311,7 @@ chat item action Sending receipts is enabled for %lld contacts - A kézbesítési jelentések engedélyezve vannak %lld partnernél + A kézbesítési jelentések engedélyezve vannak %lld partner számára No comment provided by engineer. @@ -7395,7 +7456,7 @@ chat item action Session code - Munkamenet kód + Munkamenet kódja No comment provided by engineer. @@ -7455,7 +7516,7 @@ chat item action Set profile bio and welcome message. - Névjegy és üdvözlőüzenet beállítása a profilokhoz. + Életrajz és üdvözlőüzenet beállítása a profilokhoz. No comment provided by engineer. @@ -7681,7 +7742,7 @@ chat item action SimpleX address settings - Beállítások automatikus elfogadása + SimpleX-címbeállítások alert title @@ -7756,7 +7817,7 @@ chat item action Small groups (max 20) - Kis csoportok (max. 20 tag) + Kis csoportok (legfeljebb 20 tag) No comment provided by engineer. @@ -7809,7 +7870,7 @@ report reason Start chat - Csevegés indítása + Csevegés elindítása No comment provided by engineer. @@ -8206,12 +8267,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The second tick we missed! ✅ - A második jelölés, amit kihagytunk! ✅ + A második pipa, ami már nagyon hiányzott! ✅ No comment provided by engineer. The sender will NOT be notified - A kérés küldője NEM fog értesítést kapni + A kérés küldője NEM lesz értesítve alert message @@ -8261,12 +8322,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. No comment provided by engineer. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. alert message @@ -8423,7 +8484,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To support instant push notifications the chat database has to be migrated. - Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. + Az azonnali leküldéses értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. No comment provided by engineer. @@ -8438,7 +8499,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To verify end-to-end encryption with your contact compare (or scan) the code on your devices. - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. No comment provided by engineer. @@ -8640,7 +8701,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Update database passphrase - Az adatbázis jelmondatának módosítása + Adatbázis jelmondatának módosítása No comment provided by engineer. @@ -8845,7 +8906,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso User selection - Felhasználó kijelölése + Felhasználó kiválasztása No comment provided by engineer. @@ -8860,37 +8921,37 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Verify code with desktop - Kód hitelesítése a számítógépen + Kód ellenőrzése a számítógépen No comment provided by engineer. Verify connection - Kapcsolat hitelesítése + Kapcsolat ellenőrzése No comment provided by engineer. Verify connection security - Biztonságos kapcsolat hitelesítése + Biztonságos kapcsolat ellenőrzése No comment provided by engineer. Verify connections - Kapcsolatok hitelesítése + Kapcsolatok ellenőrzése No comment provided by engineer. Verify database passphrase - Az adatbázis jelmondatának hitelesítése + Adatbázis jelmondatának ellenőrzése No comment provided by engineer. Verify passphrase - Jelmondat hitelesítése + Jelmondat ellenőrzése No comment provided by engineer. Verify security code - Biztonsági kód hitelesítése + Biztonsági kód ellenőrzése No comment provided by engineer. @@ -8918,6 +8979,10 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videók és fájlok legfeljebb 1GB méretig @@ -9202,7 +9267,7 @@ Megismétli a csatlakozási kérést? You are not connected to the server used to receive messages from this connection (no subscription). - Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás). subscription status explanation @@ -9287,7 +9352,7 @@ Megismétli a csatlakozási kérést? You can start chat via app Settings / Database or by restarting the app - A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el + A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával No comment provided by engineer. @@ -9322,7 +9387,7 @@ Megismétli a csatlakozási kérést? You could not be verified; please try again. - Nem sikerült hitelesíteni; próbálja meg újra. + Nem sikerült ellenőrizni; próbálja meg újra. No comment provided by engineer. @@ -9434,12 +9499,12 @@ Megismétli a kapcsolódási kérést? You will stop receiving messages from this chat. Chat history will be preserved. - Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. + Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. No comment provided by engineer. You will stop receiving messages from this group. Chat history will be preserved. - Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. + Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. No comment provided by engineer. @@ -9514,7 +9579,7 @@ Megismétli a kapcsolódási kérést? Your contact sent a file that is larger than currently supported maximum size (%@). - A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. + A partnere a jelenleg támogatott legnagyobb (%@) fájlméretnél nagyobbat küldött. No comment provided by engineer. @@ -9524,7 +9589,7 @@ Megismétli a kapcsolódási kérést? Your contacts will remain connected. - A partnerei továbbra is kapcsolódva maradnak. + A partnereivel továbbra is kapcsolatban marad. No comment provided by engineer. @@ -9704,7 +9769,7 @@ Megismétli a kapcsolódási kérést? audio call (not e2e encrypted) - hanghívás (nem e2e titkosított) + hanghívás (végpontok között NEM titkosított) No comment provided by engineer. @@ -9845,7 +9910,7 @@ marked deleted chat item preview text connecting call… - kapcsolódási hívás… + hívás kapcsolása… call status @@ -9880,12 +9945,12 @@ marked deleted chat item preview text contact has e2e encryption - a partner e2e titkosítással rendelkezik + a partner végpontok közötti titkosítással rendelkezik No comment provided by engineer. contact has no e2e encryption - a partner nem rendelkezik e2e titkosítással + a partner nem rendelkezik végpontok közötti titkosítással No comment provided by engineer. @@ -9981,7 +10046,7 @@ pref value e2e encrypted - e2e titkosított + végpontok között titkosított No comment provided by engineer. @@ -10041,12 +10106,12 @@ pref value ended - befejeződött + hívás vége No comment provided by engineer. ended call %@ - %@ hívása befejeződött + %@ hívása véget ért call status @@ -10091,12 +10156,12 @@ pref value iOS Keychain is used to securely store passphrase - it allows receiving push notifications. - Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a leküldéses értesítések fogadását. No comment provided by engineer. iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a leküldéses értesítések fogadását. No comment provided by engineer. @@ -10261,12 +10326,12 @@ pref value no e2e encryption - nincs e2e titkosítás + nincs végpontok közötti titkosítás No comment provided by engineer. no subscription - nincs előfizetés + nincs feliratkozás No comment provided by engineer. @@ -10354,12 +10419,12 @@ time to disappear received answer… - válasz fogadása… + válasz érkezett… No comment provided by engineer. received confirmation… - visszaigazolás fogadása… + visszaigazolás érkezett… No comment provided by engineer. @@ -10498,7 +10563,7 @@ utoljára fogadott üzenet: %2$@ starting… - indítás… + hívás indítása… No comment provided by engineer. @@ -10583,7 +10648,7 @@ utoljára fogadott üzenet: %2$@ video call (not e2e encrypted) - videóhívás (nem e2e titkosított) + videóhívás (végpontok között NEM titkosított) No comment provided by engineer. @@ -10838,12 +10903,12 @@ utoljára fogadott üzenet: %2$@ Comment - Hozzászólás + Megjegyzés No comment provided by engineer. Currently maximum supported file size is %@. - Jelenleg támogatott legnagyobb fájl méret: %@. + Jelenleg támogatott legnagyobb fájlméret: %@. No comment provided by engineer. @@ -10948,7 +11013,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 5f057cd8bb..e2c826f334 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -792,6 +792,10 @@ swipe action Tutti i membri del gruppo resteranno connessi. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Tutti i messaggi e i file vengono inviati **crittografati end-to-end**, con sicurezza resistenti alla quantistica nei messaggi diretti. @@ -1142,6 +1146,10 @@ swipe action Chiamate audio e video No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Chiamate audio/video @@ -2579,6 +2587,14 @@ swipe action Eliminare il messaggio del membro? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Eliminare il messaggio? @@ -2587,7 +2603,8 @@ swipe action Delete messages Elimina messaggi - alert button + alert action +alert button Delete messages after @@ -3851,6 +3868,10 @@ snd error text File e contenuti multimediali vietati! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtra le chat non lette e preferite. @@ -4335,6 +4356,10 @@ Errore: %2$@ L'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Immediatamente @@ -4594,6 +4619,10 @@ Altri miglioramenti sono in arrivo! Invita amici No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Invita membri @@ -4817,6 +4846,10 @@ Questo è il tuo link per il gruppo %@! Desktop collegati No comment provided by engineer. + + Links + No comment provided by engineer. + List Elenco @@ -4942,6 +4975,10 @@ Questo è il tuo link per il gruppo %@! Il membro è eliminato - impossibile accettare la richiesta No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Segnalazioni dei membri @@ -4965,12 +5002,12 @@ Questo è il tuo link per il gruppo %@! Member will be removed from chat - this cannot be undone! Il membro verrà rimosso dalla chat, non è reversibile! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Il membro verrà rimosso dal gruppo, non è reversibile! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5854,7 +5891,7 @@ Richiede l'attivazione della VPN. Open new group - Apri un gruppo nuovo + Apri il nuovo gruppo new chat action @@ -6595,7 +6632,11 @@ swipe action Remove Rimuovi - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6620,7 +6661,7 @@ swipe action Remove member? Rimuovere il membro? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7079,31 @@ chat item action La barra di ricerca accetta i link di invito. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Cerca o incolla un link SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secondario @@ -8918,6 +8979,10 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Video e file fino a 1 GB diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 9a42ab3f7e..efd47aa52d 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -625,6 +625,7 @@ swipe action Active connections + アクティブな接続 No comment provided by engineer. @@ -634,10 +635,12 @@ swipe action Add friends + 友達を追加 No comment provided by engineer. Add list + リストを追加 No comment provided by engineer. @@ -661,6 +664,7 @@ swipe action Add team members + チームメンバーを追加 No comment provided by engineer. @@ -670,6 +674,7 @@ swipe action Add to list + リストに追加 No comment provided by engineer. @@ -719,6 +724,7 @@ swipe action Address settings + アドレス設定 No comment provided by engineer. @@ -742,6 +748,7 @@ swipe action All + すべて No comment provided by engineer. @@ -772,12 +779,17 @@ swipe action グループ全員の接続が継続します。 No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. All messages will be deleted - this cannot be undone! + すべてのメッセージが削除されます。この操作は元に戻せません! No comment provided by engineer. @@ -829,6 +841,7 @@ swipe action Allow calls? + 通話を許可しますか? No comment provided by engineer. @@ -838,6 +851,7 @@ swipe action Allow downgrade + ダウングレードを許可する No comment provided by engineer. @@ -969,6 +983,7 @@ swipe action Another reason + 他の理由 report reason @@ -1046,6 +1061,7 @@ swipe action Archive + アーカイブ No comment provided by engineer. @@ -1079,6 +1095,7 @@ swipe action Archived contacts + アーカイブされた連絡先 No comment provided by engineer. @@ -1100,6 +1117,10 @@ swipe action 音声通話とビデオ通話 No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls 音声/ビデオ通話 @@ -1350,6 +1371,7 @@ swipe action Can't change profile + プロフィールを変更できません alert title @@ -1375,6 +1397,7 @@ new chat action Cancel migration + 移行を中止する No comment provided by engineer. @@ -1384,6 +1407,7 @@ new chat action Cannot forward message + メッセージを転送できません No comment provided by engineer. @@ -1460,6 +1484,7 @@ set passcode view Chat + チャット No comment provided by engineer. @@ -1514,6 +1539,7 @@ set passcode view Chat list + チャット一覧 No comment provided by engineer. @@ -1570,6 +1596,7 @@ set passcode view Check messages every 20 min. + 20分おきにメッセージを確認する。 No comment provided by engineer. @@ -2407,6 +2434,14 @@ swipe action メンバーのメッセージを削除しますか? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? メッセージを削除しますか? @@ -2415,7 +2450,8 @@ swipe action Delete messages メッセージを削除 - alert button + alert action +alert button Delete messages after @@ -3565,6 +3601,10 @@ snd error text ファイルとメディアは禁止されています! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. 未読とお気に入りをフィルターします。 @@ -4006,6 +4046,10 @@ Error: %2$@ 連絡先がオンラインになったら受信されます。しばらくお待ちください! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately 即座に @@ -4241,6 +4285,10 @@ More improvements are coming soon! 友人を招待する No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members メンバーを招待する @@ -4448,6 +4496,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4563,6 +4615,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4583,12 +4639,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! メンバーをグループから除名する (※元に戻せません※)! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6045,7 +6101,11 @@ swipe action Remove 削除 - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6067,7 +6127,7 @@ swipe action Remove member? メンバーを除名しますか? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6442,10 +6502,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8117,6 +8197,10 @@ To connect, please ask your contact to create another connection link and check 動画は相手がオンラインになったら受信されます。しばらくお待ちください! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 1GBまでのビデオとファイル diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 8e0cdee3ca..955607acfd 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -790,6 +790,10 @@ swipe action Alle groepsleden blijven verbonden. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Alle berichten en bestanden worden **end-to-end versleuteld** verzonden, met post-quantumbeveiliging in directe berichten. @@ -1138,6 +1142,10 @@ swipe action Audio en video oproepen No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio/video oproepen @@ -2565,6 +2573,14 @@ swipe action Bericht van lid verwijderen? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Verwijder bericht? @@ -2573,7 +2589,8 @@ swipe action Delete messages Verwijder berichten - alert button + alert action +alert button Delete messages after @@ -3825,6 +3842,10 @@ snd error text Bestanden en media niet toegestaan! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filter ongelezen en favoriete chats. @@ -4305,6 +4326,10 @@ Fout: %2$@ De afbeelding wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Onmiddellijk @@ -4564,6 +4589,10 @@ Binnenkort meer verbeteringen! Nodig vrienden uit No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Nodig leden uit @@ -4785,6 +4814,10 @@ Dit is jouw link voor groep %@! Gelinkte desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List Lijst @@ -4907,6 +4940,10 @@ Dit is jouw link voor groep %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Ledenrapporten @@ -4930,12 +4967,12 @@ Dit is jouw link voor groep %@! Member will be removed from chat - this cannot be undone! Lid wordt verwijderd uit de chat - dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6544,7 +6581,11 @@ swipe action Remove Verwijderen - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6568,7 +6609,7 @@ swipe action Remove member? Lid verwijderen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6982,11 +7023,31 @@ chat item action Zoekbalk accepteert uitnodigingslinks. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Zoeken of plak een SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secundair @@ -8832,6 +8893,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak De video wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Video's en bestanden tot 1 GB diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index fb46bcd1d9..f74e1e31b5 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -568,10 +568,12 @@ swipe action Accept as member + Zaakceptuj jako członka alert action Accept as observer + Zaakceptuj jako obserwatora alert action @@ -586,6 +588,7 @@ swipe action Accept contact request + Zaakceptuj prośby o kontakt alert title @@ -601,6 +604,7 @@ swipe action Accept member + Zaakceptuj członka alert title @@ -645,6 +649,7 @@ swipe action Add message + Dodaj wiadomość placeholder for sending contact request @@ -787,6 +792,10 @@ swipe action Wszyscy członkowie grupy pozostaną połączeni. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich. @@ -819,6 +828,7 @@ swipe action All servers + Wszystkie serwery No comment provided by engineer. @@ -863,6 +873,7 @@ swipe action Allow files and media only if your contact allows them. + Zezwalaj na pliki i media tylko wtedy, gdy Twój kontakt na to pozwala. No comment provided by engineer. @@ -952,6 +963,7 @@ swipe action Allow your contacts to send files and media. + Pozwól kontaktom wysyłać pliki i media. No comment provided by engineer. @@ -1134,6 +1146,10 @@ swipe action Połączenia audio i wideo No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Połączenia audio/wideo @@ -1216,6 +1232,7 @@ swipe action Better groups performance + Lepsze działanie grup No comment provided by engineer. @@ -1240,6 +1257,7 @@ swipe action Better privacy and security + Lepsza prywatność i bezpieczeństwo No comment provided by engineer. @@ -1312,6 +1330,7 @@ swipe action Bot + Bot No comment provided by engineer. @@ -1336,6 +1355,7 @@ swipe action Both you and your contact can send files and media. + Zarówno Ty, jak i Twój kontakt możecie wysyłać pliki i media. No comment provided by engineer. @@ -2528,6 +2548,14 @@ swipe action Usunąć wiadomość członka? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Usunąć wiadomość? @@ -2536,7 +2564,8 @@ swipe action Delete messages Usuń wiadomości - alert button + alert action +alert button Delete messages after @@ -3755,6 +3784,10 @@ snd error text Pliki i media zabronione! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtruj nieprzeczytane i ulubione czaty. @@ -4222,6 +4255,10 @@ Błąd: %2$@ Obraz zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Natychmiast @@ -4472,6 +4509,10 @@ More improvements are coming soon! Zaproś znajomych No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Zaproś członków @@ -4690,6 +4731,10 @@ To jest twój link do grupy %@! Połączone komputery No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4808,6 +4853,10 @@ To jest twój link do grupy %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4828,12 +4877,12 @@ To jest twój link do grupy %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Członek zostanie usunięty z grupy - nie można tego cofnąć! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6387,7 +6436,11 @@ swipe action Remove Usuń - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6411,7 +6464,7 @@ swipe action Remove member? Usunąć członka? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6809,11 +6862,31 @@ chat item action Pasek wyszukiwania akceptuje linki zaproszenia. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Wyszukaj lub wklej link SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Drugorzędny @@ -8609,6 +8682,10 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Film zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Filmy i pliki do 1gb diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d885db1350..64905cf68c 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -167,7 +167,7 @@ %d hours - %d час. + %d ч. time interval @@ -792,6 +792,10 @@ swipe action Все члены группы останутся соединены. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Все сообщения и файлы отправляются с **end-to-end шифрованием**, с постквантовой безопасностью в прямых разговорах. @@ -1134,7 +1138,7 @@ swipe action Audio & video calls - Аудио- и видеозвонки + Аудио и видеозвонки No comment provided by engineer. @@ -1142,6 +1146,10 @@ swipe action Аудио и видео звонки No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудио/видео звонки @@ -2579,6 +2587,14 @@ swipe action Удалить сообщение участника? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Удалить сообщение? @@ -2587,7 +2603,8 @@ swipe action Delete messages Удалить сообщения - alert button + alert action +alert button Delete messages after @@ -3312,6 +3329,7 @@ chat item action Error connecting to the server used to receive messages from this connection: %@ + Ошибка подключения к серверу, используемому для получения сообщений от этого соединения: %@ subscription status explanation @@ -3850,6 +3868,10 @@ snd error text Файлы и медиа запрещены! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Фильтровать непрочитанные и избранные чаты. @@ -4334,6 +4356,10 @@ Error: %2$@ Изображение будет принято, когда Ваш контакт будет в сети, подождите или проверьте позже! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Сразу @@ -4592,6 +4618,10 @@ More improvements are coming soon! Пригласить друзей No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Пригласить членов группы @@ -4815,6 +4845,10 @@ This is your link for group %@! Связанные компьютеры No comment provided by engineer. + + Links + No comment provided by engineer. + List Список @@ -4940,6 +4974,10 @@ This is your link for group %@! Член группы удалён - невозможно принять запрос No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Сообщения о нарушениях @@ -4963,12 +5001,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Член будет удален из разговора - это действие нельзя отменить! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Член группы будет удален - это действие нельзя отменить! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6593,7 +6631,11 @@ swipe action Remove Удалить - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6618,7 +6660,7 @@ swipe action Remove member? Удалить члена группы? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7036,11 +7078,31 @@ chat item action Поле поиска поддерживает ссылки-приглашения. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Искать или вставьте ссылку SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Вторичный @@ -8411,7 +8473,7 @@ You will be prompted to complete authentication before this feature is enabled.< To send - Для оправки + Для отправки No comment provided by engineer. @@ -8476,6 +8538,7 @@ You will be prompted to complete authentication before this feature is enabled.< Trying to connect to the server used to receive messages from this connection. + Попытка подключиться к серверу, используемому для получения сообщений от этого соединения. subscription status explanation @@ -8915,6 +8978,10 @@ To connect, please ask your contact to create another connection link and check Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Видео и файлы до 1гб @@ -9189,6 +9256,7 @@ Repeat join request? You are connected to the server used to receive messages from this connection. + Вы подключены к серверу, используемому для приема сообщений от этого соединения. subscription status explanation @@ -9198,6 +9266,7 @@ Repeat join request? You are not connected to the server used to receive messages from this connection (no subscription). + Вы не подключены к серверу, используемому для получения сообщений по этому соединению (нет подписки). subscription status explanation @@ -10261,6 +10330,7 @@ pref value no subscription + нет подписки No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index ecb4d20fbb..4ff953c62e 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -718,6 +718,10 @@ swipe action สมาชิกในกลุ่มทุกคนจะยังคงเชื่อมต่ออยู่. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -1034,6 +1038,10 @@ swipe action การโทรด้วยเสียงและวิดีโอ No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls การโทรด้วยเสียง/วิดีโอ @@ -2317,6 +2325,14 @@ swipe action ลบข้อความสมาชิก? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? ลบข้อความ? @@ -2325,7 +2341,8 @@ swipe action Delete messages ลบข้อความ - alert button + alert action +alert button Delete messages after @@ -3468,6 +3485,10 @@ snd error text ไฟล์และสื่อต้องห้าม! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. กรองแชทที่ยังไม่อ่านและแชทโปรด @@ -3909,6 +3930,10 @@ Error: %2$@ จะได้รับรูปภาพเมื่อผู้ติดต่อของคุณออนไลน์ โปรดรอหรือตรวจสอบในภายหลัง! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately โดยทันที @@ -4142,6 +4167,10 @@ More improvements are coming soon! เชิญเพื่อนๆ No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members เชิญสมาชิก @@ -4349,6 +4378,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4464,6 +4497,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4484,12 +4521,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5936,7 +5973,11 @@ swipe action Remove ลบ - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -5958,7 +5999,7 @@ swipe action Remove member? ลบสมาชิกออก? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6333,10 +6374,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8008,6 +8069,10 @@ To connect, please ask your contact to create another connection link and check จะได้รับวิดีโอเมื่อผู้ติดต่อของคุณออนไลน์ โปรดรอหรือตรวจสอบในภายหลัง! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb วิดีโอและไฟล์สูงสุด 1gb diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 57151a95b5..346d9a2bdc 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -792,6 +792,10 @@ swipe action Tüm grup üyeleri bağlı kalacaktır. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte. @@ -1142,6 +1146,10 @@ swipe action Sesli ve görüntülü aramalar No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Sesli/görüntülü aramalar @@ -2579,6 +2587,14 @@ swipe action Kişinin mesajı silinsin mi? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Mesaj silinsin mi? @@ -2587,7 +2603,8 @@ swipe action Delete messages Mesajları sil - alert button + alert action +alert button Delete messages after @@ -3849,6 +3866,10 @@ snd error text Dosyalar ve medya yasaklandı! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Favori ve okunmamış sohbetleri filtrele. @@ -4330,6 +4351,10 @@ Hata: %2$@ Kişi çevrimiçi olduğunda fotoğraf alınacaktır, lütfen bekleyin veya daha sonra kontrol et! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Hemen @@ -4589,6 +4614,10 @@ Daha fazla iyileştirme yakında geliyor! Arkadaşları davet et No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Üyeleri davet et @@ -4812,6 +4841,10 @@ Bu senin grup için bağlantın %@! Bağlanmış bilgisayarlar No comment provided by engineer. + + Links + No comment provided by engineer. + List Liste @@ -4937,6 +4970,10 @@ Bu senin grup için bağlantın %@! Üye silinmiş - istek kabul edilemez No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Üye raporları @@ -4960,12 +4997,12 @@ Bu senin grup için bağlantın %@! Member will be removed from chat - this cannot be undone! Üye sohbetten kaldırılacak - bu geri alınamaz! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Üye gruptan çıkarılacaktır - bu geri alınamaz! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6590,7 +6627,11 @@ swipe action Remove Sil - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6615,7 +6656,7 @@ swipe action Remove member? Kişi silinsin mi? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7033,11 +7074,31 @@ chat item action Arama çubuğu davet bağlantılarını kabul eder. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Ara veya SimpleX bağlantısını yapıştır No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary İkincil renk @@ -8912,6 +8973,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Kişiniz çevrimiçi olduğunda video alınacaktır, lütfen bekleyin veya daha sonra kontrol edin! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 1gb'a kadar videolar ve dosyalar diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 7980685349..6c103e17e1 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -792,6 +792,10 @@ swipe action Всі учасники групи залишаться на зв'язку. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Всі повідомлення та файли надсилаються **наскрізним шифруванням**, з пост-квантовим захистом у прямих повідомленнях. @@ -1140,6 +1144,10 @@ swipe action Аудіо та відеодзвінки No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудіо/відео дзвінки @@ -2574,6 +2582,14 @@ swipe action Видалити повідомлення учасника? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Видалити повідомлення? @@ -2582,7 +2598,8 @@ swipe action Delete messages Видалити повідомлення - alert button + alert action +alert button Delete messages after @@ -3841,6 +3858,10 @@ snd error text Файли та медіа заборонені! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Фільтруйте непрочитані та улюблені чати. @@ -4322,6 +4343,10 @@ Error: %2$@ Зображення буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Негайно @@ -4581,6 +4606,10 @@ More improvements are coming soon! Запросити друзів No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Запросити учасників @@ -4804,6 +4833,10 @@ This is your link for group %@! Пов'язані робочі столи No comment provided by engineer. + + Links + No comment provided by engineer. + List Список @@ -4927,6 +4960,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Повідомлення учасників @@ -4950,12 +4987,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Учасника буде видалено з чату – це неможливо скасувати! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Учасник буде видалений з групи - це неможливо скасувати! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6575,7 +6612,11 @@ swipe action Remove Видалити - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6599,7 +6640,7 @@ swipe action Remove member? Видалити учасника? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7017,11 +7058,31 @@ chat item action Рядок пошуку приймає посилання-запрошення. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Знайдіть або вставте посилання SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Вторинний @@ -8892,6 +8953,10 @@ To connect, please ask your contact to create another connection link and check Відео буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Відео та файли до 1 Гб diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index e1ce65b5ce..ff7b4fa141 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -568,10 +568,12 @@ swipe action Accept as member + 接受为成员 alert action Accept as observer + 接受为观察员 alert action @@ -586,6 +588,7 @@ swipe action Accept contact request + 接受联络请求 alert title @@ -601,6 +604,7 @@ swipe action Accept member + 接受成员 alert title @@ -645,6 +649,7 @@ swipe action Add message + 添加信息 placeholder for sending contact request @@ -787,6 +792,10 @@ swipe action 所有群组成员将保持连接。 No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. 所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。 @@ -864,6 +873,7 @@ swipe action Allow files and media only if your contact allows them. + 只有你的联系人允许的情况下才允许文件和媒体。 No comment provided by engineer. @@ -953,6 +963,7 @@ swipe action Allow your contacts to send files and media. + 允许你的联系人发送文件和媒体。 No comment provided by engineer. @@ -1135,6 +1146,10 @@ swipe action 语音和视频通话 No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls 音频/视频通话 @@ -1257,10 +1272,12 @@ swipe action Bio + 自我介绍 No comment provided by engineer. Bio too large + 自我介绍过大 alert title @@ -1315,6 +1332,7 @@ swipe action Bot + 机器人 No comment provided by engineer. @@ -1339,6 +1357,7 @@ swipe action Both you and your contact can send files and media. + 你和你的联系人都可发送文件和媒体。 No comment provided by engineer. @@ -1363,6 +1382,7 @@ swipe action Business connection + 企业连接 No comment provided by engineer. @@ -1416,6 +1436,7 @@ swipe action Can't change profile + 无法更改个人资料 alert title @@ -1633,14 +1654,17 @@ set passcode view Chat with admins + 和管理员聊天 chat toolbar Chat with member + 和成员聊天 No comment provided by engineer. Chat with members before they join. + 在成员加入前和这些人聊天 No comment provided by engineer. @@ -1650,6 +1674,7 @@ set passcode view Chats with members + 和成员聊天 No comment provided by engineer. @@ -1879,6 +1904,7 @@ set passcode view Connect faster! 🚀 + 更快地连接!🚀 No comment provided by engineer. @@ -2088,6 +2114,7 @@ This is your own one-time link! Contact requests from groups + 来自群的联络请求 No comment provided by engineer. @@ -2207,6 +2234,7 @@ This is your own one-time link! Create your address + 创建地址 No comment provided by engineer. @@ -2465,6 +2493,7 @@ swipe action Delete chat with member? + 删除和成员的聊天吗? alert title @@ -2557,6 +2586,14 @@ swipe action 删除成员消息? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? 删除消息吗? @@ -2565,7 +2602,8 @@ swipe action Delete messages 删除消息 - alert button + alert action +alert button Delete messages after @@ -2664,6 +2702,7 @@ swipe action Deprecated options + 已废弃的选项 No comment provided by engineer. @@ -2673,6 +2712,7 @@ swipe action Description too large + 描述过大 alert title @@ -2983,6 +3023,7 @@ chat item action Empty message! + 空消息! No comment provided by engineer. @@ -3022,6 +3063,7 @@ chat item action Enable disappearing messages by default. + 默认启用定时消失消息。 No comment provided by engineer. @@ -3226,6 +3268,7 @@ chat item action Error accepting member + 接受成员出错 alert title @@ -3240,6 +3283,7 @@ chat item action Error adding short link + 添加短链接出错 No comment provided by engineer. @@ -3249,6 +3293,7 @@ chat item action Error changing chat profile + 更改聊天资料出错 alert title @@ -3273,6 +3318,7 @@ chat item action Error checking token status + 查询token状态出错 No comment provided by engineer. @@ -3331,6 +3377,7 @@ chat item action Error deleting chat + 删除聊天出错 alert title @@ -3425,6 +3472,7 @@ chat item action Error opening group + 打开群时出错 No comment provided by engineer. @@ -3449,6 +3497,7 @@ chat item action Error rejecting contact request + 拒绝联络请求出错 alert title @@ -3528,6 +3577,7 @@ chat item action Error setting auto-accept + 设置自动接受出错 No comment provided by engineer. @@ -3614,6 +3664,7 @@ snd error text Error: %@. + 错误:%@。 server test error @@ -3797,6 +3848,7 @@ snd error text Files and media are prohibited in this chat. + 此聊天禁止文件和媒体。 No comment provided by engineer. @@ -3814,6 +3866,10 @@ snd error text 禁止文件和媒体! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. 过滤未读和收藏的聊天记录。 @@ -3841,10 +3897,12 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + 目的地服务器的指纹与证书不符:%@。 No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + 转发服务器的指纹与证书不符:%@。 No comment provided by engineer. @@ -3854,6 +3912,7 @@ snd error text Fingerprint in server address does not match certificate: %@. + 服务器的指纹与证书不符:%@。 No comment provided by engineer. @@ -4132,6 +4191,7 @@ Error: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. + 群资料已修改。如果你进行保存,修改后的群资料将发送给其他群成员。 alert message @@ -4294,6 +4354,10 @@ Error: %2$@ 图片将在您的联系人在线时收到,请稍等或稍后查看! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately 立即 @@ -4553,6 +4617,10 @@ More improvements are coming soon! 邀请朋友 No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members 邀请成员 @@ -4683,6 +4751,7 @@ This is your link for group %@! Keep your chats clean + 保持聊天洁净 No comment provided by engineer. @@ -4742,6 +4811,7 @@ This is your link for group %@! Less traffic on mobile networks. + 消耗更少的移动网络数据。 No comment provided by engineer. @@ -4774,6 +4844,10 @@ This is your link for group %@! 已链接桌面 No comment provided by engineer. + + Links + No comment provided by engineer. + List 列表 @@ -4801,6 +4875,7 @@ This is your link for group %@! Loading profile… + 正加载个人资料… in progress text @@ -4880,10 +4955,12 @@ This is your link for group %@! Member %@ + 成员 %@ past/unknown group member Member admission + 成员准入 No comment provided by engineer. @@ -4893,8 +4970,13 @@ This is your link for group %@! Member is deleted - can't accept request + 成员被删除——无法接受请求 No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports 成员举报 @@ -4918,15 +5000,16 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! 将从聊天中删除成员 - 此操作无法撤销! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! 成员将被移出群组——此操作无法撤消! - No comment provided by engineer. + alert message Member will join the group, accept member? + 成员将加入本群,接受成员吗? alert message @@ -5006,6 +5089,7 @@ This is your link for group %@! Message instantly once you tap Connect. + 轻按连接后即刻发消息。 No comment provided by engineer. @@ -5085,6 +5169,7 @@ This is your link for group %@! Messages are protected by **end-to-end encryption**. + 消息已通过**端到端加密**保护。 No comment provided by engineer. @@ -5344,6 +5429,7 @@ This is your link for group %@! New group role: Moderator + 新的群角色:协管 No comment provided by engineer. @@ -5363,6 +5449,7 @@ This is your link for group %@! New member wants to join the group. + 新成员要加入本群。 rcv group event chat item @@ -5407,6 +5494,7 @@ This is your link for group %@! No chats with members + 没有和成员的聊天 No comment provided by engineer. @@ -5491,6 +5579,7 @@ This is your link for group %@! No private routing session + 无私密路由会话 alert title @@ -5700,6 +5789,7 @@ Requires compatible VPN. Only you can send files and media. + 只有你可以发送文件和媒体。 No comment provided by engineer. @@ -5729,6 +5819,7 @@ Requires compatible VPN. Only your contact can send files and media. + 只有你的联系人可以发送文件和媒体。 No comment provided by engineer. @@ -5763,6 +5854,7 @@ Requires compatible VPN. Open clean link + 打开干净链接 alert action @@ -5772,6 +5864,7 @@ Requires compatible VPN. Open full link + 打开完整链接 alert action @@ -5781,6 +5874,7 @@ Requires compatible VPN. Open link? + 打开链接? alert title @@ -5790,26 +5884,32 @@ Requires compatible VPN. Open new chat + 打开新聊天 new chat action Open new group + 打开新群 new chat action Open to accept + 打开以接受 No comment provided by engineer. Open to connect + 打开以连接 No comment provided by engineer. Open to join + 打开以加入 No comment provided by engineer. Open to use bot + 打开来使用机器人 No comment provided by engineer. @@ -5870,6 +5970,8 @@ Requires compatible VPN. Other file errors: %@ + 其他文件错误: +%@ alert message @@ -6048,18 +6150,22 @@ Error: %@ Please try to disable and re-enable notfications. + 请尝试禁用并重新启用通知。 token info Please wait for group moderators to review your request to join the group. + 请等待群的协管审核你加入该群的请求。 snd group event chat item Please wait for token activation to complete. + 请等待token激活完成。 token info Please wait for token to be registered. + 请等待token注册完成。 token info @@ -6069,6 +6175,7 @@ Error: %@ Port + 端口 No comment provided by engineer. @@ -6083,6 +6190,7 @@ Error: %@ Preset servers + 预设服务器 No comment provided by engineer. @@ -6102,6 +6210,7 @@ Error: %@ Privacy for your customers. + 客户隐私。 No comment provided by engineer. @@ -6126,6 +6235,7 @@ Error: %@ Private media file names. + 私密媒体文件名。 No comment provided by engineer. @@ -6155,6 +6265,7 @@ Error: %@ Private routing timeout + 私密路由超时 alert title @@ -6209,6 +6320,7 @@ Error: %@ Prohibit reporting messages to moderators. + 禁止向 协管 举报消息。 No comment provided by engineer. @@ -6260,6 +6372,7 @@ Enable in *Network & servers* settings. Protocol background timeout + 协议后台超时 No comment provided by engineer. @@ -6284,6 +6397,7 @@ Enable in *Network & servers* settings. Proxy requires password + 代理需要密码 No comment provided by engineer. @@ -6468,6 +6582,7 @@ Enable in *Network & servers* settings. Register + 注册 No comment provided by engineer. @@ -6476,6 +6591,7 @@ Enable in *Network & servers* settings. Registered + 已注册 token status text @@ -6497,6 +6613,7 @@ swipe action Reject member? + 拒绝成员? alert title @@ -6512,10 +6629,15 @@ swipe action Remove 移除 - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? + 删除存档? No comment provided by engineer. @@ -6525,6 +6647,7 @@ swipe action Remove link tracking + 删除链接跟踪 No comment provided by engineer. @@ -6535,7 +6658,7 @@ swipe action Remove member? 删除成员吗? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6544,6 +6667,7 @@ swipe action Removes messages and blocks members. + 删除消息并封禁成员。 No comment provided by engineer. @@ -6583,46 +6707,57 @@ swipe action Report + 举报 chat item action Report content: only group moderators will see it. + 举报内容:仅协管会看到。 report reason Report member profile: only group moderators will see it. + 举报成员个人资料:仅协管会看到。 report reason Report other: only group moderators will see it. + 举报其他:仅协管会看到。 report reason Report reason? + 举报理由? No comment provided by engineer. Report sent to moderators + 举报已发送至 协管 alert title Report spam: only group moderators will see it. + 举报垃圾信息:仅协管会看到。 report reason Report violation: only group moderators will see it. + 举报违规:仅协管会看到。 report reason Report: %@ + 举报: %@ report in notification Reporting messages to moderators is prohibited. + 向协管举报消息已被禁止。 No comment provided by engineer. Reports + 举报 No comment provided by engineer. @@ -6717,10 +6852,12 @@ swipe action Review group members + 审核群成员 No comment provided by engineer. Review members + 审核成员 admission stage @@ -6759,6 +6896,7 @@ swipe action SOCKS proxy + SOCKS代理 No comment provided by engineer. @@ -6784,10 +6922,12 @@ chat item action Save (and notify members) + 保存(并通知成员) alert button Save admission settings? + 保存入群设置? alert title @@ -6817,6 +6957,7 @@ chat item action Save group profile? + 保存群资料? alert title @@ -6934,11 +7075,31 @@ chat item action 搜索栏接受邀请链接。 No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link 搜索或粘贴 SimpleX 链接 No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary 二级 @@ -6971,6 +7132,7 @@ chat item action Select chat profile + 选择聊天个人资料 No comment provided by engineer. @@ -7015,6 +7177,7 @@ chat item action Send contact request? + 发送联络请求? No comment provided by engineer. @@ -7069,6 +7232,7 @@ chat item action Send private reports + 发送私下举报 No comment provided by engineer. @@ -7083,10 +7247,12 @@ chat item action Send request + 发送请求 No comment provided by engineer. Send request without message + 发送无消息请求 No comment provided by engineer. @@ -7101,6 +7267,7 @@ chat item action Send your private feedback to groups. + 向群发送私密反馈。 No comment provided by engineer. @@ -7200,10 +7367,12 @@ chat item action Server + 服务器 No comment provided by engineer. Server added to operator %@. + 服务器已添加到运营方 %@。 alert message @@ -7223,14 +7392,17 @@ chat item action Server operator changed. + 服务器运营方已更改。 alert title Server operators + 服务器运营方 No comment provided by engineer. Server protocol changed. + 服务器协议已更改。 alert title @@ -7290,6 +7462,7 @@ chat item action Set chat name… + 设置聊天名称… No comment provided by engineer. @@ -7314,10 +7487,12 @@ chat item action Set member admission + 设置成员入群准许 No comment provided by engineer. Set message expiration in chats. + 在聊天中设置消息过期时间。 No comment provided by engineer. @@ -7337,6 +7512,7 @@ chat item action Set profile bio and welcome message. + 设置自我介绍和欢迎消息。 No comment provided by engineer. @@ -7356,6 +7532,7 @@ chat item action Settings were changed. + 设置已修改。 alert message @@ -7376,10 +7553,12 @@ chat item action Share 1-time link with a friend + 和一位好友分享一次性链接 No comment provided by engineer. Share SimpleX address on social media. + 在社媒上分享 SimpleX 地址。 No comment provided by engineer. @@ -7389,6 +7568,7 @@ chat item action Share address publicly + 公开分享地址 No comment provided by engineer. @@ -7408,14 +7588,17 @@ chat item action Share old address + 分享旧地址 alert button Share old link + 分享旧链接 alert button Share profile + 分享资料 No comment provided by engineer. @@ -7435,18 +7618,22 @@ chat item action Share your address + 分享地址 No comment provided by engineer. Short SimpleX address + SimpleX 短地址 No comment provided by engineer. Short description + 短描述 No comment provided by engineer. Short link + 短链接 No comment provided by engineer. @@ -7601,6 +7788,7 @@ chat item action SimpleX relay link + SimpleX 中继链接 simplex link type @@ -7656,6 +7844,8 @@ chat item action Some servers failed the test: %@ + 有服务器测试未通过: +%@ alert message @@ -7665,6 +7855,7 @@ chat item action Spam + 垃圾信息 blocking reason report reason @@ -7789,10 +7980,12 @@ report reason Switch audio and video during the call. + 通话期间切换音频和视频。 No comment provided by engineer. Switch chat profile for 1-time invitations. + 对一次性邀请切换聊天个人资料。 No comment provided by engineer. @@ -7812,6 +8005,7 @@ report reason TCP connection bg timeout + TCP 连接后台超时 No comment provided by engineer. @@ -7821,6 +8015,7 @@ report reason TCP port for messaging + 用于消息收发的 TCP 端口 No comment provided by engineer. @@ -7840,6 +8035,7 @@ report reason Tail + 尾部 No comment provided by engineer. @@ -7849,22 +8045,27 @@ report reason Tap Connect to chat + 轻按连接进行聊天 No comment provided by engineer. Tap Connect to send request + 轻按连接来发送请求 No comment provided by engineer. Tap Connect to use bot + 轻按“连接”使用机器人 No comment provided by engineer. Tap Create SimpleX address in the menu to create it later. + 要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址” No comment provided by engineer. Tap Join group + 轻按加入群 No comment provided by engineer. @@ -7914,6 +8115,7 @@ report reason Test notifications + 测试通知 No comment provided by engineer. @@ -7955,6 +8157,7 @@ It can happen because of some bug or when the connection is compromised. The address will be short, and your profile will be shared via the address. + 地址不会长,将通过该简短地址分享个人资料。 alert message @@ -7964,6 +8167,7 @@ It can happen because of some bug or when the connection is compromised. The app protects your privacy by using different operators in each conversation. + 应用通过在每个对话中使用不同运营方保护你的隐私。 No comment provided by engineer. @@ -7983,6 +8187,7 @@ It can happen because of some bug or when the connection is compromised. The connection reached the limit of undelivered messages, your contact may be offline. + 连接达到了未送达消息上限,你的联系人可能处于离线状态。 No comment provided by engineer. @@ -8017,6 +8222,7 @@ It can happen because of some bug or when the connection is compromised. The link will be short, and group profile will be shared via the link. + 链接不会长,群资料会通过短链接分享。 alert message @@ -8050,6 +8256,7 @@ It can happen because of some bug or when the connection is compromised. The second preset operator in the app! + 应用中的第二个预设运营方! No comment provided by engineer. @@ -8078,6 +8285,7 @@ It can happen because of some bug or when the connection is compromised. The uploaded database archive will be permanently removed from the servers. + 已上传的数据库归档将会从服务器中永久移除。 No comment provided by engineer. @@ -8087,6 +8295,7 @@ It can happen because of some bug or when the connection is compromised. These conditions will also apply for: **%@**. + 这些条件将同样适用于: **%@**。 No comment provided by engineer. @@ -8111,6 +8320,7 @@ It can happen because of some bug or when the connection is compromised. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 alert message @@ -8150,6 +8360,7 @@ It can happen because of some bug or when the connection is compromised. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + 此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。 No comment provided by engineer. @@ -8159,6 +8370,7 @@ It can happen because of some bug or when the connection is compromised. This message was deleted or not received yet. + 此消息被删除或尚未收到。 No comment provided by engineer. @@ -8168,10 +8380,12 @@ It can happen because of some bug or when the connection is compromised. This setting is for your current profile **%@**. + 此设置用于当前个人资料 **%@**。 No comment provided by engineer. Time to disappear is set only for new contacts. + 只为新联系人设置了消失时间。 No comment provided by engineer. @@ -8201,6 +8415,7 @@ It can happen because of some bug or when the connection is compromised. To protect against your link being replaced, you can compare contact security codes. + 为了防止链接被替换,你可以比较联系人安全代码。 No comment provided by engineer. @@ -8227,14 +8442,17 @@ You will be prompted to complete authentication before this feature is enabled.< To receive + 消息接收 No comment provided by engineer. To record speech please grant permission to use Microphone. + 为了记录语音请授予使用麦克风权限。 No comment provided by engineer. To record video please grant permission to use Camera. + 为了录制视频请授予使用相机权限。 No comment provided by engineer. @@ -8249,10 +8467,12 @@ You will be prompted to complete authentication before this feature is enabled.< To send + 发送 No comment provided by engineer. To send commands you must be connected. + 你必须已连接才能发送命令。 alert message @@ -8262,10 +8482,12 @@ You will be prompted to complete authentication before this feature is enabled.< To use another profile after connection attempt, delete the chat and use the link again. + 要在连接尝试后使用不同的个人资料,请删除聊天并再次使用该链接。 alert message To use the servers of **%@**, accept conditions of use. + 要使用**%@**的服务器,需接受条款。 No comment provided by engineer. @@ -8309,6 +8531,7 @@ You will be prompted to complete authentication before this feature is enabled.< Trying to connect to the server used to receive messages from this connection. + 尝试连接到用于从该连接接收消息的服务器。 subscription status explanation @@ -8358,6 +8581,7 @@ You will be prompted to complete authentication before this feature is enabled.< Undelivered messages + 未送达的消息 No comment provided by engineer. @@ -8454,6 +8678,7 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link + 不支持的连接链接 No comment provided by engineer. @@ -8483,6 +8708,7 @@ To connect, please ask your contact to create another connection link and check Updated conditions + 条款已更新 No comment provided by engineer. @@ -8492,14 +8718,17 @@ To connect, please ask your contact to create another connection link and check Upgrade + 升级 alert button Upgrade address + 升级地址 No comment provided by engineer. Upgrade address? + 升级地址? alert message @@ -8509,14 +8738,17 @@ To connect, please ask your contact to create another connection link and check Upgrade group link? + 升级群链接? alert message Upgrade link + 升级链接 No comment provided by engineer. Upgrade your address + 升级你的地址 No comment provided by engineer. @@ -8551,6 +8783,7 @@ To connect, please ask your contact to create another connection link and check Use %@ + 使用 %@ No comment provided by engineer. @@ -8560,6 +8793,7 @@ To connect, please ask your contact to create another connection link and check Use SOCKS proxy + 使用 SOCKS 代理 No comment provided by engineer. @@ -8569,10 +8803,12 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. + 当未指定端口时使用TCP端口%@。 No comment provided by engineer. Use TCP port 443 for preset servers only. + 仅预设服务器使用 TCP 协议 443 端口。 No comment provided by engineer. @@ -8587,10 +8823,12 @@ To connect, please ask your contact to create another connection link and check Use for files + 用于文件 No comment provided by engineer. Use for messages + 用于消息 No comment provided by engineer. @@ -8610,6 +8848,7 @@ To connect, please ask your contact to create another connection link and check Use incognito profile + 使用隐身个人资料 No comment provided by engineer. @@ -8639,6 +8878,7 @@ To connect, please ask your contact to create another connection link and check Use servers + 使用服务器 No comment provided by engineer. @@ -8653,6 +8893,7 @@ To connect, please ask your contact to create another connection link and check Use web port + 使用 web 端口 No comment provided by engineer. @@ -8662,6 +8903,7 @@ To connect, please ask your contact to create another connection link and check Username + 用户名 No comment provided by engineer. @@ -8729,6 +8971,10 @@ To connect, please ask your contact to create another connection link and check 视频将在您的联系人在线时收到,请稍等或稍后查看! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 最大 1gb 的视频和文件 @@ -8736,6 +8982,7 @@ To connect, please ask your contact to create another connection link and check View conditions + 查看条款 No comment provided by engineer. @@ -8745,6 +8992,7 @@ To connect, please ask your contact to create another connection link and check View updated conditions + 查看更新后的条款 No comment provided by engineer. @@ -8844,6 +9092,7 @@ To connect, please ask your contact to create another connection link and check Welcome your contacts 👋 + 欢迎联系人👋 No comment provided by engineer. @@ -8863,6 +9112,7 @@ To connect, please ask your contact to create another connection link and check When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + 当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。 No comment provided by engineer. @@ -8962,6 +9212,7 @@ To connect, please ask your contact to create another connection link and check You are already connected with %@. + 你已经与%@保持连接。 No comment provided by engineer. @@ -8998,6 +9249,7 @@ Repeat join request? You are connected to the server used to receive messages from this connection. + 你已连接到用于接收该连接消息的服务器。 subscription status explanation @@ -9007,6 +9259,7 @@ Repeat join request? You are not connected to the server used to receive messages from this connection (no subscription). + 未连接到用于从该连接接收消息的服务器(无订阅)。 subscription status explanation @@ -9026,6 +9279,7 @@ Repeat join request? You can configure servers via settings. + 你可以通过设置配置服务器。 No comment provided by engineer. @@ -9070,6 +9324,7 @@ Repeat join request? You can set connection name, to remember who the link was shared with. + 你可以设置连接名称,用来记住和谁分享了这个链接。 No comment provided by engineer. @@ -9114,6 +9369,7 @@ Repeat join request? You can view your reports in Chat with admins. + 你可以在和管理员和聊天中查看你的举报。 alert message @@ -9199,6 +9455,7 @@ Repeat connection request? You will be able to send messages **only after your request is accepted**. + **只有在你的请求被接受后**你才能发送消息。 No comment provided by engineer. @@ -9233,6 +9490,7 @@ Repeat connection request? You will stop receiving messages from this chat. Chat history will be preserved. + 你将停止从这个聊天收到消息。聊天历史将被保留。 No comment provided by engineer. @@ -9267,6 +9525,7 @@ Repeat connection request? Your business contact + 你的企业联系人 No comment provided by engineer. @@ -9286,6 +9545,7 @@ Repeat connection request? Your chat preferences + 你的聊天偏好设置 alert title @@ -9303,6 +9563,7 @@ Repeat connection request? Your contact + 你的联系人 No comment provided by engineer. @@ -9322,6 +9583,7 @@ Repeat connection request? Your credentials may be sent unencrypted. + 你的凭据可能以未经加密的方式被发送。 No comment provided by engineer. @@ -9336,6 +9598,7 @@ Repeat connection request? Your group + 你的群 No comment provided by engineer. @@ -9370,6 +9633,7 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. + 您的个人资料已修改。如果进行保存,更新后的个人资料将发送到所有联系人。 alert message @@ -9384,6 +9648,7 @@ Repeat connection request? Your servers + 你的服务器 No comment provided by engineer. @@ -9432,10 +9697,12 @@ Repeat connection request? accepted invitation + 已接受邀请 chat list item title accepted you + 接受了你 rcv group event chat item @@ -9460,6 +9727,7 @@ Repeat connection request? all + 全部 member criteria value @@ -9479,6 +9747,7 @@ Repeat connection request? archived report + 已存档的举报 No comment provided by engineer. @@ -9549,6 +9818,7 @@ marked deleted chat item preview text can't send messages + 无法发送消息 No comment provided by engineer. @@ -9653,10 +9923,12 @@ marked deleted chat item preview text contact deleted + 删除了联系人 No comment provided by engineer. contact disabled + 禁用了联系人 No comment provided by engineer. @@ -9671,10 +9943,12 @@ marked deleted chat item preview text contact not ready + 联系人未就绪 No comment provided by engineer. contact should accept… + 联系人应当接受… No comment provided by engineer. @@ -9845,6 +10119,7 @@ pref value group + shown on group welcome message @@ -9854,6 +10129,7 @@ pref value group is deleted + 群被删除了 No comment provided by engineer. @@ -9978,6 +10254,7 @@ pref value member has old version + 成员有旧版本 No comment provided by engineer. @@ -10012,6 +10289,7 @@ pref value moderator + 协管 member role @@ -10041,6 +10319,7 @@ pref value no subscription + 无订阅 No comment provided by engineer. @@ -10050,6 +10329,7 @@ pref value not synchronized + 未同步 No comment provided by engineer. @@ -10111,10 +10391,12 @@ time to disappear pending approval + 待批准 No comment provided by engineer. pending review + 待审核 No comment provided by engineer. @@ -10134,6 +10416,7 @@ time to disappear rejected + 被拒绝 No comment provided by engineer. @@ -10158,6 +10441,7 @@ time to disappear removed from group + 从群被删除了 No comment provided by engineer. @@ -10172,30 +10456,37 @@ time to disappear request is sent + 发送了请求 No comment provided by engineer. request to join rejected + 加入请求被拒绝 No comment provided by engineer. requested connection + 已请求连接 rcv group event chat item requested connection from group %@ + 来自群组%@的已请求连接 rcv direct event chat item requested to connect + 被请求连接 chat list item title review + 审核 No comment provided by engineer. reviewed by admins + 由管理员审核 No comment provided by engineer. @@ -10384,6 +10675,7 @@ last received msg: %2$@ you accepted this member + 你接受了该成员 snd group event chat item @@ -10519,22 +10811,27 @@ last received msg: %2$@ %d new events + %d条新事件 notification body From %d chat(s) + 来自 %d 条聊天 notification body From: %@ + 来自: %@ notification body New events + 新事件 notification New messages + 新消息 notification diff --git a/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings b/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings index 5ef592ec70..4e4b130fa4 100644 --- a/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings @@ -1,7 +1,15 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"%d new events" = "%d条新事件"; + +/* notification body */ +"From %d chat(s)" = "来自 %d 条聊天"; + +/* notification body */ +"From: %@" = "来自: %@"; + +/* notification */ +"New events" = "新事件"; + +/* notification */ +"New messages" = "新消息"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 2fedf0e6f1..dfb7a302b9 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -14,10 +14,10 @@ "Cannot forward message" = "Nem lehet továbbítani az üzenetet"; /* No comment provided by engineer. */ -"Comment" = "Hozzászólás"; +"Comment" = "Megjegyzés"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájl méret: %@."; +"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájlméret: %@."; /* No comment provided by engineer. */ "Database downgrade required" = "Adatbázis visszafejlesztése szükséges"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 038546f889..fb8529fb88 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1613,7 +1613,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Изтрий съобщението?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Изтрий съобщенията"; /* No comment provided by engineer. */ @@ -2750,7 +2751,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Ролята на члена ще бъде променена на \"%@\". Членът ще получи нова покана."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Членът ще бъде премахнат от групата - това не може да бъде отменено!"; /* No comment provided by engineer. */ @@ -3417,13 +3418,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Премахване"; /* No comment provided by engineer. */ "Remove member" = "Острани член"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Острани член?"; /* No comment provided by engineer. */ diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index dd486001c7..33ad97d821 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1258,7 +1258,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Smazat zprávu?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Smazat zprávy"; /* No comment provided by engineer. */ @@ -2193,7 +2194,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Role člena se změní na \"%@\". Člen obdrží novou pozvánku."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Člen bude odstraněn ze skupiny - toto nelze vzít zpět!"; /* No comment provided by engineer. */ @@ -2728,13 +2729,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Přenosový server chrání vaši IP adresu, ale může sledovat dobu trvání hovoru."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Odstranit"; /* No comment provided by engineer. */ "Remove member" = "Odstranit člena"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Odebrat člena?"; /* No comment provided by engineer. */ diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index caf58399de..f305aca473 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ @@ -3308,10 +3309,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Die Mitgliederrolle wird auf \"%@\" geändert. Das Mitglied wird eine neue Einladung erhalten."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Das Mitglied wird aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden!"; /* alert message */ @@ -4380,7 +4381,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Entfernen"; /* No comment provided by engineer. */ @@ -4395,7 +4396,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Mitglied entfernen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Das Mitglied entfernen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 5ce7ab6843..9ac7628abb 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -384,7 +384,7 @@ swipe action */ "accepted invitation" = "invitación aceptada"; /* rcv group event chat item */ -"accepted you" = "te ha aceptado"; +"accepted you" = "te ha admitido"; /* No comment provided by engineer. */ "Acknowledged" = "Confirmaciones"; @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Activar"; /* No comment provided by engineer. */ @@ -3308,10 +3309,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "El rol del miembro cambiará a \"%@\" y recibirá una invitación nueva."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No puede deshacerse!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No puede deshacerse!"; /* alert message */ @@ -3654,7 +3655,7 @@ snd error text */ "No device token!" = "¡Sin dispositivo token!"; /* item status description */ -"No direct connection yet, message is forwarded by admin." = "Aún no hay conexión directa con este miembro, el mensaje es reenviado por el administrador."; +"No direct connection yet, message is forwarded by admin." = "Aún no hay conexión directa, los mensajes son reenviados por el administrador."; /* No comment provided by engineer. */ "no e2e encryption" = "sin cifrar"; @@ -4380,7 +4381,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "El servidor de retransmisión protege tu IP pero puede ver la duración de la llamada."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Eliminar"; /* No comment provided by engineer. */ @@ -4395,7 +4396,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Expulsar miembro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "¿Expulsar miembro?"; /* No comment provided by engineer. */ @@ -6052,7 +6053,7 @@ report reason */ "You accepted connection" = "Has aceptado la conexión"; /* snd group event chat item */ -"you accepted this member" = "has aceptado al miembro"; +"you accepted this member" = "has admitido al miembro"; /* No comment provided by engineer. */ "You allow" = "Permites"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 884be40cc1..ea3f9c4386 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -943,7 +943,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Poista viesti?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Poista viestit"; /* No comment provided by engineer. */ @@ -1869,7 +1870,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Jäsenen rooli muutetaan muotoon \"%@\". Jäsen saa uuden kutsun."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Jäsen poistetaan ryhmästä - tätä ei voi perua!"; /* No comment provided by engineer. */ @@ -2398,13 +2399,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Poista"; /* No comment provided by engineer. */ "Remove member" = "Poista jäsen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Poista jäsen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index dbfac375d1..2b2a1e98e5 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1661,7 +1661,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Supprimer le message ?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Supprimer les messages"; /* No comment provided by engineer. */ @@ -3104,10 +3105,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; /* No comment provided by engineer. */ @@ -4005,7 +4006,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Le serveur relais protège votre adresse IP, mais il peut observer la durée de l'appel."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Supprimer"; /* No comment provided by engineer. */ @@ -4017,7 +4018,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Retirer le membre"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Retirer ce membre ?"; /* No comment provided by engineer. */ diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 451bdfc699..fc2f796bfd 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -41,10 +41,10 @@ "**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; /* No comment provided by engineer. */ -"**e2e encrypted** audio call" = "**e2e titkosított** hanghívás"; +"**e2e encrypted** audio call" = "**végpontok között titkosított** hanghívás"; /* No comment provided by engineer. */ -"**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; +"**e2e encrypted** video call" = "**végpontok között titkosított** videóhívás"; /* No comment provided by engineer. */ "**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; @@ -65,7 +65,7 @@ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; /* No comment provided by engineer. */ -"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ "**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; @@ -119,10 +119,10 @@ "%@ is connected!" = "%@ kapcsolódott!"; /* No comment provided by engineer. */ -"%@ is not verified" = "%@ nincs hitelesítve"; +"%@ is not verified" = "%@ nincs ellenőrizve"; /* No comment provided by engineer. */ -"%@ is verified" = "%@ hitelesítve"; +"%@ is verified" = "%@ ellenőrizve"; /* No comment provided by engineer. */ "%@ server" = "%@ kiszolgáló"; @@ -194,7 +194,7 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld partner kijelölve"; +"%lld contact(s) selected" = "%lld partner kiválasztva"; /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld fájl, %@ összméretben"; @@ -507,7 +507,7 @@ swipe action */ "All data is kept private on your device." = "Az összes adat privát módon van tárolva az eszközén."; /* No comment provided by engineer. */ -"All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; +"All group members will remain connected." = "Az összes csoporttag továbbra is kapcsolatban marad."; /* feature role */ "all members" = "összes tag"; @@ -534,10 +534,10 @@ swipe action */ "All servers" = "Összes kiszolgáló"; /* No comment provided by engineer. */ -"All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; +"All your contacts will remain connected." = "Az összes partnerével továbbra is kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ "All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra."; @@ -609,7 +609,7 @@ swipe action */ "Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra)"; /* No comment provided by engineer. */ -"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése a partnerei számára."; +"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ "Allow your contacts to send files and media." = "A fájlok és a médiatartalmak küldése engedélyezve van a partnerei számára."; @@ -630,10 +630,10 @@ swipe action */ "always" = "mindig"; /* No comment provided by engineer. */ -"Always use private routing." = "Mindig használjon privát útválasztást."; +"Always use private routing." = "Mindig legyen használva privát útválasztás."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon továbbítókiszolgálót"; +"Always use relay" = "Mindig legyen használva továbbítókiszolgáló"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -735,7 +735,7 @@ swipe action */ "Audio and video calls" = "Hang- és videóhívások"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)" = "hanghívás (nem e2e titkosított)"; +"audio call (not e2e encrypted)" = "hanghívás (végpontok között NEM titkosított)"; /* chat feature */ "Audio/video calls" = "Hang- és videóhívások"; @@ -819,10 +819,10 @@ swipe action */ "Better user experience" = "Továbbfejlesztett felhasználói élmény"; /* No comment provided by engineer. */ -"Bio" = "Névjegy"; +"Bio" = "Életrajz"; /* alert title */ -"Bio too large" = "A névjegy túl hosszú"; +"Bio too large" = "Az életrajz túl hosszú"; /* No comment provided by engineer. */ "Black" = "Fekete"; @@ -913,7 +913,7 @@ marked deleted chat item preview text */ "call" = "hívás"; /* No comment provided by engineer. */ -"Call already ended!" = "A hívás már befejeződött!"; +"Call already ended!" = "A hívás már véget ért!"; /* call status */ "call error" = "híváshiba"; @@ -1120,7 +1120,7 @@ set passcode view */ "Chinese and Spanish interface" = "Kínai és spanyol kezelőfelület"; /* No comment provided by engineer. */ -"Choose _Migrate from another device_ on the new device and scan QR code." = "Válassza az _Átköltöztetés egy másik eszközről_ opciót az új eszközén és olvassa be a QR-kódot."; +"Choose _Migrate from another device_ on the new device and scan QR code." = "Válassza az _Átköltöztetés egy másik eszközről_ beállítást az új eszközén és olvassa be a QR-kódot."; /* No comment provided by engineer. */ "Choose file" = "Fájl kiválasztása"; @@ -1138,25 +1138,25 @@ set passcode view */ "Chunks uploaded" = "Feltöltött töredékek"; /* swipe action */ -"Clear" = "Kiürítés"; +"Clear" = "Ürítés"; /* No comment provided by engineer. */ -"Clear conversation" = "Üzenetek kiürítése"; +"Clear conversation" = "Üzenetek ürítése"; /* No comment provided by engineer. */ -"Clear conversation?" = "Kiüríti az üzeneteket?"; +"Clear conversation?" = "Üríti a beszélgetés üzeneteit?"; /* No comment provided by engineer. */ -"Clear group?" = "Kiüríti a csoportot?"; +"Clear group?" = "Üríti a csoport üzeneteit?"; /* No comment provided by engineer. */ -"Clear or delete group?" = "Csoport kiürítése vagy törlése?"; +"Clear or delete group?" = "Csoport ürítése vagy törlése?"; /* No comment provided by engineer. */ -"Clear private notes?" = "Kiüríti a privát jegyzeteket?"; +"Clear private notes?" = "Üríti a privát jegyzetek tartalmát?"; /* No comment provided by engineer. */ -"Clear verification" = "Hitelesítés törlése"; +"Clear verification" = "Ellenőrzés törlése"; /* No comment provided by engineer. */ "Color chats with the new themes." = "Csevegések színezése új témákkal."; @@ -1273,7 +1273,7 @@ set passcode view */ "Connect via link" = "Kapcsolódás egy hivatkozáson keresztül"; /* new chat sheet title */ -"Connect via one-time link" = "Kapcsolódás egyszer használható meghívón keresztül"; +"Connect via one-time link" = "Kapcsolódás az egyszer használható meghívón keresztül"; /* new chat action */ "Connect with %@" = "Kapcsolódás a következővel: %@"; @@ -1291,7 +1291,7 @@ set passcode view */ "Connected servers" = "Kapcsolódott kiszolgálók"; /* No comment provided by engineer. */ -"Connected to desktop" = "Kapcsolódva a számítógéphez"; +"Connected to desktop" = "Társítva a számítógéppel"; /* No comment provided by engineer. */ "connecting" = "kapcsolódás"; @@ -1312,7 +1312,7 @@ set passcode view */ "connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)"; /* call status */ -"connecting call" = "kapcsolódási hívás…"; +"connecting call" = "hívás kapcsolása…"; /* No comment provided by engineer. */ "Connecting server…" = "Kapcsolódás a kiszolgálóhoz…"; @@ -1324,7 +1324,7 @@ set passcode view */ "Connecting to contact, please wait or check later!" = "Kapcsolódás a partnerhez, várjon vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"Connecting to desktop" = "Kapcsolódás a számítógéphez"; +"Connecting to desktop" = "Társítás számítógéppel"; /* No comment provided by engineer. */ "connecting…" = "kapcsolódás…"; @@ -1399,10 +1399,10 @@ set passcode view */ "contact disabled" = "partner letiltva"; /* No comment provided by engineer. */ -"contact has e2e encryption" = "a partner e2e titkosítással rendelkezik"; +"contact has e2e encryption" = "a partner végpontok közötti titkosítással rendelkezik"; /* No comment provided by engineer. */ -"contact has no e2e encryption" = "a partner nem rendelkezik e2e titkosítással"; +"contact has no e2e encryption" = "a partner nem rendelkezik végpontok közötti titkosítással"; /* notification */ "Contact hidden:" = "Rejtett név:"; @@ -1486,7 +1486,7 @@ set passcode view */ "Create list" = "Lista létrehozása"; /* No comment provided by engineer. */ -"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻"; +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógépes alkalmazásban](https://simplex.chat/downloads/). 💻"; /* No comment provided by engineer. */ "Create profile" = "Profil létrehozása"; @@ -1656,7 +1656,7 @@ swipe action */ "Delete after" = "Törlés ennyi idő után"; /* No comment provided by engineer. */ -"Delete all files" = "Az összes fájl törlése"; +"Delete all files" = "Összes fájl törlése"; /* No comment provided by engineer. */ "Delete and notify contact" = "Törlés, és a partner értesítése"; @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Törli az üzenetet?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ @@ -1815,7 +1816,7 @@ swipe action */ "Desktop address" = "Számítógép címe"; /* No comment provided by engineer. */ -"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; +"Desktop app version %@ is not compatible with this app." = "A számítógépes alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; /* No comment provided by engineer. */ "Desktop devices" = "Számítógépek"; @@ -1935,7 +1936,7 @@ swipe action */ "Do not use credentials with proxy." = "Ne használja a hitelesítési adatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "NE használjon privát útválasztást."; +"Do NOT use private routing." = "NE legyen használva privát útválasztás."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleXet segélyhívásokhoz."; @@ -1947,13 +1948,13 @@ swipe action */ "Don't create address" = "Ne hozzon létre címet"; /* No comment provided by engineer. */ -"Don't enable" = "Ne engedélyezze"; +"Don't enable" = "Nem engedélyezem"; /* No comment provided by engineer. */ "Don't miss important messages." = "Ne maradjon le a fontos üzenetekről."; /* alert action */ -"Don't show again" = "Ne mutasd újra"; +"Don't show again" = "Ne jelenjen meg újra"; /* No comment provided by engineer. */ "Done" = "Kész"; @@ -2002,10 +2003,10 @@ chat item action */ "Duration" = "Időtartam"; /* No comment provided by engineer. */ -"e2e encrypted" = "e2e titkosított"; +"e2e encrypted" = "végpontok között titkosított"; /* No comment provided by engineer. */ -"E2E encrypted notifications." = "Végpontok közötti titkosított értesítések."; +"E2E encrypted notifications." = "Végpontok között titkosított értesítések."; /* chat item action */ "Edit" = "Szerkesztés"; @@ -2080,7 +2081,7 @@ chat item action */ "enabled for you" = "engedélyezve az Ön számára"; /* No comment provided by engineer. */ -"Encrypt" = "Titkosít"; +"Encrypt" = "Titkosítás"; /* No comment provided by engineer. */ "Encrypt database?" = "Titkosítja az adatbázist?"; @@ -2149,10 +2150,10 @@ chat item action */ "Encryption renegotiation in progress." = "A titkosítás újraegyeztetése folyamatban van."; /* No comment provided by engineer. */ -"ended" = "befejeződött"; +"ended" = "hívás vége"; /* call status */ -"ended call %@" = "%@ hívása befejeződött"; +"ended call %@" = "%@ hívása véget ért"; /* No comment provided by engineer. */ "Enter correct passphrase." = "Adja meg a helyes jelmondatot."; @@ -2431,7 +2432,7 @@ chat item action */ "Error uploading the archive" = "Hiba történt az archívum feltöltésekor"; /* No comment provided by engineer. */ -"Error verifying passphrase:" = "Hiba történt a jelmondat hitelesítésekor:"; +"Error verifying passphrase:" = "Hiba történt a jelmondat ellenőrzésekor:"; /* No comment provided by engineer. */ "Error: " = "Hiba: "; @@ -2832,7 +2833,7 @@ snd error text */ "How SimpleX works" = "Hogyan működik a SimpleX"; /* No comment provided by engineer. */ -"How to" = "Hogyan"; +"How to" = "Útmutató"; /* No comment provided by engineer. */ "How to use it" = "Használati útmutató"; @@ -2856,7 +2857,7 @@ snd error text */ "If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:"; /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; /* No comment provided by engineer. */ "Ignore" = "Mellőzés"; @@ -2979,7 +2980,7 @@ snd error text */ "Instant" = "Azonnali"; /* No comment provided by engineer. */ -"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; +"Instant push notifications will be hidden!\n" = "Az azonnali leküldéses értesítések el lesznek rejtve!\n"; /* No comment provided by engineer. */ "Interface" = "Kezelőfelület"; @@ -3072,10 +3073,10 @@ snd error text */ "invited via your group link" = "meghíva a saját csoporthivatkozásán keresztül"; /* No comment provided by engineer. */ -"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a leküldéses értesítések fogadását."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a leküldéses értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -3141,7 +3142,7 @@ snd error text */ "Keep conversation" = "Beszélgetés megtartása"; /* No comment provided by engineer. */ -"Keep the app open to use it from desktop" = "A számítógépről való használathoz tartsd nyitva az alkalmazást"; +"Keep the app open to use it from desktop" = "Alkalmazás megnyitva tartása a számítógépről való használathoz"; /* alert title */ "Keep unused invitation?" = "Megtartja a fel nem használt meghívót?"; @@ -3195,7 +3196,7 @@ snd error text */ "Limitations" = "Korlátozások"; /* No comment provided by engineer. */ -"Link mobile and desktop apps! 🔗" = "Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗"; +"Link mobile and desktop apps! 🔗" = "Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗"; /* No comment provided by engineer. */ "Linked desktop options" = "Társított számítógép beállítások"; @@ -3252,7 +3253,7 @@ snd error text */ "Mark read" = "Megjelölés olvasottként"; /* No comment provided by engineer. */ -"Mark verified" = "Hitelesítés"; +"Mark verified" = "Megjelölés ellenőrzöttként"; /* No comment provided by engineer. */ "Markdown in messages" = "Markdown az üzenetekben"; @@ -3261,7 +3262,7 @@ snd error text */ "marked deleted" = "törlésre jelölve"; /* No comment provided by engineer. */ -"Max 30 seconds, received instantly." = "Max. 30 másodperc, azonnal érkezett."; +"Max 30 seconds, received instantly." = "Legfeljebb 30 másodperc, azonnal megérkezik."; /* No comment provided by engineer. */ "Media & file servers" = "Fájl- és médiakiszolgálók"; @@ -3308,10 +3309,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza!"; /* alert message */ @@ -3360,7 +3361,7 @@ snd error text */ "Message delivery warning" = "Üzenetkézbesítési figyelmeztetés"; /* No comment provided by engineer. */ -"Message draft" = "Üzenetvázlat"; +"Message draft" = "Piszkozatok"; /* item status text */ "Message forwarded" = "Továbbított üzenet"; @@ -3432,7 +3433,7 @@ snd error text */ "Messages sent" = "Elküldött üzenetek"; /* alert message */ -"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kijelölte őket."; +"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kiváasztotta őket."; /* No comment provided by engineer. */ "Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; @@ -3468,7 +3469,7 @@ snd error text */ "Migration error:" = "Átköltöztetési hiba:"; /* No comment provided by engineer. */ -"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; /* No comment provided by engineer. */ "Migration is completed" = "Az átköltöztetés befejeződött"; @@ -3573,10 +3574,10 @@ snd error text */ "New contact request" = "Új partneri kapcsolatkérés"; /* notification */ -"New contact:" = "Új kapcsolat:"; +"New contact:" = "Új partner:"; /* No comment provided by engineer. */ -"New desktop app!" = "Új számítógép-alkalmazás!"; +"New desktop app!" = "Új számítógépes alkalmazás!"; /* No comment provided by engineer. */ "New display name" = "Új megjelenítendő név"; @@ -3642,7 +3643,7 @@ snd error text */ "No chats with members" = "Nincsenek csevegések a tagokkal"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs partner kijelölve"; +"No contacts selected" = "Nincs partner kiválasztva"; /* No comment provided by engineer. */ "No contacts to add" = "Nincs hozzáadandó partner"; @@ -3657,7 +3658,7 @@ snd error text */ "No direct connection yet, message is forwarded by admin." = "Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja."; /* No comment provided by engineer. */ -"no e2e encryption" = "nincs e2e titkosítás"; +"no e2e encryption" = "nincs végpontok közötti titkosítás"; /* No comment provided by engineer. */ "No filtered chats" = "Nincsenek szűrt csevegések"; @@ -3696,7 +3697,7 @@ snd error text */ "No private routing session" = "Nincs privát útválasztási munkamenet"; /* No comment provided by engineer. */ -"No push server" = "Helyi"; +"No push server" = "Nincs kiszolgáló a leküldéses értesítésekhez"; /* No comment provided by engineer. */ "No received or sent files" = "Nincsenek fogadott vagy küldött fájlok"; @@ -3714,7 +3715,7 @@ snd error text */ "No servers to send files." = "Nincsenek fájlküldési kiszolgálók."; /* No comment provided by engineer. */ -"no subscription" = "nincs előfizetés"; +"no subscription" = "nincs feliratkozás"; /* copied message info in history */ "no text" = "nincs szöveg"; @@ -3738,7 +3739,7 @@ snd error text */ "Notes" = "Jegyzetek"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs semmi kijelölve"; +"Nothing selected" = "Nincs semmi kiválasztva"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3833,37 +3834,37 @@ new chat action */ "Only you can add message reactions." = "Csak Ön adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra)"; +"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra)"; /* No comment provided by engineer. */ -"Only you can make calls." = "Csak Ön tud hívásokat indítani."; +"Only you can make calls." = "Csak Ön kezdeményezhet hívásokat."; /* No comment provided by engineer. */ -"Only you can send disappearing messages." = "Csak Ön tud eltűnő üzeneteket küldeni."; +"Only you can send disappearing messages." = "Csak Ön küldhet eltűnő üzeneteket."; /* No comment provided by engineer. */ "Only you can send files and media." = "Csak Ön küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Only you can send voice messages." = "Csak Ön tud hangüzeneteket küldeni."; +"Only you can send voice messages." = "Csak Ön küldhet hangüzeneteket."; /* No comment provided by engineer. */ "Only your contact can add message reactions." = "Csak a partnere adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; +"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra)"; /* No comment provided by engineer. */ -"Only your contact can make calls." = "Csak a partnere tud hívást indítani."; +"Only your contact can make calls." = "Csak a partnere kezdeményezhet hívásokat."; /* No comment provided by engineer. */ -"Only your contact can send disappearing messages." = "Csak a partnere tud eltűnő üzeneteket küldeni."; +"Only your contact can send disappearing messages." = "Csak a partnere küldhet eltűnő üzeneteket."; /* No comment provided by engineer. */ "Only your contact can send files and media." = "Csak a partnere küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; +"Only your contact can send voice messages." = "Csak a partnere küldhet hangüzeneteket."; /* alert action */ "Open" = "Megnyitás"; @@ -4070,7 +4071,7 @@ new chat action */ "Please report it to the developers." = "Jelentse a fejlesztőknek."; /* No comment provided by engineer. */ -"Please restart the app and migrate the database to enable push notifications." = "Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges push-értesítések engedélyezéséhez."; +"Please restart the app and migrate the database to enable push notifications." = "Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges leküldéses értesítések engedélyezéséhez."; /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez."; @@ -4181,7 +4182,7 @@ new chat action */ "Prohibit messages reactions." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; /* No comment provided by engineer. */ -"Prohibit reporting messages to moderators." = "Az üzenetek a moderátorok felé történő jelentésének megtiltása."; +"Prohibit reporting messages to moderators." = "Az üzenetek jelentése a moderátorok felé le van tiltva."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között le van tiltva."; @@ -4229,10 +4230,10 @@ new chat action */ "Proxy requires password" = "A proxy jelszót igényel"; /* No comment provided by engineer. */ -"Push notifications" = "Push-értesítések"; +"Push notifications" = "Leküldéses értesítések"; /* No comment provided by engineer. */ -"Push server" = "Push-kiszolgáló"; +"Push server" = "Leküldéses értesítéskiszolgáló"; /* chat item text */ "quantum resistant e2e encryption" = "végpontok közötti kvantumbiztos titkosítás"; @@ -4241,13 +4242,13 @@ new chat action */ "Quantum resistant encryption" = "Kvantumbiztos titkosítás"; /* No comment provided by engineer. */ -"Rate the app" = "Értékelje az alkalmazást"; +"Rate the app" = "Alkalmazás értékelése"; /* No comment provided by engineer. */ "Reachable chat toolbar" = "Könnyen elérhető csevegési eszköztár"; /* chat item menu */ -"React…" = "Reagálj…"; +"React…" = "Reagálás…"; /* swipe action */ "Read" = "Olvasott"; @@ -4274,7 +4275,7 @@ new chat action */ "Receive errors" = "Üzenetfogadási hibák"; /* No comment provided by engineer. */ -"received answer…" = "válasz fogadása…"; +"received answer…" = "válasz érkezett…"; /* No comment provided by engineer. */ "Received at" = "Fogadva"; @@ -4283,7 +4284,7 @@ new chat action */ "Received at: %@" = "Fogadva: %@"; /* No comment provided by engineer. */ -"received confirmation…" = "visszaigazolás fogadása…"; +"received confirmation…" = "visszaigazolás érkezett…"; /* message info title */ "Received message" = "Fogadott üzenetbuborék színe"; @@ -4360,7 +4361,7 @@ swipe action */ "Reject" = "Elutasítás"; /* No comment provided by engineer. */ -"Reject (sender NOT notified)" = "Elutasítás (a kérés küldője NEM fog értesítést kapni)"; +"Reject (sender NOT notified)" = "Elutasítás (a kérés küldője NEM lesz értesítve)"; /* alert title */ "Reject contact request" = "Partneri kapcsolatkérés elutasítása"; @@ -4380,7 +4381,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Eltávolítás"; /* No comment provided by engineer. */ @@ -4395,7 +4396,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Eltávolítás"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ @@ -4501,7 +4502,7 @@ swipe action */ "Reset all hints" = "Tippek visszaállítása"; /* No comment provided by engineer. */ -"Reset all statistics" = "Az összes statisztika visszaállítása"; +"Reset all statistics" = "Összes statisztika visszaállítása"; /* No comment provided by engineer. */ "Reset all statistics?" = "Visszaállítja az összes statisztikát?"; @@ -4712,7 +4713,7 @@ chat item action */ "Secured" = "Biztosítva"; /* No comment provided by engineer. */ -"Security assessment" = "Biztonsági kiértékelés"; +"Security assessment" = "Biztonsági felmérés"; /* No comment provided by engineer. */ "Security code" = "Biztonsági kód"; @@ -4721,16 +4722,16 @@ chat item action */ "security code changed" = "biztonsági kódja módosult"; /* chat item action */ -"Select" = "Kijelölés"; +"Select" = "Kiválasztás"; /* No comment provided by engineer. */ -"Select chat profile" = "Csevegési profil kijelölése"; +"Select chat profile" = "Csevegési profil kiválasztása"; /* No comment provided by engineer. */ -"Selected %lld" = "%lld kijelölve"; +"Selected %lld" = "%lld kiválasztva"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; @@ -4823,13 +4824,13 @@ chat item action */ "Sending file will be stopped." = "A fájl küldése le fog állni."; /* No comment provided by engineer. */ -"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partnernél"; +"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partner számára"; /* No comment provided by engineer. */ "Sending receipts is disabled for %lld groups" = "A kézbesítési jelentések le vannak tiltva %lld csoportban"; /* No comment provided by engineer. */ -"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partnernél"; +"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partner számára"; /* No comment provided by engineer. */ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; @@ -4919,7 +4920,7 @@ chat item action */ "Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Session code" = "Munkamenet kód"; +"Session code" = "Munkamenet kódja"; /* No comment provided by engineer. */ "Set 1 day" = "Beállítva 1 nap"; @@ -4961,7 +4962,7 @@ chat item action */ "Set passphrase to export" = "Jelmondat beállítása az exportáláshoz"; /* No comment provided by engineer. */ -"Set profile bio and welcome message." = "Névjegy és üdvözlőüzenet beállítása a profilokhoz."; +"Set profile bio and welcome message." = "Életrajz és üdvözlőüzenet beállítása a profilokhoz."; /* No comment provided by engineer. */ "Set the message shown to new members!" = "Megjelenítendő üzenet beállítása az új tagok számára!"; @@ -5079,7 +5080,7 @@ chat item action */ "SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó?"; /* alert title */ -"SimpleX address settings" = "Beállítások automatikus elfogadása"; +"SimpleX address settings" = "SimpleX-címbeállítások"; /* simplex link type */ "SimpleX channel link" = "SimpleX-csatornahivatkozás"; @@ -5142,7 +5143,7 @@ chat item action */ "Skipped messages" = "Kihagyott üzenetek"; /* No comment provided by engineer. */ -"Small groups (max 20)" = "Kis csoportok (max. 20 tag)"; +"Small groups (max 20)" = "Kis csoportok (legfeljebb 20 tag)"; /* No comment provided by engineer. */ "SMP server" = "SMP-kiszolgáló"; @@ -5182,7 +5183,7 @@ report reason */ "standard end-to-end encryption" = "szabványos végpontok közötti titkosítás"; /* No comment provided by engineer. */ -"Start chat" = "Csevegés indítása"; +"Start chat" = "Csevegés elindítása"; /* No comment provided by engineer. */ "Start chat?" = "Elindítja a csevegést?"; @@ -5194,7 +5195,7 @@ report reason */ "Starting from %@." = "Statisztikagyűjtés kezdete: %@."; /* No comment provided by engineer. */ -"starting…" = "indítás…"; +"starting…" = "hívás indítása…"; /* No comment provided by engineer. */ "Statistics" = "Statisztikák"; @@ -5425,10 +5426,10 @@ report reason */ "The second preset operator in the app!" = "A második előre beállított üzemeltető az alkalmazásban!"; /* No comment provided by engineer. */ -"The second tick we missed! ✅" = "A második jelölés, amit kihagytunk! ✅"; +"The second tick we missed! ✅" = "A második pipa, ami már nagyon hiányzott! ✅"; /* alert message */ -"The sender will NOT be notified" = "A kérés küldője NEM fog értesítést kapni"; +"The sender will NOT be notified" = "A kérés küldője NEM lesz értesítve"; /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói."; @@ -5458,10 +5459,10 @@ report reason */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek."; @@ -5557,7 +5558,7 @@ report reason */ "To send commands you must be connected." = "A parancsok küldéséhez kapcsolódva kell lennie."; /* No comment provided by engineer. */ -"To support instant push notifications the chat database has to be migrated." = "Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges."; +"To support instant push notifications the chat database has to be migrated." = "Az azonnali leküldéses értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges."; /* alert message */ "To use another profile after connection attempt, delete the chat and use the link again." = "Másik profil használatához a kapcsolatfelvételi kísérlet után törölje a csevegést, és használja újra a hivatkozást."; @@ -5566,7 +5567,7 @@ report reason */ "To use the servers of **%@**, accept conditions of use." = "A(z) **%@** kiszolgálóinak használatához fogadja el a használati feltételeket."; /* No comment provided by engineer. */ -"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; /* No comment provided by engineer. */ "Toggle chat list:" = "Csevegési lista ki/be:"; @@ -5701,7 +5702,7 @@ report reason */ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Az adatbázis jelmondatának módosítása"; +"Update database passphrase" = "Adatbázis jelmondatának módosítása"; /* No comment provided by engineer. */ "Update network settings?" = "Módosítja a hálózati beállításokat?"; @@ -5830,7 +5831,7 @@ report reason */ "Use web port" = "Webport használata"; /* No comment provided by engineer. */ -"User selection" = "Felhasználó kijelölése"; +"User selection" = "Felhasználó kiválasztása"; /* No comment provided by engineer. */ "Username" = "Felhasználónév"; @@ -5845,25 +5846,25 @@ report reason */ "v%@ (%@)" = "v%@ (%@)"; /* No comment provided by engineer. */ -"Verify code with desktop" = "Kód hitelesítése a számítógépen"; +"Verify code with desktop" = "Kód ellenőrzése a számítógépen"; /* No comment provided by engineer. */ -"Verify connection" = "Kapcsolat hitelesítése"; +"Verify connection" = "Kapcsolat ellenőrzése"; /* No comment provided by engineer. */ -"Verify connection security" = "Biztonságos kapcsolat hitelesítése"; +"Verify connection security" = "Biztonságos kapcsolat ellenőrzése"; /* No comment provided by engineer. */ -"Verify connections" = "Kapcsolatok hitelesítése"; +"Verify connections" = "Kapcsolatok ellenőrzése"; /* No comment provided by engineer. */ -"Verify database passphrase" = "Az adatbázis jelmondatának hitelesítése"; +"Verify database passphrase" = "Adatbázis jelmondatának ellenőrzése"; /* No comment provided by engineer. */ -"Verify passphrase" = "Jelmondat hitelesítése"; +"Verify passphrase" = "Jelmondat ellenőrzése"; /* No comment provided by engineer. */ -"Verify security code" = "Biztonsági kód hitelesítése"; +"Verify security code" = "Biztonsági kód ellenőrzése"; /* No comment provided by engineer. */ "Via browser" = "Böngészőn keresztül"; @@ -5890,7 +5891,7 @@ report reason */ "Video call" = "Videóhívás"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)" = "videóhívás (nem e2e titkosított)"; +"video call (not e2e encrypted)" = "videóhívás (végpontok között NEM titkosított)"; /* No comment provided by engineer. */ "Video will be received when your contact completes uploading it." = "A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését."; @@ -6091,7 +6092,7 @@ report reason */ "You are invited to group" = "Ön meghívást kapott a csoportba"; /* subscription status explanation */ -"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés)."; +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás)."; /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; @@ -6148,7 +6149,7 @@ report reason */ "You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**."; /* No comment provided by engineer. */ -"You can start chat via app Settings / Database or by restarting the app" = "A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el"; +"You can start chat via app Settings / Database or by restarting the app" = "A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával"; /* No comment provided by engineer. */ "You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; @@ -6181,7 +6182,7 @@ report reason */ "you changed role of %@ to %@" = "Ön a következőre módosította %1$@ szerepkörét: „%2$@”"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; +"You could not be verified; please try again." = "Nem sikerült ellenőrizni; próbálja meg újra."; /* No comment provided by engineer. */ "You decide who can connect." = "Ön dönti el, hogy kivel beszélget."; @@ -6262,10 +6263,10 @@ report reason */ "You will still receive calls and notifications from muted profiles when they are active." = "Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak."; /* No comment provided by engineer. */ -"You will stop receiving messages from this group. Chat history will be preserved." = "Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak."; +"You will stop receiving messages from this group. Chat history will be preserved." = "Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak."; /* No comment provided by engineer. */ "You won't lose your contacts if you later delete your address." = "Nem veszíti el a partnereit, ha később törli a címét."; @@ -6307,13 +6308,13 @@ report reason */ "Your contact" = "Partner"; /* No comment provided by engineer. */ -"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg támogatott legnagyobb (%@) fájlméretnél nagyobbat küldött."; /* No comment provided by engineer. */ "Your contacts can allow full message deletion." = "A partnerei engedélyezhetik a teljes üzenet törlését."; /* No comment provided by engineer. */ -"Your contacts will remain connected." = "A partnerei továbbra is kapcsolódva maradnak."; +"Your contacts will remain connected." = "A partnereivel továbbra is kapcsolatban marad."; /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "A hitelesítési adatai titkosítatlanul is elküldhetők."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 511a6835e5..9e2a27e618 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Elimina messaggi"; /* No comment provided by engineer. */ @@ -3308,10 +3309,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Il ruolo del membro verrà cambiato in \"%@\". Il membro riceverà un invito nuovo."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Il membro verrà rimosso dalla chat, non è reversibile!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Il membro verrà rimosso dal gruppo, non è reversibile!"; /* alert message */ @@ -3899,7 +3900,7 @@ new chat action */ "Open new chat" = "Apri una chat nuova"; /* new chat action */ -"Open new group" = "Apri un gruppo nuovo"; +"Open new group" = "Apri il nuovo gruppo"; /* No comment provided by engineer. */ "Open Settings" = "Apri le impostazioni"; @@ -4380,7 +4381,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Il server relay protegge il tuo indirizzo IP, ma può osservare la durata della chiamata."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Rimuovi"; /* No comment provided by engineer. */ @@ -4395,7 +4396,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Rimuovi membro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Rimuovere il membro?"; /* No comment provided by engineer. */ diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index d4510af72f..480eb39d36 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -374,9 +374,18 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledged" = "了承済み"; +/* No comment provided by engineer. */ +"Active connections" = "アクティブな接続"; + /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。"; +/* No comment provided by engineer. */ +"Add friends" = "友達を追加"; + +/* No comment provided by engineer. */ +"Add list" = "リストを追加"; + /* No comment provided by engineer. */ "Add profile" = "プロフィールを追加"; @@ -386,9 +395,15 @@ swipe action */ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "QRコードでサーバを追加する。"; +/* No comment provided by engineer. */ +"Add team members" = "チームメンバーを追加"; + /* No comment provided by engineer. */ "Add to another device" = "別の端末に追加"; +/* No comment provided by engineer. */ +"Add to list" = "リストに追加"; + /* No comment provided by engineer. */ "Add welcome message" = "ウェルカムメッセージを追加"; @@ -404,6 +419,9 @@ swipe action */ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "アドレス変更は中止されます。古い受信アドレスが使用されます。"; +/* No comment provided by engineer. */ +"Address settings" = "アドレス設定"; + /* member role */ "admin" = "管理者"; @@ -422,6 +440,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "暗号化に同意しています…"; +/* No comment provided by engineer. */ +"All" = "すべて"; + /* No comment provided by engineer. */ "All app data is deleted." = "すべてのアプリデータが削除されます。"; @@ -434,6 +455,9 @@ swipe action */ /* No comment provided by engineer. */ "All group members will remain connected." = "グループ全員の接続が継続します。"; +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone!" = "すべてのメッセージが削除されます。この操作は元に戻せません!"; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。"; @@ -455,9 +479,15 @@ swipe action */ /* No comment provided by engineer. */ "Allow calls only if your contact allows them." = "連絡先が通話を許可している場合のみ通話を許可する。"; +/* No comment provided by engineer. */ +"Allow calls?" = "通話を許可しますか?"; + /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "連絡先が許可している場合のみ消えるメッセージを許可する。"; +/* No comment provided by engineer. */ +"Allow downgrade" = "ダウングレードを許可する"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間)"; @@ -530,6 +560,9 @@ swipe action */ /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "指定された名前の空のチャット プロファイルが作成され、アプリが通常どおり開きます。"; +/* report reason */ +"Another reason" = "他の理由"; + /* No comment provided by engineer. */ "Answer call" = "通話に応答"; @@ -569,9 +602,15 @@ swipe action */ /* No comment provided by engineer. */ "Apply to" = "に適用する"; +/* No comment provided by engineer. */ +"Archive" = "アーカイブ"; + /* No comment provided by engineer. */ "Archive and upload" = "アーカイブとアップロード"; +/* No comment provided by engineer. */ +"Archived contacts" = "アーカイブされた連絡先"; + /* No comment provided by engineer. */ "Attach" = "添付する"; @@ -668,6 +707,9 @@ swipe action */ /* No comment provided by engineer. */ "Calls" = "通話"; +/* alert title */ +"Can't change profile" = "プロフィールを変更できません"; + /* No comment provided by engineer. */ "Can't invite contact!" = "連絡先を招待できません!"; @@ -679,12 +721,18 @@ alert button new chat action */ "Cancel" = "中止"; +/* No comment provided by engineer. */ +"Cancel migration" = "移行を中止する"; + /* feature offered item */ "cancelled %@" = "キャンセルされました %@"; /* No comment provided by engineer. */ "Cannot access keychain to save database password" = "データベースのパスワードを保存するためのキーチェーンにアクセスできません"; +/* No comment provided by engineer. */ +"Cannot forward message" = "メッセージを転送できません"; + /* alert title */ "Cannot receive file" = "ファイル受信ができません"; @@ -734,6 +782,9 @@ set passcode view */ /* chat item text */ "changing address…" = "アドレスを変更しています…"; +/* No comment provided by engineer. */ +"Chat" = "チャット"; + /* No comment provided by engineer. */ "Chat console" = "チャットのコンソール"; @@ -752,6 +803,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chat is stopped" = "チャットが停止してます"; +/* No comment provided by engineer. */ +"Chat list" = "チャット一覧"; + /* No comment provided by engineer. */ "Chat preferences" = "チャット設定"; @@ -764,6 +818,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "チャット"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "20分おきにメッセージを確認する。"; + /* alert title */ "Check server address and try again." = "サーバのアドレスを確認してから再度試してください。"; @@ -1168,7 +1225,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "メッセージを削除しますか?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "メッセージを削除"; /* No comment provided by engineer. */ @@ -2103,7 +2161,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "メンバーの役割が \"%@\" に変更されます。 メンバーは新たな招待を受け取ります。"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "メンバーをグループから除名する (※元に戻せません※)!"; /* No comment provided by engineer. */ @@ -2644,13 +2702,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "リレー サーバーは IP アドレスを保護しますが、通話時間は監視されます。"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "削除"; /* No comment provided by engineer. */ "Remove member" = "メンバーを除名する"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "メンバーを除名しますか?"; /* No comment provided by engineer. */ diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 79e3da3b01..29f8bb5b3f 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1688,7 +1688,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Verwijder bericht?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Verwijder berichten"; /* No comment provided by engineer. */ @@ -3197,10 +3198,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "De rol van lid wordt gewijzigd in \"%@\". Het lid ontvangt een nieuwe uitnodiging."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Lid wordt verwijderd uit de chat - dit kan niet ongedaan worden gemaakt!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt!"; /* alert message */ @@ -4218,7 +4219,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay server beschermt uw IP-adres, maar kan de duur van het gesprek observeren."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Verwijderen"; /* No comment provided by engineer. */ @@ -4230,7 +4231,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Lid verwijderen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Lid verwijderen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 34c79eeef4..9ef572364f 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -346,12 +346,21 @@ alert action swipe action */ "Accept" = "Akceptuj"; +/* alert action */ +"Accept as member" = "Zaakceptuj jako członka"; + +/* alert action */ +"Accept as observer" = "Zaakceptuj jako obserwatora"; + /* No comment provided by engineer. */ "Accept conditions" = "Zaakceptuj warunki"; /* No comment provided by engineer. */ "Accept connection request?" = "Zaakceptować prośbę o połączenie?"; +/* alert title */ +"Accept contact request" = "Zaakceptuj prośby o kontakt"; + /* notification body */ "Accept contact request from %@?" = "Zaakceptuj prośbę o kontakt od %@?"; @@ -359,6 +368,9 @@ swipe action */ swipe action */ "Accept incognito" = "Akceptuj incognito"; +/* alert title */ +"Accept member" = "Zaakceptuj członka"; + /* call status */ "accepted call" = "zaakceptowane połączenie"; @@ -386,6 +398,9 @@ swipe action */ /* No comment provided by engineer. */ "Add list" = "Dodaj listę"; +/* placeholder for sending contact request */ +"Add message" = "Dodaj wiadomość"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -503,6 +518,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Wszystkie raporty zostaną dla Ciebie zarchiwizowane."; +/* No comment provided by engineer. */ +"All servers" = "Wszystkie serwery"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Wszystkie Twoje kontakty pozostaną połączone."; @@ -527,6 +545,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "Zezwól na obniżenie wersji"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "Zezwalaj na pliki i media tylko wtedy, gdy Twój kontakt na to pozwala."; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Zezwalaj na nieodwracalne usuwanie wiadomości tylko wtedy, gdy Twój kontakt Ci na to pozwoli. (24 godziny)"; @@ -578,6 +599,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "Zezwól swoim kontaktom na wysyłanie znikających wiadomości."; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "Pozwól kontaktom wysyłać pliki i media."; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "Zezwól swoim kontaktom na wysyłanie wiadomości głosowych."; @@ -755,6 +779,9 @@ swipe action */ /* No comment provided by engineer. */ "Better groups" = "Lepsze grupy"; +/* No comment provided by engineer. */ +"Better groups performance" = "Lepsze działanie grup"; + /* No comment provided by engineer. */ "Better message dates." = "Lepsze daty wiadomości."; @@ -767,6 +794,9 @@ swipe action */ /* No comment provided by engineer. */ "Better notifications" = "Lepsze powiadomienia"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Lepsza prywatność i bezpieczeństwo"; + /* No comment provided by engineer. */ "Better security ✅" = "Lepsze zabezpieczenia ✅"; @@ -816,6 +846,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "pogrubiona"; +/* No comment provided by engineer. */ +"Bot" = "Bot"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "Zarówno Ty, jak i Twój kontakt możecie dodawać reakcje wiadomości."; @@ -828,6 +861,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać pliki i media."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać wiadomości głosowe."; @@ -1559,7 +1595,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Usuń wiadomości"; /* No comment provided by engineer. */ @@ -2876,7 +2913,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Rola członka zostanie zmieniona na \"%@\". Członek otrzyma nowe zaproszenie."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Członek zostanie usunięty z grupy - nie można tego cofnąć!"; /* No comment provided by engineer. */ @@ -3711,7 +3748,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Serwer przekaźnikowy chroni Twój adres IP, ale może obserwować czas trwania połączenia."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Usuń"; /* No comment provided by engineer. */ @@ -3723,7 +3760,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Usuń członka"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Usunąć członka?"; /* No comment provided by engineer. */ diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 0826bca4a3..87a47ec2ab 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -164,7 +164,7 @@ "%d file(s) were not downloaded." = "%d файлов не было загружено."; /* time interval */ -"%d hours" = "%d час."; +"%d hours" = "%d ч."; /* alert title */ "%d messages not forwarded" = "%d сообщений не переслано"; @@ -729,7 +729,7 @@ swipe action */ "attempts" = "попытки"; /* No comment provided by engineer. */ -"Audio & video calls" = "Аудио- и видеозвонки"; +"Audio & video calls" = "Аудио и видеозвонки"; /* No comment provided by engineer. */ "Audio and video calls" = "Аудио и видео звонки"; @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ @@ -2238,6 +2239,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Ошибка подключения к пересылающему серверу %@. Попробуйте позже."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Ошибка подключения к серверу, используемому для получения сообщений от этого соединения: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Ошибка при создании адреса"; @@ -3305,10 +3309,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль члена будет изменена на \"%@\". Будет отправлено новое приглашение."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Член будет удален из разговора - это действие нельзя отменить!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Член группы будет удален - это действие нельзя отменить!"; /* alert message */ @@ -3710,6 +3714,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Нет серверов для отправки файлов."; +/* No comment provided by engineer. */ +"no subscription" = "нет подписки"; + /* copied message info in history */ "no text" = "нет текста"; @@ -4374,7 +4381,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay сервер защищает Ваш IP адрес, но может отслеживать продолжительность звонка."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Удалить"; /* No comment provided by engineer. */ @@ -4389,7 +4396,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Удалить члена группы"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Удалить члена группы?"; /* No comment provided by engineer. */ @@ -5545,7 +5552,7 @@ report reason */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Чтобы показать Ваш скрытый профиль, введите его пароль в поле поиска на странице **Ваши профили чата**."; /* No comment provided by engineer. */ -"To send" = "Для оправки"; +"To send" = "Для отправки"; /* alert message */ "To send commands you must be connected." = "Вы должны быть соединены, чтобы отправлять команды."; @@ -5583,6 +5590,9 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортные сессии"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Попытка подключиться к серверу, используемому для получения сообщений от этого соединения."; + /* No comment provided by engineer. */ "Turkish interface" = "Турецкий интерфейс"; @@ -6075,9 +6085,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вы уже вступаете в группу!\nПовторить запрос на вступление?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Вы подключены к серверу, используемому для приема сообщений от этого соединения."; + /* No comment provided by engineer. */ "You are invited to group" = "Вы приглашены в группу"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Вы не подключены к серверу, используемому для получения сообщений по этому соединению (нет подписки)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка."; diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 2700711773..d4b4dfd949 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -910,7 +910,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "ลบข้อความ?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "ลบข้อความ"; /* No comment provided by engineer. */ @@ -1815,7 +1816,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "บทบาทของสมาชิกจะถูกเปลี่ยนเป็น \"%@\" สมาชิกจะได้รับคำเชิญใหม่"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้!"; /* No comment provided by engineer. */ @@ -2332,13 +2333,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "เซิร์ฟเวอร์รีเลย์ปกป้องที่อยู่ IP ของคุณ แต่สามารถสังเกตระยะเวลาของการโทรได้"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "ลบ"; /* No comment provided by engineer. */ "Remove member" = "ลบสมาชิกออก"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "ลบสมาชิกออก?"; /* No comment provided by engineer. */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index 9acf2cc425..5cccb67170 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Mesaj silinsin mi?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Mesajları sil"; /* No comment provided by engineer. */ @@ -3293,10 +3294,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Üye rolü \"%@\" olarak değiştirilecektir. Ve üye yeni bir davetiye alacaktır."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Üye sohbetten kaldırılacak - bu geri alınamaz!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Üye gruptan çıkarılacaktır - bu geri alınamaz!"; /* alert message */ @@ -4362,7 +4363,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Yönlendirici sunucu IP adresinizi korur, ancak aramanın süresini gözlemleyebilir."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Sil"; /* No comment provided by engineer. */ @@ -4377,7 +4378,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Kişiyi sil"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Kişi silinsin mi?"; /* No comment provided by engineer. */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index fe8cfe22a0..305e64fbcf 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1718,7 +1718,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Видалити повідомлення?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Видалити повідомлення"; /* No comment provided by engineer. */ @@ -3263,10 +3264,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Учасника буде видалено з чату – це неможливо скасувати!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; /* alert message */ @@ -4317,7 +4318,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Сервер ретрансляції захищає вашу IP-адресу, але він може спостерігати за тривалістю дзвінка."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Видалити"; /* No comment provided by engineer. */ @@ -4329,7 +4330,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Видалити учасника"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Видалити учасника?"; /* No comment provided by engineer. */ diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 24d153afd5..ff80559fb1 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -346,12 +346,21 @@ alert action swipe action */ "Accept" = "接受"; +/* alert action */ +"Accept as member" = "接受为成员"; + +/* alert action */ +"Accept as observer" = "接受为观察员"; + /* No comment provided by engineer. */ "Accept conditions" = "接受条款"; /* No comment provided by engineer. */ "Accept connection request?" = "接受联系人?"; +/* alert title */ +"Accept contact request" = "接受联络请求"; + /* notification body */ "Accept contact request from %@?" = "接受来自 %@ 的联系人请求?"; @@ -359,12 +368,21 @@ swipe action */ swipe action */ "Accept incognito" = "接受隐身聊天"; +/* alert title */ +"Accept member" = "接受成员"; + /* call status */ "accepted call" = "已接受通话"; /* No comment provided by engineer. */ "Accepted conditions" = "已接受的条款"; +/* chat list item title */ +"accepted invitation" = "已接受邀请"; + +/* rcv group event chat item */ +"accepted you" = "接受了你"; + /* No comment provided by engineer. */ "Acknowledged" = "确认"; @@ -386,6 +404,9 @@ swipe action */ /* No comment provided by engineer. */ "Add list" = "添加列表"; +/* placeholder for sending contact request */ +"Add message" = "添加信息"; + /* No comment provided by engineer. */ "Add profile" = "添加个人资料"; @@ -461,6 +482,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "同意加密…"; +/* member criteria value */ +"all" = "全部"; + /* No comment provided by engineer. */ "All" = "全部"; @@ -530,6 +554,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "允许降级"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "只有你的联系人允许的情况下才允许文件和媒体。"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "仅有您的联系人许可后才允许不可撤回消息移除"; @@ -581,6 +608,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "允许您的联系人发送限时消息。"; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "允许你的联系人发送文件和媒体。"; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "允许您的联系人发送语音消息。"; @@ -683,6 +713,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "已存档的联系人"; +/* No comment provided by engineer. */ +"archived report" = "已存档的举报"; + /* No comment provided by engineer. */ "Archiving database" = "正在存档数据库"; @@ -782,6 +815,12 @@ swipe action */ /* No comment provided by engineer. */ "Better user experience" = "更佳的使用体验"; +/* No comment provided by engineer. */ +"Bio" = "自我介绍"; + +/* alert title */ +"Bio too large" = "自我介绍过大"; + /* No comment provided by engineer. */ "Black" = "黑色"; @@ -825,6 +864,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "加粗"; +/* No comment provided by engineer. */ +"Bot" = "机器人"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "您和您的联系人都可以添加消息回应。"; @@ -837,6 +879,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "您和您的联系人都可以发送限时消息。"; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "你和你的联系人都可发送文件和媒体。"; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "您和您的联系人都可以发送语音消息。"; @@ -849,6 +894,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Business chats" = "企业聊天"; +/* No comment provided by engineer. */ +"Business connection" = "企业连接"; + /* No comment provided by engineer. */ "Businesses" = "企业"; @@ -888,6 +936,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "无法呼叫成员"; +/* alert title */ +"Can't change profile" = "无法更改个人资料"; + /* No comment provided by engineer. */ "Can't invite contact!" = "无法邀请联系人!"; @@ -897,6 +948,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "无法向成员发送消息"; +/* No comment provided by engineer. */ +"can't send messages" = "无法发送消息"; + /* alert action alert button new chat action */ @@ -1035,9 +1089,21 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "将为你删除聊天 - 此操作无法撤销!"; +/* chat toolbar */ +"Chat with admins" = "和管理员聊天"; + +/* No comment provided by engineer. */ +"Chat with member" = "和成员聊天"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "在成员加入前和这些人聊天"; + /* No comment provided by engineer. */ "Chats" = "聊天"; +/* No comment provided by engineer. */ +"Chats with members" = "和成员聊天"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "每 20 分钟检查消息。"; @@ -1179,6 +1245,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connect automatically" = "自动连接"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "更快地连接!🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "连接到桌面"; @@ -1317,9 +1386,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "联系人已存在"; +/* No comment provided by engineer. */ +"contact deleted" = "删除了联系人"; + /* No comment provided by engineer. */ "Contact deleted!" = "联系人已删除!"; +/* No comment provided by engineer. */ +"contact disabled" = "禁用了联系人"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "联系人具有端到端加密"; @@ -1338,9 +1413,18 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "联系人姓名"; +/* No comment provided by engineer. */ +"contact not ready" = "联系人未就绪"; + /* No comment provided by engineer. */ "Contact preferences" = "联系人偏好设置"; +/* No comment provided by engineer. */ +"Contact requests from groups" = "来自群的联络请求"; + +/* No comment provided by engineer. */ +"contact should accept…" = "联系人应当接受…"; + /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "联系人将被删除-这是无法撤消的!"; @@ -1410,6 +1494,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create SimpleX address" = "创建 SimpleX 地址"; +/* No comment provided by engineer. */ +"Create your address" = "创建地址"; + /* No comment provided by engineer. */ "Create your profile" = "创建您的资料"; @@ -1583,6 +1670,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "删除聊天资料?"; +/* alert title */ +"Delete chat with member?" = "删除和成员的聊天吗?"; + /* No comment provided by engineer. */ "Delete chat?" = "删除聊天?"; @@ -1640,7 +1730,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "删除消息"; /* No comment provided by engineer. */ @@ -1709,9 +1800,15 @@ swipe action */ /* No comment provided by engineer. */ "Delivery receipts!" = "送达回执!"; +/* No comment provided by engineer. */ +"Deprecated options" = "已废弃的选项"; + /* No comment provided by engineer. */ "Description" = "描述"; +/* alert title */ +"Description too large" = "描述过大"; + /* No comment provided by engineer. */ "Desktop address" = "桌面地址"; @@ -1914,6 +2011,9 @@ chat item action */ /* No comment provided by engineer. */ "Edit group profile" = "编辑群组资料"; +/* No comment provided by engineer. */ +"Empty message!" = "空消息!"; + /* No comment provided by engineer. */ "Enable" = "启用"; @@ -1926,6 +2026,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "启用相机访问"; +/* No comment provided by engineer. */ +"Enable disappearing messages by default." = "默认启用定时消失消息。"; + /* No comment provided by engineer. */ "Enable Flux in Network & servers settings for better metadata privacy." = "在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。"; @@ -2097,15 +2200,24 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "接受联系人请求错误"; +/* alert title */ +"Error accepting member" = "接受成员出错"; + /* No comment provided by engineer. */ "Error adding member(s)" = "添加成员错误"; /* alert title */ "Error adding server" = "添加服务器出错"; +/* No comment provided by engineer. */ +"Error adding short link" = "添加短链接出错"; + /* No comment provided by engineer. */ "Error changing address" = "更改地址错误"; +/* alert title */ +"Error changing chat profile" = "更改聊天资料出错"; + /* No comment provided by engineer. */ "Error changing connection profile" = "更改连接资料出错"; @@ -2118,6 +2230,9 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "切换至隐身聊天出错!"; +/* No comment provided by engineer. */ +"Error checking token status" = "查询token状态出错"; + /* alert message */ "Error connecting to forwarding server %@. Please try later." = "连接到转发服务器 %@ 时出错。请稍后尝试。"; @@ -2148,6 +2263,9 @@ chat item action */ /* No comment provided by engineer. */ "Error decrypting file" = "解密文件时出错"; +/* alert title */ +"Error deleting chat" = "删除聊天出错"; + /* alert title */ "Error deleting chat database" = "删除聊天数据库错误"; @@ -2202,6 +2320,9 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "打开聊天时出错"; +/* No comment provided by engineer. */ +"Error opening group" = "打开群时出错"; + /* alert title */ "Error receiving file" = "接收文件错误"; @@ -2214,6 +2335,9 @@ chat item action */ /* alert title */ "Error registering for notifications" = "注册消息推送出错"; +/* alert title */ +"Error rejecting contact request" = "拒绝联络请求出错"; + /* alert title */ "Error removing member" = "删除成员错误"; @@ -2259,6 +2383,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "发送消息错误"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "设置自动接受出错"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "设置送达回执出错!"; @@ -2309,6 +2436,9 @@ file error text snd error text */ "Error: %@" = "错误: %@"; +/* server test error */ +"Error: %@." = "错误:%@。"; + /* No comment provided by engineer. */ "Error: no database file" = "错误:没有数据库文件"; @@ -2417,6 +2547,9 @@ snd error text */ /* chat feature */ "Files and media" = "文件和媒体"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "此聊天禁止文件和媒体。"; + /* No comment provided by engineer. */ "Files and media are prohibited." = "此群组中禁止文件和媒体。"; @@ -2441,6 +2574,15 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "更快地查找聊天记录"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "目的地服务器的指纹与证书不符:%@。"; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "转发服务器的指纹与证书不符:%@。"; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "服务器的指纹与证书不符:%@。"; + /* server test error */ "Fingerprint in server address does not match certificate." = "服务器地址中的证书指纹可能不正确"; @@ -2561,6 +2703,9 @@ snd error text */ /* message preview */ "Good morning!" = "早上好!"; +/* shown on group welcome message */ +"group" = "群"; + /* No comment provided by engineer. */ "Group" = "群组"; @@ -2591,6 +2736,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "群组邀请不再有效,已被发件人删除。"; +/* No comment provided by engineer. */ +"group is deleted" = "群被删除了"; + /* No comment provided by engineer. */ "Group link" = "群组链接"; @@ -2615,6 +2763,9 @@ snd error text */ /* snd group event chat item */ "group profile updated" = "群组资料已更新"; +/* alert message */ +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "群资料已修改。如果你进行保存,修改后的群资料将发送给其他群成员。"; + /* No comment provided by engineer. */ "Group welcome message" = "群欢迎词"; @@ -2990,6 +3141,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "保留未使用的邀请吗?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "保持聊天洁净"; + /* No comment provided by engineer. */ "Keep your connections" = "保持连接"; @@ -3023,6 +3177,9 @@ snd error text */ /* rcv group event chat item */ "left" = "已离开"; +/* No comment provided by engineer. */ +"Less traffic on mobile networks." = "消耗更少的移动网络数据。"; + /* email subject */ "Let's talk in SimpleX Chat" = "让我们一起在 SimpleX Chat 里聊天"; @@ -3059,6 +3216,9 @@ snd error text */ /* No comment provided by engineer. */ "Live messages" = "实时消息"; +/* in progress text */ +"Loading profile…" = "正加载个人资料…"; + /* No comment provided by engineer. */ "Local name" = "本地名称"; @@ -3110,15 +3270,27 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "成员"; +/* past/unknown group member */ +"Member %@" = "成员 %@"; + /* profile update event chat item */ "member %@ changed to %@" = "成员 %1$@ 已更改为 %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "成员准入"; + /* rcv group event chat item */ "member connected" = "已连接"; +/* No comment provided by engineer. */ +"member has old version" = "成员有旧版本"; + /* item status text */ "Member inactive" = "成员不活跃"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "成员被删除——无法接受请求"; + /* chat feature */ "Member reports" = "成员举报"; @@ -3131,12 +3303,15 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "成员角色将更改为 \"%@\"。该成员将收到一份新的邀请。"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "将从聊天中删除成员 - 此操作无法撤销!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "成员将被移出群组——此操作无法撤消!"; +/* alert message */ +"Member will join the group, accept member?" = "成员将加入本群,接受成员吗?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "群组成员可以添加信息回应。"; @@ -3185,6 +3360,9 @@ snd error text */ /* item status text */ "Message forwarded" = "消息已转发"; +/* No comment provided by engineer. */ +"Message instantly once you tap Connect." = "轻按连接后即刻发消息。"; + /* item status description */ "Message may be delivered later if member becomes active." = "如果 member 变为活动状态,则稍后可能会发送消息。"; @@ -3233,6 +3411,9 @@ snd error text */ /* No comment provided by engineer. */ "Messages & files" = "消息"; +/* No comment provided by engineer. */ +"Messages are protected by **end-to-end encryption**." = "消息已通过**端到端加密**保护。"; + /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "将显示来自 %@ 的消息!"; @@ -3311,6 +3492,9 @@ snd error text */ /* marked deleted chat item preview text */ "moderated by %@" = "由 %@ 审核"; +/* member role */ +"moderator" = "协管"; + /* time unit */ "months" = "月"; @@ -3395,6 +3579,9 @@ snd error text */ /* notification */ "New events" = "新事件"; +/* No comment provided by engineer. */ +"New group role: Moderator" = "新的群角色:协管"; + /* No comment provided by engineer. */ "New in %@" = "%@ 的新内容"; @@ -3404,6 +3591,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "新成员角色"; +/* rcv group event chat item */ +"New member wants to join the group." = "新成员要加入本群。"; + /* notification */ "new message" = "新消息"; @@ -3443,6 +3633,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "列表 %@ 中无聊天"; +/* No comment provided by engineer. */ +"No chats with members" = "没有和成员的聊天"; + /* No comment provided by engineer. */ "No contacts selected" = "未选择联系人"; @@ -3494,6 +3687,9 @@ snd error text */ /* No comment provided by engineer. */ "No permission to record voice message" = "没有录制语音消息的权限"; +/* alert title */ +"No private routing session" = "无私密路由会话"; + /* No comment provided by engineer. */ "No push server" = "本地"; @@ -3512,6 +3708,9 @@ snd error text */ /* servers error */ "No servers to send files." = "无文件发送服务器。"; +/* No comment provided by engineer. */ +"no subscription" = "无订阅"; + /* copied message info in history */ "no text" = "无文本"; @@ -3527,6 +3726,9 @@ snd error text */ /* No comment provided by engineer. */ "Not compatible!" = "不兼容!"; +/* No comment provided by engineer. */ +"not synchronized" = "未同步"; + /* No comment provided by engineer. */ "Notes" = "附注"; @@ -3634,6 +3836,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "只有您可以发送限时消息。"; +/* No comment provided by engineer. */ +"Only you can send files and media." = "只有你可以发送文件和媒体。"; + /* No comment provided by engineer. */ "Only you can send voice messages." = "只有您可以发送语音消息。"; @@ -3649,6 +3854,9 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "只有您的联系人才可以发送限时消息。"; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "只有你的联系人可以发送文件和媒体。"; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; @@ -3664,18 +3872,45 @@ new chat action */ /* authentication reason */ "Open chat console" = "打开聊天控制台"; +/* alert action */ +"Open clean link" = "打开干净链接"; + /* No comment provided by engineer. */ "Open conditions" = "打开条款"; +/* alert action */ +"Open full link" = "打开完整链接"; + /* new chat action */ "Open group" = "打开群"; +/* alert title */ +"Open link?" = "打开链接?"; + /* authentication reason */ "Open migration to another device" = "打开迁移到另一台设备"; +/* new chat action */ +"Open new chat" = "打开新聊天"; + +/* new chat action */ +"Open new group" = "打开新群"; + /* No comment provided by engineer. */ "Open Settings" = "打开设置"; +/* No comment provided by engineer. */ +"Open to accept" = "打开以接受"; + +/* No comment provided by engineer. */ +"Open to connect" = "打开以连接"; + +/* No comment provided by engineer. */ +"Open to join" = "打开以加入"; + +/* No comment provided by engineer. */ +"Open to use bot" = "打开来使用机器人"; + /* No comment provided by engineer. */ "Opening app…" = "正在打开应用程序…"; @@ -3715,6 +3950,9 @@ new chat action */ /* No comment provided by engineer. */ "other errors" = "其他错误"; +/* alert message */ +"Other file errors:\n%@" = "其他文件错误:\n%@"; + /* member role */ "owner" = "群主"; @@ -3760,6 +3998,12 @@ new chat action */ /* No comment provided by engineer. */ "Pending" = "待定"; +/* No comment provided by engineer. */ +"pending approval" = "待批准"; + +/* No comment provided by engineer. */ +"pending review" = "待审核"; + /* No comment provided by engineer. */ "Periodic" = "定期"; @@ -3826,15 +4070,33 @@ new chat action */ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "请安全地保存密码,如果您丢失了密码,您将无法更改它。"; +/* token info */ +"Please try to disable and re-enable notfications." = "请尝试禁用并重新启用通知。"; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "请等待群的协管审核你加入该群的请求。"; + +/* token info */ +"Please wait for token activation to complete." = "请等待token激活完成。"; + +/* token info */ +"Please wait for token to be registered." = "请等待token注册完成。"; + /* No comment provided by engineer. */ "Polish interface" = "波兰语界面"; +/* No comment provided by engineer. */ +"Port" = "端口"; + /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "保留最后的消息草稿及其附件。"; /* No comment provided by engineer. */ "Preset server address" = "预设服务器地址"; +/* No comment provided by engineer. */ +"Preset servers" = "预设服务器"; + /* No comment provided by engineer. */ "Preview" = "预览"; @@ -3844,6 +4106,9 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "隐私和安全"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "客户隐私。"; + /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "隐私政策和使用条款。"; @@ -3856,6 +4121,9 @@ new chat action */ /* No comment provided by engineer. */ "Private filenames" = "私密文件名"; +/* No comment provided by engineer. */ +"Private media file names." = "私密媒体文件名。"; + /* No comment provided by engineer. */ "Private message routing" = "私有消息路由"; @@ -3871,6 +4139,9 @@ new chat action */ /* alert title */ "Private routing error" = "专用路由错误"; +/* alert title */ +"Private routing timeout" = "私密路由超时"; + /* No comment provided by engineer. */ "Profile and server connections" = "资料和服务器连接"; @@ -3901,6 +4172,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit messages reactions." = "禁止消息回应。"; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "禁止向 协管 举报消息。"; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "禁止向成员发送私信。"; @@ -3928,6 +4202,9 @@ new chat action */ /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "保护您的 IP 地址免受联系人选择的消息中继的攻击。\n在*网络和服务器*设置中启用。"; +/* No comment provided by engineer. */ +"Protocol background timeout" = "协议后台超时"; + /* No comment provided by engineer. */ "Protocol timeout" = "协议超时"; @@ -3940,6 +4217,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxied servers" = "代理服务器"; +/* No comment provided by engineer. */ +"Proxy requires password" = "代理需要密码"; + /* No comment provided by engineer. */ "Push notifications" = "推送通知"; @@ -4057,6 +4337,12 @@ new chat action */ /* No comment provided by engineer. */ "Reduced battery usage" = "减少电池使用量"; +/* No comment provided by engineer. */ +"Register" = "注册"; + +/* token status text */ +"Registered" = "已注册"; + /* alert action reject incoming call via notification swipe action */ @@ -4068,6 +4354,12 @@ swipe action */ /* alert title */ "Reject contact request" = "拒绝联系人请求"; +/* alert title */ +"Reject member?" = "拒绝成员?"; + +/* No comment provided by engineer. */ +"rejected" = "被拒绝"; + /* call status */ "rejected call" = "拒接来电"; @@ -4077,16 +4369,22 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "中继服务器保护您的 IP 地址,但它可以观察通话的持续时间。"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "移除"; +/* No comment provided by engineer. */ +"Remove archive?" = "删除存档?"; + /* No comment provided by engineer. */ "Remove image" = "移除图片"; /* No comment provided by engineer. */ -"Remove member" = "删除成员"; +"Remove link tracking" = "删除链接跟踪"; /* No comment provided by engineer. */ +"Remove member" = "删除成员"; + +/* alert title */ "Remove member?" = "删除成员吗?"; /* No comment provided by engineer. */ @@ -4101,12 +4399,18 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "删除了联系地址"; +/* No comment provided by engineer. */ +"removed from group" = "从群被删除了"; + /* profile update event chat item */ "removed profile picture" = "删除了资料图片"; /* rcv group event chat item */ "removed you" = "已将您移除"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "删除消息并封禁成员。"; + /* No comment provided by engineer. */ "Renegotiate" = "重新协商"; @@ -4128,6 +4432,54 @@ swipe action */ /* chat item action */ "Reply" = "回复"; +/* chat item action */ +"Report" = "举报"; + +/* report reason */ +"Report content: only group moderators will see it." = "举报内容:仅协管会看到。"; + +/* report reason */ +"Report member profile: only group moderators will see it." = "举报成员个人资料:仅协管会看到。"; + +/* report reason */ +"Report other: only group moderators will see it." = "举报其他:仅协管会看到。"; + +/* No comment provided by engineer. */ +"Report reason?" = "举报理由?"; + +/* alert title */ +"Report sent to moderators" = "举报已发送至 协管"; + +/* report reason */ +"Report spam: only group moderators will see it." = "举报垃圾信息:仅协管会看到。"; + +/* report reason */ +"Report violation: only group moderators will see it." = "举报违规:仅协管会看到。"; + +/* report in notification */ +"Report: %@" = "举报: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "向协管举报消息已被禁止。"; + +/* No comment provided by engineer. */ +"Reports" = "举报"; + +/* No comment provided by engineer. */ +"request is sent" = "发送了请求"; + +/* No comment provided by engineer. */ +"request to join rejected" = "加入请求被拒绝"; + +/* rcv group event chat item */ +"requested connection" = "已请求连接"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "来自群组%@的已请求连接"; + +/* chat list item title */ +"requested to connect" = "被请求连接"; + /* No comment provided by engineer. */ "Required" = "必须"; @@ -4179,9 +4531,21 @@ swipe action */ /* chat item action */ "Reveal" = "揭示"; +/* No comment provided by engineer. */ +"review" = "审核"; + /* No comment provided by engineer. */ "Review conditions" = "审阅条款"; +/* No comment provided by engineer. */ +"Review group members" = "审核群成员"; + +/* admission stage */ +"Review members" = "审核成员"; + +/* No comment provided by engineer. */ +"reviewed by admins" = "由管理员审核"; + /* No comment provided by engineer. */ "Revoke" = "吊销"; @@ -4210,6 +4574,12 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "保存(并通知联系人)"; +/* alert button */ +"Save (and notify members)" = "保存(并通知成员)"; + +/* alert title */ +"Save admission settings?" = "保存入群设置?"; + /* alert button */ "Save and notify contact" = "保存并通知联系人"; @@ -4225,6 +4595,9 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "保存群组资料"; +/* alert title */ +"Save group profile?" = "保存群资料?"; + /* No comment provided by engineer. */ "Save list" = "保存列表"; @@ -4336,6 +4709,9 @@ chat item action */ /* chat item action */ "Select" = "选择"; +/* No comment provided by engineer. */ +"Select chat profile" = "选择聊天个人资料"; + /* No comment provided by engineer. */ "Selected %lld" = "选定的 %lld"; @@ -4360,6 +4736,9 @@ chat item action */ /* No comment provided by engineer. */ "Send a live message - it will update for the recipient(s) as you type it" = "发送实时消息——它会在您键入时为收件人更新"; +/* No comment provided by engineer. */ +"Send contact request?" = "发送联络请求?"; + /* No comment provided by engineer. */ "Send delivery receipts to" = "将送达回执发送给"; @@ -4390,18 +4769,30 @@ chat item action */ /* No comment provided by engineer. */ "Send notifications" = "发送通知"; +/* No comment provided by engineer. */ +"Send private reports" = "发送私下举报"; + /* No comment provided by engineer. */ "Send questions and ideas" = "发送问题和想法"; /* No comment provided by engineer. */ "Send receipts" = "发送回执"; +/* No comment provided by engineer. */ +"Send request" = "发送请求"; + +/* No comment provided by engineer. */ +"Send request without message" = "发送无消息请求"; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "发送它们来自图库或自定义键盘。"; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "给新成员发送最多 100 条历史消息。"; +/* No comment provided by engineer. */ +"Send your private feedback to groups." = "向群发送私密反馈。"; + /* alert message */ "Sender cancelled file transfer." = "发送人已取消文件传输。"; @@ -4459,6 +4850,12 @@ chat item action */ /* No comment provided by engineer. */ "Sent via proxy" = "通过代理发送"; +/* No comment provided by engineer. */ +"Server" = "服务器"; + +/* alert message */ +"Server added to operator %@." = "服务器已添加到运营方 %@。"; + /* No comment provided by engineer. */ "Server address" = "服务器地址"; @@ -4468,6 +4865,15 @@ chat item action */ /* srv error text. */ "Server address is incompatible with network settings." = "服务器地址与网络设置不兼容。"; +/* alert title */ +"Server operator changed." = "服务器运营方已更改。"; + +/* No comment provided by engineer. */ +"Server operators" = "服务器运营方"; + +/* alert title */ +"Server protocol changed." = "服务器协议已更改。"; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "服务器队列信息: %1$@\n\n上次收到的消息: %2$@"; @@ -4504,6 +4910,9 @@ chat item action */ /* No comment provided by engineer. */ "Set 1 day" = "设定1天"; +/* No comment provided by engineer. */ +"Set chat name…" = "设置聊天名称…"; + /* No comment provided by engineer. */ "Set contact name…" = "设置联系人姓名……"; @@ -4516,6 +4925,12 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "设置它以代替系统身份验证。"; +/* No comment provided by engineer. */ +"Set member admission" = "设置成员入群准许"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "在聊天中设置消息过期时间。"; + /* profile update event chat item */ "set new contact address" = "设置新的联系地址"; @@ -4531,6 +4946,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "设置密码来导出"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "设置自我介绍和欢迎消息。"; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "设置向新成员显示的消息!"; @@ -4540,6 +4958,9 @@ chat item action */ /* No comment provided by engineer. */ "Settings" = "设置"; +/* alert message */ +"Settings were changed." = "设置已修改。"; + /* No comment provided by engineer. */ "Shape profile images" = "改变个人资料图形状"; @@ -4550,9 +4971,15 @@ chat item action */ /* No comment provided by engineer. */ "Share 1-time link" = "分享一次性链接"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "和一位好友分享一次性链接"; + /* No comment provided by engineer. */ "Share address" = "分享地址"; +/* No comment provided by engineer. */ +"Share address publicly" = "公开分享地址"; + /* alert title */ "Share address with contacts?" = "与联系人分享地址?"; @@ -4562,6 +4989,18 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "分享链接"; +/* alert button */ +"Share old address" = "分享旧地址"; + +/* alert button */ +"Share old link" = "分享旧链接"; + +/* No comment provided by engineer. */ +"Share profile" = "分享资料"; + +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "在社媒上分享 SimpleX 地址。"; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "分享此一次性邀请链接"; @@ -4571,6 +5010,18 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "与联系人分享"; +/* No comment provided by engineer. */ +"Share your address" = "分享地址"; + +/* No comment provided by engineer. */ +"Short description" = "短描述"; + +/* No comment provided by engineer. */ +"Short link" = "短链接"; + +/* No comment provided by engineer. */ +"Short SimpleX address" = "SimpleX 短地址"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "显示 → 通过专用路由发送的信息."; @@ -4661,6 +5112,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX protocols reviewed by Trail of Bits." = "SimpleX 协议由 Trail of Bits 审阅。"; +/* simplex link type */ +"SimpleX relay link" = "SimpleX 中继链接"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "简化的隐身模式"; @@ -4679,6 +5133,9 @@ chat item action */ /* No comment provided by engineer. */ "SMP server" = "SMP 服务器"; +/* No comment provided by engineer. */ +"SOCKS proxy" = "SOCKS代理"; + /* blur media */ "Soft" = "软"; @@ -4694,9 +5151,16 @@ chat item action */ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "导入过程中出现一些非致命错误:"; +/* alert message */ +"Some servers failed the test:\n%@" = "有服务器测试未通过:\n%@"; + /* notification title */ "Somebody" = "某人"; +/* blocking reason +report reason */ +"Spam" = "垃圾信息"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "方形、圆形、或两者之间的任意形状."; @@ -4775,18 +5239,42 @@ chat item action */ /* No comment provided by engineer. */ "Support SimpleX Chat" = "支持 SimpleX Chat"; +/* No comment provided by engineer. */ +"Switch audio and video during the call." = "通话期间切换音频和视频。"; + +/* No comment provided by engineer. */ +"Switch chat profile for 1-time invitations." = "对一次性邀请切换聊天个人资料。"; + /* No comment provided by engineer. */ "System" = "系统"; /* No comment provided by engineer. */ "System authentication" = "系统验证"; +/* No comment provided by engineer. */ +"Tail" = "尾部"; + /* No comment provided by engineer. */ "Take picture" = "拍照"; /* No comment provided by engineer. */ "Tap button " = "点击按钮 "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "轻按连接进行聊天"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "轻按连接来发送请求"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "轻按“连接”使用机器人"; + +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址”"; + +/* No comment provided by engineer. */ +"Tap Join group" = "轻按加入群"; + /* No comment provided by engineer. */ "Tap to activate profile." = "点击以激活个人资料。"; @@ -4808,9 +5296,15 @@ chat item action */ /* No comment provided by engineer. */ "TCP connection" = "TCP 连接"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "TCP 连接后台超时"; + /* No comment provided by engineer. */ "TCP connection timeout" = "TCP 连接超时"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "用于消息收发的 TCP 端口"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4826,6 +5320,9 @@ chat item action */ /* server test failure */ "Test failed at step %@." = "在步骤 %@ 上测试失败。"; +/* No comment provided by engineer. */ +"Test notifications" = "测试通知"; + /* No comment provided by engineer. */ "Test server" = "测试服务器"; @@ -4844,9 +5341,15 @@ chat item action */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "感谢用户——通过 Weblate 做出贡献!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "地址不会长,将通过该简短地址分享个人资料。"; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "该应用可以在您收到消息或联系人请求时通知您——请打开设置以启用通知。"; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "应用通过在每个对话中使用不同运营方保护你的隐私。"; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "该应用程序将要求确认从未知文件服务器(.onion 除外)下载。"; @@ -4856,6 +5359,9 @@ chat item action */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "您扫描的码不是 SimpleX 链接的二维码。"; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "连接达到了未送达消息上限,你的联系人可能处于离线状态。"; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "您接受的连接将被取消!"; @@ -4877,6 +5383,9 @@ chat item action */ /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "下一条消息的 ID 不正确(小于或等于上一条)。\n它可能是由于某些错误或连接被破坏才发生。"; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "链接不会长,群资料会通过短链接分享。"; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "将为所有成员删除该消息。"; @@ -4892,6 +5401,9 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "旧数据库在迁移过程中没有被移除,可以删除。"; +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "应用中的第二个预设运营方!"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "我们错过的第二个\"√\"!✅"; @@ -4904,9 +5416,15 @@ chat item action */ /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "您粘贴的文本不是 SimpleX 链接。"; +/* No comment provided by engineer. */ +"The uploaded database archive will be permanently removed from the servers." = "已上传的数据库归档将会从服务器中永久移除。"; + /* No comment provided by engineer. */ "Themes" = "主题"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "这些条件将同样适用于: **%@**。"; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "这些设置适用于您当前的配置文件 **%@**。"; @@ -4919,6 +5437,9 @@ chat item action */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。"; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。"; @@ -4943,12 +5464,24 @@ chat item action */ /* No comment provided by engineer. */ "This group no longer exists." = "该群组已不存在。"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。"; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "此链接已在其他移动设备上使用,请在桌面上创建新链接。"; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "此消息被删除或尚未收到。"; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "此设置适用于您当前聊天资料 **%@** 中的消息。"; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "此设置用于当前个人资料 **%@**。"; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "只为新联系人设置了消失时间。"; + /* No comment provided by engineer. */ "Title" = "标题"; @@ -4964,6 +5497,9 @@ chat item action */ /* No comment provided by engineer. */ "To make a new connection" = "建立新连接"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "为了防止链接被替换,你可以比较联系人安全代码。"; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "为了保护时区,图像/语音文件使用 UTC。"; @@ -4976,15 +5512,36 @@ chat item action */ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "为了保护隐私,SimpleX使用针对消息队列的标识符,而不是所有其他平台使用的用户ID,每个联系人都有独立的标识符。"; +/* No comment provided by engineer. */ +"To receive" = "消息接收"; + +/* No comment provided by engineer. */ +"To record speech please grant permission to use Microphone." = "为了记录语音请授予使用麦克风权限。"; + +/* No comment provided by engineer. */ +"To record video please grant permission to use Camera." = "为了录制视频请授予使用相机权限。"; + /* No comment provided by engineer. */ "To record voice message please grant permission to use Microphone." = "请授权使用麦克风以录制语音消息。"; /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "要显示您的隐藏的个人资料,请在**您的聊天个人资料**页面的搜索字段中输入完整密码。"; +/* No comment provided by engineer. */ +"To send" = "发送"; + +/* alert message */ +"To send commands you must be connected." = "你必须已连接才能发送命令。"; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "为了支持即时推送通知,聊天数据库必须被迁移。"; +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "要在连接尝试后使用不同的个人资料,请删除聊天并再次使用该链接。"; + +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "要使用**%@**的服务器,需接受条款。"; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。"; @@ -5006,6 +5563,9 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "传输会话"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "尝试连接到用于从该连接接收消息的服务器。"; + /* No comment provided by engineer. */ "Turkish interface" = "土耳其语界面"; @@ -5036,6 +5596,9 @@ chat item action */ /* rcv group event chat item */ "unblocked %@" = "未阻止 %@"; +/* No comment provided by engineer. */ +"Undelivered messages" = "未送达的消息"; + /* No comment provided by engineer. */ "Unexpected migration state" = "未预料的迁移状态"; @@ -5102,6 +5665,9 @@ chat item action */ /* swipe action */ "Unread" = "未读"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "不支持的连接链接"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "给新成员发送了最多 100 条历史消息。"; @@ -5117,6 +5683,9 @@ chat item action */ /* No comment provided by engineer. */ "Update settings?" = "更新设置?"; +/* No comment provided by engineer. */ +"Updated conditions" = "条款已更新"; + /* rcv group event chat item */ "updated group profile" = "已更新的群组资料"; @@ -5126,9 +5695,27 @@ chat item action */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "更新设置会将客户端重新连接到所有服务器。"; +/* alert button */ +"Upgrade" = "升级"; + +/* No comment provided by engineer. */ +"Upgrade address" = "升级地址"; + +/* alert message */ +"Upgrade address?" = "升级地址?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "升级并打开聊天"; +/* alert message */ +"Upgrade group link?" = "升级群链接?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "升级链接"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "升级你的地址"; + /* No comment provided by engineer. */ "Upload errors" = "上传错误"; @@ -5150,18 +5737,30 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "使用 .onion 主机"; +/* No comment provided by engineer. */ +"Use %@" = "使用 %@"; + /* No comment provided by engineer. */ "Use chat" = "使用聊天"; /* new chat action */ "Use current profile" = "使用当前配置文件"; +/* No comment provided by engineer. */ +"Use for files" = "用于文件"; + +/* No comment provided by engineer. */ +"Use for messages" = "用于消息"; + /* No comment provided by engineer. */ "Use for new connections" = "用于新连接"; /* No comment provided by engineer. */ "Use from desktop" = "从桌面端使用"; +/* No comment provided by engineer. */ +"Use incognito profile" = "使用隐身个人资料"; + /* No comment provided by engineer. */ "Use iOS call interface" = "使用 iOS 通话界面"; @@ -5180,18 +5779,36 @@ chat item action */ /* No comment provided by engineer. */ "Use server" = "使用服务器"; +/* No comment provided by engineer. */ +"Use servers" = "使用服务器"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "使用 SimpleX Chat 服务器?"; +/* No comment provided by engineer. */ +"Use SOCKS proxy" = "使用 SOCKS 代理"; + +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "当未指定端口时使用TCP端口%@。"; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "仅预设服务器使用 TCP 协议 443 端口。"; + /* No comment provided by engineer. */ "Use the app while in the call." = "通话时使用本应用."; /* No comment provided by engineer. */ "Use the app with one hand." = "用一只手使用应用程序。"; +/* No comment provided by engineer. */ +"Use web port" = "使用 web 端口"; + /* No comment provided by engineer. */ "User selection" = "用户选择"; +/* No comment provided by engineer. */ +"Username" = "用户名"; + /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "使用 SimpleX Chat 服务器。"; @@ -5258,9 +5875,15 @@ chat item action */ /* No comment provided by engineer. */ "Videos and files up to 1gb" = "最大 1gb 的视频和文件"; +/* No comment provided by engineer. */ +"View conditions" = "查看条款"; + /* No comment provided by engineer. */ "View security code" = "查看安全码"; +/* No comment provided by engineer. */ +"View updated conditions" = "查看更新后的条款"; + /* chat feature */ "Visible history" = "可见的历史"; @@ -5330,6 +5953,9 @@ chat item action */ /* No comment provided by engineer. */ "Welcome message is too long" = "欢迎消息太大了"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "欢迎联系人👋"; + /* No comment provided by engineer. */ "What's new" = "更新内容"; @@ -5342,6 +5968,9 @@ chat item action */ /* No comment provided by engineer. */ "when IP hidden" = "当 IP 隐藏时"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。"; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "当您与某人共享隐身聊天资料时,该资料将用于他们邀请您加入的群组。"; @@ -5396,6 +6025,9 @@ chat item action */ /* No comment provided by engineer. */ "You accepted connection" = "您已接受连接"; +/* snd group event chat item */ +"you accepted this member" = "你接受了该成员"; + /* No comment provided by engineer. */ "You allow" = "您允许"; @@ -5405,6 +6037,9 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "您已经连接到 %@。"; +/* No comment provided by engineer. */ +"You are already connected with %@." = "你已经与%@保持连接。"; + /* new chat sheet message */ "You are already connecting to %@." = "您已连接到 %@。"; @@ -5423,9 +6058,15 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "你已连接到用于接收该连接消息的服务器。"; + /* No comment provided by engineer. */ "You are invited to group" = "您被邀请加入群组"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "未连接到用于从该连接接收消息的服务器(无订阅)。"; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "您未连接到这些服务器。私有路由用于向他们发送消息。"; @@ -5441,6 +6082,9 @@ chat item action */ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "您可以在外观设置中更改它。"; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "你可以通过设置配置服务器。"; + /* No comment provided by engineer. */ "You can create it later" = "您可以以后创建它"; @@ -5465,6 +6109,9 @@ chat item action */ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "您可以从存档的联系人向%@发送消息。"; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "你可以设置连接名称,用来记住和谁分享了这个链接。"; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "您可以通过设置来设置锁屏通知预览。"; @@ -5489,6 +6136,9 @@ chat item action */ /* alert message */ "You can view invitation link again in connection details." = "您可以在连接详情中再次查看邀请链接。"; +/* alert message */ +"You can view your reports in Chat with admins." = "你可以在和管理员和聊天中查看你的举报。"; + /* alert title */ "You can't send messages!" = "您无法发送消息!"; @@ -5561,6 +6211,9 @@ chat item action */ /* snd group event chat item */ "you unblocked %@" = "您解封了 %@"; +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "**只有在你的请求被接受后**你才能发送消息。"; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "您将在组主设备上线时连接到该群组,请稍等或稍后再检查!"; @@ -5579,6 +6232,9 @@ chat item action */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。"; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "你将停止从这个聊天收到消息。聊天历史将被保留。"; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "您将停止接收来自该群组的消息。聊天记录将被保留。"; @@ -5594,6 +6250,9 @@ chat item action */ /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "您正在为该群组使用隐身个人资料——为防止共享您的主要个人资料,不允许邀请联系人"; +/* No comment provided by engineer. */ +"Your business contact" = "你的企业联系人"; + /* No comment provided by engineer. */ "Your calls" = "您的通话"; @@ -5603,9 +6262,15 @@ chat item action */ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "您的聊天数据库未加密——设置密码来加密。"; +/* alert title */ +"Your chat preferences" = "你的聊天偏好设置"; + /* No comment provided by engineer. */ "Your chat profiles" = "您的聊天资料"; +/* No comment provided by engineer. */ +"Your contact" = "你的联系人"; + /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "您的联系人发送的文件大于当前支持的最大大小 (%@)。"; @@ -5615,12 +6280,18 @@ chat item action */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "与您的联系人保持连接。"; +/* No comment provided by engineer. */ +"Your credentials may be sent unencrypted." = "你的凭据可能以未经加密的方式被发送。"; + /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "您当前的聊天数据库将被删除并替换为导入的数据库。"; /* No comment provided by engineer. */ "Your current profile" = "您当前的资料"; +/* No comment provided by engineer. */ +"Your group" = "你的群"; + /* No comment provided by engineer. */ "Your ICE servers" = "您的 ICE 服务器"; @@ -5642,12 +6313,18 @@ chat item action */ /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; +/* alert message */ +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "您的个人资料已修改。如果进行保存,更新后的个人资料将发送到所有联系人。"; + /* No comment provided by engineer. */ "Your random profile" = "您的随机资料"; /* No comment provided by engineer. */ "Your server address" = "您的服务器地址"; +/* No comment provided by engineer. */ +"Your servers" = "你的服务器"; + /* No comment provided by engineer. */ "Your settings" = "您的设置"; diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 41f454b2dd..86e51e6937 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2522,4 +2522,9 @@ البصمة في عنوان الخادم لا تتطابق مع الشهادة: %1$s. لا اشتراك أنت غير متصل بالخادم المستخدم لاستقبال الرسائل من هذا الاتصال (لا يوجد اشتراك). + احذف رسائل العضو + حذف رسائل العضو؟ + احذف الرسائل + ستُحذف رسائل العضو - ولا يمكن التراجع عن ذلك! + أزل واحذف الرسائل diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index a3b97f0be2..5c8f73bf93 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1555,12 +1555,12 @@ Obrint la base de dades… Comproveu que l\'enllaç SimpleX sigui correcte. l\'enviament de fitxers encara no està suportat - Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. + Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquesta connexió. S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). Usar perfil actual Usar nou perfil incògnit Error aplicació - Esteu connectat al servidor utilitzat per rebre missatges d\'aquest contacte. + Esteu connectat al servidor utilitzat per rebre missatges d\'aquesta connexió. El teu perfil s\'enviarà al contacte del qual has rebut aquest enllaç. Heu compartit una ruta de fitxer no vàlida. Informeu-ne als desenvolupadors de l\'aplicació. Us connectareu amb tots els membres del grup. @@ -2501,4 +2501,11 @@ L\'empremta digital a l\'adreça del servidor de destinació no coincideix amb el certificat: %1$s. L\'empremta digital a l\'adreça del servidor de reenviament no coincideix amb el certificat: %1$s. L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat: %1$s. + cap subscripció + No esteu connectat(da) al servidor que s\'utilitza per rebre missatges d\'aquesta connexió (sense subscripció). + Suprimir missatges de membre + Suprimir missatges de membre? + Suprimir missatges + Els missatges de membre s\'eliminaran; això no es pot desfer! + Eliminar membre i els seus missatges diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 30e557a4e3..38507cc228 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -859,4 +859,6 @@ Tilslutning af opkald … Tilslutning af opkald Tilslutning (introduceret) + Overfør fra en anden enhed på den nye enhed og scan QR-koden.]]> + Overfør fra en anden enhed diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index e038207801..3254d0a63f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2612,4 +2612,21 @@ Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %1$s. Kein Abonnement Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). + Mitgliedsnachrichten löschen + Mitgliedsnachrichten löschen? + Mitgliedsnachrichten löschen + Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! + Mitglied entfernen und Nachrichten löschen + Alle Nachrichten + Dateien + Filter + Bilder + Links + Dateien suchen + Bilder suchen + Links suchen + Videos suchen + Sprachnachrichten suchen + Videos + Sprachnachrichten diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 9e38019c8b..6f326c33c8 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -1,39 +1,39 @@ - 1 μέρα + 1 ημέρα 1 μήνας Για το SimpleX - Σαρώστε τον QR κωδικό + Σάρωσε τον QR κωδικό α + β Για το SimpleX Chat - Σαρώστε τον κωδικό ασφαλείας από την εφαρμογή επαφών σας + Σάρωσε τον κωδικό ασφαλείας από την εφαρμογή επαφών σου Ασφαλή ουρά δε Κωδικός ασφαλείας - Σαρώστε τον κωδικό QR διακομιστή + Σάρωσε τον QR κωδικό του διακομιστή μυστικό 1 εβδομάδα αξιολόγηση ασφαλείας - Συναινώ + Επέτρεψε Αποδοχή Αποδοχή ανώνυμης περιήγησης Προσθήκη προκαθορισμένου διακομιστή Προσθήκη σε άλλη συσκευή - Όλες οι επαφές σας θα παραμείνουν ενεργές. + Όλες οι επαφές σου θα παραμείνουν ενεργές. Αποδοχή διαχειριστής - Προσθέστε μήνυμα καλωσορίσματος + Πρόσθεσε μήνυμα καλωσορίσματος Όλα τα μέλη της ομάδας θα παραμήνουν συνδεδεμένα. Προσθήκη προφίλ - Προφορά + Χρώμα έμφασης πάντα Αποδοχή - Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) - Επιτρέψτε στις επαφές σας να στέλνουν μηνύματα που εξαφανίζονται. - Επιτρέπονται τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να σας καλέσουν. - Επιτρέψτε στις επαφές σας να στέλνουν φωνητικά μηνύματα. + Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σου. + Επέτρεψε στις επαφές σου να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) + Επέτρεψε στις επαφές σου να στέλνουν μηνύματα που εξαφανίζονται. + Επέτρεψε τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σου. + Επέτρεψε στις επαφές σου να σε καλέσουν. + Επέτρεψε στις επαφές σου να στέλνουν φωνητικά μηνύματα. Να επιτρέπεται η αποστολή άμεσων μηνυμάτων στα μέλη. Επιτρέπεται η αποστολή μηνυμάτων που εξαφανίζονται. Επιτρέπεται η αποστολή φωνητικών μηνυμάτων. @@ -41,70 +41,69 @@ Αποδοχή Αποδοχή αιτήματος σύνδεσης; αποδεκτή κλήση - Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. + Πρόσβαση στους διακομιστές μέσω διαμομιστή μεσολάβησης SOCKS στη θύρα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. Προσθήκη διακομιστή Προχωρημένες ρυθμίσεις δικτύου Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών. Οι διαχειριστές μπορούν να δημιουργήσουν τους συνδέσμους συμμετοχής σε ομάδες. Όλες οι συνομιλίες και τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Τα μηνύματα θα διαγραφούν ΜΟΝΟ για εσάς. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σας. (24 ώρες) - Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σας τις επιτρέπει. + Επέτρεψε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σου. (24 ώρες) + Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σου τις επιτρέπει. Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. (24 ώρες) Να επιτρέπονται τα φωνητικά μηνύματα; Πάντα ενεργό Να χρησιμοποιείται πάντα αναμεταδότη - Αναζήτηση + Αναζήτησε Ανενεργό - "Το προφίλ σας %1$s θα μοιραστεί" - Η SimpleX διεύθυνση σας + Το προφίλ σου %1$s θα διαμοιραστεί + Η SimpleX διεύθυνση σου Αντίγραφο δεδομένων εφαρμογής 5 λεπτά - Θα συνδεθείτε όταν η συσκευή της επαφής σας είναι συνδεμένει, παρακαλώ περιμένετε ή ελέγξτε αργότερα! - Ο ICE διακομιστής σας + Θα συνδεθείς όταν η συσκευή της επαφής σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Ο ICE διακομιστής σου Έκδοση εφαρμογής - Στείλατε πρόσκληση ομάδας + Έστειλες πρόσκληση ομάδας 1 λεπτό - Ο διακομιστής σας + Ο διακομιστής σου Διεύθυνση Ακύρωση Πίσω 30 δευτερόλεπτα - Θα συνδεθείτε με όλα τα μέλη της ομάδας. + Θα συνδεθείς με όλα τα μέλη της ομάδας. %1$s θέλει να συνδεθεί μαζί σου μέσω - Επιτρέπονται αντιδράσεις μηνύματος. - Ο διακομιστής XFTP σας - Η διεύθυνση του διακομιστή σας - Το προφίλ, επαφές και παραδομένα μηνύματα σας είναι αποθηκευμένα στην συσκευή σας. - Ο διακομιστής SMP σας + Επέτρεψε τις αντιδράσεις σε μηνύματα. + Ο διακομιστής XFTP σου + Η διεύθυνση του διακομιστή σου + Το προφίλ, επαφές και παραδομένα μηνύματα σου είναι αποθηκευμένα στην συσκευή σου. + Ο διακομιστής SMP σου Επιτρέπεται να σταλούν αρχεία και μέσα. - Το τυχαίο προφίλ σας + Το τυχαίο προφίλ σου %1$s ΜΕΛΗ - Επιτρέπεται - Οι προτιμήσεις σας + Αποδοχή + Οι προτιμήσεις σου Συντάκτης - Ο ΙCE διακομιστής σας - Κωδικός εφαρμογής + Ο ΙCE διακομιστής σου + Κωδικός πρόσβασης εφαρμογής Αίτημα σύνδεσης θα σταλεί σε αυτό το μέλος της ομάδας. ΟΙΚΟΝΑ ΕΦΑΡΜΟΓΗΣ Εφαρμογή - Οι ρυθμίσεις σας + Οι ρυθμίσεις σου Έκδοση εφαρμογής: v%s σφάλμα κλήσης - "ακυρώθηκε %s" + ακυρώθηκε %s ακύρωση πρόβλεψη συνδέσμου - Αλλαγή κωδικού πρόσβασης βάση δεδομένων? + Αλλαγή φράσης πρόσβασης της βάσης δεδομένων? Αλλαγή κωδικού πρόσβασης Αλλαγή ρόλου του %s σε %s Φόντο Δεν είναι δυνατή η προετοιμασία της βάσης δεδομένων - Ένα νέο τυχαίο προφίλ θα μοιραστεί. + Ένα νέο τυχαίο προφίλ θα διαμοιραστεί. Δεν είναι δυνατή η πρόσκληση επαφών! Αλλαγή διεύθυνσης λήψης Πιστοποίηση μη διαθέσιμη - Αλλαγή - " -\nΔιαθέσιμο στην έκδοση 5.1" + Άλλαξε + \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης ΚΛΗΣΕΙΣ Αυτόματη αποδοχή @@ -126,25 +125,25 @@ Όλα τα δεδομένα της εφαρμογής διαγράφηκαν. Εμφάνιση Τέλος κλήσης %1$s - Ακύρωση + Ακύρωσε Αλλαγή διεύθυνσης λήψης; αλλαγή διεύθυνσης… Αλλαγή λειτουργίας αυτοκαταστροφής Κάμερα κλήση σε εξέλιξη Αυτόματη αποδοχή εικόνων - Αλλαγή του ρόλου σας σε %s + Αλλαγή του ρόλου σου σε %s Κλήση σε εξέλιξη Πιστοποίηση απέτυχε - "σύνδεση %1$d" + σύνδεση %1$d Δημιουργία διεύθυνση SimpleX - επαφή έχει κρυπτογράφηση από άκρο σε άκρο + η επαφή έχει κρυπτογράφηση από άκρη-σε-άκρη Δημιουργία μια ομάδας χρησιμοποιώντας ένα τυχαίο προφίλ. Δημιουργία ομάδας Δημιουργία προφίλ Η επαφή και όλα τα μηνύματα θα διαγραφούν - αυτό δεν μπορεί να αναιρεθεί! Δημιουργία προφίλ - Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή; θα μπορείτε να τα δείτε. + Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή, τα οποία θα μπορείς να τα δεις. Σύνδεση μέσω μιας εφάπαξ σύνδεσης; Δημιουργία σύνδεσμου Σύνδεση μέσω σύνδεσμο/κωδικό γρήγορης ανταπόκρισης @@ -153,11 +152,11 @@ συνδέεται… Όνομα επαφής Δημιουργία διεύθυνσης - Αντιγραφή + Αντέγραψε Συνέχεια Σύνδεση μέσω σύνδεσμο; Επαφή υπάρχει ήδη - Σύνδεση στον εαυτό σας; + Σύνδεση στον εαυτό σου; Δημιουργία μυστικής ομάδας Συνδεδεμένη σε επιφάνεια εργασίας δημιουργός @@ -180,8 +179,8 @@ Συνδε Σύνδεση ανώνυμης περιήγησης σύνδεση επετεύχθη - επαφή δεν έχει κρυπτογράφηση από άκρο σε άκρο - Επαφή επιτρέπει + η επαφή δεν έχει κρυπτογράφηση από άκρη-σε-άκρη + Η επαφή επιτρέπει σύνδεση (ανακοινώθηκε) Συνδεδεμένος συνδέεται… @@ -194,29 +193,29 @@ Δημιουργία μυστικής ομάδας Σφάλμα σύνδεσης Η επαφή δεν είναι συνδράμει αυτή τη στιγμή! - Συνδεδεμένο απευθείας - Η ιδιωτικότητά σας - Η επαφή σας έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). - Το προφίλ της συνομιλίας σας θα σταλεί στην επαφή σας + αιτούμενη σύνδεση + Η ιδιωτικότητά σου + Η επαφή σου έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). + Το προφίλ της συνομιλίας σου θα σταλεί\nστην επαφή σου Χρήση νέου ανώνυμου προφίλ Ήδη συνδέεται - Απορρίψατε την πρόσκληση της ομάδας + Απέρριψες την πρόσκληση της ομάδας Σύνδεση μέσω διεύθυνση επαφής - Χρήση του τρέχων προφίλ - Σύνδεση - Το τρέχον προφίλ σας - αφαιρέσατε %1$s - "Συμμετοχή ομάδας;" - Οι επαφες σας θα παραμένουν συνδεδεμένες. - Προσπάθεια σύνδεσης με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν την επαφή. + Χρήση του τρέχοντος προφίλ + Συνδέσου + Το τρέχον προφίλ σου + αφαίρεσες %1$s + Συμμετοχή ομάδας; + Οι επαφες σου θα παραμένουν συνδεδεμένες. + Προσπάθεια σύνδεσης με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν τη σύνδεση. Το προφίλ σου θα σταλεί στην επαφή από την οποία έλαβες αυτόν τον σύνδεσμο. σφάλμα Άνοιγμα βάση δεδομένων… Η προβολή συνετρίβη συνδεδεμένο - Κοινοποιήσατε μια μη έγκυρη διαδρομή αρχείου. Αναφέρετε το πρόβλημα στους προγραμματιστές της εφαρμογής. + Κοινοποίησες μία μη έγκυρη διαδρομή αρχείου. Ανέφερε το πρόβλημα στους προγραμματιστές της εφαρμογής. Μη έγκυρη διαδρομή αρχείου - Είστε συνδεδεμένοι στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν την επαφή. + Είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν τη σύνδεση. %d μύνημα επισημάνθηκε ως διαγραμμένο %1$d μυνήματα συντονίζονται από %2$s επισημάνθηκε ως διαγραμμένο @@ -230,22 +229,22 @@ Η αλλαγή διεύθυνσης θα ακυρωθεί. Θα χρησιμοποιηθεί η παλιά διεύθυνση παραλαβής. Ενεργές συνδέσεις Προχωρημένες ρυθμίσεις - Πρόσθετος τόνος + Επιπρόσθετο χρώμα έμφασης Προσθήκη επαφής - Διακοπή αλλαγής διεύθυνσης + Ακύρωση αλλαγής διεύθυνσης Προχωρημένες ρυθμίσεις Οι διαχειριστές μπορούν να αποκλείσουν ένα μέλος για όλους. Αναγνωρισμένο παραπάνω, λοιπόν: - Προσθέστε τη διεύθυνση στο προφίλ σας, έτσι ώστε οι επαφές σας να μπορούν να τη μοιραστούν με άλλα άτομα. Το ενημέρωμένο προφίλ θα σταλεί στις επαφές σας. + Πρόσθεσε τη διεύθυνση στο προφίλ σου, έτσι ώστε οι επαφές σου να μπορούν να τη διαμοιραστούν με άλλα άτομα. Το ενημέρωμένο σου προφίλ θα αποσταλεί στις επαφές σου. διαχειριστές Λάθη αναγνώρισης - Προειδοποίηση: το αρχείο θα διαγραφεί.]]> + Προειδοποίηση: το αρχείο αρχειοθέτησης θα διαγραφεί.]]> Υπέρβαση χωρητικότητας - ο παραλήπτης δεν έλαβε μηνύματα που στάλθηκαν προηγουμένως. αποκλεισμένος από τον διαχειριστή Συνομιλίες όλα τα μέλη - Όλες οι επαφές σας θα παραμείνουν ενεργές. Το ανανεωμένο προφίλ σας θα αποσταλεί στις επαφές σας. + Όλες οι επαφές σου θα παραμείνουν ενεργές. Το ανανεωμένο προφίλ σου θα αποσταλεί στις επαφές σου. Να χρησιμοποιείται πάντα ιδιωτική δρομολόγηση. Ένα κενό προφίλ συνομιλίας με το παρεχόμενο όνομα δημιουργείται και η εφαρμογή ανοίγει ως συνήθως. Η βάση δεδομένων της συνομιλίας διαγράφηκε @@ -263,7 +262,7 @@ Η εφαρμογή κρυπτογραφεί νέα τοπικά αρχεία (εκτός απο βίντεο). Καλύτερες ομάδες Γίνεται ήδη συμμετοχή στην ομάδα! - Αρχειοθέτηση και αποστολή + Αρχειοθέτηση και ανέβασμα %1$d διαφορετικό/κα σφάλμα/τα αρχείου/ων. Η υπηρεσία παρασκηνίου λειτουργεί πάντα - οι ειδοποιήσεις θα εμφανίζονται μόλις τα μηνύματα είναι διαθέσιμα. %1$d αρχείο/α ακόμα κατεβαίνουν. @@ -272,10 +271,10 @@ %1$d αρχείο/α δεν κατέβηκε/καν. %1$s μήνυμα/τα δεν προωθήθηκε/καν Προφίλ συνομιλίας - για κάθε προφίλ συνομιλίας που έχετε στην εφαρμογή.]]> - Παρακαλώ σημειώστε: οι αναμεταδότες μηνυμάτων και αρχείων συνδέονται μέσω διακομιστή μεσολάβησης SOCKS. Οι κλήσεις και οι προεπισκοπήσεις συνδέσμων αποστολής χρησιμοποιούν άμεση σύνδεση.]]> + για κάθε προφίλ συνομιλίας που έχεις στην εφαρμογή.]]> + Παρακαλώ σημείωσε: οι αναμεταδότες μηνυμάτων και αρχείων συνδέονται μέσω διακομιστή μεσολάβησης SOCKS. Οι κλήσεις και οι προεπισκοπήσεις συνδέσμων αποστολής χρησιμοποιούν άμεση σύνδεση.]]> Πάντα - Η ενημέρωση της εφαρμογής κατεβαίνει + Η ενημέρωση της εφαρμογής κατέβηκε Έλεγχος για ενημερώσεις Οποιοσδήποτε μπορεί να φιλοξενήσει διακομιστές. κλήση ήχου (χωρίς κρυπτογράφηση e2e) @@ -291,21 +290,21 @@ Χρώματα συνομιλίας ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ Η συνομιλία εκτελείται - Παρακαλώ σημειώστε: ΔΕΝ θα μπορείτε να ανακτήσετε ή να αλλάξετε τη φράση πρόσβασης εάν τη χάσετε.]]> + Παρακαλώ σημείωσε: ΔΕΝ θα μπορείς να ανακτήσεις ή να αλλάξεις τη φράση πρόσβασης εάν τη χάσεις.]]> Αποκλεισμός για όλους - Και εσείς και η επαφή σας μπορείτε να προσθέστε αντιδράσεις μηνυμάτων. - Και εσείς και η επαφή σας μπορείτε να κάνετε κλήσεις. + Και εσύ και η επαφή σου μπορείτε να προσθέστε αντιδράσεις μηνυμάτων. + Και εσύ και η επαφή σου μπορείτε να κάνετε κλήσεις. Επιτρέψτε την αποστολή συνδέσμων SimpleX. Αραβικά, Βουλγαρικά, Φινλανδικά, Εβραϊκά, Ταϊλανδέζικα και Ουκρανικά - χάρη στους χρήστες και το Weblate. Μεταφορά δεδομένων εφαρμογής Θάμπωμα για καλύτερη ιδιωτικότητα. Η συνομιλία έχει μεταφερθεί! Αρχειοθέτηση της βάσης δεδομένων - Όλες οι επαφές, συζητήσεις και αρχεία θα κρυπτογραφηθούν με ασφάλεια και θα μεταφορτωθούν σε διαμορφωμένα κομμάτια αναμετάδοσης XFTP. + Όλες οι επαφές, οι συζητήσεις και τα αρχεία θα κρυπτογραφηθούν με ασφάλεια και θα μεταφορτωθούν τμηματικά σε διαμορφωμένους αναμεταδότες XFTP. Κινητή τηλεφωνία - Δημιουργία ομάδας : για την δημιουργίας νέας ομάδας.]]> - Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και δοκιμάστε ξανά - Συζήτηση με τους προγραμματιστές + Δημιουργία ομάδας : για να δημιουργήσεις μία νέα ομάδα.]]> + Έλεγξε τη σύνδεσή σου στο διαδίκτυο και δοκίμασε ξανά + Συνομίλησε με τους προγραμματιστές Ζήτησε να λάβει το βίντεο Δεν είναι δυνατή η αποστολή μηνυμάτων στο μέλος της ομάδας Αλλαγή λειτουργίας κλειδώματος @@ -313,16 +312,16 @@ άλλαξε η διεύθυνση για εσάς και %d άλλες εκδηλώσεις Μαύρο - Πρόσθετο δευτερεύον - Και εσείς και η επαφή σας μπορείτε να διαγράψετε απεσταλμένα μηνύματα χωρίς ανατροπή. (24 ώρες) - Και εσείς και η επαφή σας μπορείτε να στείλετε ηχητικά μηνύματα. + Επιπρόσθετο δευτερεύων + Και εσύ και η επαφή σου μπορείτε να διαγράψετε απεσταλμένα μηνύματα χωρίς ανατροπή. (24 ώρες) + Και εσύ και η επαφή σου μπορείτε να στείλετε ηχητικά μηνύματα. Η συνομιλία σταμάτησε - Η συνομιλία έχει διακοπεί. Εάν χρησιμοποιήσατε ήδη αυτήν τη βάση δεδομένων σε άλλη συσκευή, θα πρέπει να τη μεταφέρετε πίσω προτού ξεκινήσετε τη συνομιλία. - Η λειτουργία βελτιστοποίησης της μπαταρίας είναι ενεργή, η υπηρεσία παρασκηνίου και τα περιοδικά αιτήματα για νέα μηνύματα θα απενεργοποιηθούν. Μπορείτε να τα ενεργοποιήσετε ξανά μέσω των ρυθμίσεων. - σύνδεσμος μιας χρήσης + Η συνομιλία έχει διακοπεί. Εάν ήδη χρησιμοποίησες αυτήν τη βάση δεδομένων σε άλλη συσκευή, θα πρέπει να τη μεταφέρεις πίσω προτού ξεκινήσεις τη συνομιλία. + Η λειτουργία βελτιστοποίησης της μπαταρίας είναι ενεργή, η υπηρεσία παρασκηνίου και τα περιοδικά αιτήματα για νέα μηνύματα θα απενεργοποιηθούν. Μπορείς να τα ενεργοποιήσεις ξανά μέσω των ρυθμίσεων. + σύνδεσμος 1-χρήσης Κλήσεις ήχου & βίντεο Κλήσεις ήχου/βίντεο - Κωδικός εφαρμογής + Κωδικός πρόσβασης εφαρμογής Συνεδρία εφαρμογής Η συνομιλία σταμάτησε Έλεγχος για ενημερώσεις @@ -331,47 +330,47 @@ Bluetooth έντονο Κονσόλα συνομιλίας - Παρακαλώ σημειώστε: η χρήση της ίδιας βάσης δεδομένων σε δύο συσκευές θα διακόψει την αποκρυπτογράφηση των μηνυμάτων από τις συνδέσεις σας, ως προστασία ασφαλείας.]]> + Παρακαλώ σημείωσε: η χρήση της ίδιας βάσης δεδομένων σε δύο συσκευές θα διακόψει την αποκρυπτογράφηση των μηνυμάτων από τις συνδέσεις σου, ως προστασία ασφαλείας.]]> Χρησιμοποιεί περισσότερη μπαταρία! Η εφαρμογή εκτελείται πάντα στο παρασκήνιο - οι ειδοποιήσεις εμφανίζονται αμέσως.]]> Η βάση δεδομένων της συνομιλίας εξάχθηκε κλήση Κακή διεύθυνση Desktop - Μεταφορά απο άλλη συσκευή στη νέα συσκευή και σαρώστε τον κωδικό QR.]]> + Μεταφορά από άλλη συσκευή στη νέα συσκευή και σάρωσε τον κωδικό QR.]]> Με προφίλ συνομιλίας (προεπιλογή) ή μέσω σύνδεσης (BETA). Κάμερα και μικρόφωνο 6 νέες γλώσσες διεπαφής - Καλό για την μπαταρία. Η εφαρμογή ελέγχει για την παραλαβή μηνυμάτων κάθε 10 λεπτά. Ενδέχεται να χάσετε κλήσεις ή επείγοντα μηνύματα.]]> + Καλό για την μπαταρία. Η εφαρμογή ελέγχει για την παραλαβή μηνυμάτων κάθε 10 λεπτά. Ενδέχεται να χάσεις κλήσεις ή επείγοντα μηνύματα.]]> Επισύναψη - Διακοπή αλλαγής διεύθυνσης; + Ακύρωση αλλαγής διεύθυνσης; Επιλέξτε ένα αρχείο Όλα τα νέα μηνύνματα απο %s θα αποκρυφθούν! Δεν είναι δυνατή η λήψη του αρχείου Πιστοποίηση Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! - Ελέγχει νέα μηνύματα κάθε 10 λεπτά για έως και 1 λεπτό + Ελέγχει νέα μηνύματα κάθε 10 λεπτά για εώς και 1 λεπτό Η εφαρμογή μπορεί να λαμβάνει ειδοποιήσεις μόνο όταν εκτελείται, καμία υπηρεσία δεν θα ξεκινήσει στο παρασκήνιο Μπορεί να απενεργοποιηθεί μέσω των ρυθμίσεων – οι ειδοποιήσεις θα εξακολουθούν να εμφανίζονται ενώ η εφαρμογή εκτελείται.]]> - Επιτρέψτε τις επαφές σας να χρησιμοποιούν αντιδράσεις μηνυμάτων. - Και εσείς και η επαφή σας μπορείτε να στείλετε μηνύματα που εξαφανίζονται. + Επέτρεψε στις επαφές σου να χρησιμοποιούν αντιδράσεις μηνυμάτων. + Και εσύ και η επαφή σου μπορείτε να στείλετε μηνύματα που εξαφανίζονται. Κάμερα μη διαθέσιμη Ελέγξτε την διεύθυνση του διακομιστή και δοκιμάστε ξανά. - Επιτρέψτε αντιδράσεις μηνυμάτων εφόσον οι επαφές σας το επιτρέπουν. + Επέτρεψε αντιδράσεις μηνυμάτων εφόσον οι επαφές σου το επιτρέπουν. %1$d μήνυμα/τα παραλήφθηκε/καν. Κλήσεις απογορευμένες! Δεν είναι δυνατή η αποστολή μηνύματος Η κλήση έχει ήδη τερματιστεί! Ο κωδικός πρόσβασης της εφαρμογής αντικαθίσταται με κωδικό πρόσβασης αυτοκαταστροφής. Το Android Keystore θα χρησιμοποιηθεί για την ασφαλή αποθήκευση της φράσης πρόσβασης μετά την επανεκκίνηση της εφαρμογής ή την αλλαγή της φράσης πρόσβασης - θα επιτρέπει τη λήψη ειδοποιήσεων. - Δεν είναι δυνατή η πρόσβαση στο Keystore για αποθήκευση του κωδικού πρόσβασης της βάσης δεδομένων + Δεν είναι δυνατή η πρόσβαση στο Keystore για αποθήκευση του κωδικού της βάσης δεδομένων Αποκλεισμός μέλους Αποκλεισμός μέλους; Προτιμήσεις συνομιλίας Καλύτερα μηνύματα Εφαρμογή - Συνέναιση υποβάθμισης + Συναίνεση υποβάθμισης Κάμερα κλήση ήχου - Αρχειοθετήστε τις επαφές για να συνομιλήσετε αργότερα. + Αρχειοθέτησε τις επαφές για να συνομιλήσεις αργότερα. Όλα τα προφίλ %1$d μηνύμα/τα παραλείφθηκε/καν κακό μήνυμα hash @@ -380,7 +379,7 @@ Κακό αναγνωριστικό μηνύματος ΣΥΝΟΜΙΛΙΕΣ Η βάση δεδεδομένων της συνομιλίας εισάχθηκε - "συμφωνία κρυπτογράφησης για %s…" + συμφωνία κρυπτογράφησης για %s… Να επιτραπούν οι κλήσεις; Αποκλεισμός μέλους για όλους; Κλήσεις ήχου και βίντεο @@ -390,16 +389,16 @@ Καλύτερη εμπειρία χρήστη Δεν είναι δυνατή η κλήση μέλους ομάδας Ζήτησε να λάβει την εικόνα - για κάθε επαφή και μέλος ομάδας .\nΛάβετε υπόψη: εάν έχετε πολλές συνδέσεις, η κατανάλωση της μπαταρίας και της κυκλοφορίας μπορεί να είναι σημαντικά υψηλότερη και ορισμένες συνδέσεις μπορεί να αποτύχουν.]]> - Προσθήκη επαφής : για να δημιουργήσετε έναν νέο σύνδεσμο πρόσκλησης ή να συνδεθείτε μέσω ενός συνδέσμου που λάβατε.]]> - Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνετε ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> + για κάθε επαφή και μέλος ομάδας .\nΛάβε υπόψη: εάν έχεις πολλές συνδέσεις, η κατανάλωση της μπαταρίας και της χρήσης ίντερνετ μπορεί να είναι σημαντικά υψηλότερη και ορισμένες συνδέσεις μπορεί να αποτύχουν.]]> + Προσθήκη επαφής : για να δημιουργήσεις ένα νέο σύνδεσμο πρόσκλησης ή να συνδεθείς μέσω ενός συνδέσμου που έλαβες.]]> + Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνεις ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> Beta Καλύτερες κλήσεις %1$d σφάλμα/τα αρχείου/ων:\n%2$s - 1 συζήτηση με ένα μέλος + 1 συνομιλία με ένα μέλος 1 αναφορά 1 χρόνος - Σχετικά με χειρηστές + Σχετικά με τους χειριστές Αποδοχή Αποδοχή Αποδοχή ως μέλος @@ -409,32 +408,2117 @@ Αποδοχή αιτήματος επαφής αποδέχτηκε %1$s Αποδεχούμενοι όροι - αποδέχτηκε τη πρόσκληση + αποδέχτηκε την πρόσκληση σε αποδέχτηκε Αποδοχή μέλους Προστέθηκαν διακομιστές πολυμέσων και αρχείων Προστέθηκε διακομιστής μυνημάτων Προσθήκη φίλων - Πρόσθετος τόνος 2 + Επιπρόσθετο χρώμα έμφασης 2 Προσθήκη λίστας Προσθήκη μυνήματος - Διεύθυνση ή σύνδεσμος μιας χρήσης; + Διεύθυνση ή σύνδεσμος 1-χρήσης; Ρυθμίσεις διεύθυνσης Προσθήκη μέλη ομάδας - Προσθήκη στην λίστα + Προσθήκη στη λίστα Πρόσθεσε τα μέλη της ομάδας σου στις συνομιλίες. όλα - Όλα + Όλες Όλες οι συζητήσεις θα διαγραφτούν απο την λίστα %s, και η λίστα θα διαγραφτεί Όλα τα καινούργια μυνήματα από αυτά τα μέλη θα είναι κρυμμένα! Επιτρέψτε τα αρχεία και πολυμέσα μόνο αν η επαφή σου το επιτρέπει. Επιτρέψτε την αναφορά μυνημάτων στους διαχειριστές. - Επιτρέψτε τις επαφές σας να σας στέλνουν αρχεία και πολυμέσα. + Επέτρεψε στις επαφές σου να σου στέλνουν αρχεία και πολυμέσα. Όλες η αναφορές θα αρχειοθετηθούν για εσένα. Όλοι οι διακομιστές Άλλος λόγος Η εφαρμογή πάντα να τρέχει στο παρασκήνιο - Αρχειοθέτηση + Αρχειοθέτησε Αρχειοθέτηση όλων των αναφορών; αρχειοθετημένη αναφορά + 4 νέες γλώσσες διεπαφής + Γραμμές εργαλείων εφαρμογής + αρχειοθετημένη αναφορά από %s + Να αρχειοθετηθούν %d αναφορές; + Αρχειοθέτηση αναφοράς + Αρχειοθέτηση αναφοράς; + Αρχειοθέτηση αναφορών + Ερώτηση + Καλύτερη απόδοση ομάδων + Καλύτερη ιδιωτικότητα και ασφάλεια + Βιογραφικό: + Το βιογραφικό είναι πολύ μεγάλο + Αποκλεισμός μελών για όλους; + Θόλωμα + Μποτ + Εσύ και η επαφή σου, μπορείτε να στέλνετε αρχεία και πολυμέσα. + Διεύθυνση επιχείρησης + Επαγγελματικές συνομιλίες + Επαγγελματική σύνδεση + Επιχειρήσεις + Χρησιμοποιώντας το SimpleX Chat συμφωνείς να:\n- στέλνεις μόνο νόμιμο περιεχόμενο στις δημόσιες ομάδες.\n- σέβεσαι τους άλλους χρήστες – όχι spam. + Δεν μπορείς να αλλάξεις το προφίλ + δεν μπορείς να στείλεις μηνύματα + Καταλανικά, Ινδονησιακά, Ρουμανικά και Βιετναμέζικα – ευχαριστούμε τους χρήστες μας! + με μία μόνο επαφή - προσωπικό διαμοιρασμό ή μέσω οποιασδήποτε εφαρμογής μηνυμάτων.]]> + με κρυπτογράφηση από άκρη-σε-άκρη και με μετα-κβαντική ασφάλεια σε άμεσα μηνύματα.]]> + Επέτρεψε το στο επόμενο παράθυρο διαλόγου για να λαμβάνεις ειδοποιήσεις άμεσα.]]> + Συσκευές Xiaomi: ενεργοποίησε το Αυτόματο Ξεκίνημα στις ρυθμίσεις συστήματος για να λειτουργούν οι ειδοποιήσεις.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s βρίσκεται σε κακή κατάσταση]]> + Σάρωση QR κωδικού.]]> + %s με τον λόγο: %s]]> + σαρώσεις τον QR κωδικό στη βιντεοκλήση, ή η επαφή σου να διαμοιραστεί ένα σύνδεσμο πρόσκλησης.]]> + δείξε τον QR κωδικό στη βιντεοκλήση, ή μοιράσου το σύνδεσμο.]]> + (νέο)]]> + (αυτή η συσκευή v%s)]]> + κρυπτογράφηση από άκρη-σε-άκρη.]]> + κρυπτογράφηση από άκρη-σε-άκρη με πλήρη εμπιστευτικότητα, δυνατότητα απόρριψης και ανάκτηση μετά από παραβίαση.]]> + κβαντο-ανθεκτική κρυπτογράφηση e2e και με πλήρη εμπιστευτικότητα, δυνατότητα απόρριψης και ανάκτηση μετά από παραβίαση.]]> + %s έχει μη υποστηριζόμενη έκδοση. Βεβαιώσου ότι χρησιμοποιείς την ίδια έκδοση και στις δύο συσκευές.]]> + %s είναι απασχολημένο]]> + %s είναι ανενεργό]]> + %s δεν υπάρχει]]> + %s έχει αποσυνδεθεί]]> + %s έχει αποσυνδεθεί]]> + Άνοιγμα στην εφαρμογή κινητού, μετά πάτα Σύνδεση μέσα στην εφαρμογή.]]> + Χρήση από τον υπολογιστή στην εφαρμογή του κινητού και σκάναρε τον QR κωδικό.]]> + Οδηγό Χρήσης.]]> + αποθετήριό μας στο GitHub.]]> + Χρήση .onion hosts σε Όχι, αν ο διακομιστής μεσολάβησης SOCKS δεν τα υποστηρίζει.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %1$s!]]> + %s]]> + Χρήση μπαταρίας εφαρμογής / Απεριόριστη στις ρυθμίσεις της εφαρμογής.]]> + το SimpleX τρέχει στο παρασκήνιο αντί να χρησιμοποιεί ειδοποιήσεις push.]]> + Χρήση μπαταρίας εφαρμογής / Απεριόριστη στις ρυθμίσεις της εφαρμογής.]]> + %s, αποδέξου τους όρους χρήσης.]]> + %1$s.]]> + %1$s.]]> + %1$s.]]> + %1$s.]]> + πρέπει να χρησιμοποιείς την ίδια βάση δεδομένων σε δύο συσκευές.]]> + Άνοιγμα στην εφαρμογή κινητού κουμπί.]]> + συνδεθείς με τους δημιουργούς του SimpleX Chat για να κάνεις ερωτήσεις και να λαμβάνεις ενημερώσεις.]]> + μόνο αφού γίνει αποδεκτό το αίτημά σου.]]> + Να αλλάξεις την αυτόματη διαγραφή μηνυμάτων; + Αλλαγή των προφίλ συνομιλίας + Αλλαγή λίστας + Αλλαγή σειράς + Συνομιλία + Η συνομιλία υπάρχει ήδη! + Συνομιλίες με μέλη + Η συνομιλία θα διαγραφεί για όλα τα μέλη – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η συνομιλία θα διαγραφεί για εσένα – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Συνομίλησε με τους διαχειριστές + Συνομίλησε με τους διαχειριστές + Συνομίλησε με τους διαχειριστές + Συνομίλησε με μέλος + Συνομιλία με μέλη, πριν συνενωθούν. + Έλεγχος μηνυμάτων κάθε 10 λεπτά + Τα κομμάτια κατέβηκαν + Τα τμήματα των αρχείων ανέβηκαν + Καθάρισε + Καθαρισμός + Καθαρισμός + Καθαρισμός συνομιλίας + Καθαρισμός συνομιλίας; + Καθαρισμός ιδιωτικών σημειώσεων; + Καθαρισμός επαλήθευσης + Κάνε κλικ στο κουμπί πληροφοριών δίπλα στο πεδίο διεύθυνσης για να επιτρέψεις τη χρήση του μικροφώνου. + Κουμπί κλεισίματος + χρωματισμένο + Λειτουργία χρώματος + Έρχεται σύντομα! + Παράβαση των κατευθυντήριων γραμμών της κοινότητας + Σύγκριση αρχείου + Σύγκρινε τους κωδικούς ασφαλείας με τις επαφές σου. + ολοκληρώθηκε + Ολοκληρωμένο + Οι όροι έγιναν αποδεκτοί στις: %s. + Όροι χρήσης + Οι όροι θα γίνουν αποδεκτοί για τους ενεργούς χειριστές μετά από 30 ημέρες. + Οι όροι θα γίνουν αποδεκτοί στις: %s. + Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. + Διαμορφωμένοι SMP διακομιστές + Διαμορφωμένοι XFTP διακομιστές + Διαμορφωμένοι ICE διακομιστές + Διαμόρφωση χειριστών διακομιστή + Επιβεβαίωσε + Επιβεβαίωση διαγραφής επαφής; + Επιβεβαίωση αναβαθμίσεων βάσης δεδομένων + Επιβεβαίωση αρχείων από άγνωστους διακομιστές. + Επιβεβαίωση ρυθμίσεων δικτύου + Επιβεβαίωση νέας φράσης πρόσβασης… + Επιβεβαίωση κωδικού πρόσβασης + Επιβεβαίωση κωδικού + Επιβεβαίωσε ότι θυμάσαι τη φράση πρόσβασης της βάσης δεδομένων για να τη μεταφέρεις. + Επιβεβαίωση μεταφόρτωσης + Επιβεβαίωση των διαπιστευτηρίων σου + σύνδεση + Σύνδεση + Σύνδεση + Σύνδεση + Αυτόματη σύνδεση + Απευθείας σύνδεση; + συνδεδεμένος + συνδεδεμένος + συνδεδεμένος + Συνδεδεμένος + Συνδεδεμένος + Συνδεδεμένος υπολογιστής + Συνδεδεμένοι διακομιστές + Συνδέσου γρηγορότερα! 🚀 + Συνδέεται + κλήση σε σύνδεση… + Σύνδεση κλήσης + σύνδεση (σε εξέλιξη) + συνδέεται (μέσω πρόσκλησης) + Σύνδεση με την επαφή, περίμενε ή δοκίμασε αργότερα! + Κατάσταση σύνδεσης και διακομιστών. + Σύνδεση μπλοκαρισμένη + Η σύνδεση έχει μπλοκαριστεί από τον χειριστή του διακομιστή:\n%1$s. + Η σύνδεση δεν είναι έτοιμη. + Η σύνδεση απαιτεί επαναδιαπραγμάτευση κρυπτογράφησης. + Συνδέσεις + Ασφάλεια σύνδεσης + Η σύνδεση διακόπηκε + Η σύνδεση διακόπηκε + Η σύνδεση με τον υπολογιστή βρίσκεται σε κακή κατάσταση + - σύνδεση με υπηρεσία καταλόγου (δοκιμαστικό)!\n- επιβεβαιώσεις παράδοσης (εώς 20 μέλη).\n- γρηγορότερα και πιο σταθερά. + Συνδέσου με τους φίλους σου πιο γρήγορα. + η επαφή %1$s άλλαξε σε %2$s + Η επαφή ελέγχθηκε + η επαφή διαγράφηκε + Η επαφή διαγράφηκε! + η επαφή απενεργοποιήθηκε + Κρυμμένη επαφή: + Η επαφή διαγράφηκε. + η επαφή δεν είναι έτοιμη + ΑΙΤΗΣΕΙΣ ΕΠΑΦΩΝ ΑΠΟ ΟΜΑΔΕΣ + Επαφές + η επαφή πρέπει να αποδεχτεί… + Η επαφή θα διαγραφεί – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Το περιεχόμενο παραβιάζει τους όρους χρήσης + Εικονίδιο περιεχομένου + Συνέχεια + Συνέχεια + Συνεισφορά + Έλεγξε το δίκτυό σου + Η συζήτηση διαγράφηκε! + Αντιγράφηκε στο πρόχειρο + Σφάλμα αντιγραφής + Έκδοση πυρήνα: v%s + Γωνία + Δημιουργία + Δημιουργία συνδέσμου 1-χρήσης + Δημιούργησε μια διεύθυνση για να μπορούν οι άλλοι να συνδεθούν μαζί σου. + Δημιουργήθηκε + Δημιουργήθηκε στις + Δημιουργήθηκε στις: %s + Δημιουργία λίστας + Δημιουργία νέου προφίλ στην εφαρμογή υπολογιστή. 💻 + Δημιουργία συνδέσμου 1-χρήσης + Δημιουργία ουράς + Δημιούργησε τη διεύθυνσή σου + Δημιουργία συνδέσμου αρχειοθέτησης + Δημιουργία συνδέσμου… + Κρίσιμο σφάλμα + (τρέχον) + Το κείμενο των τρεχουσών προϋποθέσεων δεν φορτώθηκε, μπορείς να τις δεις μέσω αυτού του συνδέσμου: + Το μέγιστο υποστηριζόμενο μέγεθος αρχείου αυτήν τη στιγμή είναι %1$s. + Τρέχων κωδικός πρόσβασης + Τρέχουσα φράση πρόσβασης… + Τρέχον προφίλ + προσαρμοσμένος + Προσαρμόσιμη μορφή μηνύματος. + Προσάρμοσε και μοίρασε τα θέματα χρωμάτων. + Προσαρμογή θέματος + Προσαρμοσμένα θέματα + Προσαρμοσμένος χρόνος + Σκούρο + Σκούρο + Σκοτεινή λειτουργία + Χρώματα σκοτεινής λειτουργίας + Σκούρο θέμα + Υποβάθμιση βάσης δεδομένων + Η βάση δεδομένων κρυπτογραφήθηκε! + Η φράση πρόσβασης για την κρυπτογράφηση της βάσης δεδομένων θα ενημερωθεί. + Η φράση πρόσβασης για την κρυπτογράφηση της βάσης δεδομένων θα ενημερωθεί και θα αποθηκευτεί στις ρυθμίσεις. + Η φράση κρυπτογράφησης της βάσης δεδομένων θα ενημερωθεί και θα αποθηκευτεί στο Keystore. + Σφάλμα βάσης δεδομένων + Αναγνωριστικό βάσης δεδομένων + Αναγνωριστικό βάσης δεδομένων: %d + Αναγνωριστικά βάσης δεδομένων και επιλογή απομόνωσης μεταφοράς. + Η βάση δεδομένων είναι κρυπτογραφημένη με τυχαία φράση πρόσβασης. Παρακαλώ άλλαξέ την πριν την εξαγωγή. + Η βάση δεδομένων είναι κρυπτογραφημένη με τυχαία φράση πρόσβασης, μπορείς να την αλλάξεις. + Η μετεγκατάσταση της βάσης δεδομένων βρίσκεται σε εξέλιξη.\nΜπορεί να χρειαστούν λίγα λεπτά. + Φράση πρόσβασης βάσης δεδομένων + Φράση πρόσβασης βάσης δεδομένων και εξαγωγή αυτής + Η φράση πρόσβασης της βάσης δεδομένων διαφέρει από αυτή που έχει αποθηκευτεί στο Keystore. + Η φράση πρόσβασης της βάσης δεδομένων απαιτείται για να ανοίξεις τη συνομιλία. + Αναβάθμιση βάσης δεδομένων + Η έκδοση της βάσης δεδομένων είναι νεότερη από την εφαρμογή, αλλά δεν υπάρχει δυνατότητα υποβάθμισης για: %s + Η βάση δεδομένων θα κρυπτογραφηθεί. + Η βάση δεδομένων θα κρυπτογραφηθεί και η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις. + Η βάση δεδομένων θα κρυπτογραφηθεί και η φράση πρόσβασης θα αποθηκευτεί στο Keystore. + ημέρες + %d συνομιλία/ες + %d συνομιλίες με μέλη + %d επαφή/ές επιλέχθηκε/καν + %dη + %d ημέρα + %d ημέρες + Αποστολή για αποσφαλμάτωση + Αποκεντρωμένο + Σφάλμα αποκωδικοποίησης + Σφάλμα αποκρυπτογράφησης + σφάλματα αποκρυπτογράφησης + προεπιλογή (%s) + προεπιλογή (%s) + Διέγραψε + Διαγραφή + Διαγραφή + Διαγραφή + Διαγραφή διεύθυνσης + Διαγραφή διεύθυνσης; + Διαγραφή μετά + Διαγραφή όλων των αρχείων + Διαγραφή και ειδοποίηση επαφής + Διαγραφή συνομιλίας + Διαγραφή συνομιλίας + Διαγραφή συνομιλίας; + Διαγραφή μηνυμάτων συνομιλίας από τη συσκευή σου. + Διαγραφή προφίλ συνομιλίας + Διαγραφή προφίλ συνομιλίας; + Διαγραφή προφίλ συνομιλίας; + Διαγραφή προφίλ συνομιλίας για + Διαγραφή συνομιλίας με μέλος; + Διαγραφή επαφής + Διαγραφή επαφής; + Διαγράφηκε + Διαγράφηκε στις + Διαγραφή βάσης δεδομένων + Διαγραφή βάσης δεδομένων από αυτήν τη συσκευή + Διαγράφηκε στις: %s + διεγραμμένη επαφή + διεγραμμένη ομάδα + Διαγραφή %d μηνυμάτων; + Διαγραφή %d μηνυμάτων μελών; + Διαγραφή αρχείου + Διαγραφή μηνυμάτων και πολυμέσων; + Διαγραφή μηνυμάτων για όλα τα προφίλ συνομιλίας + Διαγραφή για όλους + Διαγραφή για μένα + Διαγραφή ομάδας + Διαγραφή ομάδας; + Διαγραφή εικόνας + Διαγραφή συνδέσμου + Διαγραφή συνδέσμου; + Διαγραφή λίστας; + Διαγραφή μηνύματος μέλους; + Διαγραφή μηνυμάτων μέλους + Διαγραφή μηνυμάτων μέλους; + Διαγραφή μηνύματος; + Διαγραφή μηνυμάτων + Διαγραφή μηνυμάτων + Διαγραφή μηνυμάτων μετά + Διαγραφή ή διαχείριση εώς 200 μηνυμάτων. + Να διαγραφεί η εκκρεμής σύνδεση; + Διαγραφή προφίλ + Διαγραφή ουράς + Διαγραφή αναφοράς + Διαγραφή διακομιστή + Διαγραφή εώς 20 μηνυμάτων ταυτόχρονα. + Διαγραφή χωρίς ειδοποίηση + Σφάλματα διαγραφής + Παράδοση + Επιβεβαιώσεις παράδοσης! + Οι επιβεβαιώσεις παράδοσης είναι απενεργοποιημένες! + Απαρχαιωμένες επιλογές + Περιγραφή + Περιγραφή πολύ μεγάλη + Υπολογιστής + Διεύθυνση υπολογιστή + Η έκδοση της εφαρμογής για υπολογιστή %s δεν είναι συμβατή με αυτήν την εφαρμογή. + Συσκευές υπολογιστή + Ο υπολογιστής έχει μη υποστηριζόμενη έκδοση. Βεβαιώσου ότι χρησιμοποιείς την ίδια έκδοση και στις δύο συσκευές. + Ο υπολογιστής έχει λάθος κωδικό πρόσκλησης + Ο υπολογιστής είναι απασχολημένος + Ο υπολογιστής είναι ανενεργός + Ο υπολογιστής έχει αποσυνδεθεί + Η διεύθυνση διακομιστή προορισμού %1$s δεν είναι συμβατή με τις ρυθμίσεις του διακομιστή προώθησης %2$s. + Σφάλμα διακομιστή προορισμού: %1$s + Η έκδοση του διακομιστή προορισμού %1$s δεν είναι συμβατή με τον διακομιστή προώθησης %2$s. + Αναλυτικά στατιστικά + Λεπτομέρειες + Επιλογές προγραμματιστή + Εργαλεία προγραμματιστή + ΣΥΣΚΕΥΗ + Η επαλήθευση συσκευής είναι απενεργοποιημένη. Απενεργοποιείται το SimpleX Lock. + Η επαλήθευση συσκευής δεν είναι ενεργοποιημένη. Μπορείς να ενεργοποιήσεις το SimpleX Lock από τις Ρυθμίσεις, αφού πρώτα ενεργοποιήσεις την επαλήθευση συσκευής. + Συσκευές + %d αρχείο/α με συνολικό μέγεθος %s + %d συμβάντα ομάδας + %dω + %dώρα + %dώρες + διαφορετική μετεγκατάσταση στην εφαρμογή/βάση δεδομένων: %s / %s + Διαφορετικά ονόματα, avatar και απομόνωση μεταφοράς. + άμεσα + Άμεσα μηνύματα + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα. + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα σε αυτή τη συνομιλία + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα σε αυτήν την ομάδα. + Απενεργοποίηση + Απενεργοποίηση + Απενεργοποίηση αυτόματης διαγραφής μηνυμάτων; + απενεργοποιημένο + απενεργοποιημένο + Απενεργοποιημένο + Απενεργοποίηση διαγραφής μηνυμάτων + Απενεργοποίηση για όλους + Απενεργοποίηση για όλες τις ομάδες + πενεργοποίηση (διατήρηση παρακάμψεων ομάδας) + Απενεργοποίηση (διατήρηση παρακάμψεων) + Απενεργοποίηση ειδοποιήσεων + Απενεργοποίηση αναφορών παράδοσης; + Απενεργοποιίηση αναφορών παράδοσης για τις ομάδες; + Απερνεργοποίση SimpleX Lock + Μήνυμα που εξαφανίζεται + Μηνύματα που εξαφανίζονται + Μηνύματα που εξαφανίζονται + Είναι απαγορευμένα τα μηνύματα που εξαφανίζονται. + Είναι απαγορευμένα τα μηνύματα που εξαφανίζονται σε αυτήν τη συνομιλία. + Να εξαφανιστεί σε + Να εξαφανιστεί σε: %s + Αποσύνδεση + Αποσύνδεση + Αποσύνδεση υπολογιστή; + Αποσυνδεδεμένος + Αποσυνδεδεμένος με το λόγο: %s + Αποσύνδεση τηλεφώνων + Ανιχνεύσιμο μέσω τοπικού δικτύου + Ανακάλυψε και συνδέσου σε ομάδες + Ανακάλυψη μέσω τοπικού δικτύου + Το εμφανιζόμενο όνομα δεν μπορεί να περιέχει κενά. + %dμ + %dμηνύματα + %dμηνύματα μπλοκαρισμενα από το διαχειριστή + %d λπτ + %d λεπτά + %dμήνας + %d μήνες + %dμν + Να μη σταλεί το ιστορικό σε νέα μέλη. + ΜΗΝ στέλνεις μηνύματα απευθείας, ακόμα κι αν ο δικός σου διακομιστής ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Μην χρησιμοποιείς διαπιστευτήρια με το διακομιστή μεσολάβησης (proxy). + ΜΗΝ χρησιμοποιείς ιδιωτική δρομολόγηση. + Μην δημιουργήσεις διεύθυνση + Μην ενεργοποιήσεις + Μην χάσεις σημαντικά μηνύματα. + Να μην εμφανιστεί ξανά + Υποβάθμιση και άνοιγμα συνομιλίας + Κατέβασμα + Κατέβασμα + Κατέβηκε + Κατεβασμένα αρχεία + Σφάλματα λήψης + Η λήψη απέτυχε + Λήψη αρχείου + Η αναβάθμιση εφαρμογής βρίσκεται σε εξέλιξη, μην κλείσεις την εφαρμογή + Λήψη αρχείου αρχειοθέτησης + Λήψη λεπτομερειών συνδέσμου + Κατέβασε νέες εκδόσεις από το GitHub. + Κατέβασμα %s (%s) + %d αναφορές + %dδ + %dδευτ + %dδευτερόλεπτα + Διπλότυπο εμφανιζόμενο όνομα! + διπλότυπο μήνυμα + διπλότυπα + %dε + %d εβδομάδα + %d εβδομάδες + e2e κρυπτογραφημένο + e2e κρυπτογραφημένη φωνητική κλήση + e2e κρυπτογραφημένη βιντεοκλήση + Ακουστικό + Επεξεργάσου + Επεξεργασία + επεξεργάστηκε + Επεξεργασία προφίλ ομάδας + Επεξεργασία εικόνας + Email + Ενεργοποίηση + Ενεργοποίηση αυτόματης διαγραφής μηνυμάτων + Ενεργοποίηση φωνητικών κλήσεων από την οθόνη κλειδώματος μέσω των Ρυθμίσεων + Ενεργοποίηση πρόσβασης κάμερας + ενεργοποιημένο + Ενεργοποιημένο για + ενεργοποιημένο για την επαφή + ενεργοποιημένο για εσένα + Ενεργοποίηση μηνυμάτων που εξαφανίζονται από προεπιλογή. + Ενεργοποίησε το Flux στις ρυθμίσεις Δικτύου & διακομιστών για καλύτερη προστασία μεταδεδομένων. + Ενεργοποίηση για όλα + Ενεργοποίηση για όλες τις ομάδες + Ενεργοποίηση στις άμεσες συνομιλίες (ΔΟΚΙΜΑΣΤΙΚΟ)! + Ενεργοποίηση (διατήρηση παρακάμψεων ομάδας) + Ενεργοποίηση (διατήρηση παρακάμψεων) + Ενεργοποίηση κλειδώματος + Ενεργοποίηση αρχείων καταγραφής δραστηριότητας + Ενεργοποίηση αναφορών παράδοσης; + Ενεργοποίηση αναφορών παράδοσης για τις ομάδες; + Ενεργοποίηση αυτοκαταστροφής + Ενεργοποίηση κωδικού αυτοκαταστροφής + Ενεργοποίηση SImpleX Lock + Ενεργοποίηση TCP keep-alive + Κρυπτογράφηση + Κρυπτογράφηση βάσης δεδομένων; + Κρυπτογραφημένη βάση δεδομένων + κρυπτογράφηση συμφωνήθηκε + κρυπτογράφηση συμφωνήθηκε για %s + κρυπτογράφηση οκ + κρυπτογράφηση οκ για %s + επιτρέπεται επαναδιαπραγμάτευση κρυπτογράφησης + επιτρέπεται επαναδιαπραγμάτευση κρυπτογράφησης για %s + Σφάλμα κατά την επαναδιαπραγμάτευση κρυπτογράφησης + Αποτυχία κατά την επαναδιαπραγμάτευση κρυπτογράφησης + Επαναδιαπραγμάτευση κρυπτογράφησης σε εξέλιξη. + απαιτείται επαναδιαπραγμάτευση κρυπτογράφησης + απαιτείται επαναδιαπραγμάτευση κρυπτογράφησης για %s + Κρυπτογράφηση τοπικών αρχείων + Κρυπτογράφηση αποθηκευμένων αρχείων & πολυμέσων + Τερματισμός κλήσης + τερματίστηκε + Εισήγαγε σωστή φράση πρόσβασης. + Εισήγαγε όνομα ομάδας: + Εισήγαγε κωδικό πρόσβασης + Εισήγαγε φράση πρόσβασης + Εισήγαγε φράση πρόσβασης… + Εισήγαγε κωδικό στην αναζήτηση + Χειροκίνητη εισαγωγή διακομιστή + Εισήγαγε το όνομα συσκευής… + Εισήγαγε το μήνυμα καλωσορίσματος… + Εισήγαγε το μήνυμα καλωσορίσματος… (προαιρετικό) + Εισήγαγε το όνομά σου: + Σφάλμα + Σφάλμα + Σφάλμα + Σφάλμα + Σφάλμα: %1$s + Σφάλμα κατά την ακύρωση αλλαγής διεύθυνσης + Σφάλμα κατά την αποδοχή των όρων + Σφάλμα κατά την αποδοχή του αιτήματος επαφής + Σφάλμα κατά την αποδοχή μέλους + Επιδιόρθωση σύνδεσης; + Επιδιόρθωση σύνδεσης; + Διόρθωσε την κρυπτογράφηση μετά από επαναφορά αντιγράφων ασφαλείας. + Η επιδιόρθωση δεν υποστηρίζεται από την επαφή + Η επιδιόρθωση δεν υποστηρίζεται από μέλος ομάδας + Αντιστροφή κάμερας + Μέγεθος γραμματοσειράς + Για όλους τους διαχειριστές + για καλύτερη ιδιωτικότητα μεταδεδομένων + Για το προφίλ συνομιλίας %s: + ΓΙΑ ΚΟΝΣΟΛΑ + Για όλους + Για παράδειγμα, αν η επαφή σου λαμβάνει μηνύματα μέσω κάποιου SimpleX Chat διακομιιστή, η εφαρμογή σου θα τα παραδίδει μέσω ενός Flux διακομιστή. + Για μένα + Για ιδιωτική δρομολόγηση + Για μέσα κοινωνικής δικτύωσης + Προώθηση + Προώθηση %1$s μηνύματος/ων; + Προώθηση και αποθήκευση μηνυμάτων + προωθήθηκε + Προωθήθηκε + Προωθήθηκε από + Προώθηση %1$s μηνυμάτων + Διακομιστής προώθησης: %1$s\nΣφάλμα διακομιστή προορισμού: %2$s + Διακομιστής προώθησης: %1$s\nΣφάλμα: %2$s + Ο διακομιστής προώθησης %1$s δεν κατάφερε να συνδεθεί με τον διακομιστή προορισμού %2$s. Δοκίμασε ξανά αργότερα. + Η διεύθυνση του διακομιστή προώθησης είναι ασύμβατη με τις ρυθμίσεις του δικτύου: %1$s. + Η έκδοση του διακομιστή προώθησης είναι ασύμβατη με τις ρυθμίσεις του δικτύου: %1$s. + Προώθηση μηνύματος… + Προώθηση μηνυμάτων… + Προώθηση μηνυμάτων χωρίς τα αρχεία; + Προώθησε μέχρι και 20 μηνύματα μαζί. + Βρέθηκε υπολογιστής + Διεπαφή στα γαλλικά + Από τη Συλλογή + Πλήρης σύνδεσμος + Πλήρης σύνδεσμος + Πλήρες όνομα: + Πλήρως αποκεντρωμένο – ορατό μόνο στα μέλη. + Περαιτέρω μειωμένη κατανάλωση μπαταρίας + Λάβε ειδοποίηση όταν σε αναφέρουν. + Καλησπέρα! + Καλημέρα! + Χορήγησε στις ρυθμίσεις + Χορήγησε άδειες + Χορήγησε άδεια/ες για να κάνεις φωνητικές κλήσεις + Ομάδα + Ομάδα + Η ομάδα υπάρχει ήδη! + η ομάδα διαγράφηκε + Πλήρες όνομα ομάδας: + Ανενεργή ομάδα + Έληξε η πρόσκληση ομάδας + Η πρόσκληση για την ομάδα δεν ισχύει πια, αφαιρέθηκε από τον αποστολέα. + η ομάδα διαγράφηκε + Σύνδεσμος ομάδας + Σύνδεσμοι ομάδας + Διαχείριση ομάδας + Η ομάδα δεν βρέθηκε! + Προτιμήσεις ομάδας + Το προφίλ της ομάδας αποθηκεύεται στις συσκευές των μελών και όχι στους διακομιστές. + το προφίλ της ομάδας ανανεώθηκε + Ομάδες + Μήνυμα καλωσορίσματος ομάδας + Η ομάδα θα διαγραφεί για όλα τα μέλη – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η ομάδα θα διαγραφεί για σένα – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Τερματισμός κλήσης + Ακουστικά + βοήθεια + ΒΟΗΘΕΙΑ + Βοήθησε τους διαχειριστές να διαχειρίζονται τις ομάδες τους. + Γεια σου!\nΣυνδέσου μαζί μου μέσω SimpleX Chat: %s + Κρυφό + Κρυμμένα προφίλ συνομιλίας + Κρυφό συνθηματικό προφίλ + Κρύψε + Κρύψε + Κρύψε + Κρύψε: + Απόκρυψη της οθόνης της εφαρμογής στις πρόσφατες εφαρμογές. + Απόκρυψη επαφής και μηνύματος + Απόκρυψη προφίλ + Ιστορικό + Το ιστορικό δεν αποστέλλεται σε νέα μέλη. + Φιλοξένησε + ώρες + Πως επηρεάζει τη μπαταρία + Πως βοηθάει την ιδιωτικότητα + Πως δουλεύει + Πως δουλεύει το SimpleX + Πως να + Πως να το χρησιμοποιήσεις + Πως να χρησιμοποιήσεις markdown σύνταξη + Πως να χρησιμοποιήσεις τους διακομιστές σου + Διεπαφή στα Ουγγρικά και Τουρκικά + ICE διακομιστές (ένας σε κάθε γραμμή) + Αν δεν μπορείς να συναντηθείς προσωπικά, δείξε τον QR κωδικό σε μια βιντεοκλήση ή μοιράσου τον σύνδεσμο. + Αν επιλέξεις να απορρίψεις, ο αποστολέας ΔΕΝ θα ειδοποιηθεί. + Αν επιβεβαιώσεις, οι διακομιστές μηνυμάτων θα μπορούν να δουν τη διεύθυνση IP σου, και ο πάροχός σου – σε ποιους διακομιστές συνδέεσαι. + Αν εισάγεις αυτόν τον κωδικό κατά το άνοιγμα της εφαρμογής, όλα τα δεδομένα της εφαρμογής θα διαγραφούν οριστικά! + Αν εισάγεις τον κωδικό αυτοκαταστροφής κατά το άνοιγμα της εφαρμογής: + Αν έλαβες σύνδεσμο πρόσκλησης για SimpleX Chat, μπορείς να τον ανοίξεις στον περιηγητή σου: + Αγνόησε + Εικόνα + Εικόνα + Η εικόνα αποθηκεύτηκε στη Συλλογή + Η εικόνα στάλθηκε + Η εικόνα θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή της. + Η εικόνα θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, περίμενε ή έλεγξε αργότερα! + Άμεσα + Ανοσοποιημένο στο spam + Εισαγωγή + Εισαγωγή βάσης δεδομένων συνομιλίας; + Εισαγωγή βάσης δεδομένων + Η εισαγωγή απέτυχε + Εισαγωγή αρχείου αρχειοθέτησης + Εισαγωγή θέματος + Σφάλμα εισαγωγής θέματος + Βελτιωμένη πλοήγηση συνομιλίας + Βελτιωμένη παράδοση μηνύματος + Βελτιωμένη παράδοση μηνύματος + Βελτιωμένη ιδιωτικότητα και ασφάλεια + Βελτιωμένη διαμόρφωση διακομιστή + ανενεργό + Ακατάλληλο περιεχόμενο + Ακατάλληλο προφίλ + Ήχοι κατά τη διάρκεια κλήσης + Ανώνυμο + Ανώνυμες ομάδες + Ανώνυμη λειτουργία + Η ανώνυμη λειτουργία προστατεύει το απόρρητό σου χρησιμοποιώντας ένα νέο τυχαίο προφίλ για κάθε επαφή. + ανώνυμα μέσω συνδέσμου διεύθυνσης επαφής + ανώνυμα μέσω συνδέσμου ομάδας + ανώνυμα μέσω συνδέσμου 1-χρήσης + Εισερχόμενη φωνητική κλήση + Εισερχόμενη βιντεοκλήση + Ασύμβατη έκδοση βάσης δεδομένων + Ασύμβατη έκδοση + Λανθασμένος κωδικός πρόσβασης + Λανθασμένος κωδικός ασφάλειας! + Μεγένθυση γραμματοσειράς + έμμεσο (%1$s) + Πληροφορίες + Αρχικός ρόλος + Για να συνεχίσεις, η συνομιλία θα πρέπει να σταματήσει. + Σε απάντηση του + Εγκαταστάθηκε επιτυχημένα + Εγκατέστησε το SimpleX Chat για το τερματικό + Εγκατάσταση αναβάθμισης + Άμεσα + Άμεσες ειδοποιήσεις + Άμεσες ειδοποιήσεις! + Οι άμεσες ειδοποιήσεις είναι απενεργοποιημένες! + ΧΡΩΜΑΤΑ ΔΙΕΠΑΦΗΣ + Εσωτερικό σφάλμα + μη έγκυρη συνομιλία + Μη έγκυρος σύνδεσμος + μη έγκυρα δεδομένα + Μη έγκυρο εμφανιζόμενο όνομα! + Μη έγκυρος σύνδεσμος + Μη έγκυρος σύνδεσμος + Μη έγκυρος σύνδεσμος! + μη έγκυρη διαμόρφωση μηνύματος + Μη έγκυρη επιβεβαίωση μετεγκατάστασης + Μη έγκυρο όνομα! + Μη έγκυρος QR κωδικός + Μη έγκυρος QR κωδικός + Μη έγκυρη διεύθυνση διακομιστή! + Η πρόσκληση έληξε! + πρόσκληση στην ομάδα %1$s + Προσκάλεσε + Προσκάλεσε + προσκαλεσμένος + προσκεκλημένος %1$s + προσκεκλημένος για σύνδεση + προσκεκλημένος μέσω του συνδέσμου της ομάδας σου + Προσκάλεσε φίλους + Προσκάλεσε μέλη + Προσκάλεσε μέλη + Προσκάλεσε για συνομιλία + Προσκάλεσε σε ομάδα + Οριστική διαγραφή μηνύματος + Η οριστική διαγραφή μηνύματος απαγορεύεται. + Η οριστική διαγραφή μηνύματος απαγορεύεται σε αυτή τη συνομιλία. + Διεπαφή στα Ιταλικά + πλάγια γραφή + Σου επιτρέπει να έχεις πολλές ανώνυμες συνδέσεις χωρίς κοινά δεδομένα μεταξύ τους σε ένα μόνο προφίλ συνομιλίας. + Μπορεί να συμβεί όταν:\n1. Τα μηνύματα λήγουν στον αποστολέα μετά από 2 ημέρες ή στον διακομιστή μετά από 30 ημέρες.\n2. Η αποκρυπτογράφηση μηνύματος απέτυχε, επειδή εσύ ή η επαφή σου χρησιμοποιήσατε παλιό αντίγραφο ασφαλείας της βάσης δεδομένων.\n3. Η σύνδεση έχει παραβιαστεί. + Μπορεί να συμβεί όταν εσύ ή η σύνδεσή σου χρησιμοποιήσατε παλιό αντίγραφο ασφαλείας της βάσης δεδομένων. + Προστατεύει τη διεύθυνση IP και τις συνδέσεις σου. + Διεπαφή στα Ιαπωνικά και Πορτογαλικά + Συμμετοχή + Συμμετοχή ως %s + Συμμετοχή στην ομάδα + Συμμετοχή στην ομάδα; + Συμμετοχή στις συνομιλίες της ομάδας + Ανώνυμη συμμετοχή + Συμμετοχή στη ομάδα + Θέλεις να συμμετάσχεις στην ομάδα σου; + χ + Διατήρηση + Διατήρηση συνομιλίας + Διατήρηση αχρησιμοποίητων προσκλήσεων; + Διατήρησε καθαρές τις συνομιλίες σου + Διατήρησε τις συνδέσεις + Σφάλμα κλειδιού + Μεγάλο αρχείο! + Μάθε περισσότερα + Αποχώρησε + Αποχώρησε από τη συνομιλία + Αποχώρηση από τη συνομιλία; + Αποχώρησε από την ομάδα + Αποχώρηση από την ομάδα; + αποχώρησε + αποχώρησε + Λιγότερη κίνηση στα δίκτυα κινητής τηλεφωνίας. + Ας μιλήσουμε στο SimpleX Chat + Ανοιχτόχρωμο + Ανοιχτόχρωμο + Ανοιχτόχρωμη λειτουργία + Σύνδεσε ένα τηλέφωνο + Επιλογές συνδεδεμένου υπολογιστή + Συνδεδεμένοι υπολογιστές + Συνδεδεμένα τηλέφωνα + Σύνδεσε τις εφαρμογές κινητού και υπολογιστή! 🔗 + εικόνα προεπισκόπησης συνδέσμου + Λίστα + Όνομα λίστας... + Το όνομα της λίστας και το emoji πρέπει να είναι διαφορετικά για όλες τις λίστες. + Διεπαφή στα Λιθουανικά + ΖΩΝΤΑΝΑ + Ζωντανό μήνυμα! + Ζωντανά μηνύματα! + Φόρτωση συνομιλιών… + Φόρτωση προφίλ… + Φόρτωση αρχείου + Τοπικό όνομα + Μόνο τοπικά δεδομένα προφίλ + Κλείδωμα μετά + Λειτουργία κλειδώματος + Σύνδεση χρησιμοποιώντας τα στοιχεία σου + Δημιούργησε μία ιδιωτική συνομιλία + Εξαφάνισε ένα μήνυμα + Κάνε το προφίλ ιδιωτικό! + Βεβαιώσου ότι έχεις σωστή διαμόρφωση του διακομιστή μεσολάβησης. + Βεβαιώσου ότι οι διευθύνσεις του διακομιστή SMP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. + Βεβαιώσου ότι το αρχείο έχει σωστή σύνταξη YAML. Κάνε εξαγωγή ενός θέματος για να έχεις παράδειγμα της δομής αρχείου των θεμάτων. + "Βεβαιώσου ότι οι διευθύνσεις των διακομιστών WebRTC ICE έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες." + Βεβαιώσου ότι οι διευθύνσεις των διακομιστών XFTP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. + Κάνε τις συνομιλίες σου να ξεχωρίζουν! + Βοήθεια στη Markdown σύνταξη + Σύνταξη Markdown στα μηνύματα + Επισήμανση ως αναγνωσμένο + Επισήμανση ως μη αναγνωσμένο + Επισήμανση ως επαληθευμένο + Μέχρι 40 δευτερόλεπτα, λαμβάνεται άμεσα. + Διακομιστές πολυμέσων & αρχείων + Μεσαίο + μέλος + ΜΕΛΟΣ + Μέλος %1$s + το μέλος %1$s άλλαξε σε %2$s + Εγγραφή μέλους + το μέλος έχει παλαιότερη έκδοση + Το μέλος είναι ανενεργό + Το μέλος διαγράφηκε – δεν μπορεί να γίνει αποδοχή του αιτήματος + Τα μηνύματα του μέλους θα διαγραφούν – αυτό δεν μπορεί να αναιρεθεί! + Αναφορές μέλους + Τα μέλη μπορούν να προσθέτουν αντιδράσεις στα μηνύματα. + Τα μέλη μπορούν να διαγράψουν οριστικά τα απεσταλμένα μηνύματα. (24 ώρες) + Τα μέλη μπορούν να αναφέρουν μηνύματα στους διαχειριστές. + Τα μέλη μπορούν να στέλνουν απευθείας μηνύματα. + Τα μέλη μπορούν να στέλνουν μηνύματα που εξαφανίζονται. + Τα μέλη μπορούν να στέλνουν αρχεία και πολυμέσα. + Τα μέλη μπορούν να στέλνουν συνδέσμους SimpleX. + Τα μέλη μπορούν να στέλνουν φωνητικά μηνύματα. + Τα μέλη θα αφαιρεθούν από τη συνομιλία – αυτό δεν μπορεί να αναιρεθεί! + Τα μέλη θα αφαιρεθούν από τη ομάδα – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα αφαιρεθεί από τη συνομιλία – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα αφαιρεθεί από την ομάδα – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα συμμετάσχει στην ομάδα, να γίνει αποδοχή του; + Επισήμανση μελών 👋 + Μενού & προειδοποιήσεις + μήνυμα + Μήνυμα + Σφάλμα παράδοσης μηνύματος + Αναφορές παράδοσης μηνύματος! + Προειδοποίηση παράδοσης μηνύματος + Πρόχειρο μήνυμα + Πρόχειρο μήνυμα + Το μήνυμα προωθήθηκε + Στείλε μήνυμα αμέσως μόλις πατήσεις Σύνδεση. + Το μήνυμα είναι πολύ μεγάλο! + Το μήνυμα μπορεί να παραδοθεί αργότερα όταν το μέλος γίνει ενεργό. + Πληροφορίες ουράς μηνυμάτων + Αντιδράσεις μηνυμάτων + Αντιδράσεις μηνυμάτων + Απαγορεύονται οι αντιδράσεις στα μηνύματα. + Απαγορεύονται οι αντιδράσεις στα μηνύματα σε αυτήν τη συνομιλία. + Λήψη μηνυμάτων + Εναλλακτική δρομολόγηση μηνυμάτων + Λειτουργία δρομολόγησης μηνυμάτων + Μηνύματα + ΜΗΝΥΜΑΤΑ ΚΑΙ ΑΡΧΕΙΑ + Διακομιστές μηνυμάτων + Θα εμφανιστούν τα μηνύματα από το %s! + Θα εμφανιστούν τα μηνύματα από αυτά τα μέλη! + Μορφή μηνύματος + Τα μηνύματα σε αυτήν τη συνομιλία δεν θα διαγραφούν ποτέ. + Η πηγή του μηνύματος παραμένει ιδιωτική. + Ληφθέντα μηνύματα + Απεσταλμένα μηνύματα + Κατάσταση μηνύματος + Κατάσταση μηνύματος: %s + Τα μηνύματα διαγράφηκαν αφού τα επιλέξατε. + Τα μηνύματα θα διαγραφούν - αυτό δεν μπορεί να αναιρεθεί! + Τα μηνύματα θα επισημανθούν για διαγραφή. Ο/Οι παραλήπτης/ες θα μπορούν να αποκαλύψουν αυτά τα μηνύματα. + Κείμενο μηνύματος + Το μήνυμα είναι πολύ μεγάλο + Το μήνυμα θα διαγραφεί - αυτό δεν μπορεί να αναιρεθεί! + Το μήνυμα θα επισημανθεί για διαγραφή. Ο/Οι παραλήπτης/ες θα μπορούν να αποκαλύψουν αυτό το μήνυμα. + Μικρόφωνο + Μετεγκατάσταση συσκευής + Μετεγκατάσταση από άλλη συσκευή + Μετεγκατάσταση εδώ + Μετεγκατάσταση σε άλλη συσκευή + Μετεγκατάσταση σε άλλη συσκευή μέσω QR κωδικού. + Μετεγκατάσταση σε εξέλιξη + Η μετεγκατάσταση ολοκληρώθηκε + Μετεγκαταστάσεις: %s + λεπτά + αναπάντητη κλήση + Αναπάντητη κλήση + Διαχειρίσου + διαχειρίζεται + Διαχειρίστηκε στις + Διαχειρίστηκε στις: %s + διαχειριστής + διαχειριστές + μήνες + Περισσότερα + Σύντομα έρχονται περισσότερες βελτιώσεις! + Σύντομα έρχονται περισσότερες βελτιώσεις! + Πιο αξιόπιστη σύνδεση δικτύου. + - πιο σταθερή παράδοση μηνυμάτων.\n- λίγο καλύτερες ομάδες.\n- και πολλά ακόμα! + Πιθανότατα αυτή η επαφή να έχει διαγράψει τη σύνδεση μαζί σου. + Πολλαπλά προφίλ συνομιλίας + Σίγαση + Σίγαση + Σίγαση όλων + Σε σίγαση όταν είναι ανενεργό! + Σύνδεση δικτύου + Αποκέντρωση δικτύου + Προβλήματα δικτύου - το μήνυμα έληξε μετά από πολλές προσπάθειες αποστολής. + Διαχείριση δικτύου + Χειριστής δικτύου + Χειριστές δικτύου + Δίκτυο & διακομιστές + Κατάσταση δικτύου + ποτέ + Ποτέ + Νέα συνομιλία + Νέα εμπειρία συνομιλίας 🎉 + Νέα θέματα συνομιλίας + Νέο αίτημα επαφής + Νέο αρχείο βάσης δεδομένων + Νέα εφαρμογή για υπολογιστές! + Νέο εμφανιζόμενο όνομα: + δευτερόλεπτα + Το βιογραφικό σου: + Πάτα Σύνδεση για να συνομιλήσεις + Πάτα Σύνδεση για αποστολή αιτήματος + Πάτα Σύνδεση για να χρησιμοποιήσεις το μποτ + Πατήστε Δημιουργία διεύθυνσης SimpleX στο μενού, για να τη δημιουργήσετε αργότερα. + Πάτα Συμμετοχή στην ομάδα + Πάτα για να ενεργοποιήσεις το προφίλ. + Πάτα για Σύνδεση + Πάτα για συμμετοχή + Πάτα για ανώνυμη συμμετοχή + Πάτα για επικόλληση συνδέσμου + Πάτα για σάρωση + Πάτα για να ξεκινήσεις μία νέα συνομιλία + Σύνδεση TCP + Χρόνος λήξης σύνδεσης TCP στο παρασκήνιο + Χρόνος λήξης σύνδεσης TCP + Θύρα TCP για ανταλλαγή μηνυμάτων + Σφάλμα προσωρινού αρχείου + Η δοκιμή απέτυχε στο βήμα %s. + Δοκιμή διακομιστή + Δοκιμή διακομιστών + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Σε ευχαριστούμε που εγκατέστησες το SimpleX Chat! + Η διεύθυνση θα είναι σύντομη και το προφίλ σου θα κοινοποιηθεί μέσω αυτής. + Η εφαρμογή λαμβάνει νέα μηνύματα περιοδικά — καταναλώνει ένα μικρό ποσοστό της μπαταρίας ανά ημέρα. Η εφαρμογή δεν χρησιμοποιεί ειδοποιήσεις push — τα δεδομένα από τη συσκευή σου δεν αποστέλλονται στους διακομιστές. + Η εφαρμογή ενδέχεται να κλείσει μετά από 1 λεπτό στο παρασκήνιο. + Η εφαρμογή προστατεύει το απόρρητό σου χρησιμοποιώντας διαφορετικούς χειριστές σε κάθε συνομιλία. + Η εφαρμογή θα ζητήσει επιβεβαίωση για λήψεις από άγνωστους διακομιστές αρχείων (εκτός από .onion ή όταν είναι ενεργοποιημένος ο διακομιστής μεσολάβησης SOCKS). + Η προσπάθεια αλλαγής της φράσης πρόσβασης της βάσης δεδομένων δεν ολοκληρώθηκε. + Ο κωδικός που σάρωσες δεν είναι κωδικός QR ενός συνδέσμου SimpleX. + Η σύνδεση έφτασε στο όριο των μη παραδοθέντων μηνυμάτων, η επαφή σου ενδέχεται να είναι εκτός σύνδεσης. + Η σύνδεση που αποδέχθηκες θα ακυρωθεί! + Η επαφή με την οποία μοιράστηκες αυτόν το σύνδεσμο, ΔΕΝ θα μπορεί να συνδεθεί! + Η βάση δεδομένων δεν λειτουργεί σωστά. Πάτησε για να μάθεις περισσότερα. + Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές. + Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού. + Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης! + Το μέλλον στην ανταλλαγή μηνυμάτων + Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός. + Ο αναγνωριστικός κωδικός του επόμενου μηνύματος είναι λανθασμένος (μικρότερος ή ίσος με τον προηγούμενο).\nΑυτό μπορεί να συμβεί λόγω κάποιου σφάλματος ή όταν η σύνδεση έχει παραβιαστεί. + Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. + Ο σύνδεσμος θα είναι σύντομος και το προφίλ της ομάδας θα κοινοποιηθεί μέσω αυτού. + Θέμα + ΘΕΜΑΤΑ + Τα μηνύματα θα διαγραφούν για όλα τα μέλη. + Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. + Το μήνυμα θα διαγραφεί για όλα τα μέλη. + Το μήνυμα θα επισημανθεί ως υπό έλεγχο για όλα τα μέλη. + Η πλατφόρμα μηνυμάτων και εφαρμογών που προστατεύει το απόρρητο και την ασφάλειά σου. + Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. + Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. + Το προφίλ κοινοποιείται μόνο στις επαφές σου. + Η αναφορά θα αρχειοθετηθεί για εσένα. + Ο ρόλος θα αλλάξει σε %s. Όλοι οι συμμετέχοντες στη συνομιλία θα ειδοποιηθούν. + Ο ρόλος θα αλλάξει σε %s. Όλα τα μέλη της ομάδας θα ενημερωθούν. + Ο ρόλος θα αλλάξει σε %s. Το μέλος θα λάβει νέα πρόσκληση. + Ο δεύτερος προκαθορισμένος χειριστής στην εφαρμογή! + Το δεύτερο τικ που χάσαμε! ✅ + Ο αποστολέας ΔΕΝ θα ειδοποιηθεί. + Οι διακομιστές για τις νέες συνδέσεις του τρέχοντος προφίλ συνομιλίας σου + Οι διακομιστές για τα νέα αρχεία του τρέχοντος προφίλ συνομιλίας σου + Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου + Το κείμενο που επικόλλησες δεν είναι σύνδεσμος SimpleX. + Το αρχείο της βάσης δεδομένων που μεταφορτώθηκε, θα διαγραφεί οριστικά από τους διακομιστές. + Το βίντεο δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε ένα άλλο βίντεο ή επικοινώνησε με τους προγραμματιστές. + Μπορούν να παρακαμφθούν στις ρυθμίσεις επαφών και ομάδων. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - όλα τα ληφθέντα και απεσταλμένα αρχεία και πολυμέσα θα διαγραφούν. Οι εικόνες χαμηλής ανάλυσης θα παραμείνουν. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - τα μηνύματα που έχουν αποσταλεί και παραληφθεί πριν από την επιλεγμένη ημερομηνία, θα διαγραφούν. Η διαδικασία μπορεί να διαρκέσει αρκετά λεπτά. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - τα μηνύματα που έχουν αποσταλεί και παραληφθεί σε αυτήν τη συνομιλία, πριν από την επιλεγμένη ημερομηνία, θα διαγραφούν. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - το προφίλ, οι επαφές, τα μηνύματα και τα αρχεία σου, θα χαθούν οριστικά και ανεπανόρθωτα. + Αυτή η συνομιλία προστατεύεται με κρυπτογράφηση από άκρη-σε-άκρη. + Αυτή η συνομιλία προστατεύεται με κβαντο-ανθεκτική κρυπτογράφηση από άκρη-σε-άκρη. + Αυτή η συσκευή + Το όνομα αυτής της συσκευής + Το εμφανιζόμενο όνομα δεν είναι έγκυρο. Επέλεξε ένα άλλο όνομα. + Αυτή η λειτουργία δεν υποστηρίζεται ακόμη. Δικίμασε την επόμενη έκδοση. + Αυτή η ομάδα έχει πάνω από %1$d μέλη, δεν αποστέλλονται αναφορές παράδοσης. + Αυτή η ομάδα δεν υπάρχει πλέον. + Αυτός είναι ο δικός σου σύνδεσμος 1-χρήσης! + Αυτή είναι η διεύθυνση σου SimpeX! + Αυτός ο σύνδεσμος δεν είναι έγκυρος! + Αυτός ο σύνδεσμος απαιτεί νεότερη έκδοση της εφαρμογής. Αναβάθμισε την εφαρμογή ή ζήτησε από την επαφή σου να σου στείλει ένα συμβατό σύνδεσμο. + Αυτός ο σύνδεσμος χρησιμοποιήθηκε με άλλη κινητή συσκευή. Δημιούργησε ένα νέο σύνδεσμο στον υπολογιστή σου. + Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. + Αυτός ο κωδικός QR δεν είναι σύνδεσμος! + Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. + Αυτή η ρύθμιση αφορά το τρέχον προφίλ σου. + Αυτό το κείμενο δεν είναι σύνδεσμος! + Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις + Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή + Ο χρόνος εξαφάνισης ορίζεται μόνο για τις νέες επαφές. + Τίτλος + Για να επιτρέψεις σε μια εφαρμογή κινητού να συνδεθεί στον υπολογιστή, άνοιξε αυτήν τη θύρα στο τείχος προστασίας σου, εάν το έχεις ενεργοποιήσει. + Για να λαμβάνεις ειδοποιήσεις σχετικά με τις νέες εκδόσεις, ενεργοποίησε τον περιοδικό έλεγχο για σταθερές ή δοκιμαστικές εκδόσεις. + Για να συνδεθείς μέσω συνδέσμου + Για να συνδεθείς, η επαφή σου μπορεί να σαρώσει τον κωδικό QR ή να χρησιμοποιήσει τον σύνδεσμο στην εφαρμογή. + Εναλλαγή λίστας συνομιλιών: + Ενεργοποίηση ανώνυμης λειτουργίας κατά τη σύνδεση. + Για απόκρυψη ανεπιθύμητων μηνυμάτων. + Για να πραγματοποιήσεις κλήσεις, επέτρεψε τη χρήση του μικροφώνου σου. Τερμάτισε την κλήση και προσπάθησε να καλέσεις ξανά. + Πάρα πολλές εικόνες! + Πάρα πολλά βίντεο! + Για να προστατευτείς από αντικατάσταση του συνδέσμου σου, μπορείς να συγκρίνεις τους κωδικούς ασφαλείας των επαφών σου. + Για την προστασία της ζώνης ώρας, τα αρχεία εικόνας/φωνής χρησιμοποιούν UTC ώρα. + Για να προστατεύσεις τις πληροφορίες σου, ενεργοποίησε το SimpleX Lock.\nΘα σου ζητηθεί να ολοκληρώσεις την επαλήθευση ταυτότητας πριν ενεργοποιηθεί αυτή η λειτουργία. + Για την προστασία της IP διεύθυνσής σου, η ιδιωτική δρομολόγηση χρησιμοποιεί τους διακομιστές SMP για την παράδοση μηνυμάτων. + Για την προστασία της ιδιωτικότητάς σου, το SimpleX χρησιμοποιεί ξεχωριστά αναγνωριστικά για κάθε μία από τις επαφές σου. + Για λήψη + Για να λαμβάνεις ειδοποιήσεις, παρακαλώ εισήγαγε τη φράση πρόσβασης της βάσης δεδομένων. + Για να αποκαλύψεις το κρυφό προφίλ σου, εισήγαγε έναν πλήρη κωδικό στο πεδίο αναζήτησης στη σελίδα Τα προφίλ συνομιλίας σου. + Για αποστολή + Για αποστολή εντολών, θα πρέπει να είσαι συνδεδεμένος. + (για διαμοιρασμό με την επαφή σου) + Για να ξεκινήσεις μία νέα συνομιλία + Συνολικά + Για να χρησιμοποιήσεις άλλο προφίλ μετά την προσπάθεια σύνδεσης, διέγραψε τη συνομιλία και χρησιμοποίησε ξανά τον σύνδεσμο. + Για να επαληθεύσεις την κρυπτογράφηση από άκρη-σε-άκρη με την επαφή σου, συγκρίνετε (ή σαρώστε) τον κωδικό στις συσκευές σας. + Διαφάνεια + Απομόνωση μεταφοράς + Απομόνωση μεταφοράς + Μεταφορές συνεδριών + Ενεργοποίηση + μη εξουσιοδοτημένη αποστολή + Ξεμπλοκάρισμα + ξεμπλοκαρισμένο %s + Ξεμπλοκάρισμα για όλους + Ξεμπλοκάρισμα μέλους + Ξεμπλοκάρισμα μέλους; + Ξεμπλοκάρισμα μέλους για όλους; + Ξεμπλοκάρισμα μελών για όλους; + Μηνύματα που δεν παραδόθηκαν + Αφαίρεση από τα αγαπημένα + Εμφάνιση + Εμφάνιση προφίλ συνομιλίας + Εμφάνιση προφίλ + άγνωστο + Άγνωστο σφάλμα βάσης δεδομένων: %s + Άγνωστο σφάλμα + άγνωστη μορφή μηνύματος + Άγνωστοι διακομιστές + Άγνωστοι διακομιστές! + άγνωστη κατάσταση + Εκτός αν η επαφή σου διέγραψε τη σύνδεση ή αυτός ο σύνδεσμος είχε ήδη χρησιμοποιηθεί, μπορεί να πρόκειται για σφάλμα - παρακαλούμε να το αναφέρεις.\nΓια να συνδεθείς, ζήτησε από την επαφή σου να δημιουργήσει έναν άλλο σύνδεσμο σύνδεσης και έλεγξε ότι έχεις σταθερή σύνδεση δικτύου. + Αποσύνδεση + Αποσύνδεση υπολογιστή; + Ξεκλείδωμα + Απενεργοποίηση σίγασης + Απενεργοποίηση σίγασης + Απροστάτευτο + αδιάβαστο + Μη αναγνωσμένες αναφορές + Μη υποστηριζόμενος σύνδεσμος σύνδεσης + Αναβάθμιση + Αναβάθμιση + Αναβάθμιση + Διαθέσιμη αναβάθμιση: %s + Ενημέρωση φράσης πρόσβασης της βάσης δεδομένων + Ενημερωμένοι όροι + ενημερωμένο προφίλ ομάδας + Η λήψη της ενημέρωσης ακυρώθηκε + ενημερωμένο προφίλ + Ενημέρωση ρυθμίσεων δικτύου; + Ενημέρωση της λειτουργίας απομόνωσης μεταφοράς; + Ενημέρωσε τη διεύθυνσή σου + Η ενημέρωση των ρυθμίσεων θα επανασυνδέσει την εφαρμογή με όλους τους διακομιστές. + Αναβάθμιση + Αναβάθμιση διεύθυνσης + Αναβάθμιση διεύθυνσης; + Αναβάθμιση και άνοιγμα συνομιλίας + Αυτόματη αναβάθμιση εφαρμογής + Αναβάθμιση συνδέσμου ομάδας + Αναβάθμιση συνδέσμου ομάδας; + Ανέβηκε + Ανεβασμένα αρχεία + Σφάλματα μεταφόρτωσης + Αποτυχία μεταφόρτωσης + Ανέβασμα αρχείου + Ανεβαίνει το αρχείο αρχειοθέτησης + Τα τελευταία 100 μηνύματα αποστέλλονται στα νέα μέλη. + Χρήση συνομιλίας + Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε σύνδεση. + Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε προφίλ. + Χρήση απευθείας σύνδεσης στο Διαδίκτυο; + Χρήση για αρχεία + Χρήση για μηνύματα + Χρήση για νέες συνδέσεις + Χρήση από τον υπολογιστή + Χρήση ανώνυμου προφίλ + Χρήση κεωτρικών διακομιστών .onion + Χρήση ιδιωτικής δρομολόγησης με άγνωστους διακομιστές. + Χρήση ιδιωτικής δρομολόγησης με άγνωστους διακομιστές όταν η διεύθυνση IP δεν προστατεύεται. + Χρήση τυχαίων διαπιστευτηρίων + Χρήση τυχαίας φράσης πρόσβασης + Όνομα χρήστη + Χρήση %s + Χρήση διακομιστή + Χρήση διακομιστών + Χρήση διακομιστών SimpleX Chat; + Χρήση δικομιστή μεσολάβησης SOCKS + Χρήση διακομιστή μεσολάβησης SOCKS; + Χρήση της θύρας TCP %1$s όταν δεν έχει καθοριστεί θύρα. + Χρήση της θύρας TCP 443 μόνο για προκαθορισμένους διακομιστές. + Χρήση της εφαρμογής κατά τη διάρκεια μίας κλήσης. + Χρήση της εφαρμογής με το ένα χέρι + Χρήση θύρας web + Χρήση διακομιστών SimpleX Chat. + Σφάλμα κατά την προσθήκη μέλους/ων + Σφάλμα κατά την προσθήκη διακομιστή + Σφάλμα κατά το μπλοκάρισμα του μέλους, για όλους + Σφάλμα κατά την αλλαγή διεύθυνσης + Σφάλμα κατά την αλλαγή προφίλ + Σφάλμα κατά την αλλαγή ρόλου + Σφάλμα κατά την αλλαγή της ρύθμισης + Σφάλμα κατά τη σύνδεση με το διακομιστή προώθησης %1$s. Παρακαλώ δοκίμασε ξανά αργότερα. + Σφάλμα κατά τη σύνδεση με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση: %1$s. + Σφάλμα κατά τη δημιουργία διεύθυνσης + Σφάλμα κατά τη δημιουργία της λίστας συνομιλιών + Σφάλμα κατά τη δημιουργία συνδέσμου ομάδας + Σφάλμα κατά τη δημιουργία επαφής μέλους + Σφάλμα κατά τη δημιουργία μηνύματος + Σφάλμα κατά τη δημιουργία προφίλ! + Σφάλμα κατά τη δημιουργία της αναφοράς + Σφάλμα κατά τη διαγραφή της συνομιλίας + Σφάλμα κατά τη διαγραφή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά τη διαγραφή της επαφής + Σφάλμα κατά τη διαγραφή του αιτήματος της επαφής + Σφάλμα κατά τη διαγραφή της βάσης δεδομένων + Σφάλμα κατά τη διαγραφή ομάδας + Σφάλμα κατά τη διαγραφή του συνδέσμου ομάδας + Σφάλμα κατά τη διαγραφή εκκρεμούς σύνδεσης επαφής + Σφάλμα κατά τη διαγραφή ιδιωτικών σημειώσεων + Σφάλμα κατά τη διαγραφή του προφίλ χρήστη + Σφάλμα κατά τη λήψη του αρχείου αρχειοθέτησης + Σφάλμα κατά την ενεργοποίηση των αναφορών παράδοσης! + Σφάλμα κατά την κρυπτογράφηση της βάσης δεδομένων + Σφάλμα κατά την εξαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά την εξαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα προώθησης μηνυμάτων + Σφάλμα κατά την εισαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά την αρχικοποίηση του WebView. Βεβαιώσου ότι έχεις εγκαταστήσει το WebView και ότι η υποστηριζόμενη αρχιτεκτονική είναι arm64.\nΣφάλμα: %s + Σφάλμα κατά την αρχικοποίηση του WebView. Ενημέρωσε το σύστημά σου στη νέα έκδοση. Επικοινώνησε με τους προγραμματιστές.\nΣφάλμα: %s + Σφάλμα κατά τη συμμετοχή στην ομάδα + Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών + Σφάλμα κατά τη φόρτωση των λεπτομερειών + Σφάλμα κατά τη φόρτωση των διακομιστών SMP + Σφάλμα κατά τη φόρτωση των διακομιστών XFTP + Σφάλμα επισήμανσης ως αναγνωσμένου + Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης + Σφάλμα κατά το άνοιγμα της συνομιλίας + Σφάλμα κατά το άνοιγμα της ομάδας + Σφάλμα κατά την ανάγνωση της φράσης πρόσβασης της βάσης δεδομένων + Σφάλμα κατά τη λήψη του αρχείου + Σφάλμα κατά την επανασύνδεση του διακομιστή + Σφάλμα κατά την επανασύνδεση των διακομιστών + Σφάλμα κατά την απόρριψη αιτήματος της επαφής + Σφάλμα κατά την αφαίρεση του μέλους + Σφάλμα επαναφοράς στατιστικών στοιχείων + Σφάλμα: %s + Σφάλματα + Σφάλμα κατά την αποθήκευση της βάσης δεδομένων + Σφάλμα κατά την αποθήκευση του αρχείου + Σφάλμα κατά την αποθήκευση του προφίλ ομάδας + Σφάλμα κατά την αποθήκευση των διακομιστών ICE + Σφάλμα κατά την αποθήκευση του διακομιστή μεσολάβησης + Σφάλμα κατά την αποθήκευση διακομιστών + Σφάλμα κατά την αποθήκευση των ρυθμίσεων + Σφάλμα κατά την αποθήκευση των ρυθμίσεων + Σφάλμα κατά την αποθήκευση των διακομιστών SMP + Σφάλμα κατά την αποθήκευση του κωδικού πρόσβασης χρήστη + Σφάλμα κατά την αποθήκευση διακομιστών XFTP + Σφάλμα κατά την αποστολή της πρόσκλησης + Σφάλμα κατά την αποστολή του μηνύματος + Σφάλμα κατά τη ρύθμιση της διεύθυνσης + σφάλμα κατά την εμφάνιση του περιεχομένου + σφάλμα εμφάνισης μηνύματος + Σφάλμα στην εμφάνιση της ειδοποίησης, επικοινώνησε με τους προγραμματιστές. + Σφάλματα στη διαμόρφωση των διακομιστών. + Σφάλμα κατά την έναρξη της συνομιλίας + Σφάλμα κατά τη διακοπή της συνομιλίας + Σφάλμα κατά την εναλλαγή προφίλ + Σφάλμα κατά την αλλαγή προφίλ! + Σφάλμα κατά τo συγχρονισμό της σύνδεσης + Σφάλμα κατά την ενημέρωση της λίστας συνομιλιών + Σφάλμα κατά την ενημέρωση του συνδέσμου ομάδας + Σφάλμα κατά την ενημέρωση της διαμόρφωσης δικτύου + Σφάλμα κατά την αναβάθμιση του διακομιστή + Σφάλμα κατά την ενημέρωση των ρυθμίσεων απορρήτου χρήστη + Σφάλμα κατά το ανέβασμα του αρχείου αρχειοθέτησης + Σφάλμα κατά την επαλήθευση της φράσης πρόσβασης: + Ακόμα και όταν είναι απενεργοποιημένη στη συνομιλία. + Η εκτέλεση της λειτουργίας διαρκεί πολύ χρόνο: %1$d δευτερόλεπτα: %2$s + Έξοδος χωρίς αποθήκευση + Επέκτεινε + Επέκταση επιλογής ρόλου + ΠΕΙΡΑΜΑΤΙΚΟ + Πειραματικά χαρακτηριστικά + έληξε + Εξαγωγή της βάσης δεδομένων + Το εξαγόμενο αρχείο δεν υπάρχει + Εξαγωγή θέματος + Αποτυχία φόρτωσης συνομιλίας + Αποτυχία φόρτωσης συνομιλιών + Γρήγορα και χωρίς αναμονή μέχρι να συνδεθεί ο αποστολέας! + Ταχύτερη διαγραφή ομάδων. + Ταχύτερη σύνδεση και πιο αξιόπιστα μηνύματα. + Ταχύτερη αποστολή μηνυμάτων. + Αγαπημένο + Αγαπημένα + Αρχείο + Αρχείο + Σφάλμα αρχείου + Το αρχείο έχει αποκλειστεί από το χειριστή του διακομιστή:\n%1$s. + Το αρχείο δεν βρέθηκε + Το αρχείο δεν βρέθηκε - πιθανότατα το αρχείο διαγράφηκε ή ακυρώθηκε. + Αρχείο: %s + Αρχεία + ΑΡΧΕΙΑ + Αρχεία και πολυμέσα + Απαγορεύονται τα αρχεία και τα πολυμέσα. + Τα αρχεία και τα πολυμέσα, απαγορεύονται σε αυτήν τη συνομιλία. + Δεν επιτρέπονται αρχεία και πολυμέσα + Απαγορεύονται αρχεία και πολυμέσα! + Το αρχείο αποθηκεύτηκε + Σφάλμα διακομιστή αρχείων: %1$s + Αρχεία & πολυμέσα + Κατάσταση αρχείου + Κατάσταση αρχείου: %s + Το αρχείο διαγράφηκε ή ο σύνδεσμος δεν είναι έγκυρος. + Το αρχείο θα διαγραφεί από τους διακομιστές. + Το αρχείο θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή του. + Το αρχείο θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Γέμισμα οθόνης + Φίλτραρε τις μη αναγνωσμένες και τις αγαπημένες συνομιλίες. + Ολοκλήρωση της μετεγκατάστασης + Ολοκλήρωσε τη μετεγκατάσταση σε άλλη συσκευή. + Επιτέλους, τα έχουμε! 🚀 + Βρες τις συνομιλίες πιο γρήγορα + Βρες αυτήν την άδεια στις ρυθμίσεις Android και παραχώρησέ την χειροκίνητα. + Το αποτύπωμα στη διεύθυνση του διακομιστή προορισμού δεν ταιριάζει με το πιστοποιητικό: %1$s. + Το αποτύπωμα στη διεύθυνση του διακομιστή προώθησης δεν ταιριάζει με το πιστοποιητικό: %1$s. + Το αποτύπωμα στη διεύθυνση του διακομιστή δεν ταιριάζει με το πιστοποιητικό. + Το αποτύπωμα στη διεύθυνση του διακομιστή δεν ταιριάζει με το πιστοποιητικό: %1$s. + Προσαρμογή στην οθόνη + Επιδιόρθωση + Επιδιόρθωση + Επιδιόρθωση σύνδεσης + Νέος ρόλος ομάδας: Συντονιστής + Νέο στο %s + Επιλογές νέων πολυμέσων + Νέος ρόλος μέλους + Νέο μέλος θέλει να ενταχθεί στην ομάδα. + νέο μήνυμα + Νέο μήνυμα + Νέα συσκευή τηλεφώνου + Νέος κωδικός πρόσβασης + Νέα φράση πρόσβασης + Νέος διακομιστής + Κάθε φορά που εκκινείς την εφαρμογή, θα χρησιμοποιούνται νέα διαπιστευτήρια SOCKS. + Νέα διαπιστευτήρια SOCKS θα χρησιμοποιούνται για κάθε διακομιστή. + όχι + Όχι + Όχι + Όχι + Χωρίς κωδικό πρόσβασης εφαρμογής + Χωρίς κλήσεις στο παρασκήνιο + Χωρίς υπηρεσία παρασκηνίου + Χωρίς συνομιλίες + Δεν βρέθηκαν συνομιλίες + Δεν υπάρχουν συνομιλίες στη λίστα %s. + Δεν υπάρχουν συνομιλίες με μέλη + Δεν υπάρχει συνδεδεμένο κινητό + Δεν έχουν επιλεγεί επαφές + Δεν υπάρχουν επαφές για προσθήκη + Δεν υπάρχουν πληροφορίες παράδοσης + χωρίς λεπτομέρειες + Δεν υπάρχει ακόμη άμεση σύνδεση, το μήνυμα προωθείται από το διαχειριστή. + χωρίς κρυπτογράφηση e2e + Καμία φιλτραρισμένη συνομιλία + Καμία φιλτραρισμένη επαφή + Χωρίς ιστορικό + Δεν υπάρχουν πληροφορίες, δοκίμασε να επαναφορτώσεις + Χωρίς διακομιστές πολυμέσων και αρχείων. + Κανένα μήνυμα + Χωρίς διακομιστές μηνυμάτων. + κανένα + Δεν υπάρχει σύνδεση δικτύου + Καμία συνεδρία ιδιωτικής δρομολόγησης + Δεν υπάρχουν ληφθέντα ή απεσταλμένα αρχεία + Δεν έχει επιλεγεί συνομιλία + Δεν υπάρχουν διακομιστές για τη δρομολόγηση ιδιωτικών μηνυμάτων. + Δεν υπάρχουν διακομιστές για τη λήψη αρχείων. + Δεν υπάρχουν διακομιστές για τη λήψη μηνυμάτων. + Δεν υπάρχουν διακομιστές για την αποστολή αρχείων. + χωρίς συνδρομή + Μη συμβατό! + Σημειώσεις + χωρίς κείμενο + Δεν έχει επιλεγεί τίποτα + Δεν υπάρχει τίποτα να προωθήσεις! + Προεπισκόπηση ειδοποίησης + Ειδοποιήσεις + Ειδοποιήσεις και μπαταρία + Υπηρεσία ειδοποιήσεων + Οι ειδοποιήσεις θα παραδίδονται μόνο μέχρι να σταματήσει η εφαρμογή! + Οι ειδοποιήσεις θα σταματήσουν να λειτουργούν μέχρι να επανεκκινήσεις την εφαρμογή. + μη συγχρονισμένο + Δεν υπάρχουν μη αναγνωσμένες συνομιλίες + Χωρίς αναγνωριστικά χρήστη. + Τώρα οι διαχειριστές μπορούν:\n- να διαγράφουν τα μηνύματα των μελών.\n- να απενεργοποιούν μέλη (ρόλος παρατηρητή) + παρατηρητής + κλειστό` + κλειστό + κλειστό + Κλειστό + Κλειστή + προσφέρεται %s + προσφέρθηκε %s: %2s + ΟΚ + Παλιό αρχείο βάσης δεδομένων + ανοιχτό + Σύνδεσμος πρόσκλησης 1-χρήσης + Σύνδεσμος πρόσκλησης 1-χρήσης + Για τη σύνδεση θα απαιτηθούν διακομιστές Onion.\nΣημείωση: δεν θα μπορείς να συνδεθείς στους διακομιστές χωρίς διεύθυνση .onion. + Οι κεντρικοί υπολογιστές Onion θα χρησιμοποιούνται όταν είναι διαθέσιμοι. + Οι κεντρικοί υπολογιστές Onion δεν θα χρησιμοποιηθούν. + Μπορούν να σταλούν μόνο 10 εικόνες ταυτόχρονα + Μπορούν να σταλούν μόνο 10 βίντεο ταυτόχρονα + Μόνο οι ιδιοκτήτες του chat μπορούν να αλλάξουν τις προτιμήσεις. + Μόνο οι συσκευές αποθηκεύουν προφίλ χρηστών, επαφές, ομάδες και μηνύματα. + Διαγραφή μόνο της συνομιλίας + Μόνο οι ιδιοκτήτες ομάδων μπορούν να αλλάξουν τις προτιμήσεις της ομάδας. + Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν αρχεία και πολυμέσα. + Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν τα φωνητικά μηνύματα. + Μόνο μία συσκευή μπορεί να λειτουργεί ταυτόχρονα + Μόνο ο αποστολέας και οι διαχειριστές μπορούν να το δουν + (αποθηκεύεται μόνο από τα μέλη της ομάδας) + Μόνο εσύ και οι διαχειριστές το βλέπετε + Μόνο εσύ μπορείς να προσθέσεις αντιδράσεις σε μηνύματα. + Μόνο εσύ μπορείς να διαγράψεις οριστικά τα μηνύματα (η επαφή σου μπορεί να τα επισημάνει για διαγραφή). (24 ώρες) + Μόνο εσύ μπορείς να πραγματοποιήσεις κλήσεις. + Μόνο εσύ μπορείς να στέλνεις μηνύματα που εξαφανίζονται. + Μόνο εσύ μπορείς να στέλνεις αρχεία και πολυμέσα. + Μόνο εσύ μπορείς να στέλνεις φωνητικά μηνύματα. + Μόνο η επαφή σου μπορεί να προσθέσει αντιδράσεις σε μηνύματα. + Μόνο η επαφή σου μπορεί να διαγράψει οριστικά τα μηνύματα (μπορείς να τα επισημάνεις για διαγραφή). (24 ώρες) + Μόνο η επαφή σου μπορεί να πραγματοποιεί κλήσεις. + Μόνο η επαφή σου μπορεί να στείλει μηνύματα που εξαφανίζονται. + Μόνο η επαφή σου μπορεί να στείλει αρχεία και πολυμέσα. + Μόνο η επαφή σου μπορεί να στείλει φωνητικά μηνύματα. + άνοιγμα + Άνοιξε + Άνοιγμα + Άνοιξε τις ρυθμίσεις της εφαρμογής + Ανοιχτές αλλαγές + Άνοιγμα συνομιλίας + Άνοιγμα συνομιλίας + Άνοιγμα κονσόλας συνομιλίας + - Άνοιγμα συνομιλίας στο πρώτο μη αναγνωσμένο μήνυμα.\n- Μετάβαση στα αναφερόμενα μηνύματα. + Άνοιγμα καθαρού συνδέσμου + Ανοιχτές προϋποθέσεις + Άνοιγμα φακέλου βάσης δεδομένων + Άνοιγμα θέσης αρχείου + Άνοιγμα πλήρους συνδέσμου + Άνοιγμα ομάδας + Το άνοιγμα του συνδέσμου στον περιηγητή μπορεί να μειώσει την ιδιωτικότητα και την ασφάλεια της σύνδεσης. Οι μη αξιόπιστοι σύνδεσμοι SimpleX θα εμφανίζονται με κόκκινο χρώμα. + Άνοιγμα συνδέσμου + Άνοιγμα συνδέσμων από τη λίστα συνομιλιών + Άνοιξε την οθόνη μετεγκατάστασης + Άνοιξε νέα συνομιλία + Άνοιξε νέα ομάδα + Άνοιγμα θύρας στο τείχος προστασίας + Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost. + Άνοιγμα ρυθμίσεων διακομιστή + Άνοιγμα ρυθμίσεων + Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση + Άνοιξε για να αποδεχθείς + Άνοιξε για να συνδεθείς + Άνοιξε για να συμμετάσχεις + Άνοιξε για να χρησιμοποιήσεις το μποτ + Άνοιγμα συνδέσμου ιστού; + Άνοιγμα με %s + Χειριστής + Διακομιστής χειριστή + - προαιρετική ειδοποίηση για διεγραμμένες επαφές.\n- ονόματα προφίλ με κενά.\n- και πολλά άλλα! + Οργάνωσε τις συνομιλίες σε λίστες + Ή εισαγωγή αρχείου αρχειοθέτησης + Ή επικόλλησε το σύνδεσμο του αρχείου αρχειοθέτησης + Ή σάρωσε τον κωδικό QR + Ή μοιράσου με ασφάλεια αυτόν τον σύνδεσμο αρχείου + Ή δείξε αυτόν τον κωδικό + Ή για να μοιραστείς ιδιωτικά + άλλο + Άλλο + άλλα σφάλματα + Άλλοι διακομιστές SMP + Άλλοι διακομιστές XFTP + ιδιοκτήτης + ιδιοκτήτες + Κωδικός πρόσβασης + Ο κωδικός πρόσβασης αλλάχθηκε! + Εισαγωγή κωδικού πρόσβασης + Ο κωδικός πρόσβασης δεν έχει αλλάξει! + Ο κωδικός πρόσβασης έχει οριστεί! + Η φράση πρόσβασης στο Keystore δεν μπορεί να διαβαστεί, παρακαλώ εισήγαγέ τη χειροκίνητα. Αυτό μπορεί να συνέβη μετά από ενημέρωση του συστήματος που δεν είναι συμβατή με την εφαρμογή. Εάν δεν είναι αυτή η περίπτωση, παρακαλώ επικοινώνησε με τους προγραμματιστές. + Η φράση πρόσβασης στο Keystore δεν μπορεί να διαβαστεί. Αυτό μπορεί να συνέβη μετά από ενημέρωση του συστήματος που δεν είναι συμβατή με την εφαρμογή. Εάν δεν είναι αυτή η περίπτωση, επικοινώνησε με τους προγραμματιστές. + Απαιτείται φράση πρόσβασης + Ο φράση πρόσβασης δεν βρέθηκε στο Keystore, παρακαλώ εισήγαγέ τη χειροκίνητα. Αυτό μπορεί να συνέβη αν επανέφερες τα δεδομένα της εφαρμογής χρησιμοποιώντας ένα εργαλείο δημιουργίας αντιγράφων ασφαλείας. Αν δεν είναι αυτή η περίπτωση, παρακαλώ επικοινώνησε με τους προγραμματιστές. + Κωδικός + Κωδικός για εμφάνιση + Επικόλληση + Επικόλληση συνδέσμου αρχείου αρχειοθέτησης + Επικόλληση διεύθυνσης υπολογιστή + Επικόλληση συνδέσμου + Επικόλλησε το σύνδεσμο για να συνδεθείς! + Επικόλλησε το σύνδεσμο που έλαβες + Επικόλλησε το σύνδεσμο που έλαβες για να συνδεθείς με την επαφή σου… + από άκρη-σε-άκρη + εκκρεμής + Εκκρεμής + Εκκρεμής + σε αναμονή έγκρισης + Εκκρεμής κλήση + σε αναμονή για έλεγχο + Περιοδικά + Περιοδικές ειδοποιήσεις + Οι περιοδικές ειδοποιήσεις είναι απενεργοποιημένες! + Η άδεια απορρίφθηκε! + Διεπαφή στα Περσικά + Κλήσεις σε λειτουργία εικόνα-μέσα-στην-εικόνα + Μέτρηση PING + εσωτερικό PING + Αναπαραγωγή από τη λίστα συνομιλιών. + Παρακαλώ ζήτησε από την επαφή σου να ενεργοποιήσει τις κλήσεις. + Παρακαλώ ζήτησε από την επαφή σου να ενεργοποιήσει τα φωνητικά μηνύματα. + Έλεγξε ότι το κινητό και ο υπολογιστής είναι συνδεδεμένοι στο ίδιο τοπικό δίκτυο και ότι το τείχος προστασίας του υπολογιστή επιτρέπει τη σύνδεση.\nΕνημέρωσε τους προγραμματιστές για τυχόν άλλα προβλήματα. + Έλεγξε ότι ο σύνδεσμος SimpleX είναι σωστός. + Έλεγξε ότι χρησιμοποιείς το σωστό σύνδεσμο ή ζήτησε από την επαφή σου να σου στείλει έναν άλλο. + Έλεγξε τη σύνδεσή σου στο δίκτυο με %1$s και δοκίμασε ξανά. + Επιβεβαίωσε ότι οι ρυθμίσεις δικτύου είναι σωστές για αυτήν τη συσκευή. + Παρακαλώ επικοινώνησε με το διαχειριστή της ομάδας. + Εισήγαγε τη σωστή τρέχουσα φράση πρόσβασης. + Εισήγαγε τον προηγούμενο κωδικό μετά την επαναφορά του αντιγράφου ασφαλείας της βάσης δεδομένων. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. + Μείωσε το μέγεθος του μηνύματος και απέστειλέ το ξανά. + Μείωσε το μέγεθος του μηνύματος ή αφαίρεσε τα αρχεία πολυμέσων και απέστειλέ το ξανά. + Παρακαλώ θυμήσου ή αποθήκευσε το με ασφάλεια - δεν υπάρχει τρόπος να ανακτήσεις έναν χαμένο κωδικό! + Παρακαλώ ανάφερέ το στους προγραμματιστές. + Παρακαλώ ανάφερέ το στους προγραμματιστές: \n%s + Παρακαλώ ανάφερέ το στους προγραμματιστές: \n%s\n\nΠροτείνεται η επανεκκίνηση της εφαρμογής. + Παρακαλώ επανεκκίνησε την εφαρμογή. + Αποθήκευσε τη φράση πρόσβασης σε ασφαλές μέρος, καθώς ΔΕΝ θα μπορείς να έχεις πρόσβαση στη συνομιλία αν τη χάσεις. + Αποθήκευσε τη φράση πρόσβασης σε ασφαλές μέρος, καθώς ΔΕΝ θα μπορείς να την αλλάξεις σε περίπτωση απώλειας. + Παρακαλώ δοκίμασε αργότερα. + Ενημέρωσε την εφαρμογή και επικοινώνησε με τους προγραμματιστές. + Παρακαλώ περίμενε μέχρι οι διαχειριστές της ομάδας να εξετάσουν το αίτημά σου για συμμετοχή στην ομάδα. + Παρακαλώ, περίμενε ενώ το αρχείο φορτώνεται από το συνδεδεμένο κινητό + Διεπαφή στα Πολωνικά + Μετέφερε + θύρα%d + Προετοιμασία λήψης + Προετοιμασία μεταφόρτωσης + Διατήρηση του τελευταίου πρόχειρου μηνύματος, με τα συνημμένα. + Προκαθορισμένος διακομιστής + Διεύθυνση προκαθορισμένου διακομιστή + Προκαθορισμένοι διακομιστές + Προκαθορισμένοι διακομιστές + Προεπισκόπηση + Προηγούμενοι συνδεδεμένοι διακομιστές + Προστασία της ιδιωτικότητας των πελατών σου. + Πολιτική απορρήτου και όροι χρήσης. + Επαναπροσδιορισμός της ιδιωτικότητας + Απόρρητο & ασφάλεια + Οι ιδιωτικές συνομιλίες, οι ομάδες και οι επαφές σου δεν είναι προσβάσιμες στους χειριστές του διακομιστή. + Ιδιωτικά ονόματα αρχείων + Ιδιωτικά ονόματα αρχείων πολυμέσων. + Δρομολόγηση ιδιωτικών μηνυμάτων 🚀 + ΔΡΟΜΟΛΟΓΗΣΗ ΙΔΙΩΤΙΚΩΝ ΜΗΝΥΜΑΤΩΝ + Ιδιωτικές σημειώσεις + Ιδιωτικές σημειώσεις + Ιδιωτικές ειδοποιήσεις + Ιδιωτική δρομολόγηση + Σφάλμα ιδιωτικής δρομολόγησης + Λήξη χρονικού ορίου ιδιωτικής δρομολόγησης + Προφίλ και συνδέσεις διακομιστή + εικόνα προφίλ + θέση για εικόνα προφίλ + Εικόνες προφίλ + Όνομα προφίλ: + Κωδικός προφίλ + Θέμα προφίλ + Η ενημέρωση του προφίλ θα σταλεί στις επαφές σου. + Απαγόρευση κλήσεων ήχου/βίντεο. + Απαγόρευση της μη αναστρέψιμης διαγραφής μηνυμάτων. + Απαγόρευση αντιδράσεων σε μήνυμα. + Απαγόρευση αντιδράσεων σε μηνύματα. + Απαγόρευση αναφοράς μηνυμάτων στους διαχειριστές. + Απαγόρευση αποστολής άμεσων μηνυμάτων στα μέλη. + Απαγόρευση αποστολής μηνυμάτων που εξαφανίζονται. + Απαγόρευση αποστολής μηνυμάτων που εξαφανίζονται. + Απαγόρευση αποστολής αρχείων και πολυμέσων. + Απαγόρευση αποστολής αρχείων και πολυμέσων. + Απαγόρευση αποστολής συνδέσμων SimpleX + Απαγόρευση αποστολής φωνητικών μηνυμάτων. + Απαγόρευση αποστολής φωνητικών μηνυμάτων. + Προστασία οθόνης εφαρμογής + Προστασία διεύθυνσης IP + Προστάτεψε τα προφίλ συνομιλίας σου με έναν κωδικό! + Προστάτεψε τη διεύθυνση IP σου από τα κέντρα διαβίβασης μηνυμάτων που επιλέγουν οι επαφές σου.\nΕνεργοποίησε την επιλογή στις ρυθμίσεις *Δίκτυο και διακομιστές*. + Χρονικό όριο πρωτοκόλλου + Χρονικό όριο πρωτοκόλλου + Χρονικό όριο πρωτοκόλλου ανά KB + Μέσω διακομιστή μεσολάβησης + Διακομιστές μέσω proxy + Πιστοποίηση διακομιστή μεσολάβησης + Κωδικός QR + κβαντο-ανθεκτική κρυπτογράφηση e2e + Κβαντο-ανθεκτική κρυπτογράφηση + Τυχαία + Η τυχαία φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο.\nΜπορείς να την αλλάξεις αργότερα. + Αξιολόγησε την εφαρμογή + Προσβάσιμες γραμμές εργαλείων εφαρμογής + Προσβάσιμη γραμμή εργαλείων συνομιλίας + Προσβάσιμη γραμμή εργαλείων συνομιλίας + Διάβασε περισσότερα + Οι αναφορές παράδοσης είναι απενεργοποιημένες + απάντηση που παραλήφθηκε… + Παραλήφθηκε στις + Παραλήφθηκε στις: %s + επιβεβαίωση που παραλήφθηκε… + Μήνυμα που παραλήφθηκε + Μήνυμα που παραλήφθηκε + Μηνύματα που παραλήφθηκαν + παραλήφθηκε, απαγορεύεται + Παραλήφθηκε απάντηση + Σύνολο που παραλήφθηκε + Σφάλματα παραλαβής + Η διεύθυνση παραλαβής θα αλλάξει σε διαφορετικό διακομιστή. Η αλλαγή διεύθυνσης θα ολοκληρωθεί μετά την σύνδεση του αποστολέα. + Λήψη ταυτόχρονης πρόσβασης + η λήψη αρχείων δεν υποστηρίζεται ακόμη + Η λήψη αρχείων θα διακοπεί. + Λήψη μηνυμάτων… + Λήψη μέσω + Πρόσφατο ιστορικό και βελτιωμένο μποτ καταλόγου. + Ο/Οι παραλήπτης/ες δεν μπορούν να δουν από ποιον προέρχεται αυτό το μήνυμα. + Οι παραλήπτες βλέπουν τις ενημερώσεις καθώς τις πληκτρολογείς. + Επανασύνδεση + Επανασύνδεσε όλους τους συνδεδεμένους διακομιστές για να επιβάλεις την παράδοση μηνυμάτων. Χρησιμοποιεί επιπλέον κίνηση. + Επανασύνδεση όλων των διακομιστών + Επανασύνδεση διακομιστή; + Επανασύνδεση διακομιστών; + Επανασύνδεση διακομιστή για να επιβληθεί η παράδοση μηνυμάτων. Χρησιμοποιεί επιπλέον κίνηση. + Η εγγραφή ενημερώθηκε στις + Η εγγραφή ενημερώθηκε στις: %s + Εγγραφή φωνητικού μηνύματος + Μειωμένη χρήση μπαταρίας + Ανανέωση + Απόρριψη + Απόρριψη + Απόρριψη + Απόρριψη αιτήματος επαφής + απορρίφθηκε + απορρίφθηκε + απορριφθείσα κλήση + Απορριφθείσα κλήση + Απόρριψη μέλους; + Ο διακομιστής αναμετάδοσης χρησιμοποιείται μόνο αν είναι απαραίτητο. Οι άλλοι μπορούν να δουν τη διεύθυνση IP σου. + Ο διακομιστής αναμετάδοσης προστατεύει τη διεύθυνση IP σου, αλλά μπορεί να παρακολουθεί τη διάρκεια της κλήσης. + Υπενθύμιση αργότερα + Απομακρυσμένα κινητά τηλέφωνα + Κατάργηση + Κατάργηση + Κατάργηση και διαγραφή μηνυμάτων + Κατάργηση αρχείου αρχειοθέτησης; + καταργήθηκε + καταργήθηκε %1$s + διεγραμμένη διεύθυνση επαφής + αφαιρέθηκε από την ομάδα + αφαιρέθηκε η φωτογραφία προφίλ + σε αφαίρεσε + Κατάργηση εικόνας + Κατάργηση παρακολούθησης συνδέσμων + Κατάργηση μέλους + Κατάργηση μέλους + Κατάργηση μέλους; + Κατάργηση μελών; + Κατάργηση φράσης πρόσβασης από το Keystore; + Κατάργηση φράσης πρόσβασης από τις ρυθμίσεις; + Κατάργηση μηνυμάτων και μπλοκάρισμα μελών. + Επαναδιαπραγμάτευση + Επαναδιαπραγμάτευση κρυπτογράφησης + Επαναδιαπραγμάτευση κρυπτογράφησης; + Επανάληψη στην οθόνη + Επανάληψη αιτήματος σύνδεσης; + Επανάληψη λήψης + Επανάληψη εισαγωγής + Επανάληψη αιτήματος συμμετοχής; + Επανάληψη μεταφόρτωσης + Απάντησε + Ανέφερε + Αναφορά περιεχομένου: μόνο οι διαχειριστές της ομάδας θα το δουν. + Η αναφορά μηνυμάτων απαγορεύεται σε αυτήν την ομάδα. + Αναφορά προφίλ μέλους: μόνο οι διαχειριστές της ομάδας θα το δουν. + Άλλη αναφορά: μόνο οι διαχειριστές της ομάδας θα το δουν. + Αιτία αναφοράς; + Αναφορά: %s + Αναφορές + Η αναφορά εστάλη στους διαχειριστές + Επανεκκίνηση + Αναφορά spam: μόνο οι διαχειριστές της ομάδας θα το δουν. + Αναφορά παραβίασης κανόνων: μόνο οι διαχειριστές της ομάδας θα τη δουν. + αιτήσου σύνδεση από την ομάδα %1$s + αιτήσου να συνδεθείς + το αίτημα αποστέλλεται + το αίτημα συμμετοχής απορρίφθηκε + Απαιτείται + Επανέφερε + Επαναφορά + Επαναφορά όλων των υποδείξεων + Επαναφορά όλων των στατιστικών + Επαναφορά όλων των στατιστικών; + Επαναφορά χρώματος + Επαναφορά χρωμάτων + Επαναφορά στο θέμα της εφαρμογής + Επαναφορά στις προεπιλογές + Επαναφορά στο θέμα χρήστη + Επανεκκίνηση συνομιλίας + Επανεκκίνησε την εφαρμογή για να δημιουργήσεις ένα νέο προφίλ συνομιλίας. + Επανεκκίνησε την εφαρμογή για να χρησιμοποιήσεις την εισαγώμενη βάση δεδομένων. + Επαναφορά + Επαναφορά αντιγράφου ασφαλείας βάσης δεδομένων + Επαναφορά αντιγράφου ασφαλείας βάσης δεδομένων; + Σφάλμα επαναφοράς βάσης δεδομένων + Επανέλαβε + Αποκάλυψε + ανασκόπηση + Προϋποθέσεις ελέγχου + ελέγχθηκε από τους διαχειριστές + Έλεγχος μελών ομάδας + Έλεγχος αργότερα + Έλεγχος μελών + Έλεγχος μελών πριν την αποδοχή τους (knocking). + Ανάκληση + Ανάκληση αρχείου + Ανάκληση αρχείου; + Ρόλος + ΕΚΚΙΝΗΣΗ ΣΥΝΟΜΙΛΙΑΣ + Εκτελείται όταν η εφαρμογή είναι ανοιχτή + Ασφαλής λήψη αρχείων + Ασφαλέστερες ομάδες + %s και %s + %s και %s συνδέθηκαν + %s στις %s + Αποθήκευσε + Αποθήκευση + Αποθήκευση + Αποθήκευση ρυθμίσεων εισόδου; + Αποθήκευση και ειδοποίηση επαφής + Αποθήκευση και ειδοποίηση επαφών + Αποθήκευση και ειδοποίηση μελών ομάδας + Αποθήκευση και επανασύνδεση + Αποθήκευση και ενημέρωση προφίλ ομάδας + αποθηκευμένο + Αποθηκευμένο + Αποθηκευμένο από + αποθηκευμένο από %s + Αποθηκευμένο μήνυμα + Οι αποθηκευμένοι διακομιστές WebRTC ICE θα αφαιρεθούν. + Αποθήκευση προφίλ ομάδας + Αποθήκευση λίστας + Αποθήκευση φράσης πρόσβασης και άνοιγμα συνομιλίας + Αποθήκευση φράσης πρόσβασης στο Keystore + Αποθήκευση φράσης πρόσβασης στις ρυθμίσεις + Αποθήκευση προτιμήσεων; + Αποθήκευση κωδικού προφίλ + Αποθήκευση διακομιστών + Αποθήκευση διακομιστών; + Αποθήκευση ρυθμίσεων; + Αποθήκευση ρυθμίσεων διεύθυνσης SimpleX + Αποθήκευση μηνύματος καλωσορίσματος; + Αποθήκευση %1$s μηνυμάτων + Κλιμάκωση στην οθόνη + Σάρωση κωδικού + Σάρωση από κινητό + (σάρωσε ή επικόλλησε από το πρόχειρο) + Σάρωση / Επικόλληση συνδέσμου + Σάρωσε τον κωδικό QR από τον υπολογιστή + %s συνδέθηκε + %s (τρέχον) + %s κατέβηκαν + αναζήτηση + Η γραμμή αναζήτησης δέχεται συνδέσμους πρόσκλησης. + Αναζήτηση ή επικόλληση συνδέσμου SimpleX + Δευτερεύων + Ασφαλής + ο κωδικός ασφαλείας άλλαξε + Επιλογή + Επέλεξε + Επέλεξε προφίλ συνομιλίας + Επέλεξε επαφές + Οι επιλεγμένες προτιμήσεις συνομιλίας απαγορεύουν αυτό το μήνυμα. + Επιλέχθηκαν %d + Επέλεξε τους χειριστές δικτύου που θέλεις να χρησιμοποιήσεις. + Αυτοκαταστροφή + Κωδικός αυτοκαταστροφής + Κωδικός αυτοκαταστροφής + Ο κωδικός αυτοκαταστροφής άλλαξε! + Ο κωδικός αυτοκαταστροφής ενεργοποιήθηκε! + Απέστειλε + Απέστειλε + Στείλε ένα ζωντανό μήνυμα - θα ενημερώνεται για τον παραλήπτη ή τους παραλήπτες καθώς το πληκτρολογείς. + Αποστολή αιτήματος επαφής; + ΑΠΟΣΤΟΛΗ ΑΝΑΦΟΡΩΝ ΠΑΡΑΔΟΣΗΣ ΣΕ + Αποστολή άμεσου μηνύματος + Στείλε άμεσο μήνυμα για να συνδεθείς + Αποστολή μηνύματος που εξαφανίζεται + Ο αποστολέας ακύρωσε τη μεταφορά αρχείων. + Ο αποστολέας ενδέχεται να έχει διαγράψει το αίτημα σύνδεσης. + Σφάλματα αποστολής + αποτυχία αποστολής + Η αποστολή αναφορών παράδοσης θα είναι ενεργοποιημένη για όλες τις επαφές. + Η αποστολή αναφορών παράδοσης θα είναι ενεργοποιημένη για όλες τις επαφές σε όλα τα ορατά προφίλ συνομιλίας. + η αποστολή αρχείων δεν υποστηρίζεται ακόμη + Η αποστολή του αρχείου θα διακοπεί. + Η αποστολή αναφορών είναι απενεργοποιημένη για %d επαφές + Η αποστολή αναφορών είναι απενεργοποιημένη για %d ομάδες + Η αποστολή αναφορών είναι ενεργοποιημένη για %d επαφές + Η αποστολή αναφορών είναι ενεργοποιημένη για %d ομάδες + Αποστέλλεται μέσω + Αποστολή προεπισκόπησης συνδέσμων + Αποστολή ζωντανού μηνύματος + Αποστολή Μηνύματος + Στείλε μηνύματα απευθείας όταν η διεύθυνση IP είναι προστατευμένη και ο διακομιστής σου ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Στείλε μηνύματα απευθείας όταν ο διακομιστής σου ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Στείλε μήνυμα για να ενεργοποιήσεις τις κλήσεις. + Αποστολή ιδιωτικών αναφορών + Στείλε ερωτήσεις και ιδέες + Αποστολή αναφορών + Αποστολή αιτήματος + Αποστολή αιτήματος χωρίς μήνυμα + αποστολή για σύνδεση + Αποστολή εώς και 100 τελευταίων μηνυμάτων σε νέα μέλη. + Στείλε μας ένα mail + Στείλε τα προσωπικά σου σχόλια στις ομάδες. + στάλθηκε + Στάλθηκε στις + Στάλθηκε στις: %s + Στάλθηκε απευθείας + Απεσταλμένο μήνυμα + Απεσταλμένο μήνυμα + Απεσταλμένα μηνύματα + Τα αποσταλμένα μηνύματα θα διαγραφούν μετά από καθορισμένο χρονικό διάστημα. + Απεσταλμένη απάντηση + Σύνολο απεσταλμένων + Αποστέλλεται στην επαφή σου μετά τη σύνδεση. + Αποστολή μέσω διακομιστή μεσολάβησης + Διακομιστής + Ο διακομιστής προστέθηκε στο χειριστή %s. + Διεύθυνση διακομιστή + Η διεύθυνση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου. + Η διεύθυνση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου: %1$s. + Ο χειριστής του διακομιστή άλλαξε. + Χειριστές διακομιστή + Αλλαγή πρωτοκόλλου διακομιστή. + πληροφορίες ουράς διακομιστή: %1$s\n\nτελευταίο ληφθέν μήνυμα: %2$s + Ο διακομιστής απαιτεί εξουσιοδότηση για τη δημιουργία ουρών, έλεγξε τον κωδικό. + Ο διακομιστής απαιτεί εξουσιοδότηση για ανέβασμα αρχείων, έλεγξε τον κωδικό. + ΔΙΑΚΟΜΙΣΤΕΣ + Πληροφορίες διακομιστών + Θα γίνει επαναφορά στα στατιστικά στοιχεία των διακομιστών - αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η δοκιμή του διακομιστή απέτυχε! + Η έκδοση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου. + Η έκδοση του διακομιστή δεν είναι συμβατή με την εφαρμογή σου: %1$s. + Κωδικός συνεδρίας + Όρισε σε 1 ημέρα + Όρισε το όνομα συνομιλίας… + Όρισε το όνομα επαφής + Όρισε το όνομα επαφής… + Όρισε τη φράση πρόσβασης της βάσης δεδομένων + Όρισε το προεπιλεγμένο θέμα + Όρισε τις προτιμήσεις ομάδας + Όρισέ τον αντί για την πιστοποίηση συστήματος. + Όρισε την εισαγωγή μέλους + Όρισε τη λήξη των μηνυμάτων στις συνομιλίες. + ορίστε νέα διεύθυνση επαφής + όρισε νέα εικόνα προφίλ + Όρισε κωδικό πρόσβασης + Όρισε φράση πρόσβασης + Όρισε φράση πρόσβασης για εξαγωγή + Όρισε το βιογραφικό του προφίλ και το μήνυμα καλωσορίσματος. + Όρισε το εμφανιζόμενο μήνυμα για τα νέα μέλη! + Ρυθμίσεις + Ρυθμίσεις + ΡΥΘΜΙΣΕΙΣ + Όρισε τη φράση πρόσβασης της βάσης δεδομένων + Διαμόρφωση εικόνων προφίλ + Διαμοίρασε + Διαμοίρασε το σύνδεσμο 1-χρήσης + Διαμοίρασε το σύνδεσμο 1-χρήσης με ένα φίλο + Διαμοιρασμός διεύθυνσης + Δημόσιος διαμοιρασμός διεύθυνσης + Διαμοιρασμός διεύθυνσης με τις επαφές; + Διαμοιρασμός αρχείου… + Διαμοιρασμός συνδέσμου + Διαμοιρασμός πολυμέσων… + Διαμοιρασμός μηνύματος… + Διαμοιρασμός παλιάς διεύθυνσης + Διαμοιρασμός παλιού συνδέσμου + Διαμοιρασμός προφίλ + Διαμοιρασμός διεύθυνσης SimpleX σε εφαρμογές κοινωνικής δικτύωσης. + Διαμοιρασμός αυτού του συνδέσμου 1-χρήσης + Διαμοιρασμός με τις επαφές + Διαμοιρασμός της διεύθυνσής σου + Σύντομη περιγραφή: + Σύντομος σύνδεσμος + Σύντομη διεύθυνση SimpleX + Εμφάνιση + Εμφάνιση: + Εμφάνιση λίστας μηνυμάτων σε νέο παράθυρο + Εμφάνιση κονσόλας τερματικού σε νέο παράθυρο + Εμφάνιση επαφής και μηνύματος + Εμφάνιση επιλογών για προγραμματιστές + Εμφάνιση πληροφοριών για + Εμφάνιση εσωτερικών σφαλμάτων + Εμφάνιση τελευταίων μηνυμάτων + Εμφάνιση κατάστασης μηνύματος + Εμφάνιση μόνο της επαφής + Εμφάνιση ποσοστού + Εμφάνιση προεπισκόπησης + Εμφάνιση κωδικού QR + Εμφάνιση αργών κλήσεων API + Απενεργοποίηση + Απενεργοποίηση; + SImpleX + SimpleX διεύθυνση + Διεύθυνση SimpleX + Η διεύθυνση SimpleX και οι σύνδεσμοι 1-χρήσης είναι ασφαλές να διαμοιράζονται μέσω οποιασδήποτε εφαρμογής ανταλλαγής μηνυμάτων. + Διεύθυνση SimpleX ή σύνδεσμος 1-χρήσης; + Το SimpleX δεν μπορεί να λειτουργήσει στο παρασκήνιο. Θα λαμβάνεις τις ειδοποιήσεις μόνο όταν η εφαρμογή είναι σε λειτουργία. + Σύνδεσμος καναλιού SimpleX + Η SimpleX Chat και η Flux σύναψαν συμφωνία για την ενσωμάτωση των διακομιστών που λειτουργεί η Flux, στην εφαρμογή. + Κλήσεις SimpleX Chat + Μηνύματα SimpleX Chat + Η ασφάλεια του SimpleX Chat ελέγχθηκε από την Trail of Bits. + Υπηρεσία SimpleX Chat + Διεύθυνση επικοινωνίας SimpleX + Σύνδεσμος ομάδας SimpleX + Σύνδεσμοι SimpleX + Σύνδεσμοι SimpleX + Οι σύνδεσμοι SimpleX απαγορεύονται. + Οι σύνδεσμοι SimpleX δεν επιτρέπονται + SimpleX Lock + SimpleX Lock + Λειτουργία SimpleX Lock + Το SimpleX Lock δεν είναι ενεργοποιημένο! + Το SimpleX Lock είναι ενεργοποιημένο + SimpleX Logo + simplexmq: v%s (%2s) + Πρόσκληση 1-χρήσης SimpleX + Πρωτόκολλα SimpleX που έχουν ελεγχθεί από την Trail of Bits. + Σύνδεσμος αναμεταδότη SimpleX + SimpleX Team + Απλοποιημένη ανώνυμη λειτουργία + %s δεν έχει επαληθευτεί + %s έχει επαληθευτεί + Μέγεθος + Παράλειψη πρόσκλησης μελών + Παραλειπόμενα μηνύματα + Παράλειψη αυτής της έκδοσης + Αργή λειτουργία + Μικρές ομάδες (μέγιστο 20 άτομα) + Διακομιστής SMP + Διακομιστές SMP + Διακομιστής μεσολάβησης SOCKS + ΔΙΑΚΟΜΙΣΤΗΣ ΜΕΣΟΛΑΒΗΣΗΣ SOCKS + Ρυθμίσεις διακομιστή μεσολάβησης SOCKS + Απαλό + Κάποιο/α αρχείο/α δεν εξήχθησαν + Κατά την εισαγωγή προέκυψαν ορισμένα μη κρίσιμα σφάλματα: + Ορισμένοι διακομιστές απέτυχαν στη δοκιμή: + Ήχος σε σίγαση + Spam + Spam + Ηχείο + Απενεργοποίηση ηχείου + Εεργοποίηση ηχείου + Τετράγωνο, κύκλος ή οτιδήποτε μεταξύ τους. + %s: %s + %s, %s και %d μέλη + %s, %s και %d άλλα μέλη συνδεδεμένα + %s, %s και %s συνδεδεμένα + %s δευτερόλεπτο/α + %s διακομιστές + Σταθερή + τυποποιημένη κρυπτογράφηση από άκρη-σε-άκρη + Αστέρι στο GitHub + Εκκίνηση συνομιλίας + Εκκίνηση συνομιλίας; + εκκινεί… + Εκκινεί από %s. + Εκκινεί από %s.\nΌλα τα δεδομένα παραμένουν ιδιωτικά στη συσκευή σου. + Εκκίνηση νέας συνομιλίας + Εκκινεί περιοδικά + Στατιστικά + Διακοπή + Διακοπή + Διακοπή συνομιλίας + Διακοπή συνομιλίας; + Διέκοψε τη συνομιλία για να εξάγεις, να εισάγεις ή να διαγράψεις τη βάση δεδομένων συνομιλιών. Δεν θα μπορείς να λαμβάνεις και να στέλνεις μηνύματα ενώ η συνομιλία έχει διακοπεί. + Διακοπή αρχείου + Διακοπή συνομιλίας + Διακοπή λήψης αρχείου; + Διακοπή αποστολής αρχείου; + Διακοπή διαμοιρασμού + Διακοπή διαμοιρασμού διεύθυνσης; + διαγράμμιση + Έντονο + Υποβολή + Εγγεγραμμένος + Σφάλματα εγγραφής + Η εγγραφή αγνοήθηκε + %s ανεβασμένα + Υποστήριξη bluetooth και άλλων βελτιώσεων. + ΥΠΟΣΤΗΡΙΞΗ SIMPLEX CHAT + Ενάλλαξε + Εναλλαγή ήχου και βίντεο κατά τη διάρκεια της κλήσης. + Αλλαγή προφίλ συνομιλίας για προσκλήσεις 1-χρήσης. + Σύστημα + Σύστημα + Σύστημα + Σύστημα + Αυθεντικοποίηση συστήματος + Λειτουργία συστήματος + Ουρά + Πάτα το κουμπί + Επαλήθευση κωδικού στο κινητό + Επαλήθευση κωδικού με υπολογιστή + Επαλήθευση σύνδεσης + Επαλήθευση συνδέσεων + Επαλήθευση ασφάλειας σύνδεσης + Επαλήθευση φράσης πρόσβασης της βάσης δεδομένων + Επαλήθευση φράσης πρόσβασης + Επαλήθευση κωδικού ασφαλείας + μέσω %1$s + Μέσω περιηγητή + μέσω του συνδέσμου διεύθυνσης επαφής + μέσω συνδέσμου ομάδας + μέσω συνδέσμου 1-χρήσης + μέσω αναμεταδότη + Μέσω ασφαλούς κβαντο-ανθεκτικού πρωτοκόλλου + βίντεο + Βίντεο + Βίντεο + βιντεοκλήση + Βιντεοκλήση + βιντεοκλήση (χωρίς κρυπτογράφηση e2e) + Βίντεο απενεργοποιημένο + Βίντεο ενεργοποιημένο + Βίντεο και αρχεία εώς 1gb + Βίντεο απεστάλη + Το βίντεο θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή του. + Το βίντεο θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Δες τους όρους + Προβολή κωδικού ασφαλείας + Προβολή ενημερωμένων συνθηκών + Ορατό ιστορικό + Φωνητικό μήνυμα + Φωνητικό μήνυμα… + Φωνητικό μήνυμα (%1$s) + Φωνητικά μηνύματα + Φωνητικά μηνύματα + Τα φωνητικά μηνύματα απαγορεύονται. + Τα φωνητικά μηνύματα απαγορεύονται σε αυτήν τη συνομιλία. + Τα φωνητικά μηνύματα δεν επιτρέπονται + Τα φωνητικά μηνύματα απαγορεύονται! + - φωνητικά μηνύματα εώς 5 λεπτά.\n- προσαρμοσμένος χρόνος εξαφάνισης.\n- ιστορικό επεξεργασίας. + αναμονή για απάντηση… + αναμονή για επιβεβαίωση… + Αναμονή για τον υπολογιστή… + Αναμονή για το αρχείο + Αναμονή για την εικόνα + Αναμονή για την εικόνα + Αναμονή σύνδεσης κινητού: + Αναμονή για το βίντεο + Αναμονή για το βίντεο + Χρωματική έμφαση ταπετσαρίας + Φόντο ταπετσαρίας + θέλει να συνδεθεί μαζί σου! + Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. + Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! + Διακομιστές WebRTC ICE + Ιστοσελίδα + Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. + εβδομάδες + Καλωσόρισες! + Καλωσόρισες %1$s! + Μήνυμα καλωσορίσματος + Μήνυμα καλωσορίσματος + Μήνυμα καλωσορίσματος + Το μήνυμα καλωσορίσματος είναι πολύ μεγάλο + Καλωσόρισε τις επαφές σου 👋 + Τι νέο υπάρχει + Όταν η εφαρμογή είναι σε λειτουργία + Όταν είναι διαθέσιμο + Κατά τη σύνδεση κλήσεων ήχου και βίντεο. + Όταν η IP είναι κρυφή + Όταν είναι ενεργοποιημένοι περισσότεροι από ένας χειριστές, κανένας από αυτούς δεν διαθέτει μεταδεδομένα για να μάθει ποιος επικοινωνεί με ποιον. + Όταν κάποιος ζητήσει να συνδεθεί, μπορείς να αποδεχτείς ή να απορρίψεις το αίτημα. + Όταν μοιράζεσε ένα ανώνυμο προφίλ με κάποιον, αυτό το προφίλ θα χρησιμοποιείται για τις ομάδες στις οποίες σε προσκαλούν. + WiFi + Θα ενεργοποιηθεί στις άμεσες συνομιλίες! + Ενσύρματο ethernet + Με κρυπτογραφημένα αρχεία και μέσα. + Με προαιρετικό μήνυμα καλωσορίσματος. + Χωρίς Tor ή VPN, η διεύθυνση IP σου θα είναι ορατή στους διακομιστές αρχείων. + Χωρίς Tor ή VPN, η διεύθυνση IP σου θα είναι ορατή σε αυτούς τους XFTP αναμεταδότες:\n%1$s. + Με μειωμένη χρήση της μπαταρίας. + Με μειωμένη χρήση της μπαταρίας. + Λανθασμένη φράση πρόσβασης της βάσης δεδομένων + Λανθασμεο κλειδί ή άγνωστη σύνδεση - πιθανότατα αυτή η σύνδεση έχει διαγραφεί. + Λανθασμένο κλειδί ή άγνωστη διεύθυνση τμήματος αρχείου - πιθανότατα το αρχείο έχει διαγραφεί. + Λανθασμένη φράση πρόσβασης! + Διακομιστής XFTP + Διακομιστές XFTP + ναι + Ναι + Ναι + εσύ + ΕΣΥ + εσύ: %1$s + Αποδέχθηκες τη σύνδεση + αποδέχθηκες αυτό το μέλος + Επιτρέπεις + Έχεις ήδη ένα προφίλ συνομιλίας με το ίδιο όνομα εμφάνισης. Παρακαλώ επέλεξε ένα άλλο όνομα. + Είσαι ήδη συνδεδεμένος στο %1$s. + Ήδη συνδέεσαι μέσω αυτού του μοναδικού συνδέσμου! + Έχεις ήδη ενταχθεί στην ομάδα μέσω αυτού του συνδέσμου. + Είσαι προσκεκλημένος στην ομάδα + Είσαι προσκεκλημένος στην ομάδα + Είσαι προσκεκλημένος στην ομάδα. Αποδέξου την πρόσκληση για να συνδεθείς με τα μέλη της ομάδας. + Δεν είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση (δεν υπάρχει συνδρομή). + Δεν είσαι συνδεδεμένος σε αυτούς τους διακομιστές. Για την παράδοση μηνυμάτων σε αυτούς, χρησιμοποιείται ιδιωτική δρομολόγηση. + είσαι παρατηρητής + είσαι παρατηρητής + μπλόκαρες %s + Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. + Μπορείς να διαμορφώσεις τους χειριστές στις ρυθμίσεις Δικτύου & διακομιστών. + Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. + Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. + Μπορείς να το δημιουργήσεις αργότερα + Μπορείς να το ενεργοποιήσεις αργότερα μέσω των Ρυθμίσεων. + Μπορείς να τις ενεργοποιήσεις αργότερα μέσω των ρυθμίσεων απορρήτου και ασφάλειας της εφαρμογής. + Μπορείς να δοκιμάσεις ξανά. + Μπορείς να δοκιμάσεις ξανά. + Μπορείς να αποκρύψεις ή να σιγάσεις ένα προφίλ χρήστη - κράτησέ το πατημένο για να εμφανιστεί το μενού. + Μπορείς να το κάνεις ορατό στις επαφές σου στο SimpleX μέσω των Ρυθμίσεων. + Μπορείς να αναφέρεις εώς και %1$s μέλη ανά μήνυμα! + Μπορείς να στείλεις μηνύματα στην επαφή %1$s από τις αρχειοθετημένες επαφές. + Μπορείς να ορίσεις το όνομα της σύνδεσης για να θυμάσε με ποιον μοιράστηκες το σύνδεσμο. + Μπορείς να μοιραστείς ένα σύνδεσμο ή έναν κωδικό QR - οποιοσδήποτε θα μπορεί να συμμετάσχει στην ομάδα. Δεν θα χάσεις μέλη της ομάδας αν τον διαγράψεις αργότερα. + Μπορείς να μοιραστείς αυτήν τη διεύθυνση με τις επαφές σου για να τους επιτρέψεις να συνδεθούν με την επαφή %s. + Μπορείς να διαμοιραστείς τη διεύθυνσή σου ως σύνδεσμο ή κωδικό QR - οποιοσδήποτε θα μπορεί να συνδεθεί μαζί σου. + Μπορείς να ξεκινήσεις τη συνομιλία μέσω της εφαρμογής Ρυθμίσεις / Βάση δεδομένων ή επανεκκινώντας την εφαρμογή. + Μπορείς ακόμα να δεις τη συνομιλία με την επαφή %1$s, στη λίστα των συνομιλιών. + Δεν μπορείς να στείλεις μηνύματα! + Μπορείς να ενεργοποιήσεις το SimpleX Lock μέσω των Ρυθμίσεων. + Μπορείς να χρησιμοποιήσεις σύνταξη markdown για να μορφοποιήσεις τα μηνύματα: + Μπορείς να δεις ξανά το σύνδεσμο πρόσκλησης στις λεπτομέρειες σύνδεσης. + Μπορείς να δείς τις αναφορές σου στη Συνομιλία με τους διαχειριστές. + άλλαξες διεύθυνση + άλλαξες διεύθυνση για %s + άλλαξες ρόλο για τον εαυτό σου σε %s + άλλαξες το ρόλο του μέλους %s σε %s + Έχεις τον έλεγχο της συνομιλίας σου! + Δεν ήταν δυνατή η επαλήθευση. Παρακαλώ, δοκίμασε ξανά. + Εσύ αποφασίζεις ποιος μπορεί να συνδεθεί. + Έχεις ήδη ζητήσει σύνδεση μέσω αυτής της διεύθυνσης! + Δεν έχεις συνομιλίες + Πρέπει να εισάγεις τη φράση πρόσβασης κάθε φορά που ξεκινά η εφαρμογή - δεν αποθηκεύεται στη συσκευή. + Προσκάλεσες μία επαφή + Εντάχθηκες σε αυτήν την ομάδα + Έχεις ενταχθεί σε αυτή την ομάδα. Σύνδεση με το μέλος που σε προσκάλεσε. + αποχώρησες + αποχώρησες + Μπορείς να μεταφέρεις την εξαγώμενη βάση δεδομένων. + Μπορείς να αποθηκεύσεις το εξαγώμενο αρχείο. + Πρέπει να χρησιμοποιήσεις την πιο πρόσφατη έκδοση της βάσης δεδομένων συνομιλιών σου σε ΜΟΝΟ μία συσκευή, διαφορετικά ενδέχεται να σταματήσεις να λαμβάνεις μηνύματα από ορισμένες επαφές. + Πρέπει να επιτρέψεις στην επαφή σου να σε καλέσει για να μπορείς να την καλέσεις πίσω. + Για να μπορείς να στέλνεις φωνητικά μηνύματα, πρέπει να επιτρέψεις στην επαφή σου να στέλνει φωνητικά μηνύματα. + Η επαγγελματική σου επαφή + Η κλήσεις σου + Η βάση δεδομένων συνομιλιών σου + Η βάση δεδομένων συνομιλιών σου δεν είναι κρυπτογραφημένη - όρισε μία φράση πρόσβασης για να την προστατεύσεις. + Τα προφίλ συνομιλιών σου + Το προφίλ συνομιλίας σου θα σταλεί στα μέλη της συνομιλίας. + Το προφίλ συνομιλίας σου θα σταλεί στα μέλη της ομάδας. + Η σύνδεσή σου μεταφέρθηκε στο προφίλ %s, αλλά προέκυψε σφάλμα κατά την εναλλαγή του. + Η επαφή σου + Η επαφή σου πρέπει να είναι συνδεδεμένη στο διαδίκτυο για να ολοκληρωθεί η σύνδεση.\nΜπορείς να ακυρώσεις αυτήν τη σύνδεση και να καταργήσεις την επαφή (και να δοκιμάσεις αργότερα με έναν νέο σύνδεσμο). + Οι επαφές σου + Οι επαφές σου μπορούν να επιτρέψουν την πλήρη διαγραφή μηνυμάτων. + Τα διαπιστευτήριά σου ενδέχεται να αποσταλούν χωρίς κρυπτογράφηση. + Η τρέχουσα βάση δεδομένων συνομιλιών σου θα ΔΙΑΓΡΑΦΕΙ και θα ΑΝΤΙΚΑΤΑΣΤΑΘΕΙ με την εισαγώμενη.\nΑυτή η ενέργεια δεν μπορεί να αναιρεθεί - το προφίλ, οι επαφές, τα μηνύματα και τα αρχεία σου θα χαθούν οριστικά. + Προσπαθείς να προσκαλέσεις μία επαφή με την οποία έχεις μοιραστεί ένα ανώνυμο προφίλ στην ομάδα στην οποία χρησιμοποιείς το κύριο προφίλ σου. + Χρησιμοποιείς ένα ανώνυμο προφίλ για αυτήν την ομάδα - για να αποφύγεις την κοινή χρήση του κύριου προφίλ σου, δεν επιτρέπεται η πρόσκληση επαφών. + Η ομάδα σου + Το προφίλ σου + Το προφίλ σου αποθηκεύεται στη συσκευή σου και κοινοποιείται μόνο στις επαφές σου. Οι διακομιστές της SimpleX δεν μπορούν να δουν το προφίλ σου. + Οι διακομιστές σου + διαμοιράστηκες ένα σύνδεσμο 1-χρήσης + διαμοιράστηκες ένα σύνδεσμο 1-χρήσης ανώνυμα + ξεμπλόκαρες %s + Θα συνδεθείς στην ομάδα όταν η συσκευή του διαχειριστή της ομάδας είναι συνδεδεμένη στο διαδίκτυο. Παρακαλώ περίμενε ή έλεγξε αργότερα! + Θα συνδεθείς όταν γίνει αποδεκτό το αίτημά σου για σύνδεση. Παρακαλώ περίμενε ή έλεγξε αργότερα! + Θα σου ζητηθεί να πραγματοποιήσεις έλεγχο ταυτότητας όταν ξεκινήσεις ή συνεχίσεις την εφαρμογή μετά από 30 δευτερόλεπτα στο παρασκήνιο. + Θα συνεχίσεις να λαμβάνεις κλήσεις και ειδοποιήσεις από τα προφίλ που έχεις σε σίγαση όταν αυτά θα είναι ενεργά. + Δεν θα λαμβάνεις πλέον μηνύματα από αυτήν τη συνομιλία. Το ιστορικό συνομιλιών θα διατηρηθεί. + Δεν θα λαμβάνεις πλέον μηνύματα από αυτήν την ομάδα. Το ιστορικό συνομιλιών θα διατηρηθεί. + Δεν θα χάσεις τις επαφές σου αν διαγράψεις αργότερα τη διεύθυνσή σου. + Μεγέθυνση + Όλα τα μηνύματα + Αρχεία + ΦΙλτράρισμα + Εικόνες + Σύνδεσμοι + Αναζήτηση αρχείων + Αναζήτηση εικόνων + Αναζήτηση συνδέσμων + Αναζήτηση βίντεο + Αναζήτηση φωνητικών μηνυμάτων + Βίντεο + Φωνητικά μηνύματα diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index b5e756aaad..c233d8eabc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1732,7 +1732,7 @@ Descargar Reenviar Reenviado - Mensaje reenviado… + Reenviando mensaje… Los destinatarios no ven de quién procede este mensaje. Bluetooth Concurrencia en la recepción @@ -1906,7 +1906,7 @@ Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! Descargado Servidor SMP - Aún no hay conexión directa, el mensaje es reenviado por el administrador. + Aún no hay conexión directa, los mensajes son reenviados por el administrador. Otros servidores SMP Otros servidores XFTP Escanear / Pegar enlace @@ -2391,7 +2391,7 @@ Error al aceptar el miembro ¿Guardar configuración? Por favor, espera a que tu solicitud sea revisada por los moderadores del grupo. - has aceptado al miembro + has admitido al miembro pendiente de revisión por revisar Chat con administradores @@ -2419,7 +2419,7 @@ ¡No puedes enviar mensajes! Puedes ver tus informes en Chat con administradores has salido - te ha aceptado + te ha admitido Un miembro nuevo desea unirse al grupo. todos Chat con miembros @@ -2537,4 +2537,21 @@ La huella en la dirección del servidor de reenvío no coincide con el certificado: %1$s. Sin suscripciones No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). + Eliminar mensajes del miembro + ¿Eliminar mensajes del miembro? + Eliminar mensajes + Los mensajes del miembro serán eliminados. ¡No puede deshacerse! + Eliminar miembro y sus mensajes + Todos los mensajes + Archivos + Filtro + Imágenes + Enlaces + Buscar archivos + Buscar imágenes + Buscar enlaces + Buscar vídeos + Buscar mensajes de voz + Vídeos + Mensajes de voz diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index eba31ba788..5483becb91 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -2532,4 +2532,5 @@ اثر انگشت در نشانی سرور مقصد با گواهی مطابقت ندارد: ‎%1$s. اثر انگشت در نشانی سرور انتقال با گواهی مطابقت ندارد: ‎%1$s. اثر انگشت در نشانی سرور با گواهی مطابقت ندارد: ‎%1$s. + پاک کردن پیام کاربر diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 12b578edbf..38f8a81d3a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -21,7 +21,7 @@ A SimpleXről Kiemelőszín fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt a beállítást. Elfogadás Elfogadás gombra fent, majd: @@ -39,7 +39,7 @@ Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva. Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> - hivatkozás előnézetének visszavonása + hivatkozáselőnézet visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. @@ -48,7 +48,7 @@ Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének küldése közvetlen kapcsolatot használ.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. + Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. @@ -59,7 +59,7 @@ Hang- és videóhívások Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). Hívás fogadása - Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. + Az eltűnő üzenetek küldése engedélyezve van a partnerei számára. Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen @@ -70,7 +70,7 @@ Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. - Hívás befejeződött + A hívás véget ért HÍVÁSOK és további %d esemény Cím @@ -86,7 +86,7 @@ Vissza Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. - Hívások a zárolási képernyőn: + Hívások a zárolási képernyőn titkosítás elfogadása… Nem lehet meghívni a partnert! hibás az üzenet azonosítója @@ -97,7 +97,7 @@ Hozzáadás egy másik eszközhöz A reakciók hozzáadása az üzenetekhez engedélyezve van. Fájlelőnézet visszavonása - Az összes csoporttag kapcsolatban marad. + Az összes csoporttag továbbra is kapcsolatban marad. Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> Letiltás adminisztrátor @@ -114,11 +114,11 @@ Az alkalmazásjelkód helyettesítve lesz egy önmegsemmisítő jelkóddal. Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Engedélyezi a hangüzeneteket? - Mindig használjon továbbítókiszolgálót + Mindig legyen használva továbbítókiszolgáló mindig - A hívás már befejeződött! + A hívás már véget ért! Engedélyezés - Az összes partnerével kapcsolatban marad. + Az összes partnerével továbbra is kapcsolatban marad. Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) Hang- és videóhívások @@ -130,7 +130,7 @@ Megjelenés Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. Ezt a beállításokban újraengedélyezheti. Letiltja a tagot? - %1$s hívása befejeződött + %1$s hívása véget ért Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) @@ -165,7 +165,7 @@ A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva - hanghívás (nem e2e titkosított) + hanghívás (végpontok között NEM titkosított) letiltva Módosítja az adatbázis jelmondatát? kapcsolódva @@ -195,11 +195,11 @@ Kapcsolódás partneri kapcsolatot kért kapcsolat %1$d - a partner e2e titkosítással rendelkezik + a partner végpontok közötti titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódik az egyszer használható meghívóval? + Kapcsolódik az egyszer használható meghívón keresztül? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -214,7 +214,7 @@ Kapcsolódik saját magához? Vágólapra másolva Kapcsolódási kérés elküldve! - Kapcsolódás a számítógéphez + Társítás számítógéppel Kapcsolat Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor @@ -224,7 +224,7 @@ Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva - a partner nem rendelkezik e2e titkosítással + a partner nem rendelkezik végpontok közötti titkosítással Partner engedélyezi Rejtett név: Társítás számítógéppel @@ -266,25 +266,25 @@ kapcsolódás… Csevegési profil törlése egyéni - kapcsolódási hívás… + hívás kapcsolása… Téma személyre szabása - Jelenleg támogatott legnagyobb fájl méret: %1$s. + Jelenleg támogatott legnagyobb fájlméret: %1$s. Fájl törlése Hamarosan! cím módosítása %s számára… Csevegési adatbázis importálva Üzenetek törlése - Kiürítés + Ürítés Bezárás gomb A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. Törli a csevegési profilt? Titkos csoport létrehozása - Kapcsolódva a számítógéphez + Társítva a számítógéppel ICE-kiszolgálók beállítása Csoport törlése - Hitelesítés törlése + Ellenőrzés törlése készítő Megerősítés Csak nálam @@ -314,7 +314,7 @@ Egyéni időköz Kapcsolódás inkognitóban CSEVEGÉSEK - Új profil létrehozása a számítógép alkalmazásban. 💻 + Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) kapcsolódás… Csevegési adatbázis törölve @@ -333,7 +333,7 @@ Titkos csoport létrehozása Elvetés Törli a partnert? - Kiürítés + Ürítés Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás @@ -341,9 +341,9 @@ Törli az üzenetet? Törli a függőben lévő kapcsolatot? Adatbázis titkosítva! - Kiüríti a csevegést? + Üríti a csevegés üzeneteit? Adatbázis visszafejlesztése - Üzenetek kiürítése + Csevegés üzeneteinek ürítése Az adatbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba @@ -390,15 +390,15 @@ %2$s %1$d üzenetet moderált Eltűnő üzenet Ne hozzon létre címet - Ne mutasd újra + Ne jelenjen meg újra SimpleX-zár kikapcsolása - e2e titkosított + végpontok között titkosított ESZKÖZ - e2e titkosított videóhívás + végpontok között titkosított videóhívás közvetlen Számítógép %d perc - %d partner kijelölve + %d partner kiválasztva Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. @@ -419,7 +419,7 @@ Törlés, és a partner értesítése letiltva %d mp - Az összes fájl törlése + Összes fájl törlése Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. @@ -435,7 +435,7 @@ %d csoportesemény %d hónap Csoportprofil szerkesztése - e2e titkosított hanghívás + végpontok között titkosított hanghívás %d mp Decentralizált Dekódolási hiba @@ -443,12 +443,12 @@ Értesítések letiltása Eszközök Látható a helyi hálózaton - Ne engedélyezze + Nem engedélyezem Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben. alapértelmezett (%s) duplikált üzenet Leválasztja a számítógépet? - A számítógép-alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. + A számítógépes alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. Kézbesítés %d fájl, %s összméretben A csevegés megnyitásához adja meg az adatbázis jelmondatát. @@ -495,7 +495,7 @@ A csoportprofil a tagok eszközein tárolódik, nem a kiszolgálókon. Adja meg a jelmondatot… Hiba történt a felhasználói adatvédelem frissítésekor - Titkosít + Titkosítás Csoport nem található! Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása @@ -566,7 +566,7 @@ Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. Hiba történt a tag eltávolításakor - befejeződött + hívás vége A csoport üdvözlőüzenete Adja meg a csoport nevét: Hiba történt a meghívó elküldésekor @@ -631,7 +631,7 @@ Téves jelkód Azonnali Inkognitócsoportok - Hogyan + Útmutató Összecsukás Kép Továbbfejlesztett adatvédelem és biztonság @@ -695,7 +695,7 @@ moderált A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. - Nincs partner kijelölve + Nincs partner kiválasztva Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -711,13 +711,13 @@ Helyi név Hálózat és kiszolgálók Értesítésekben megjelenő információk - Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 + Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗 közvetett (%1$s) Hamarosan további fejlesztések érkeznek! A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. Helytelen biztonsági kód! Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. - Új számítógép-alkalmazás! + Új számítógépes alkalmazás! Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat (megfigyelő szerepkör) meghívta őt: %1$s A reakciók hozzáadása az üzenetekhez le van tiltva. @@ -753,14 +753,14 @@ Az üzenetek végleges törlése le van tiltva. %s nevű hordozható eszköz le lett választva]]> hónap - Üzenetvázlat + Piszkozatok Egy üzenet eltüntetése Végleges üzenettörlés Egyszerre csak 10 videó küldhető el Csak Ön adhat hozzá reakciókat az üzenetekhez. elhagyta a csoportot Az üzenetek végleges törlése le van tiltva ebben a csevegésben. - Max 40 másodperc, azonnal fogadható. + Legfeljebb 40 másodperc, azonnal megérkezik. inkognitó a kapcsolattartási címhivatkozáson keresztül Onion kiszolgálók szükségesek a kapcsolódáshoz.\nMegjegyzés: .onion cím nélkül nem fog tudni kapcsolódni a kiszolgálókhoz. Olasz kezelőfelület @@ -777,7 +777,7 @@ Csak a csoport tulajdonosai engedélyezhetik a fájlok és a médiatartalmak küldését. Fájl betöltése… Nincs hozzáadandó partner - Üzenetvázlat + Piszkozatok függőben lévő kapcsolat Egyszer használható meghívó Értesítések @@ -818,7 +818,7 @@ Menük és figyelmeztetések Tagok meghívása Csatlakozás mint: %s - Nincs csevegés kijelölve + Nincs csevegés kiválasztva Csak helyi profiladatok inkognitó egy egyszer használható meghívón keresztül Moderálva: %s @@ -827,7 +827,7 @@ Beszélgessünk a SimpleX Chatben Moderálva Élő üzenetek - Hitelesítés + Megjelölés ellenőrzöttként Üzenetkézbesítési jelentések! hivatkozás előnézeti képe Elhagyja a csoportot? @@ -838,7 +838,7 @@ Új megjelenítendő név: Új jelmondat… nem fogadott hívás - Átköltöztetés: %s + Átköltöztetések: %s Válaszul erre Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! @@ -851,7 +851,7 @@ dőlt Érvénytelen a fájl elérési útvonala Csatlakozik a csoporthoz? - nincs e2e titkosítás + nincs végpontok közötti titkosítás Új adatbázis-archívum Élő üzenet! Meghívás a csoportba @@ -872,7 +872,7 @@ Időszakos fogadott, tiltott Megismétli a kapcsolódási kérést? - Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) + Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra) Szerepkör SimpleX kapcsolattartási cím Megállítás @@ -895,17 +895,17 @@ Jelentse a fejlesztőknek. Ön dönti el, hogy kivel beszélget. Az eltűnő üzenetek küldése le van tiltva. - Csak Ön tud hangüzeneteket küldeni. + Csak Ön küldhet hangüzeneteket. Frissítés Videó elküldve - Az adatbázis jelmondatának módosítása + Adatbázis jelmondatának módosítása Alkalmazásbeállítások megnyitása A jelkód nem módosult! Frissítés - Kijelölés - Csak Ön tud hívásokat indítani. + Kiválasztás + Csak Ön kezdeményezhet hívásokat. Biztonságos várólista - Értékelje az alkalmazást + Alkalmazás értékelése Egyszer használható meghívó megosztása Hiba történt az adatbázis visszaállításakor %s és %s @@ -918,7 +918,7 @@ Fogadott üzenet Üdvözlőüzenet %s, %s és további %d tag kapcsolódott - Csak a partnere tud hívást indítani. + Csak a partnere kezdeményezhet hívásokat. TÉMÁK Túl sok videó! Üdvözöljük! @@ -937,10 +937,10 @@ Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. jogosulatlan küldés - Csak a partnere tud hangüzeneteket küldeni. + Csak a partnere küldhet hangüzeneteket. Beállítások A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. - visszaigazolás fogadása… + visszaigazolás érkezett… Biztonsági kód beolvasása a partnere alkalmazásából. Lépjen kapcsolatba a csoport adminisztrátorával. Videó bekapcsolva @@ -952,7 +952,7 @@ Keresés Újraegyezteti a titkosítást? Az önmegsemmisítő jelkód engedélyezve! - Biztonsági kiértékelés + Biztonsági felmérés Cím Üzenet elküldése Adatbázismentés visszaállítása @@ -1027,13 +1027,13 @@ SIMPLEX CHAT TÁMOGATÁSA SimpleX Chat szolgáltatás Ön megfigyelő - %s hitelesítve + %s ellenőrizve Jelszó a megjelenítéshez Adatvédelem és biztonság Eltávolítás A jelkód beállítva! Elküldött üzenet - Partnerek kijelölése + Partnerek kiválasztása ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet @@ -1041,7 +1041,7 @@ A profilfrissítés el lesz küldve a partnerei számára. Egyszerűsített inkognitómód Menti az üdvözlőüzenetet? - Új csevegési fiók létrehozásához indítsa újra az alkalmazást. + Új csevegési profil létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! Függőben lévő hívás Adatbázis megnyitása… @@ -1049,7 +1049,7 @@ Jelmondat szükséges Privát értesítések Ön meghívta egy partnerét - %s nincs hitelesítve + %s nincs ellenőrizve Koppintson ide a kapcsolódáshoz Ennek az eszköznek a neve Jelenlegi profil @@ -1081,7 +1081,7 @@ Újraindítás SMP-kiszolgálók Videó - SimpleX-cím beállításainak mentése + SimpleX-címbeállítások mentése Újraegyeztetés Várakozás a videóra Saját XFTP-kiszolgálók @@ -1119,11 +1119,11 @@ Várakozás a képre Hangüzenetek Eltávolítja a tagot? - Biztonsági kód hitelesítése + Biztonsági kód ellenőrzése eltávolította Önt SimpleX-cím Megjelenítve: - válasz fogadása… + válasz érkezett… Visszaállítja az adatbázismentést? Üzenetek fogadása… %s és %s kapcsolódott @@ -1160,7 +1160,7 @@ Kihagyott üzenetek A hangüzenetek küldése le van tiltva. Partner nevének beállítása - Csak Ön tud eltűnő üzeneteket küldeni. + Csak Ön küldhet eltűnő üzeneteket. Médiatartalom megosztása… Ön: %1$s Beállítások @@ -1170,7 +1170,7 @@ A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás Port nyitása a tűzfalban - indítás… + hívás indítása… Leállítás elküldve SOCKS proxy használata @@ -1217,7 +1217,7 @@ Rendszer-hitelesítés Böngészőn keresztül Védje meg a csevegési profiljait egy jelszóval! - Csak a partnere tud eltűnő üzeneteket küldeni. + Csak a partnere küldhet eltűnő üzeneteket. Saját ICE-kiszolgálók QR-kód beolvasása a számítógépről SimpleX logó @@ -1239,7 +1239,7 @@ SimpleX-zár bekapcsolva elküldés a partnernek Beolvasás hordozható eszközről - Kapcsolatok hitelesítése + Kapcsolatok ellenőrzése Üzenet megosztása… másodperc A SimpleX-zár nincs bekapcsolva! @@ -1248,7 +1248,7 @@ Csevegési adatbázis eltávolította őt: %1$s Sikertelen kiszolgáló teszt! - Kapcsolat hitelesítése + Kapcsolat ellenőrzése Tudjon meg többet A fájl küldője visszavonta az átvitelt. Megállítja a csevegést? @@ -1256,7 +1256,7 @@ Beállítva 1 nap Felfedés Fogadott üzenetbuborék színe - Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra) Az önmegsemmisítő jelkód módosult! SimpleX Chat kiszolgálók használatban. SimpleX Chat kiszolgálók használata? @@ -1273,27 +1273,27 @@ Az üzenetváltás jövője Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: - Biztonságos kapcsolat hitelesítése + Biztonságos kapcsolat ellenőrzése fájlok küldése egyelőre még nem támogatott Ön módosította a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. - videóhívás (nem e2e titkosított) + videóhívás (végpontok között NEM titkosított) Használat új kapcsolatokhoz - Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. + Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ leküldéses értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése a kapcsolattartási címhivatkozáson keresztül - a SimpleX a háttérben fut a push értesítések használata helyett.]]> + a SimpleX a háttérben fut a leküldéses értesítések használata helyett.]]> A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a kapcsolatot és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. - A partnerei továbbra is kapcsolódva maradnak. + A partnereivel továbbra is kapcsolatban marad. A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - Nem sikerült hitelesíteni; próbálja meg újra. + Nem sikerült ellenőrizni; próbálja meg újra. Az üzenet az összes tag számára moderáltként lesz megjelölve. Értesítések fogadásához adja meg az adatbázis jelmondatát A teszt a(z) %s lépésnél sikertelen volt. @@ -1329,14 +1329,14 @@ %1$s.]]> Profil felfedése Ez nem egy érvényes kapcsolattartási hivatkozás! - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik Ön meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Ön meghívást kapott a csoportba - A partnere a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. + A partnere a jelenleg támogatott legnagyobb (%1$s) fájlméretnél nagyobbat küldött. A partnerei és az üzenetek (kézbesítés után) nem a SimpleX kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> @@ -1348,14 +1348,14 @@ Átvitelelkülönítés Akkor lesz kapcsolódva, ha a kapcsolódási kérését elfogadják, várjon, vagy ellenőrizze később! A hangüzenetek küldése le van tiltva. - Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> + Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Biztonságos kvantumbiztos protokollon keresztül. - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni időkorlát beállítása az üzenetek eltűnéséhez.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> + Onion kiszolgálók használata beállítást „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX kiszolgálók nem láthatják a profilját. @@ -1366,7 +1366,7 @@ Csoportmeghívó elküldve Frissíti az átvitelelkülönítési módot? Átvitelelkülönítés - Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. + Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. Közvetlen internetkapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. @@ -1389,8 +1389,8 @@ A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! Ön a következőre módosította a saját szerepkörét: „%s” - A csevegési szolgáltatás elindítható a „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával. - Kód hitelesítése a hordozható eszközön + A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával. + Kód ellenőrzése a hordozható eszközön Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. @@ -1402,7 +1402,7 @@ %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! - Kód hitelesítése a számítógépen + Kód ellenőrzése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. A csatlakozási kérése el lesz küldve ennek a csoporttagnak. Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt. @@ -1414,12 +1414,12 @@ A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. Protokoll időtúllépése kB-onként Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. - Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. A profilja csak a partnereivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. - A kézbesítési jelentések engedélyezve vannak %d partnernél + A kézbesítési jelentések engedélyezve vannak %d partner számára Küldés a következőn keresztül: Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. @@ -1443,7 +1443,7 @@ Köszönet a felhasználóknak a Weblate-en való közreműködésért! Jelmondat mentése a beállításokban Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem lesznek elküldve. - A második jelölés, amit kihagytunk! ✅ + A második pipa, ami már nagyon hiányzott! ✅ A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. @@ -1452,10 +1452,10 @@ Profil és kiszolgálókapcsolatok Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. Koppintson ide a profil aktiválásához. - A kézbesítési jelentések le vannak tiltva %d partnernél - Munkamenet kód + A kézbesítési jelentések le vannak tiltva %d partner számára + Munkamenet kódja Köszönet a felhasználóknak a Weblate-en való közreműködésért! - Kis csoportok (max. 20 tag) + Kis csoportok (legfeljebb 20 tag) Az Ön által elfogadott kapcsolat vissza lesz vonva! Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI @@ -1519,7 +1519,7 @@ Számítógép elfoglalt Számítógép inaktív Csevegés újraindítása - Időtúllépés a számítógéphez való csatlakozáskor + Időtúllépés a számítógéphez való társításkor A számítógép le lett választva A kapcsolat megszakadt A kapcsolat megszakadt @@ -1555,7 +1555,7 @@ Privát jegyzetek Hiba történt a privát jegyzetek törlésekor Hiba történt az üzenet létrehozásakor - Kiüríti a privát jegyzeteket? + Üríti a privát jegyzetek tartalmát? Létrehozva Mentett üzenet Létrehozva: %s @@ -1586,7 +1586,7 @@ Az üdvözlőüzenet túl hosszú Az adatbázis átköltöztetése folyamatban van.\nEz eltarthat néhány percig. Hanghívás - A hívás befejeződött + Hívás vége Videóhívás Hiba történt a böngésző megnyitásakor A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további információkat a SimpleX Chat fejlesztőivel. @@ -1613,14 +1613,14 @@ Hiba történt a beállítások mentésekor Hiba történt az archívum letöltésekor Hiba történt az archívum feltöltésekor - Hiba történt a jelmondat hitelesítésekor: + Hiba történt a jelmondat ellenőrzésekor: Az exportált fájl nem létezik A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve Archívum importálása Feltöltés előkészítése - Az adatbázis jelmondatának hitelesítése - Jelmondat hitelesítése + Adatbázis jelmondatának ellenőrzése + Jelmondat ellenőrzése Jelmondat beállítása Kép a képben hívások Biztonságosabb csoportok @@ -1633,10 +1633,10 @@ A folytatáshoz a csevegést meg kell szakítani. Csevegés megállítása folyamatban Vagy ossza meg biztonságosan ezt a fájlhivatkozást - Csevegés indítása + Csevegés elindítása Nem szabad ugyanazt az adatbázist használni egyszerre két eszközön.]]> Az átköltöztetéshez erősítse meg, hogy emlékszik az adatbázis jelmondatára. - Átköltöztetés egy másik eszközről opciót az új eszközén és olvassa be a QR-kódot.]]> + Átköltöztetés egy másik eszközről beállítást az új eszközén és olvassa be a QR-kódot.]]> Átköltöztetés véglegesítése Átköltöztetés véglegesítése egy másik eszközön. Letöltés előkészítése @@ -1739,10 +1739,10 @@ Nem Nem védett Igen - NE használjon privát útválasztást. + NE legyen használva privát útválasztás. Privát útválasztás Privát útválasztás használata az ismeretlen kiszolgálókhoz. - Mindig használjon privát útválasztást. + Mindig legyen használva privát útválasztás. Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. @@ -1816,7 +1816,7 @@ Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1843,7 +1843,7 @@ Újrakapcsolódás az összes kiszolgálóhoz Hiba történt a statisztikák visszaállításakor Visszaállítás - Az összes statisztika visszaállítása + Összes statisztika visszaállítása Visszaállítja az összes statisztikát? A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! Részletes statisztikák @@ -1976,12 +1976,12 @@ Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. A(z) %1$s nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. Üzenet… - Kijelölés + Kiválasztás Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs semmi kijelölve + Nincs semmi kiválasztva Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? - %d kijelölve + %d kiválasztva Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. @@ -2024,7 +2024,7 @@ CSEVEGÉSI ADATBÁZIS Profil megosztása Rendszerbeállítások használata - Csevegési profil kijelölése + Csevegési profil kiválasztása Ne használja a hitelesítési adatokat proxyval. Különböző proxy-hitelesítési adatok használata az összes profilhoz. Különböző proxy-hitelesítési adatok használata az összes kapcsolathoz. @@ -2048,7 +2048,7 @@ %1$s üzenet nem lett továbbítva Továbbít %1$s üzenetet? Továbbítja az üzeneteket fájlok nélkül? - Az üzeneteket törölték miután kijelölte őket. + Az üzeneteket törölték miután kiválasztotta őket. %1$s üzenet mentése Hiba történt az üzenetek továbbításakor Hang elnémítva @@ -2060,8 +2060,8 @@ Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatok lesznek használva. Alkalmazás munkamenete Az összes kiszolgálóhoz új, SOCKS-hitelesítési adatok lesznek használva. - Kattintson a címmező melletti info gombra a mikrofon használatának engedélyezéséhez. - Nyissa meg a Safari Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése lehetőséget. + Kattintson a címmező melletti információ gombra a mikrofon használatának engedélyezéséhez. + Nyissa meg a Safari / Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése beállítást. Hívások kezdeményezéséhez engedélyezze a mikrofon használatát. Fejezze be a hívást, és próbálja meg a hívást újra. Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. @@ -2178,7 +2178,7 @@ Értesítések és akkumulátor Az alkalmazás mindig fut a háttérben Elhagyja a csevegést? - Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. + Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. Csevegés törlése Meghívás a csevegésbe Barátok hozzáadása @@ -2236,7 +2236,7 @@ Nincsenek olvasatlan csevegések Lista létrehozása Lista mentése - Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s + Az összes csevegés el lesz távolítva a(z) %s nevű listáról, és a lista is törölve lesz Törlés Törli a listát? Szerkesztés @@ -2293,7 +2293,7 @@ alapértelmezett (%s) Csevegési üzenetek törlése az eszközről. Módosítja az automatikus üzenettörlést? - Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz Webport használata @@ -2301,7 +2301,7 @@ Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! Az üzenetek jelentése a moderátorok felé engedélyezve van. - Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az üzenetek jelentése a moderátorok felé le van tiltva. Archiválja az összes jelentést? Archivál %d jelentést? Csak magamnak @@ -2393,7 +2393,7 @@ csatlakozási kérés elutasítva Ön elhagyta a csoportot a tag régi verziót használ - Hiba a csevegés törlésekor + Hiba történt a csevegés törlésekor Ön nem tud üzeneteket küldeni! a partner nem áll készen nincs szinkronizálva @@ -2450,9 +2450,9 @@ TCP-kapcsolat időtúllépése a háttérben Profil betöltése… Rövid leírás: - Saját névjegy: - Névjegy: - A névjegy túl hosszú + Saját életrajz: + Életrajz: + Az életrajz túl hosszú A leírás túl hosszú Partneri kapcsolatkérés elfogadása Üzleti kapcsolat @@ -2468,7 +2468,7 @@ Saját cím létrehozása Eltűnő üzenetek engedélyezése alapértelmezetten. Tartsa tisztán a csevegéseit - Névjegy és üdvözlőüzenet beállítása a profilokhoz. + Életrajz és üdvözlőüzenet beállítása a profilokhoz. Saját cím megosztása Rövid SimpleX-cím Cím frissítése @@ -2503,6 +2503,23 @@ A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. - nincs előfizetés - Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). + nincs feliratkozás + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás). + Tag üzeneteinek törlése + Törli a tag üzeneteit? + Üzenetek törlése + A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza! + Eltávolítás és az üzeneteinek törlése + Összes üzenet + Fájlok + Szűrő + Képek + Hivatkozások + Fájlok keresése + Képek keresése + Hivatkozások keresése + Videók keresése + Hangüzenetek keresése + Videók + Hangüzenetek diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 909c6c7cfe..2257d93efa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2509,4 +2509,9 @@ Sidik jari di alamat server tidak cocok dengan sertifikat: %1$s. tidak berlangganan Anda tidak terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini (tidak berlangganan). + Hapus pesan anggota + Hapus pesan anggota? + Hapus pesan + Pesan anggota akan dihapus - ini tidak dapat dibatalkan! + Hapus pesan diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 1c7e39d51e..1c191a78bd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2452,7 +2452,7 @@ Entra nel gruppo Apri la chat Apri una chat nuova - Apri un gruppo nuovo + Apri il nuovo gruppo Apri per accettare Apri per connettere Apri per entrare @@ -2541,4 +2541,21 @@ L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato: %1$s. nessuna iscrizione Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). + Elimina i messaggi del membro + Eliminare i messaggi del membro? + Elimina i messaggi + I messaggi del membro verranno eliminati. Non è reversibile! + Rimuovi ed elimina i messaggi + Tutti i messaggi + File + Immagini + Link + Cerca file + Cerca immagini + Cerca link + Cerca video + Cerca messaggi vocali + Video + Messaggi vocali + Filtro diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 6b413c9bfa..fb83b83735 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -2138,4 +2138,17 @@ פתח שיחה חדשה פתח קבוצה חדשה שלח את המשוב הפרטי שלך לקבוצות. + הסכמה לבקשת חבר + הערות + לא נבחר כלום להעברה! + התראות וסוללה + הוסף הודעה + אפשר קבצים ומדיה רק כאשר החבר מאשר אותם + אפשר לאנשי קשר שלך לשלוח קבצים ומדיה + אודות: + האודות ארוך מדי + בוט + אתה והאיש קשר שלך יכולים לשלוח קבצים ומדיה + צ\'אט עסקי + אי אפשר לשנות תמונת פרופיל diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index cc85e49a8c..1c4d265515 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -347,7 +347,7 @@ データベースパスフレーズ データベースをエクスポート データベースを削除 - データベースを読み込みますか? + データベースのインポート 新しいデータベースのアーカイブ 過去のデータベースアーカイブ ファイルを全て削除 @@ -2042,4 +2042,17 @@ プライベートメッセージルーティング用のサーバーがありません。 メディアおよびファイルサーバーは存在しません。 ファイルを送信するサーバーがありません。 + ソーシャルメディア向け + サーバを利用する + あなたのサーバ + ビデオ + ファイル + 画像 + リンク + すべて + 音声メッセージ + フィルター + メンバーとして承認する + オブザーバーとして承認する + スパム diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml new file mode 100644 index 0000000000..0ea9328085 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -0,0 +1,837 @@ + + + Profîla niha bişuxulîne + Komê veke + Komeke nû veke + Lînka xelet + Databas tê vekirin… + xeletî + Profîl nikarîbû were çêkirin! + Profîl nikarîbû were guhertin! + Ti serverên medya & dosyayan nînin. + Ji min re + Ji hemû moderatoran re + Xeletî: %1$s + Cewab bide + Kopî bike + Qeyd bike + Biguhere + Melûmat + Lê bigere + Li sûretan bigere + Li vîdyoyan bigere + Li dosyayan bigere + Li lînkan bigere + Sûret + Vîdyo + Dosya + Lînk + Tarîx + Tarîx nîne + Cewab ji bo + Qeydkirî + Hatiye qeydkirin ji + Jê bibe + Veşêre + Bihêle + Gilî bike + Hilbijêre + Mezin bike + Şandina dosyayê bisekinîne? + Şandina dosyayê wê bê sekinandin. + Standina dosyayê bisekinîne? + Bisekinîne + Dosya wê ji serveran bê jêbirin. + Daxe + Lîste + Endam ne aktîv e + guhertî + şandî + şandin bi ser neket + nexwendî + Bi xêr hatî %1$s! + Bi xêr hatî! + Ev nivîs di eyaran de heye + Eyar + Bi navê %s bikeviyê + redkirî + Hemû + Lîste lê zêde bike + 1 gilîkirin + %d gilîkirin + Gilîkirinên endaman + Zêde sûret hene! + Zêde vîdyo hene! + Tenê 10 sûret karin di derbekê de werin şandin + Tenê 10 vîdyo karin di derbekê de werin şandin + Xeletiya dekodkirinê + Sûret nikare were dekodkirin. Bi xêra xwe, sûretekî dî biceribîne yan jî xeberê bide mielifan. + Dosya û medya memnû in! + Tenê xwediyên koman karin dosya û medya aktîv bikin. + Lînkên SimpleXê memnû in + Xwestinê bişîne + xwestin şandî ye + ne sinkronîzekirî ye + Bi xêra xwe xeberê bide admînê komê. + kom jêbirî ye + ji komê derxistî + tu derketî + Sûret + Li hêviya sûret e + Sûret hat şandin + Li hêviya sûret e + Vîdyo + Li hêviya vîdyo ye + Vîdyo hat şandin + Li hêviya vîdyoyê ye + Dosya + Koda emniyetê tesdîq bike + Kamera + Ji Galeriyê + Dosya + Dosyakê hilbijêre + Sûret + Vîdyo + Pêl pişkokê bike + li ser, piştre: + Komekê çêke: ji bo çêkirina komeke nû.]]> + Qebûl bike + Red bike + Heya kesê ko şandiye jê çênabe. + Endam hatiye jêbirin - nikare xwestinê qebûl bike + Jê bibe + Jê bibe + Xwendî nîşan bide + Nexwendî nîşan bide + Bêdeng bike + Hemûka bêdeng bike + Bêdengkirinê betal bike + Bike favorît + Ji favorîtan derxe + Behsên nexwendî + Lîste çêke + Li lîstê zêde bike + Lîstê biguhere + Lîstê qeyd bike + Navê lîstê... + Navê lîstê û emojiya wê divê ji bo her lîsteyî cuda be. + Jê bibe + Lîstê jê bibe? + Biguhere + Rêzê biguhere + sûretê profîlê + Pişkoka girtinê + Eyar + Koda QRyê + Adresa SimpleXê + arîkarî + Taximê SimpleXê + Logoya SimpleXê + E-poste + Bêhtir + Koda QRyê nîşan bide + Lînka 1-carê bi hevalekî re parve bike + Ji bo ko tu xwe ji guhertina lînka biparêzî, tu karî kodên emniyetê yên kontaktê qiyas bikî. + Yan jî vê kodê nîşan bide + Lînka timam + Lînka kurt + Profîlê parve bike + Profîl nikarîbû were guhertin + Yan jî koda QRyê skan bike + Dewetiya neşuxulandî bihêle? + Bihêle + Lînk tê çêkirin… + Dîsa biceribîne + Vê lînka 1-carê parve bike + Lînka ko te standiye bizeliqîne + Nivîsa ko te zeliqand ne lînkeke SimpleXê ye. + Pêl vir bike ji bo zeliqandina lînkê + Profîla te + Profîl nikare were guhertin + Kod skan bike + Koda emniyetê xelet e! + Koda emniyetê + Tesdîqkirî nîşan bide + Tesdîqkirinê jê bibe + %s tesdîqkirî ye + %s ne tesdîqkirî ye + Eyarên te + Adresa te yî SimpleXê + Li ser SimpleX Chat + Çawa tê şuxulandin + Arîkariya Markdownê + Pirsan û fikran bişîne + E-poste ji me re bişîne + Server bi kar bîne + Serverên SimpleX Chat tên şuxulandin. + Çilo + Çilo yek serverên xwe dişuxulîne + Mecbûrî + Neparastî + Ne ti carî + Erê + Wextê ko IP veşartî ye + Na + Girtî + Saxlem + Beta + %s (%s) daxe + Cihê dosyayê veke + Dûvre bîne bîra min + Adresê jê bibe? + Lînkê parve bike + Adresa SimpleXê çêke + Hew adresê parve bike? + Hew parve bike + Qebûlkirina ji ber xwe ve + Eyaran qeyd bike? + Eyarên adresa SimpleXê qeyd bike + Adresê jê bibe + Hevalan dewet bike + Em li SimpleX Chat qise bikin + Ji bo medyaya sosyal + Yan ji bo parvekirina şexsî + Adresa SimpleXê yan jî lînka 1-carê? + Lînka 1-carê çêke + Eyarên adresê + Sûret jê bibe + Tercihan qeyd bike? + Bê qeydkirinê derkeve + Profîlê veşêre + Şîfra profîlê qeyd bike + Li ser SimpleXê + Markdown çawa tê şuxulandin + qalin + xwehr + a + b + bi reng + tê telefonkirin… + Kamera + Kamera û mîkrofon + Van destûran bide ji bo telefonkirinê + Di eyaran de destûrê bide + Vê destûrê di eyarên Androidê de bibîne û bixwe destûrê bide. + Eyaran veke + Bluetooth + Profîla xwe çêke + Çawa dişuxule + SimpleX çawa dişuxule + Çawa tesîrê li pîlê dike + Her serê pêlekê + Di cih de + Notîfîkasyon û pîl + Tu karî serveran ji eyaran eyar bikî. + Dewam bike + Qebûl bike + Red bike + Qebûl bike + Nîşan bide + Eyar nikarîbû were guhertin + Dewam bike + Şîfrê ji eyaran bibe? + Jê bibe + Şîfra niha… + Şîfra nû… + Endaman dewet bike + Çêtir tecrûba karber + Adresa xwe parve bike + saniye + deqe + seet + roj + heftî + heyv + Hilbijêre + Telefonekê girê de + Telefonên girêdayî + Ji telefonê skan bike + Navê vê cihazê + (ev cihaz v%s)]]> + Telefona girêdayî + Girêdayî telefonê + Navê vê cihazê binivîsîne… + Xeletî + Ev cihaz + Cihaz + Cihaza mobîlê yî nû + %s hat qutkirin]]> + Girêdan sekinî + Girêdan sekinî + Hemû statîstîkan vala bike + Serveran qeyd bike? + Skan bike / Lînk bizeliqîne + Koda QRyê skan bike + Ji kompîterê koda QRyê skan bike + Koda QRyê ya serverê skan bike + %s (niha) + %s daxistî + lê bigere + Lê bigere yan jî lînka SimpleXê bizeliqîne + san + Ê diwan + Dora emîn + koda emniyetê hat guhertin + Xeletiyên şandinê + şandina dosyayan hê ne mimkun e + Tê şandin bi riya + Pêşdîtinên lînkan bişîne + Gilîkirinên şexsî bişîne + Wextê şandinê: + Cewaba şandî + Bi riya proksiyê şandî + Server + Adresa serverê + Adres + Adresa serverê li eyarên torê nayê. + SERVER + Melûmata serveran + Ceribandina serverê bi ser neket! + Versiyona serverê li eyarên torê nayê. + 1 roj deyne + Tercihên komê diyar bike + EYAR + Parve bike + Lînka 1-carê parve bike + Adresê parve bike + Adresê bi hişkereyî parve bike + Dosya parve bike… + Medya parve bike… + Adresa kevn parve bike + Lînka kevn parve bike + Adresa SimpleXê li medayaya sosyal parve bike. + Behsa kin: + Adresa SimpleXê yî kin + Nîşan bide: + Pêşdîtinê nîşan bide + Bigire + Bigire? + SimpleX + Adresa SimpleXê + Lînka qenala SimpleXê + Xizmeta SimpleX Chatê + Lînka komê ya SimpleXê + Lînkên SimpleXê + Lînkên SimpleXê + Lînkên SimpleXê memnû in. + simplexmq: v%s (%2s) + Dewetiya yek carê ya SimpleXê + Mezinbûnî + Ser bakirina endaman ve derbas bibe + Funksiyona hêdî + Komên piçûk (herî zêde 20) + Servera SMPyê + Serverên SMPyê + Nerm + Bêdeng + Spam + Spam + Çarçik, girover, yan çi tiştê di neqebê de. + %s: %s + %s saniye + %s server + Li GitHubê stêrkê bide + dest pê dike… + Her serê pêlekê dest pê dike + Statîstîk + Bisekinîne + Dosyayê bisekinîne + xet/xêz/xîşk + Biqewet + Abonekirî + PIŞT BIDE SIMPLEX CHATÊ + Biguhere + Sîstem + Sîstem + Sîstem + Sîstem + Moda sîstemê + Terî/Dûvik + Pêl Adresa SimpleXê çêke di meniwê de ji bo ko tu dûvre çêkî. + Pêl Bikeve komê bike + Bikeviyê + Bikeve komê + Bikeve komê? + Bikeve komê? + Dikeve komê + Bikeve koma xwe? + %1$d dosya hê tê(n) daxistin. + %1$d dosya nikarîbû(n) wer(e/in) daxistin. + %1$d dosya hat(in) jêbirin. + %1$d dosya nehat(in) daxistin. + %1$d xeletiyên dosyayê ên dî. + %1$s ENDAM + 1 roj + 1 deqe + 1 heyv + lînka 1-carê + 1 heftî + 1 sal + 30 saniye + 5 deqe + Betal bike + Guhertina adresê betal bike + Guhertina adresê betal bike? + Li ser adresa SimpleXê + Qebûl bike + Qebûl bike + Qebûl bike + Wek endamekî qebûl bike + Şertan qebûl bike + %1$s hat qebûlkirin + Şertên qebûlkirî + dewetiyê qebûl kir + tu qebûl kirî + Endam qebûl bike + Girêdanên aktîv + Hevalan lê zêde bike + Guhertina adresê wê bê betalkirin. Adresa berê yî standinê wê bê şuxulandin. + Adres yan jî lînka 1-carê? + Serverekê lê zêde bike + Bi riya skankirina kodên QRyê serveran lê zêde bike. + Endamên têxim lê zêde bike + Cihazeke dî lê zêde bike + admîn + admîn + Admîn karin endamekî ji bo her kesî blok bikin. + Admîn karin lînkên lêzêdebûna koman çêkin. + Eyarên torê ên pêşketî + Eyarên pêşketî + Eyarên pêşketî + Hinek tiştên dî + hemû + Hemû dataya aplîkasyonê hat jêbirin. + hemû endam + Bihêle + Bihêle ko dosya û medya werin şandin. + Bihêle ko lînkên SimpleXê werin şandin. + Hemû profîl + Hemû server + Jixwe tê girêdan! + Jixwe dikeve komê! + hercar + Hercar + Hercar vekirî + û %d hewadîsên dî + Biguhere + Şîfra databasê biguhere? + rola %s hat guhertin %s + rola te hat guhertin %s + Rola komê biguhere? + Adresa standinê biguhere + Adresa standinê biguhere? + Rolê biguhere + adres tê guhertin… + adres tê guhertin… + adresa %s tê guhertin… + %1$d xeletiyên dosyayan:\n%2$s + %1$s dixwaze bi te re bikeve danûstandinê bi riya + Adresa serverê kontrol bike û dîsa biceribîne. + Girêdana xwe yî înternetê kontrol bike û dîsa biceribîne + Notên şexsî vala bike? + Pêl pişkoka melûmatê ya nêzîkî cihê adresê bike ji bo destûrdana mîkrofonê. + Moda reng + Di wextekî nêzîk de tê! + Dosya qiyas bike + timam + Timam bûye + Vala bike + Vala bike + Vala bike + Şert di %s de hatin qebûlkirin. + Şertên şuxulandinê + Şert wê di %s de bên qebûlkirin. + Serverên SMPyê ên eyarkirî + Serverên XFTPyê ên eyarkirî + Serverên ICEyê eyar bike + Dosyayên ji serverên nenas qebûl bike. + Eyarên torê tesdîq bike. + Şîfra nû dîsa binivîsîne… + Bi xwe re bikeve danûstandinê? + Bi riya lînkê bikeve danûstandinê + Bi riya lînkê bikeve danûstandinê? + Bi riya lînkê / koda QRyê bikeve danûstandinê + Bi riya lînka yek carê bikeve danûstandinê? + Bi %1$s re bikeve danûstandinê? + Muhtewa ne li gora şertên şuxulandinê ye + Îkona kontekstê + Dewam bike + Beşdar bibe + Tora xwe kontrol bike + Xeletiyê kopî bike + Çêke + Çêke + Adres çêke + Adresekê çêke ji bo ko xelk karibin bi te re bikevin danûstandinê. + Hat çêkirin + Wextê çêkirinê + Wextê çêkirinê: %s + Dosya çêke + Kom çêke + Lînka komê çêke + Lînk çêke + Lînkeke dewetiyê ya yek carî çêke + Profîl çêke + Profîl çêke + Dor çêke + Komeke veşartî çêke + Komeke veşartî çêke + Adresa xwe çêke + Lînka arşîvê tê çêkirin + kesê ko çêkiriye + Xeletiya cidî + (niha) + Profîla niha + Tarî + Tarî + Moda tarî + Rengên moda tarî + Xuyakirina tarî + IDya databasê + IDya databasê: %d + %dr + %d roj + %d roj + jiberxweve (%s) + jiberxweve (%s) + Jê bibe piştî + Hemû dosyayan jê bibe + Jêbirî + Wextê jêbirinê + Wextê jêbirinê: %s + Dosya jê bibe + Ji bo min jê bibe + Komê jê bibe + Komê jê bibe? + Lînkê jê bibe + Lînkê jê bibe? + Profîlê jê bibe + Dorê jê bibe + Serverê jê bibe + Xeletiyên jêbirinê + Gihan/Gihiştin + Cihazên kompîter + Kompîter mijûl e + Kompîter ne aktîv e + Girêdana bi kompîterê re qut bû + Detay + CIHAZ + %d dosya bi mezibnbûniya timam ya %s + %d hewadîsên komê + %d seet + %d seet + Bigire + girtî + girtî + Ji bo her kesî bigire + Ji bo hemû koman bigire + %d deqe + %d deqe + %d heyv + %d heyv + %d heyv + Adres çêneke + Dîsa nîşan nede + Daxe + Daxistî + Dosyayên daxistî + Xeletiyên daxistinê + Daxistin bi ser neket + Dosya daxe + Detayên lînkê tên daxistin + %d heftî + Profîla komê biguhere + Sûret biguhere + Veke + vekirî + Vekirî heta + ji te re vekirî + Ji bo her kesî veke + Ji bo hemû koman veke + xilasbûyî + Navê komê binivîsîne: + Şîfra rast binivîsîne. + Şîfrê binivîsîne + Şîfrê binivîsîne… + Di lêgeranê de şîfrê binivîsîne + Navê xwe binivîsîne: + Xeletî + Xeletî + Xeletî + Xeletî di betalkirina guhertina adresê de + Xeletî di qebûlkirina şertan de + Xeletî di qebûlkirina xwestina ketina danûstandinê de + Xeletî di qebûlkirina endêm de + Xeletî di lêzêdekirina endam(an) de + Xeletî di lêzêdekirina serverê de + Xeletî di guhertina adresê de + Xeletî di guhertina profîlê de + Xeletî di guhertina rolê de + Xeletî di çêkirina adresê de + Serverên te yên XFTPyê + Te dewetîke komê şand + Serverên te yên SMPyê + Serverên te + Adresa servera te + Servera te + Profîla te yî %1$s wê bê parvekirin. + Tercihên te + Serverên te yên ICEyê + Serverên te yên ICEyê + Koma te + te %1$s derxist + Te dewetiya komê red kir + Profîla te yî niha + Tu karî dûvre wê çêkî + Tu dikarî wê di Eyarên xuyakirinê de biguherî. + te %s blok kir + Tu hatiye dewetkirinî komê + Tu jixwe dikevî vê komê bi riya vê lînkê. + Tu dihêlî + te ev endam qebûl kir + tu: %1$s + TU + tu + Erê + erê + Serverên XFTPyê + Servera XFTPyê + Şîfra xelet! + Şîfra xelet ya databasê + Bi kêmtir xerckirina pîlê. + Bi kêmtir xerckirina pîlê. + Bê Tor yan VPNê, adresa te yî IPyê wê ji van relayên XFTPyê re xuya bike:\n%1$s, + Bê Tor yan jî VPNê, wê adresa te yî IPyê ji serverên dosyayen re xuya bike. + Etherneta bi qeblo + WiFi + Çi yî nû heye + Websîte + Serverên WebRTC ICEyê + Hişyarî: hinek dataya te kare winda bibe! + dixwaze bi te re bikeve danûstandinê! + Girtî + Bi xêra xwe aplîkasyonê ji nû ve veke. + Veşêre: + Navê profîlê: + Navê timam: + Qeyd bike û xeberê bide endamên komê + Şîfra nîşandanê + Şîfra profîla veşartî + Tu karî markdownê bişuxulînî ji bo formatkirina mesajan: + Bi şuxulandina SimpleX Chatê tu qebûl dikî ku tu:\n- di komên vekirî tenê muhtewaya qanûnî bişînî.\n- hurmeta karberên dî bigirî – spam çênabe. + Veke + bi riya relayê + Vidyo girtî + Vîdyo vekirî + Deng girtî + Deng vekirî + Ekrana aplîkasyonê biparêze + Ji ber xwe ve sûretan qebûl bike + Adresa IPyê biparêze + Girtî + Na + Bipirse + Ber lînka webê were vekirin? + Lînkê veke + Lînka timam veke + Lînka paqij veke + ARÎKARÎ + APLÎKASYON + DOSYA + Ji nû ve veke + PROKSIYA SOCKSÊ + Sûretên profîlan + Girêdana torê + Ji kompîterê bişuxulîne + Xeletiya databasê + Dosya: %s + Xeletî: %s + Xeletiya nenaskirî + dewetiya ji bo koma %1$s + Derkeve + Kom nehat dîtin! + Ev kom nema heye. + derket + tu derketî + %s û %s + %s, %s û %d endam + adresa ji bo te hat guhertin + te adresa ji bo %s guhert + te adres guhert + mielif + endam + moderator + xwedî + redkirî + derxistî + derketiye + nayê zanîn + Endam %1$s + Rola endamên nû + Rola pêşî + Ji komê derkeve + Lînka komê + Xeletî di şandina dewetiyê de + Halê dosyayê + Wextê standinê + Wextê nûkirina qeydiyê: %s + Halê dosyayê: %s + Wextê şandinê: %s + Wextê standinê: %s + nivîs nîne + Endam derxe? + Endaman derxe? + Endam derxe + Derxe + Endam derxe + Endam blok bike + Blok bike + Ji admîn blokkirî + blokkirî + ne aktîv + ENDAM + Rol + Kom + Te standin bi riya + Halê torê + Girêdanê biedilîne + Biedilîne + Navê timam î komê: + Profîla komê di cihazên endaman de qeydkirî ye, ne di serveran de. + Profîla komê qeyd bike + Serveran bişuxulîne + %s bişuxulîne + Li şertan meyzîne + Şertên nûkirî + %s bişuxulînî, şertên şuxulandinê qebûl bike.]]> + Ji bo dosyayan bişuxulîne + Serverên medya & dosyayê ên lêzedekirî + Şertan veke + Guhertinan veke + Protokola serverê hat guhertin. + Reqema PINGan + TCP keep-alive aktîv bike + Qeyd bike + Qeyd bike û dîse girê de + Eyarên torê nû bike? + Profîl lê zêde bike + Girêdanên profîl û serveran + Veşêre + Nîşan bide + Bêdeng bike + Bêdengkirinê betal bike + Profîlê bike şexsî! + Şîfra profîlê + Rehnik + Rehnik + Reş + Sernav + Cewaba standî + Sûret jê bibe + Mezinbûniya fontê + Şefafî + Êvara te bi xêr! + Sibeha te bi xêr! + Dagire + Endam karin dosya û medya bişînin. + Dosya û medya memnû in. + Endam karin lînkên SimpleXê bişînin. + %d san + %d heftî + UIa farisî + Eyarên nû yên medyayê + Aplîkasyonê bi yek destî bişuxulîne. + Mezinbûniya fontê zêde bike. + Sebeba qutbûna girêdanê: %s + Ev lînk bi telefoneke dî re hatiye şuxulandin, bi xêra xwe li kompîterê lînkeke nû çêke. + Girêdana bi kompîterê re qut bike? + Tenê yek cihaz kare di eynî wextî de bişuxule + Li hêviya girêdana telefonê: + Ji ber xwe ve girê de + %s ne aktîv e]]> + %s mijûl e]]> + %s re di halekî xirab de ye]]> + Siḧbetê veke + Xeletî di çêkirina lîsta siḧbetan de + Xeletî di vekirina siḧbetê de + Siḧbetê bisekinîne + Profîlên siḧbetê biguhere + Siḧbet + Ti siḧbetên te nînin + Ti siḧbet di lîsta %s de nînin. + Ti siḧbetên nexwendî nînin + Siḧbet nînin + Ti siḧbet nehatin dîtin + Siḧbeta hilbijartî nîne + Tiştekî hilbijartî nîne + %d hilbijartî + Favorît + Kom + %d siḧbetên bi endaman + 1 siḧbeta bi yek endamî + %d siḧbet + Robot + Kom + Navê siḧbetê deyne… + Siḧbeteke nû bide destpêkirin + Ji bo ko yek siḧbete nû bide destpêkirin + Siḧbet ber were valakirin? + Hemû mesaj wê bên jêbirin - ev nikare were betalkirin/vegerandin! Wê mesaj TENÊ ji bo te bên jêbirin. + Siḧbetê vala bike + Hemû siḧbet wê ji lîsta %s bên jêbirin, û wê lîste bê jêbirin + Siḧbeta nû + Profîla sihbetê hilbijêre + Profîlên te yên siḧbetê + Profîla siḧbetê çêke + Ber serverên SimpleX Chatê werin şuxulandin? + Profîla siḧbetê + Tu siḧbeta xwe qontrol dikî! + Siḧbetê bişuxulîne + SIḦBET + Rengên siḧbetê + Siḧbet sekinandî ye + DATABASA SIḦBETÊ + Ber siḧbet were sekinandin? + Xeletî di sekinandina siḧbetê de + Ber profîla siḧbetê were jêbirin? + ne ti carî + Şîfra databasê lazim e ji bo vekirina siḧbetê. + Şîfre qeyd bike û siḧbetê veke + Siḧbetê veke + Siḧbet sekinandî ye + Ber siḧbet were destpêkirin? + Tu dixwazî ji siḧbetê derkevî? + Siḧbetê jê bibe + Ber siḧbet were jêbirin? + Wê siḧbet ji bo te bê jêbirin - ev nikare were betalkirin/vegerandin! + Ji siḧbetê derkeve + Tenê xwediyên siḧbetê karin tercihan biguherin. + Bi admînan re siḧbetê bike + Bi endam re siḧbetê bike + Wê endêm ji siḧbetê bê derxistin - ev nikare were betalkirin/vegerandin! + Wê endam ji siḧbetê bên derxistin - ev nikare were betalkirin/vegerandin! + Siḧbet + Wê profîla te yî siḧbetê ji endamên komê re bê şandin + Wê profîla te yî siḧbetê ji endamên siḧbetê re bê şandin + Serverên ji bo dosyayên nû ên profîla te yî siḧbetê ya niha + Ber profîla siḧbetê were jêbirin? + Hemû mesaj wê bên jêbirin - ev nikare were betalkirin/vegerandin! + Profîla sihbetê jê bibe + Profîla siḧbetê hew veşêre + Vegerîne temaya aplîkasyonê + Vegerîne temaya karber + Temaya serî/pêşî diyar bike + Moda reḧnik + na + vekirî + girtî` + Tercihên siḧbetê + Mesajên ko winda dibin li vê siḧbetê nayên qebûlkirin. + Jêbirina ko nikare were betalkirin/vegerandin di vê siḧbetê de nayê qebûlkirin. + Siḧbetên bi endam + Ti siḧbetên bi endam nînin + Siḧbetê jê bibe + Bi admînan re siḧbetê bike + Siḧbetê ji nû ve veke + Siḧbet tê sekinandin + Siḧbetê bide destpêkirin + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 0b59cc1b06..2f26545913 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -25,12 +25,12 @@ zmoderowane przez %s wysyłanie plików nie jest jeszcze obsługiwane odbieranie plików nie jest jeszcze obsługiwane - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. + Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia. Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). nieznany format wiadomości SimpleX Ty - Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. + Jesteś połączony z serwerem, który służył do odbierania wiadomości z tego połączenia. Twój profil zostanie wysłany do kontaktu, od którego otrzymałeś ten link. udostępniłeś jednorazowy link incognito przez link grupowy @@ -71,10 +71,10 @@ Błąd usuwania kontaktu Błąd usuwania grupy Błąd usuwania oczekującego połączenia kontaku - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy + Odcisk palca w adresie serwera nie pasuje do certyfikatu. Bezpieczna kolejka Nadawca mógł usunąć prośbę o połączenie. - Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło + Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło. Test nie powiódł się na etapie %s. Błąd usuwania profilu użytkownika Błąd aktualizacji prywatności użytkownika @@ -141,7 +141,7 @@ Usunąć wiadomość członka\? edytowana Dla wszystkich - dołącz jako %s + Dołącz jako %s wysyłanie nie powiodło się wyślij Udostępnij plik… @@ -154,7 +154,7 @@ oznacz jako nieprzeczytane Witaj! Witaj %1$s! - jesteś zaproszony do grupy + Jesteś zaproszony do grupy Nie masz czatów Czaty Poproszony o odbiór obrazu @@ -179,7 +179,7 @@ Oczekiwanie na film Oczekiwanie na film jesteś obserwatorem - Nie możesz wysyłać wiadomości! + Jesteś obserwatorem Połączony Obecnie maksymalny obsługiwany rozmiar pliku to %1$s. Usuń kontakt @@ -428,9 +428,9 @@ Jak to działa Jak SimpleX działa Natychmiastowy - Można to później zmienić w ustawieniach. + Jak wpływa na baterię Nawiąż prywatne połączenie - dwuwarstwowego szyfrowania end-to-end.]]> + Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości. Okresowo Prywatne powiadomienia repozytorium GitHub.]]> @@ -814,10 +814,10 @@ %d mies %ds %d sek - Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) - Członkowie grupy mogą wysyłać bezpośrednie wiadomości. + Członkowie mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) + Członkowie mogą wysyłać bezpośrednie wiadomości. Nieodwracalne usuwanie wiadomości jest na tym czacie zabronione. - Nieodwracalne usuwanie wiadomości jest w tej grupie zabronione. + Usuwanie wiadomości nieodwracalnych jest zabronione. Tylko Ty możesz nieodwracalnie usunąć wiadomości (Twój kontakt może oznaczyć je do usunięcia). (24 godziny) Tylko Ty możesz wysyłać znikające wiadomości. Tylko Ty możesz wysyłać wiadomości głosowe. @@ -827,7 +827,7 @@ Zabroń wysyłania bezpośrednich wiadomości do członków. Zabroń wysyłania znikających wiadomości. Wiadomości głosowe są zabronione na tym czacie. - Wiadomości głosowe są zabronione w tej grupie. + Wiadomości głosowe są zabronione. Administratorzy mogą tworzyć linki do dołączania do grup. Automatyczne akceptowanie próśb o kontakt anulowano %s @@ -921,7 +921,7 @@ %d dni Usuń Usuń wiadomości po - Znikające wiadomości są zabronione w tej grupie. + Znikające wiadomości są zabronione. Błąd usuwania prośby o kontakt Nie znaleziono pliku Błąd zapisu serwerów SMP @@ -939,9 +939,9 @@ zaproponował %s: %2s Tylko właściciele grup mogą włączyć wiadomości głosowe. Tylko Twój kontakt może nieodwracalnie usunąć wiadomości (możesz oznaczyć je do usunięcia). (24 godziny) - Hasło nie zostało znalezione w Keystore, wprowadź je ręcznie. Może się tak zdarzyć, gdy przywrócisz dane aplikacji za pomocą narzędzia do kopii zapasowych. Jeśli tak nie jest, skontaktuj się z programistami. - Członkowie grupy mogą wysyłać znikające wiadomości. - Członkowie grupy mogą wysyłać wiadomości głosowe. + Hasło nie znalezione w Keystore, proszę wpisać je ręcznie. Mogło się to zdarzyć, jeśli przywróciłeś dane aplikacji za pomocą narzędzia do tworzenia kopii zapasowej. Jeśli tak nie jest, skontaktuj się z deweloperami. + Członkowie mogą wysyłać znikające wiadomości. + Członkowie mogą wysyłać wiadomości głosowe. Grupa zostanie usunięta dla wszystkich członków - nie można tego cofnąć! Jak korzystać z Twoich serwerów zeskanować kod QR w rozmowie wideo, lub Twój rozmówca może udostępnić link z zaproszeniem.]]> @@ -951,7 +951,7 @@ Zaimportować bazę danych czatu\? Tryb incognito chroni Twoją prywatność używając nowego losowego profilu dla każdego kontaktu. pośrednie (%1$s) - pozwolić SimpleX na działanie tle w następnym oknie dialogowym. W przeciwnym razie powiadomienia zostaną wyłączone.]]> + Pozwól w następnym oknie dialogowym natychmiast otrzymywać powiadomienia.]]> Zainstaluj SimpleX Chat na terminal Nieprawidłowe potwierdzenie migracji zaproszenie do grupy %1$s @@ -985,8 +985,8 @@ Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną. Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online. Ten link nie jest prawidłowym linkiem połączenia! - SimpleX - zużywa ona kilka procent baterii dziennie.]]> - Aby chronić prywatność, zamiast identyfikatorów użytkowników używanych przez wszystkie inne platformy, SimpleX ma identyfikatory dla kolejek wiadomości, oddzielne dla każdego z Twoich kontaktów. + SimpleX działa w tle zamiast korzystać z powiadomień push.]]> + Aby chronić Twoją prywatność, SimpleX używa oddzielnych identyfikatorów dla każdego z Twoich kontaktów. Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach. Użyj dla nowych połączeń O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go. @@ -1142,7 +1142,7 @@ Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Utwórz adres, aby ludzie mogli się z Tobą połączyć. Utwórz adres SimpleX - Zapisz ustawienia automatycznej akceptacji + Zapisz ustawienia adresów SimpleX Udostępnij kontaktom Możesz go utworzyć później Adres @@ -1173,10 +1173,10 @@ Wyślij znikającą wiadomość Zabroń reakcje wiadomości. Reakcje wiadomości - Członkowie grupy mogą dodawać reakcje wiadomości. + Członkowie mogą dodawać reakcje na wiadomości. godziny Reakcje wiadomości są zabronione na tym czacie. - Reakcje wiadomości są zabronione w tej grupie. + Reakcje na wiadomości są zabronione. minuty miesiące Tylko Ty możesz dodawać reakcje wiadomości. @@ -1245,9 +1245,9 @@ Pozwól na wysyłanie plików i mediów. Brak filtrowanych czatów Nieulubione - Członkowie grupy mogą wysyłać pliki i media. + Członkowie mogą wysyłać pliki i media. Zakaz wysyłania plików i mediów. - Pliki i media są zabronione w tej grupie. + Pliki i media są zabronione. Tylko właściciele grup mogą włączać pliki i media. Szukaj Wyłączono @@ -1378,7 +1378,7 @@ Błąd tworzenia kontaktu członka Wyślij wiadomość bezpośrednią aby połączyć wyślij wiadomość bezpośrednią - połącz bezpośrednio + Prośba o połączenie usunięto kontakt Utwórz grupę Utwórz profil @@ -1556,7 +1556,7 @@ członek %1$s zmienił na %2$s ustaw nowy adres kontaktu zaktualizowano profil - Były członek %1$s + Członek %1$s Zablokować członka dla wszystkich? Utworzony o Zachowano wiadomość @@ -1712,7 +1712,7 @@ Wiadomości głosowe są niedozwolone Włączony dla właściciele - Linki SimpleX są zablokowane na tej grupie. + Linki SimpleX są zablokowane. Inne WiFi Połączenie ethernet (po kablu) @@ -1720,7 +1720,7 @@ wszyscy członkowie Zezwól na wysyłanie linków SimpleX. Sieć komórkowa - Członkowie grupy mogą wysyłać linki SimpleX. + Członkowie mogą wysyłać linki SimpleX. Brak połączenia z siecią Zabroń wysyłania linków SimpleX Linki SimpleX @@ -1929,7 +1929,7 @@ Wyświetlanie informacji dla Statystyki Sesje transportowe - Zaczynanie od %s. \nWszystkie dane są prywatne na Twoim urządzeniu. + Zaczynanie od %s.\nWszystkie dane są prywatne na Twoim urządzeniu. Połącz ponownie wszystkie serwery Połączyć ponownie serwer? Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -2040,7 +2040,7 @@ Zaznacz Wiadomości zostaną usunięte dla wszystkich członków. Wiadomości zostaną oznaczone jako moderowane dla wszystkich członków. - Osiągalny pasek narzędzi czatu + Osiągalny pasek narzędzi Wyeksportowano bazę danych czatu Kontynuuj Serwery mediów i plików @@ -2094,7 +2094,7 @@ Dźwięk wyciszony Wybierz profil czatu Udostępnij profil - Twoje połączenie zostało przeniesione do %s, ale podczas przekierowania do profilu wystąpił nieoczekiwany błąd. + Twoje połączenie zostało przeniesione na %s, ale pojawił się błąd podczas zmiany profilu. Tryb systemu Przesłane archiwum bazy danych zostanie trwale usunięte z serwerów. Serwer @@ -2189,4 +2189,369 @@ Nowy członek chce dołączyć do grupy. 1 rok Akceptuj + rozmowa z członkiem grupy + Akceptuj + Akceptuj jako członek grupy + Akceptuj jako obserwator grupy + Akceptuj dodanie kontaktu + Akceptuj dodanie kontaktu + Akceptuj użytkownika grupy + Dodaj wiadomość + Wszystko + Wszystkie nowe wiadomości od tego użytkownika będą ukryte + Zezwól na wszystkie pliki i media tylko jeśli twój kontakt na to pozwala. + Zezwól na zgłaszanie raportów do moderatorów. + Zezwól twoim kontaktom na wysyłanie plików i mediów. + Zezwól na archiwizowanie raportów dla ciebie. + Wszystkie serwery + Zarchiwizować wszystkie raporty? + Zarchiwizować %d raportów? + Archiwizuj raporty + Lepsze działanie grupy + Lepsza prywatność i bezpieczeństwo + Opis: + Opis zbyt duży + Bot + Ty i twój kontakt możecie wysyłać pliki i media. + Kontakt służbowy + Uzywając SimpleX Chat zgadzasz się na:\n- wysyłanie tylko prawnie dopuszczonych treści na publicznych grupach.\n- szanowanie innych użytkowników - nie wysyłanie SPAM-u + Nie mogę zmienić profilu + nie mogę wysłać wiadomości + 4 nowe języki interfejsu + Wszystkie wiadomości + Blokowanie członków dla wszystkich? + Kataloński, indonezyjski, rumuński i wietnamski - dzięki naszym użytkownikom! + szyfrowanie end-to-end.]]> + tylko po zaakceptowaniu twojego żądania.]]> + Zmienić automatyczne usuwania wiadomości? + Czaty z członkami + Czat z administratorami + Czar z administratorami + Czat z administratorami + Czat z członkiem + Czatuj z członkami, zanim dołączą. + Konfigurowanie operatorów serwerów + Połącz + Połącz się szybciej! 🚀 + kontakt usunięty + kontakt zablokowany + kontakt nie gotowy + PROŚBY O KONTAKT OD GRUP + kontakt powinien zaakceptować… + Stwórz swój adres + %d czat(y) + %d czaty z członkami + domyślny (%s) + Usuń czat + Usuń wiadomości czatu z urządzenia. + Skasować czat z tym członkiem? + Skasuj wiadomości od tego członka + Skasować wiadomości od tego członka? + Skasuj wiadomości + Opcje wycofane + Opis jest zbyt duży + Bezpośrednie wiadomości między członkami są zabronione. + Wiadomości bezpośrednie między członkami są zabronione na tym czacie. + Wyłączyć automatyczne usuwanie wiadomości? + Zablokuj skasowane wiadomości + %d wiadomości + Nie przegap ważnych wiadomości. + %d raporty + Edytuj + Włącz domyślne znikanie wiadomości. + Włącz Flux w ustawieniach sieci i serwerów, aby uzyskać lepszą prywatność metadanych. + Włącz logi + Renegocjacja szyfrowania jest w toku. + Błąd podczas akceptacji warunków + Błąd podczas akceptacji członka + Błąd podczas dodawania serwera + Błąd podczas zmiany profilu + Błąd podczas tworzenia listy czatu + Błąd podczas tworzenia raportu + Błąd usuwania czatu + Błąd ładowania list czatu + Błąd oznaczania odczytu + Błąd otwierania czatu + Błąd otwierania grupy + Błąd odczytu bazy danych hasła + Błąd odrzucenia prośby o kontakt + Błąd zapisywania bazy danych + Błąd zapisywania serwerów + Błąd zapisywania ustawień + Błąd w konfiguracji serwerów. + Błąd aktualizowania listy czatu + Błąd aktualizacji serwera + Szybsze usuwania grup. + Szybsze wysyłanie wiadomości. + Ulubione + Plik jest zablokowany przez operatora serwera:\n%1$s. + Pliki + Pliki i media są zabronione na tym czacie. + Filtr + Odcisk palca w docelowym serwerze nie pasuje do certyfikatu: %1$s. + Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. + Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. + Napraw + Naprawić połączenie? + Dla wszystkich moderatorów + Lepsza prywatność metadanych. + Dla profilu czatu %s: + Na przykład, jeśli kontakt otrzyma wiadomości za pośrednictwem serwera czatu SimpleX, aplikacja dostarczy je za pośrednictwem serwera Flux. + Dla mnie + Dla prywatnego routingu + Dla mediów społecznościowych + Pełny link + Otrzymaj powiadomienie jeśli ktoś wspomni. + Grupa + grupa została usunięta + Grupy + Pomóż administratorom moderować ich grupy. + Jak to pomaga prywatności + Zdjęcia + Poprawiona nawigacja czatu + Niewłaściwa zawartość + Niewłaściwy profil + Zaproszenie do czatu + Dołącz do grupy + Zachowaj swoje czaty czyste + Opuść czat + Opuścić czat? + Mniejszy ruch w sieciach mobilnych. + Linki + Lista + Lista imion... + Nazwa i emoji powinny być inne dla wszystkich list. + Wczytywanie profilu… + Przyjęcie członkostwa + członek posiada starą wersję + Członek został usunięty - nie można przyjąć żądania + Wiadomości członkowskie zostaną usunięte - nie można tego cofnąć! + Raporty członkowskie + Członkowie mogą zgłaszać wiadomości moderatorom. + Członkowie zostaną usunięci z czatu - tego nie da się cofnąć! + Członkowie zostaną usunięci z grupy - nie można tego cofnąć! + Członek zostanie usunięty z czatu - nie można tego cofnąć! + Członek dołączy do grupy, czy zaakceptować tego członka? + Wspomnij członka 👋 + Wyślij wiadomość natychmiast po dotknięciu Połącz. + Wiadomość jest za duża! + Wiadomości od tych członków zostaną pokazane! + Wiadomości na tym czacie nigdy nie zostaną usunięte. + moderator + moderatorzy + Wycisz wszystko + Decentralizacja sieci + Operator sieci + Operatorzy sieci + Nowa rola w grupie: Moderator + Nowy serwer + Nie + Brak usług w tle + Żadnych czatów + Nie znaleziono żadnych czatów + Nie ma czatów na liście %s. + Żadnych rozmów z członkami + Brak mediów i serwerów plików multimedialnych. + Brak wiadomości + Brak serwerów wiadomości. + Brak prywatnej sesji routingu + Brak serwerów prywatnej sesji routingu + Brak serwerów do otrzymania plików. + Brak serwerów aby otrzymać wiadomości. + Brak serwerów do wysyłania plików. + brak subskrypcji + Notatki + Powiadomienia i bateria + nie zsynchronizowano + Brak nieprzeczytanych czatów + wyłączony + Wyłącz + Tylko właściciele czatu mogą zmieniać preferencje. + Widzą to tylko nadawca i moderatorzy + Widzisz to tylko Ty i moderatorzy + Tylko Ty możesz wysyłać pliki i multimedia. + Tylko Twój kontakt może wysyłać pliki i multimedia. + Otwórz zmiany + Otwórz czat + - Otwórz czat w pierwszej nieprzeczytanej wiadomości.\n- Przejdź do cytowanych wiadomości. + Otwórz czysty link + Otwórz warunki + Otwórz pełen link + Otwórz link + Otwórz linki z listy czatów + Otwórz nowy czat + Otwórz nową grupę + Otwórz by zaakceptować + Otwórz aby się połączyć + Otwórz aby dołączyć + Otwórz aby skorzystać z bota + Otworzyć link sieci web? + Otwórz z %s + Operator + Serwer Operatora + Organizuj czaty jako listy + Lub zaimportuj plik archiwalny + Lub udostępnij prywatnie + Nie można odczytać hasła w magazynie kluczy. Wprowadź je ręcznie. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. + Nie można odczytać hasła w magazynie kluczy. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. + oczekuje + oczekiwanie zaakceptowane + oczekująca recenzja + Zmniejsz rozmiar wiadomości i wyślij ją ponownie. + Zmniejsz rozmiar wiadomości lub usuń multimedia i wyślij ponownie. + Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. + Domyślne serwery + Domyślne serwery + Prywatność dla Twoich klientów. + Polityka prywatności i warunki korzystania. + Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów. + Nazwy prywatnych plików multimedialnych. + Limit czasu routingu prywatnego + Zabroń raportowania wiadomości moderatorom. + Zabroń wysyłania plików i multimediów. + Limit czasu protokołu w tle + Dostępny pasek narzędzi czatu + Odrzuć + Odrzuć prośbę o kontakt + odrzucono + odrzucono + Odrzucić członka? + Zdalne telefony komórkowe + Usuń i skasuj wiadomości + przeniesiono z grupy + Usuń śledzenie linków + Usunąć członka? + Usuwa wiadomości i blokuje członków. + Zgłoś + Zgłoś treść: zobaczą ją tylko moderatorzy grupy. + Na tej grupie zabronione jest zgłaszanie wiadomości. + Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. + Zgłoś inne: zobaczą to tylko moderatorzy grupy. + Jaki jest powód zgłoszenia? + Zgłoś: %s + Zgłoszenia + Zgłoszenia wysłane do moderatorów + Zgłoś spam: tylko moderatorzy grupy będą to widzieć. + Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy. + Prośba o połączenie od grupy %1$s + poproszono o połączenie + prośba została wysłana + prośba o dołączenie została odrzucona + przejrzyj + Przejrzyj warunki + sprawdzone przez administratorów + Przejrzyj członków grupy + Przejrzyj później + Przejrzyj członków + Przejrzyj członków przed przyjęciem (pukanie). + Zapisać ustawienia wstępu? + Zachowaj listę + Poszukaj plików + Poszukaj obrazów + Poszukaj linków + Poszukaj wideo + Poszukaj wiadomości głosowych + Wybierz operatora sieci + Wysłać prośbę o kontakt? + Wyślij prywatne zgłoszenia + Wyślij prośbę + Wyślij prośbę bez wiadomości + Wyślij swoją prywatną opinię do grup. + Wysłano do Twojego kontaktu po połączeniu. + Serwer dodany do operatora %s. + Operator serwera zmieniony. + Operatorzy serwera + Protokół serwera zmieniony. + Ustaw nazwę czatu… + Ustaw przyjęcie członka + Ustaw datę wygaśnięcia wiadomości na czatach. + Ustaw biografię profilu i wiadomość powitalną. + Udostępnij adres publicznie + Udostępnij stary adres + Udostępnij stary link + Udostępnij adres SimpleX w mediach społecznościowych. + Udostępnij swój adres + Krótki opis: + Krótki link + Krótki adres SimpleX + Link do kanału na SimpleX + SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux. + łącze przekaźnikowe SimpleX + Spam + Spam + %s serwery + Dotknij Połącz aby rozpocząć czat + Dotknij Połącz, aby wysłać prośbę + Dotknij Połącz aby użyć bota + Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. + Dotknij Dołącz do grupy + Przekroczono limit czasu połączenia TCP + Port TCP dla wiadomości + Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu. + Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. + Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. + Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. + Raport zostanie dla Ciebie zarchiwizowany. + Rola zostanie zmieniona na %s. Wszyscy uczestnicy czatu zostaną powiadomieni. + Drugi predefiniowany operator w aplikacji! + Nadawca NIE zostanie poinformowany. + Serwery dla nowych plików Twojego bieżącego profilu czatu + Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. + Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. + Ta wiadomość została usunięta lub jeszcze nie otrzymana. + To ustawienie jest dla Twojego obecnego profilu. + Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. + Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. + Żeby odebrać + Żeby wysłać + Aby wysyłać polecenia, musisz być podłączony. + Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie. + Przeźroczystość + Odblokować członków dla wszystkich? + Niedostarczone wiadomości + Nieprzeczytane wzmianki + Nieobsługiwane łącze połączenia + Aktualizacja + Warunki aktualizacji + Aktualizuj swój adres + Upgrade + Uaktualnij adres + Uaktualnić adres? + Uaktualnij link do grupy + Uaktualnić link do grupy? + Użyj dla plików + Użyj dla wiadomości + Użyj profilu incognito + Użyj %s + Użyj serwerów + Użyj portu TCP %1$s, jeśli nie określono żadnego portu. + Używaj portu TCP 443 tylko dla wstępnie ustawionych serwerów. + Użyj portu internetowego + Wideo + Zobacz warunki + Zobacz zaktualizowane warunki + Wiadomości głosowe + Strona Internetowa + Wiadomość powitalna + Powitaj swoje kontakty + Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. + Tak + zaakceptowałeś tego członka + Nie masz połączenia z serwerem używanym do odbierania wiadomości z tego połączenia (brak subskrypcji). + Możesz skonfigurować operatorów w ustawieniach sieci i serwerów. + Serwery można skonfigurować w ustawieniach. + Możesz skopiować i zmniejszyć rozmiar wiadomości, aby ją wysłać. + Możesz wzmiankować do %1$s członków na wiadomość! + Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony. + Nie możesz wysyłać wiadomości! + Możesz przeglądać swoje raporty na czacie z administratorami. + odszedłeś + Twój opis: + Twój kontakt biznesowy + Twój profil na czacie zostanie wysłany do członków czatu + Twój kontakt + Twoja grupa + Twój profil + Twoje serwery + Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 95e5ed9409..5445c57055 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -340,7 +340,7 @@ Одноразовая ссылка Настройки - Ваш SimpleX адрес + Ваш адрес SimpleX База данных Подробнее о SimpleX Chat Как использовать @@ -428,7 +428,7 @@ Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. - Введите ваше имя: + Имя: Создать О SimpleX @@ -496,7 +496,7 @@ видеозвонок аудиозвонок - Аудио- и видеозвонки + Аудио и видеозвонки Ваши звонки Всегда соединяться через relay Звонки на экране блокировки: @@ -627,7 +627,7 @@ Текущий пароль… Новый пароль… Подтвердите новый пароль… - Поменять пароль + Сменить пароль Пожалуйста, введите правильный пароль. База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные. Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. @@ -636,7 +636,7 @@ Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. Пароль не сохранён на устройстве — Вы будете должны ввести его при каждом запуске чата. Зашифровать базу данных? - Поменять пароль базы данных? + Сменить пароль базы данных? База данных будет зашифрована. База данных будет зашифрована и пароль сохранён в Keystore. Пароль базы данных будет изменён и сохранён в Keystore. @@ -660,7 +660,7 @@ Введите пароль… Сохранить пароль и открыть чат Открыть чат - Попытка поменять пароль базы данных не была завершена. + Попытка изменить пароль базы данных не была завершена. Восстановить резервную копию Восстановить резервную копию? Введите предыдущий пароль после восстановления резервной копии. Это действие нельзя отменить. @@ -713,7 +713,7 @@ Вы поменяли роль себе на: %s Вы удалили %1$s Вы покинули группу - профиль группы обновлен + профиль группы обновлён поменял(а) адрес для Вас смена адреса… @@ -818,7 +818,7 @@ Включить TCP keep-alive Сохранить Обновить настройки сети? - Обновление настроек приведет к переподключению клиента ко всем серверам. + Обновление настроек приведёт к переподключению клиента ко всем серверам. Обновить Инкогнито @@ -880,7 +880,7 @@ Прямые сообщения между членами группы запрещены. Члены могут необратимо удалять отправленные сообщения. (24 часа) Необратимое удаление сообщений запрещено. - Члены могут отправлять голосовые сообщения. + Участники могут отправлять голосовые сообщения. Голосовые сообщения запрещены. Минимальный расход батареи. Вы получите уведомления только когда приложение запущено, без фонового сервиса.]]> Уведомления @@ -981,7 +981,7 @@ Только локальные данные профиля Сообщения Серверы для новых соединений Вашего текущего профиля чата - Ваши профили + Профили Все чаты и сообщения будут удалены - это нельзя отменить! Сборка приложения: %s Версия приложения: v%s @@ -1059,7 +1059,7 @@ Установите приветственное сообщение для новых членов группы. Нажмите на профиль, чтобы переключиться на него. Благодаря пользователям - добавьте переводы через Weblate! - Вы все равно получите звонки и уведомления в профилях без звука, когда они активные. + Вы всё равно получите звонки и уведомления в профилях без звука, когда они активные. Вы можете скрыть или отключить уведомления профиля - нажмите и удерживайте профиль, чтобы открыть меню. Изображение будет принято когда Ваш контакт его загрузит. Файл будет принят когда Ваш контакт загрузит его. @@ -1074,7 +1074,7 @@ версия базы данных новее чем приложения, но нет миграции для отката: %s разная миграция в приложении/базе данных: %s / %s Откатить версию и открыть чат - Предупреждение: Вы можете потерять какие то данные! + Предупреждение: Вы можете потерять некоторые данные! ID базы данных и опция Отдельные транспортные сессии. Показать опции для разработчиков Удалить профиль чата @@ -1203,7 +1203,7 @@ Изменить код самоуничтожения Самоуничтожение Код самоуничтожения включен! - Код доступа в приложение будет заменен кодом самоуничтожения. + Код доступа в приложение будет заменён кодом самоуничтожения. Включить код самоуничтожения Код самоуничтожения Код самоуничтожения изменен! @@ -1243,13 +1243,13 @@ Ваши контакты сохранятся. Настроить тему Создайте адрес, чтобы можно было соединиться с Вами. - Все Ваши контакты сохранятся. Обновленный профиль будет отправлен Вашим контактам. + Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам. Добавьте адрес в свой профиль, чтобы Ваши контакты могли поделиться им. Профиль будет отправлен Вашим контактам. Создать адрес SimpleX Поделиться с контактами Прекратить делиться адресом\? Автоприём - Введите приветственное сообщение... (опционально) + Введите приветственное сообщение... (по желанию) Сохранить настройки\? Прекратить делиться Продолжить @@ -1373,7 +1373,7 @@ Пересогласовать шифрование Быстрый поиск чатов Отчёты о доставке сообщений! - Еще несколько изменений + Ещё несколько изменений Отчёты о доставке! Включить Даже когда они выключены в разговоре. @@ -1622,7 +1622,7 @@ Ошибка создания сообщения Ошибка удаления заметки Венгерский и Турецкий интерфейс - Искать или вставьте ссылку SimpleX + Поиск или вставить ссылку SimpleX Этот QR-код не является SimpleX-ccылкой. С зашифрованными файлами и медиа. С уменьшенным потреблением батареи. @@ -1682,15 +1682,15 @@ Сохранённое сообщение неизвестно неизвестный статус - %d сообщений заблокировано администратором + %d сообщений заблокировано админом %s заблокирован %s разблокирован Вы разблокировали %s Разблокировать для всех Заблокировать члена группы для всех? заблокирован - заблокировано администратором - Заблокирован администратором + заблокировано админом + Заблокирован админом Заблокировать для всех Ошибка при блокировании члена группы для всех Разблокировать члена группы для всех? @@ -1750,7 +1750,7 @@ Завершить миграцию Или передайте эту ссылку Миграция завершена - Внимание: запуск чата на нескольких устройствах не поддерживается и приведет к сбоям доставки сообщений. + Внимание: запуск чата на нескольких устройствах не поддерживается и приведёт к сбоям доставки сообщений. не должны использовать одну и ту же базу данных на двух устройствах.]]> Проверьте подключение к Интернету и повторите попытку Подтвердите, что Вы помните пароль базы данных для её миграции. @@ -1815,7 +1815,7 @@ Ссылки SimpleX Разрешить отправлять ссылки SimpleX. Запретить отправку ссылок SimpleX - Члены могут отправлять SimpleX ссылки. + Участники могут отправлять ссылки SimpleX. админы все члены владельцы @@ -1836,9 +1836,9 @@ ФАЙЛЫ Новые темы чатов нет - Светлая - Системная - Цвета тёмного режима + Светлый + Системный + Цвета темного режима Получайте файлы безопасно Конфиденциальная доставка 🚀 Улучшенная доставка сообщений @@ -1871,7 +1871,7 @@ Всегда Подтверждать файлы с неизвестных серверов. Всегда использовать конфиденциальную доставку. - Тёмная + Тёмный Отладка доставки Ошибка инициализации WebView. Обновите Вашу систему до новой версии. Свяжитесь с разработчиками. \nОшибка: %s @@ -1948,7 +1948,7 @@ Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удален. Выбранные настройки чата запрещают это сообщение. Ошибка файла - Сканировать / Вставить ссылку + Сканировать QR-код/ Вставить ссылку Другие XFTP-серверы Настроенные XFTP-серверы Загрузка %s (%s) @@ -2016,7 +2016,7 @@ Слабое Среднее Выключено - Доступная панель приложения + Панель приложения внизу Текущий профиль Нет информации, попробуйте перезагрузить Информация о серверах @@ -2217,7 +2217,7 @@ %s.]]> %s.]]> %s, примите условия использования.]]> - Для оправки + Для отправки Дополнительные серверы сообщений Использовать для файлов Открыть условия @@ -2341,11 +2341,11 @@ Ошибка обновления списка чата Ошибка создания списка чатов Список - Никаких чатов в списке %s. + Нет чатов в списке %s. Без непрочитанных чатов Никаких чатов Чаты не найдены - Все чаты будут удалены из списка %s, а сам список удален + Все чаты будут удалены из списка %s, а сам список удалён Добавить список Примечания Открыть в %s @@ -2380,7 +2380,7 @@ Улучшенная приватность и безопасность Ускорено удаление групп. Ускорена отправка сообщений. - Помогайте администраторам модерировать их группы. + Помогайте админам модерировать их группы. Организуйте чаты в списки Вы можете сообщить о нарушениях Установите время исчезания сообщений в чатах. @@ -2426,7 +2426,7 @@ модератор ожидает утверждения ожидает - Обновленные условия + Обновлённые условия Запретить жаловаться модераторам группы. Члены группы могут пожаловаться модераторам. Сообщения в этом чате никогда не будут удалены. @@ -2566,7 +2566,7 @@ Член группы удалён - невозможно принять запрос Чтобы использовать другой профиль после попытки соединения, удалите чат и используйте ссылку снова. Приветственное сообщение - О Вас: + О себе: Ваш профиль Описание слишком длинное Использовать профиль инкогнито @@ -2615,4 +2615,23 @@ Хэш в адресе сервера не соответствует сертификату: %1$s. Ссылка SimpleX relay Хэш в адресе сервера назначения не соответствует сертификату: %1$s. + Удалить сообщения участника + Удалить сообщения участника? + Удалить сообщения + Сообщения участника будут удалены - это действие не обратимо! + нет подписки + Вы не подключенны к серверу через который Вы получали сообщения от этого контакта (без подписки). + Удалить члена группы и удалить сообщения + Все сообщения + Файлы + Фильтр + Изображения + Ссылки + Поиск файлов + Поиск изображений + Поиск ссылок + Поиск видео + Поиск голосовых сообщений + Видео + Голосовые сообщения diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml new file mode 100644 index 0000000000..55344e5192 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 501f07ea50..16d821637b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -836,7 +836,7 @@ Mesaj tepkileri Tercihleriniz Mesaj tepkileri yasaklıdır. - Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. + Bu bağlantıdan gelen mesajları almak için kullanılan sunucuya bağlısınız. Zaten %1$s e bağlısınız Doğrulanamadınız; lütfen tekrar deneyin. SimpleX Kilidini Ayarlar üzerinden açabilirsiniz. @@ -938,7 +938,7 @@ kapalı açık Kilit modunu değiştir - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. + Bu bağlantıdan gelen mesajları almak için kullanılan sunucuya bağlanmayı dene. Lütfen doğru bağlantıyı kullandığınızı kontrol edin veya irtibat kişinizden size başka bir bağlantı göndermesini isteyin. SimpleX arka planda çalışır.]]> Periyodik bildirimler @@ -2509,4 +2509,7 @@ Komutlar gönderebilmek için bağlanmanış olmanız gereklidir. Üye silinmiş - isteği kabul edemeyecek Grup linkini güncelle + Abonelik yok + Bu bağlantıdan mesaj almak için kullanılan sunucuya bağlı değilsiniz (abonelik yok). + SimpleX Relay Linki diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 5f729d4ea3..3e5e09c039 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -2525,4 +2525,21 @@ 服务器地址证书和证书不匹配:%1$s。 无订阅 未连接到用于从该连接接收消息的服务器(无订阅)。 + 删除成员消息 + 删除成员消息吗? + 删除消息 + 成员消息将被删除 - 这无法撤销! + 移除并删除消息 + 所有消息 + 文件 + 筛选器 + 图片 + 链接 + 搜索文件 + 搜索图片 + 搜索链接 + 搜索视频 + 搜索语音消息 + 视频 + 语音消息 diff --git a/website/src/directory.html b/website/src/directory.html index 0235583ece..30d6765553 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -260,6 +260,7 @@ active_directory: true app.

    SimpleX Directory is also available as a SimpleX chat bot.

    Read about how to add your community.

    +

    Under maintenance — you can't join these groups until 17:00 UTC, 01/09.

    From 024df7099d44ee1ea32934d8cb6f6dd11f8b441e Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Wed, 4 Mar 2026 09:11:55 +0000 Subject: [PATCH 019/112] multiplatform: fix image loading performance and layout stability (#6631) - Replace runBlocking { imageAndFilePath(file) } with LaunchedEffect + withContext(Dispatchers.IO) to unblock main thread on all platforms - Set fixed container size (width + aspectRatio) from preview bitmap to eliminate layout shifts during async image loading - Cache base64ToBitmap() with remember() in CIImageView and FramedItemView - Desktop: replace imageBitmap.toAwtImage().toPainter() with BitmapPainter to eliminate unnecessary round-trip conversion - Desktop: add LRU cache for base64ToBitmap (200 entries) and getLoadedImage (30 entries) to survive LazyColumn item disposal - Clear loaded image cache on app file deletion via expect/actual Co-authored-by: Evgeny Poberezkin --- .../common/views/helpers/Utils.android.kt | 2 ++ .../common/views/chat/item/CIImageView.kt | 23 +++++++++++-------- .../common/views/chat/item/FramedItemView.kt | 4 ++-- .../simplex/common/views/helpers/Utils.kt | 3 +++ .../simplex/common/platform/Images.desktop.kt | 9 +++++++- .../views/chat/item/CIImageView.desktop.kt | 3 ++- .../common/views/helpers/Utils.desktop.kt | 13 +++++++++-- 7 files changed, 41 insertions(+), 16 deletions(-) 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 9d1e0c4e97..a5021ae54c 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 @@ -182,6 +182,8 @@ private fun spannableStringToAnnotatedString( actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() +actual fun clearImageCaches() {} + // https://developer.android.com/training/data-storage/shared/documents-files#bitmap actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 1be2110b1f..064b5370bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* @Composable fun CIImageView( @@ -38,6 +38,7 @@ fun CIImageView( receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } + val previewBitmap = remember(image) { base64ToBitmap(image) } @Composable fun progressIndicator() { CircularProgressIndicator( @@ -144,7 +145,7 @@ fun CIImageView( .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentAlignment = Alignment.Center ) { - imageView(base64ToBitmap(image), onClick = { + imageView(previewBitmap, onClick = { if (fileSource != null) { openFile(fileSource) } @@ -178,14 +179,16 @@ fun CIImageView( Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) + .then( + if (!smallView) { + val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH + Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat()) + } else Modifier + ) .desktopModifyBlurredState(!smallView, blurred, showMenu), contentAlignment = Alignment.TopEnd ) { - val res: MutableState?> = remember { - mutableStateOf( - if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } - ) - } + val res: MutableState?> = remember { mutableStateOf(null) } if (chatModel.connectedToRemote()) { LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { withBGApi { @@ -195,9 +198,9 @@ fun CIImageView( } } } else { - KeyChangeEffect(file) { + LaunchedEffect(file) { if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = imageAndFilePath(file) + res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } } } } @@ -206,7 +209,7 @@ fun CIImageView( val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) } else { - imageView(base64ToBitmap(image), onClick = { + imageView(previewBitmap, onClick = { if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index f36da6c908..900fa238a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -144,7 +144,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image) + val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) } Image( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), @@ -156,7 +156,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image) + val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) } Image( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), 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 5c18fa3d47..c4821d1a20 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 @@ -130,6 +130,8 @@ const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI +expect fun clearImageCaches() + // https://developer.android.com/training/data-storage/shared/documents-files#bitmap expect suspend fun getLoadedImage(file: CIFile?): Pair? @@ -423,6 +425,7 @@ fun deleteAppFiles() { } catch (e: java.lang.Exception) { Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}") } + clearImageCaches() } fun directoryFileCountAndSize(dir: String): Pair { // count, size in bytes diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index d3b8cdcb58..3a93df406d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -21,12 +21,19 @@ import kotlin.math.sqrt private fun errorBitmap(): ImageBitmap = ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap() +private val base64BitmapCache = Collections.synchronizedMap(object : LinkedHashMap(200, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > 200 +}) + actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { + base64BitmapCache[base64ImageString]?.let { return it } val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") return try { - ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap() + ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap().also { + base64BitmapCache[base64ImageString] = it + } } catch (e: Throwable) { Log.e(TAG, "base64ToBitmap error: $e") errorBitmap() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 38054cb873..b4a24e3572 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* @@ -17,7 +18,7 @@ actual fun SimpleAndAnimatedImageView( ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { // LALAL make it animated too - ImageView(imageBitmap.toAwtImage().toPainter()) { + ImageView(BitmapPainter(imageBitmap)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> ImageFullScreenView(imageProvider, close) 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 d541a5780e..8d69607c62 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 @@ -16,6 +16,7 @@ import kotlinx.coroutines.delay import java.io.ByteArrayInputStream import java.io.File import java.net.URI +import java.util.* import javax.imageio.ImageIO import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -128,6 +129,14 @@ actual fun getAppFileUri(fileName: String): URI { } } +private val loadedImageCache = Collections.synchronizedMap(object : LinkedHashMap>(30, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry>): Boolean = size > 30 +}) + +actual fun clearImageCaches() { + loadedImageCache.clear() +} + actual suspend fun getLoadedImage(file: CIFile?): Pair? { var filePath = getLoadedFilePath(file) if (chatModel.connectedToRemote() && filePath == null) { @@ -135,10 +144,10 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? filePath = getLoadedFilePath(file) } return if (filePath != null) { - try { + loadedImageCache[filePath] ?: try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() val bitmap = getBitmapFromByteArray(data, false) - if (bitmap != null) bitmap to data else null + if (bitmap != null) (bitmap to data).also { loadedImageCache[filePath] = it } else null } catch (e: Exception) { Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString()) null From b97868d79fade4e68945742c89b6875e48cd22cc Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:13:24 +0000 Subject: [PATCH 020/112] ios: channels and chat relays ui (#6634) --- apps/ios/CODE.md | 4 + apps/ios/Shared/ContentView.swift | 7 +- apps/ios/Shared/Model/AppAPITypes.swift | 76 ++- apps/ios/Shared/Model/ChatModel.swift | 30 + apps/ios/Shared/Model/SimpleXAPI.swift | 51 +- .../Shared/Views/Chat/ChatItemsMerger.swift | 1 + apps/ios/Shared/Views/Chat/ChatView.swift | 127 +++- .../Chat/ComposeMessage/ComposeView.swift | 338 ++++++++--- .../Views/Chat/Group/ChannelMembersView.swift | 90 +++ .../Views/Chat/Group/ChannelRelaysView.swift | 118 ++++ .../Views/Chat/Group/GroupChatInfoView.swift | 199 ++++-- .../Views/Chat/Group/GroupLinkView.swift | 28 +- .../Chat/Group/GroupMemberInfoView.swift | 99 ++- .../Views/ChatList/ChatListNavLink.swift | 12 +- .../Shared/Views/ChatList/ChatListView.swift | 3 +- .../Shared/Views/Helpers/ViewModifiers.swift | 9 + .../Shared/Views/NewChat/AddChannelView.swift | 396 ++++++++++++ .../Shared/Views/NewChat/AddGroupView.swift | 3 +- .../Views/NewChat/NewChatMenuButton.swift | 8 + .../Shared/Views/NewChat/NewChatView.swift | 134 +++-- .../NetworkAndServers/ChatRelayView.swift | 323 ++++++++++ .../NetworkAndServers/NetworkAndServers.swift | 53 +- .../NetworkAndServers/NewServerView.swift | 9 +- .../NetworkAndServers/OperatorView.swift | 49 +- .../ProtocolServerView.swift | 4 +- .../ProtocolServersView.swift | 76 ++- .../ScanProtocolServer.swift | 6 +- apps/ios/SimpleX SE/ShareAPI.swift | 8 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 16 + apps/ios/SimpleXChat/APITypes.swift | 6 + apps/ios/SimpleXChat/ChatTypes.swift | 123 +++- apps/ios/product/concepts.md | 1 + apps/ios/product/flows/connection.md | 21 +- apps/ios/product/flows/group-lifecycle.md | 16 +- apps/ios/product/flows/messaging.md | 6 +- apps/ios/product/gaps.md | 3 + apps/ios/product/glossary.md | 20 +- apps/ios/product/rules.md | 29 + apps/ios/product/views/chat-list.md | 17 + apps/ios/product/views/chat.md | 9 + apps/ios/product/views/group-info.md | 97 +++ apps/ios/product/views/new-chat.md | 52 +- apps/ios/product/views/settings.md | 31 +- apps/ios/spec/api.md | 569 +++++++++--------- apps/ios/spec/architecture.md | 49 ++ apps/ios/spec/client/chat-list.md | 16 + apps/ios/spec/client/chat-view.md | 174 ++++-- apps/ios/spec/client/compose.md | 89 +-- apps/ios/spec/client/navigation.md | 70 ++- apps/ios/spec/impact.md | 33 +- apps/ios/spec/state.md | 260 ++++---- bots/api/COMMANDS.md | 39 ++ bots/api/TYPES.md | 19 +- bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Responses.hs | 1 + bots/src/API/Docs/Types.hs | 5 + bots/src/API/TypeInfo.hs | 2 + .../types/typescript/src/commands.ts | 14 + .../types/typescript/src/responses.ts | 9 + .../types/typescript/src/types.ts | 14 +- plans/2026-02-17-ios-channels-product-plan.md | 506 ++++++++++++++++ src/Simplex/Chat.hs | 2 + src/Simplex/Chat/Controller.hs | 15 +- src/Simplex/Chat/Library/Commands.hs | 53 +- src/Simplex/Chat/Library/Internal.hs | 8 +- src/Simplex/Chat/Library/Subscriber.hs | 54 +- src/Simplex/Chat/Operators.hs | 14 + src/Simplex/Chat/Store/Connections.hs | 4 +- src/Simplex/Chat/Store/Groups.hs | 78 ++- src/Simplex/Chat/Store/Messages.hs | 8 +- src/Simplex/Chat/Store/Profiles.hs | 27 +- src/Simplex/Chat/Store/RelayRequests.hs | 1 + .../SQLite/Migrations/agent_query_plans.txt | 8 +- .../SQLite/Migrations/chat_query_plans.txt | 109 +++- src/Simplex/Chat/Store/Shared.hs | 8 +- src/Simplex/Chat/Types.hs | 43 +- src/Simplex/Chat/Types/Shared.hs | 31 + src/Simplex/Chat/View.hs | 14 +- tests/ChatClient.hs | 2 + tests/ChatTests/ChatRelays.hs | 84 ++- 80 files changed, 4170 insertions(+), 971 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift create mode 100644 apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift create mode 100644 apps/ios/Shared/Views/NewChat/AddChannelView.swift create mode 100644 apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift create mode 100644 plans/2026-02-17-ios-channels-product-plan.md diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md index adb5ef8c42..5a8356f656 100644 --- a/apps/ios/CODE.md +++ b/apps/ios/CODE.md @@ -174,6 +174,8 @@ After completing all changes (code + documentation), you MUST run an adversarial | Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | | Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md | | Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/ChannelMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/ChannelRelaysView.swift | spec/client/chat-view.md | product/views/group-info.md | | Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md | | Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md | | Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md | @@ -199,6 +201,8 @@ After completing all changes (code + documentation), you MUST run an adversarial | SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md | | SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md | | SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Views/Chat/ChatItemsMerger.swift | spec/client/chat-view.md | product/views/chat.md | +| SimpleX SE/ShareAPI.swift | spec/api.md | product/flows/messaging.md | ### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`) diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index a6896fa51d..ba49c767da 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -451,7 +451,12 @@ struct ContentView: View { func connectViaUrl_(_ url: URL) { dismissAllSheets() { var path = url.path - if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { + if path == "/r" { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + } else if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { path.removeFirst() let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index f82a2fd2eb..336d21da3b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -45,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -61,7 +61,7 @@ enum ChatCommand: ChatCmdProtocol { case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) - case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) + case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -70,6 +70,8 @@ enum ChatCommand: ChatCmdProtocol { case apiGetNtfConns(nonce: String, encNtfInfo: String) case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) + case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) + case apiGetGroupRelays(groupId: Int64) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) @@ -126,7 +128,7 @@ enum ChatCommand: ChatCmdProtocol { case apiChangeConnectionUser(connId: Int64, userId: Int64) case apiConnectPlan(userId: Int64, connLink: String) case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) - case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) + case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) @@ -230,10 +232,11 @@ enum ChatCommand: ChatCmdProtocol { return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))" case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" - case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" @@ -252,9 +255,10 @@ enum ChatCommand: ChatCmdProtocol { case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl): + case let .apiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl): let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + let asGroup = sendAsGroup ? " as_group=on" : "" + return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -263,6 +267,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" + case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))" + case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" @@ -329,7 +335,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))" - case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))" + case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))" case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)" case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)" case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))" @@ -449,6 +455,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetNtfConns: return "apiGetNtfConns" case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" case .apiNewGroup: return "apiNewGroup" + case .apiNewPublicGroup: return "apiNewPublicGroup" + case .apiGetGroupRelays: return "apiGetGroupRelays" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiAcceptMember: return "apiAcceptMember" @@ -660,7 +668,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) case serverOperatorConditions(conditions: ServerOperatorConditions) case userServers(user: UserRef, userServers: [UserOperatorServers]) - case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]) case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) case chatItemTTL(user: UserRef, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) @@ -728,7 +736,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") - case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .userServersValidation(u, serverErrors, serverWarnings): return withUser(u, "serverErrors: \(String(describing: serverErrors))\nserverWarnings: \(String(describing: serverWarnings))") case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) @@ -779,7 +787,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case startedConnectionToContact(user: UserRef, contact: Contact) - case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo) + case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo, relayResults: [RelayConnectionResult]) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) @@ -900,7 +908,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact)) - case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .startedConnectionToGroup(u, groupInfo, relayResults): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nrelayResults: \(String(describing: relayResults))") case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) } @@ -911,6 +919,8 @@ enum ChatResponse1: Decodable, ChatAPIResult { enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) + case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) + case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay]) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) @@ -961,6 +971,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { var responseType: String { switch self { case .groupCreated: "groupCreated" + case .publicGroupCreated: "publicGroupCreated" + case .groupRelays: "groupRelays" case .sentGroupInvitation: "sentGroupInvitation" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .userDeletedMembers: "userDeletedMembers" @@ -1007,6 +1019,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { var details: String { switch self { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") + case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)") case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") @@ -1086,10 +1100,11 @@ enum ChatEvent: Decodable, ChatAPIResult { case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) + case userJoinedGroup(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupUpdated(user: UserRef, toGroup: GroupInfo) + case groupLinkRelaysUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) @@ -1166,6 +1181,7 @@ enum ChatEvent: Decodable, ChatAPIResult { case .joinedGroupMember: "joinedGroupMember" case .connectedToGroupMember: "connectedToGroupMember" case .groupUpdated: "groupUpdated" + case .groupLinkRelaysUpdated: "groupLinkRelaysUpdated" case .newMemberContactReceivedInv: "newMemberContactReceivedInv" case .rcvFileAccepted: "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" @@ -1242,10 +1258,11 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo, _): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkRelaysUpdated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails @@ -1284,6 +1301,7 @@ enum ChatEvent: Decodable, ChatAPIResult { struct NewUser: Encodable { var profile: Profile? var pastTimestamp: Bool + var userChatRelay: Bool = false } enum ChatPagination { @@ -1331,8 +1349,14 @@ enum ContactAddressPlan: Decodable, Hashable { case contactViaAddress(contact: Contact) } +public struct GroupShortLinkInfo: Decodable, Hashable { + public var direct: Bool + public var groupRelays: [String] + public var sharedGroupId: String? +} + enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkData_: GroupShortLinkData?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) @@ -1712,6 +1736,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { var `operator`: ServerOperator? var smpServers: [UserServer] var xftpServers: [UserServer] + var chatRelays: [UserChatRelay] var id: String { if let op = self.operator { @@ -1741,21 +1766,29 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { static var sampleData1 = UserOperatorServers( operator: ServerOperator.sampleData1, smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] + xftpServers: [UserServer.sampleData.xftpPreset], + chatRelays: [] ) static var sampleDataNilOperator = UserOperatorServers( operator: nil, smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] + xftpServers: [UserServer.sampleData.xftpPreset], + chatRelays: [] ) } +public enum UserServersWarning: Decodable { + case noChatRelays(user: UserRef?) +} + enum UserServersError: Decodable { case noServers(protocol: ServerProtocol, user: UserRef?) case storageMissing(protocol: ServerProtocol, user: UserRef?) case proxyMissing(protocol: ServerProtocol, user: UserRef?) case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + case duplicateChatRelayName(duplicateChatRelay: String) + case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { @@ -1774,6 +1807,10 @@ enum UserServersError: Decodable { case .smp: return globalSMPError case .xftp: return globalXFTPError } + case let .duplicateChatRelayName(duplicateChatRelay): + return String.localizedStringWithFormat(NSLocalizedString("Duplicate chat relay name: %@", comment: "servers error"), duplicateChatRelay) + case let .duplicateChatRelayAddress(_, duplicateAddress): + return String.localizedStringWithFormat(NSLocalizedString("Duplicate chat relay address: %@", comment: "servers error"), duplicateAddress) default: return nil } } @@ -1913,6 +1950,11 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable { } } +struct RelayConnectionResult: Decodable { + var relayMember: GroupMember + var relayError: ChatError? +} + enum ProtocolTestStep: String, Decodable, Equatable { case connect case disconnect diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 46e9df1ef8..023dc1926c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -333,6 +333,22 @@ class ConnectProgressManager: ObservableObject { } } +class ChannelRelaysModel: ObservableObject { + static let shared = ChannelRelaysModel() + @Published var groupId: Int64? = nil + @Published var groupRelays: [GroupRelay] = [] + + func set(groupId: Int64, groupRelays: [GroupRelay]) { + self.groupId = groupId + self.groupRelays = groupRelays + } + + func reset() { + groupId = nil + groupRelays = [] + } +} + // Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @@ -366,6 +382,9 @@ final class ChatModel: ObservableObject { @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @Published var membersLoaded = false + // Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart. + // APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join. + @Published var channelRelayHostnames: [Int64: [String]] = [:] // items in the terminal view @Published var showingTerminal = false @Published var terminalItems: [TerminalItem] = [] @@ -1196,13 +1215,24 @@ final class ChatModel: ObservableObject { // Spec: spec/state.md#removeChat func removeChat(_ id: String) { + var groupId: Int64? withAnimation { if let i = getChatIndex(id) { let removed = chats.remove(at: i) + groupId = removed.chatInfo.groupInfo?.groupId ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats) removeWallpaperFilesFromChat(removed) } } + if chatId == id { + groupMembers = [] + groupMembersIndexes.removeAll() + // Remove channelRelayHostnames for this channel only, preserving other prepared channels + if let gId = groupId { + channelRelayHostnames.removeValue(forKey: gId) + } + membersLoaded = false + } } func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7eb2de11ab..0819d74ec1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -503,8 +503,8 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, throw r.unexpected } -func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { - let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) +func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { + let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } @@ -536,8 +536,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } @@ -795,10 +795,10 @@ func setUserServers(userServers: [UserOperatorServers]) async throws { throw r.unexpected } -func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { +func validateServers(userServers: [UserOperatorServers]) async throws -> ([UserServersError], [UserServersWarning]) { let userId = try currentUserId("validateServers") let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) - if case let .userServersValidation(_, serverErrors) = r { return serverErrors } + if case let .userServersValidation(_, serverErrors, serverWarnings) = r { return (serverErrors, serverWarnings) } logger.error("validateServers error: \(String(describing: r))") throw r.unexpected } @@ -1121,9 +1121,9 @@ func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactS throw r.unexpected } -func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { +func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { let userId = try currentUserId("apiPrepareGroup") - let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData)) + let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData)) if case let .newPreparedChat(_, chat) = r { return chat } throw r.unexpected } @@ -1147,9 +1147,9 @@ func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgConten return nil } -func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? { +func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg)) - if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo } + if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) } if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) } return nil } @@ -1826,6 +1826,22 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf throw r.unexpected } +func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])? { + let userId = try currentUserId("apiNewPublicGroup") + let r: APIResult? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile)) + switch r { + case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)): + return (groupInfo, groupLink, groupRelays) + default: if let r { throw r.unexpected } else { return nil } + } +} + +func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] { + let r: APIResult = await chatApiSendCmd(.apiGetGroupRelays(groupId: groupId)) + if case let .result(.groupRelays(_, _, relays)) = r { return relays } + return [] +} + func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) if case let .sentGroupInvitation(_, _, _, member) = r { return member } @@ -2461,9 +2477,9 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupLinkConnecting(user, groupInfo, hostMember): if !active(user) { return } - await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, hostMember) if let hostConn = hostMember.activeConn { m.dismissConnReqView(hostConn.id) m.removeChat(hostConn.id) @@ -2526,10 +2542,11 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(groupInfo) } } - case let .userJoinedGroup(user, groupInfo): + case let .userJoinedGroup(user, groupInfo, hostMember): if active(user) { await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, hostMember) } if m.chatId == groupInfo.id { if groupInfo.membership.memberPending { @@ -2561,6 +2578,16 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(toGroup) } } + case let .groupLinkRelaysUpdated(user, groupInfo, _, groupRelays): + if active(user) { + await MainActor.run { + m.updateGroup(groupInfo) + let relaysModel = ChannelRelaysModel.shared + if relaysModel.groupId == groupInfo.groupId { + relaysModel.set(groupId: groupInfo.groupId, groupRelays: groupRelays) + } + } + } case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 5f2102b8bc..0b074c6370 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -267,6 +267,7 @@ struct ListItem: Hashable { case .directRcv: 1 case .groupSnd: 2 case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash + case .channelRcv: 3 case .localSnd: 4 case .localRcv: 5 } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 057bf7f75f..87f6b8a787 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -65,7 +65,6 @@ struct ChatView: View { @State private var showUserSupportChatSheet = false @State private var showCommandsMenu = false @State private var supportChatMemberInfoLinkActive = false - @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial @@ -135,12 +134,6 @@ struct ChatView: View { .padding(.top) } if selectedChatItems == nil { - let reason = chat.chatInfo.userCantSendReason - let composeEnabled = ( - chat.chatInfo.sendMsgEnabled || - (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || // allow to join prepared group without message - (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) // allow to accept or reject contact request - ) ComposeView( chat: chat, im: im, @@ -149,17 +142,8 @@ struct ChatView: View { keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, selectedRange: $selectedRange, - disabledText: reason?.composeLabel + disabledText: chat.chatInfo.userCantSendReason?.composeLabel ) - .disabled(!composeEnabled) - .if(!composeEnabled) { v in - v.disabled(true).onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: reason?.alertMessage - ) - } - } } else { SelectedItemsBottomToolbar( im: im, @@ -405,6 +389,7 @@ struct ChatView: View { chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false + ChannelRelaysModel.shared.reset() } } } @@ -701,6 +686,17 @@ struct ChatView: View { } } } + if case let .group(groupInfo, _) = cInfo, groupInfo.useRelays { + Task { await chatModel.loadGroupMembers(groupInfo) } + if groupInfo.membership.memberRole == .owner { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { + ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays) + } + } + } + } updateAvailableContent() } if chatModel.draftChatId == cInfo.id && !composeState.forwarding, @@ -1029,12 +1025,12 @@ struct ChatView: View { switch groupInfo.businessChat?.chatType { case .none: if groupInfo.nextConnectPrepared { - "Tap Join group" + groupInfo.useRelays ? "Tap Join channel" : "Tap Join group" } else { switch (groupInfo.membership.memberStatus) { - case .memInvited: "Join group" - case .memCreator: "Your group" - default: "Group" + case .memInvited: groupInfo.useRelays ? "Join channel" : "Join group" + case .memCreator: groupInfo.useRelays ? "Your channel" : "Your group" + default: groupInfo.useRelays ? "Channel" : "Group" } } case .business: @@ -1062,10 +1058,14 @@ struct ChatView: View { nil } case let .group(groupInfo, _): - switch (groupInfo.membership.memberStatus) { - case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil - case .memAccepted: "connecting…" - default: nil + if groupInfo.useRelays { + nil + } else { + switch (groupInfo.membership.memberStatus) { + case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil + case .memAccepted: "connecting…" + default: nil + } } default: nil } @@ -1653,6 +1653,8 @@ struct ChatView: View { let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir { groupMember.groupMemberId == prevGroupMember.groupMemberId + } else if case .channelRcv = chatItem.chatDir, case .channelRcv = prevItem.chatDir { + true } else { chatItem.chatDir.sent == prevItem.chatDir.sent } @@ -1668,16 +1670,21 @@ struct ChatView: View { func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool { let oldIsGroupRcv = switch older?.chatDir { case .groupRcv: true + case .channelRcv: true default: false } let sameMember = switch (older?.chatDir, current.chatDir) { case (.groupRcv(let oldMember), .groupRcv(let member)): oldMember.memberId == member.memberId + case (.channelRcv, .channelRcv): + true default: false } if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { return true + } else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { + return true } else { return false } @@ -1843,7 +1850,74 @@ struct ChatView: View { _ itemSeparation: ItemSeparation ) -> some View { let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 - if case let .groupRcv(member) = ci.chatDir, + if case .channelRcv = ci.chatDir, + case let .group(groupInfo, _) = chat.chatInfo { + if showAvatar { + VStack(alignment: .leading, spacing: 4) { + if ci.content.showMemberName { + Group { + Group { + if #available(iOS 16.0, *) { + MemberLayout(spacing: 16, msgWidth: msgWidth) { + Text(groupInfo.chatViewName) + .lineLimit(1) + Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages")) + .fontWeight(.semibold) + .lineLimit(1) + .padding(.trailing, 8) + } + } else { + HStack(spacing: 16) { + Text(groupInfo.chatViewName) + .lineLimit(1) + Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages")) + .fontWeight(.semibold) + .lineLimit(1) + .layoutPriority(1) + } + } + } + .frame( + maxWidth: maxWidth, + alignment: chatItem.chatDir.sent ? .trailing : .leading + ) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) + .padding(.top, 3) + } + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.trailing, 12) + } + HStack(alignment: .top, spacing: 10) { + ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + .simultaneousGesture(TapGesture().onEnded { + showChatInfoSheet = true + }) + chatItemWithMenu(ci, range, maxWidth, itemSeparation) + .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } + } + } + } + .padding(.bottom, bottomPadding) + .padding(.trailing) + .padding(.leading, 12) + } else { + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } + chatItemWithMenu(ci, range, maxWidth, itemSeparation) + .padding(.trailing) + .padding(.leading, 10 + memberImageSize + 12) + } + .padding(.bottom, bottomPadding) + } + } else if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo, _) = chat.chatInfo { if showAvatar { VStack(alignment: .leading, spacing: 4) { @@ -2043,6 +2117,7 @@ struct ChatView: View { switch (prevItem?.chatDir) { case .groupSnd: return true case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + case .channelRcv: return true default: return false } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 2c462df9e4..af9b4673c0 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -364,6 +364,8 @@ struct ComposeView: View { @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false @State private var updatingCompose = false + @State private var relayListExpanded = false + @StateObject private var channelRelaysModel = ChannelRelaysModel.shared // Spec: spec/client/compose.md#body var body: some View { @@ -371,6 +373,7 @@ struct ComposeView: View { Divider() if chat.chatInfo.nextConnectPrepared, + !composeState.inProgress, let user = chatModel.currentUser { ContextProfilePickerView( chat: chat, @@ -379,85 +382,148 @@ struct ComposeView: View { Divider() } - if let groupInfo = chat.chatInfo.groupInfo, - case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, - case let .memberSupport(member) = groupScopeInfo, - let member = member, - member.memberPending, - composeState.contextItem == .noContextItem, - composeState.noPreview { - ContextPendingMemberActionsView( - groupInfo: groupInfo, - member: member - ) - Divider() - } - - if case let .reportedItem(_, reason) = composeState.contextItem { - reportReasonView(reason) - Divider() - } - // preference checks should match checks in forwarding list - let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) - let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) - let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) - let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited - if simplexLinkProhibited { - msgNotAllowedView("SimpleX links not allowed", icon: "link") - Divider() - } else if fileProhibited { - msgNotAllowedView("Files and media not allowed", icon: "doc") - Divider() - } else if voiceProhibited { - msgNotAllowedView("Voice messages not allowed", icon: "mic") - Divider() - } - contextItemView() - switch (composeState.editing, composeState.preview) { - case (true, .filePreview): EmptyView() - case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed - default: previewView() - } - - let contact = chat.chatInfo.contact - - if chat.chatInfo.groupInfo?.nextConnectPrepared == true { - if chat.chatInfo.groupInfo?.businessChat == nil { - connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup) + if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays { + if gInfo.membership.memberRole == .owner { + let relays = channelRelaysModel.groupId == gInfo.groupId + ? channelRelaysModel.groupRelays : [] + let activeCount = relays.filter { $0.relayStatus == .rsActive }.count + if !relays.isEmpty && activeCount < relays.count { + ownerChannelRelayBar(relays: relays, activeCount: activeCount) + } } else { - sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) - } - } else if contact?.nextSendGrpInv == true { - contextSendMessageToConnect("Send direct message to connect") - Divider() - HStack (alignment: .center) { - attachmentAndCommandsButtons().disabled(true) - sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) - } - .padding(.horizontal, 12) - } else if let contact, - contact.nextConnectPrepared == true, - let linkType = contact.preparedContact?.uiConnLinkType { - switch linkType { - case .inv: - connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) - case .con: - if contact.isBot { - connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) - } else { - sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted() + let relayMembers = chatModel.groupMembers + .filter { $0.wrapped.memberRole == .relay } + .sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") } + let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress + let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count + let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count + let resolvedCount = connectedCount + deletedCount + let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count + if total > 0, !showProgress || resolvedCount < total { + subscriberChannelRelayBar( + hostnames: hostnames, + relayMembers: relayMembers, + connectedCount: connectedCount, + deletedCount: deletedCount, + total: total, + showProgress: showProgress + ) } } - } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { - ContextContactRequestActionsView(contactRequestId: crId) - } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { - ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) - } else { - HStack (alignment: .center) { - attachmentAndCommandsButtons() - sendMessageView(disableSendButton) + } + + let composeEnabled = ( + chat.chatInfo.sendMsgEnabled || + (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || + (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) + ) + Group { + + if let groupInfo = chat.chatInfo.groupInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(member) = groupScopeInfo, + let member = member, + member.memberPending, + composeState.contextItem == .noContextItem, + composeState.noPreview { + ContextPendingMemberActionsView( + groupInfo: groupInfo, + member: member + ) + Divider() + } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } + // preference checks should match checks in forwarding list + let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) + let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited + if simplexLinkProhibited { + msgNotAllowedView("SimpleX links not allowed", icon: "link") + Divider() + } else if fileProhibited { + msgNotAllowedView("Files and media not allowed", icon: "doc") + Divider() + } else if voiceProhibited { + msgNotAllowedView("Voice messages not allowed", icon: "mic") + Divider() + } + contextItemView() + switch (composeState.editing, composeState.preview) { + case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed + default: previewView() + } + + let contact = chat.chatInfo.contact + + if chat.chatInfo.groupInfo?.nextConnectPrepared == true { + if chat.chatInfo.groupInfo?.businessChat == nil { + let isChannel = chat.chatInfo.groupInfo?.useRelays == true + connectButtonView( + isChannel ? "Join channel" : "Join group", + icon: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.fill", + connect: connectPreparedGroup + ) + } else { + sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) + } + } else if contact?.nextSendGrpInv == true { + contextSendMessageToConnect("Send direct message to connect") + Divider() + HStack (alignment: .center) { + attachmentAndCommandsButtons().disabled(true) + sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) + } + .padding(.horizontal, 12) + } else if let contact, + contact.nextConnectPrepared == true, + let linkType = contact.preparedContact?.uiConnLinkType { + switch linkType { + case .inv: + connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) + case .con: + if contact.isBot { + connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) + } else { + sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + } + } + } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { + ContextContactRequestActionsView(contactRequestId: crId) + } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { + ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) + } else { + HStack (alignment: .center) { + attachmentAndCommandsButtons() + sendMessageView( + disableSendButton, + placeholder: chat.chatInfo.groupInfo.map { gi in + gi.useRelays && gi.membership.memberRole >= .owner + ? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner") + : nil + } ?? nil + ) + } + .padding(.horizontal, 12) + } + + } // Group + .disabled(!composeEnabled) + .if(!composeEnabled) { v in + v.onTapGesture { + if let reason = chat.chatInfo.userCantSendReason { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: reason.alertMessage + ) + } } - .padding(.horizontal, 12) } } .background { @@ -653,18 +719,129 @@ struct ComposeView: View { } } + private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int) -> some View { + let total = relays.count + let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } + return VStack(spacing: 0) { + relayBarHeader { + if activeCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + } + if relayListExpanded { + ForEach(sorted) { relay in + relayBarDetailRow { + Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) + Spacer() + relayStatusIndicator(relay.relayStatus) + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func subscriberChannelRelayBar( + hostnames: [String], + relayMembers: [GMember], + connectedCount: Int, + deletedCount: Int, + total: Int, + showProgress: Bool + ) -> some View { + VStack(spacing: 0) { + relayBarHeader { + let activeTotal = total - deletedCount + if showProgress && connectedCount < activeTotal { + RelayProgressIndicator(active: connectedCount, total: activeTotal) + } + if showProgress { + if deletedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d deleted", comment: "channel subscriber relay bar progress with deleted"), connectedCount, activeTotal, deletedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, activeTotal)) + } + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total)) + } + } + if relayListExpanded { + if relayMembers.isEmpty { + ForEach(hostnames, id: \.self) { relay in + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(relay))) + .foregroundColor(theme.colors.secondary) + Spacer() + } + } + } else { + ForEach(relayMembers) { member in + let m = member.wrapped + let host = m.relayLink.map { hostFromRelayLink($0) } + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) + .foregroundColor(theme.colors.secondary) + Spacer() + let status = relayConnStatus(m) + Circle() + .fill(status.color) + .frame(width: 8, height: 8) + Text(status.text) + .foregroundColor(theme.colors.secondary) + } + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func relayBarHeader(@ViewBuilder content: () -> Content) -> some View { + Button { + withAnimation(nil) { relayListExpanded.toggle() } + } label: { + HStack(spacing: 8) { + content() + Spacer() + Image(systemName: relayListExpanded ? "chevron.down" : "chevron.up") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } + .font(.callout) + .foregroundColor(theme.colors.secondary) + .padding(.top, 8) + .padding(.bottom, relayListExpanded ? 4 : 8) + .padding(.leading, 12) + .padding(.trailing) + } + } + + private func relayBarDetailRow(@ViewBuilder content: () -> Content) -> some View { + HStack { + content() + } + .font(.caption) + .padding(.leading, 12) + .padding(.trailing) + .padding(.vertical, 2) + } + private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View { Button(action: connect) { ZStack(alignment: .trailing) { Label(label, systemImage: icon) .frame(maxWidth: .infinity) - if composeState.progressByTimeout { + if composeState.progressByTimeout && chat.chatInfo.groupInfo?.useRelays != true { ProgressView() .padding() } } } - .frame(height: 60) + .frame(height: 57) .disabled(composeState.inProgress) } @@ -851,9 +1028,12 @@ struct ComposeView: View { await sending() let mc = connectCheckLinkPreview() let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault - if let groupInfo = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { + if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateGroup(groupInfo) + self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId) + self.chatModel.groupMembers = relayResults.map { GMember($0.relayMember) } + self.chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -1322,6 +1502,7 @@ struct ComposeView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, scope: chat.chatInfo.groupChatScope(), + sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, live: live, ttl: ttl, composedMessages: msgs @@ -1347,6 +1528,7 @@ struct ComposeView: View { toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, toScope: chat.chatInfo.groupChatScope(), + sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, fromScope: fromChatInfo.groupChatScope(), diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift new file mode 100644 index 0000000000..b3317cbc6b --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -0,0 +1,90 @@ +// +// ChannelMembersView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 20.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelMembersView: View { + @ObservedObject var chat: Chat + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + + var body: some View { + let allMembers = chatModel.groupMembers + .filter { m in + let s = m.wrapped.memberStatus + return s != .memLeft && s != .memRemoved && m.wrapped.groupMemberId != groupInfo.membership.groupMemberId + } + let owners = allMembers.filter { $0.wrapped.memberRole >= .owner } + // TODO [relays] subscriber/owner counts require backend support for accurate totals + let subscribers = allMembers.filter { $0.wrapped.memberRole < .owner && $0.wrapped.memberRole != .relay } + List { + Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { + if groupInfo.membership.memberRole >= .owner { + memberRow(GMember(groupInfo.membership), user: true) + } + ForEach(owners) { member in + memberRow(member, user: false) + } + } + if groupInfo.isOwner { + Section(header: Text("\(subscribers.count) subscribers").foregroundColor(theme.colors.secondary)) { + if subscribers.isEmpty { + Text("No subscribers") + .foregroundColor(theme.colors.secondary) + } else { + ForEach(subscribers) { member in + memberRow(member, user: false) + } + } + } + } + } + } + + @ViewBuilder private func memberRow(_ gMember: GMember, user: Bool) -> some View { + let member = gMember.wrapped + let nameText = Text(member.chatViewName) + .foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) + let displayName = member.verified + ? (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption).baselineOffset(2).kerning(-2) + .foregroundColor(theme.colors.secondary) + nameText + : nameText + let row = HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + displayName + .lineLimit(1) + Spacer() + } + if user { + row + } else { + NavigationLink { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: gMember, + scrollToItemId: Binding.constant(nil) + ) + .navigationBarHidden(false) + } label: { + row + } + } + } +} + +#Preview { + ChannelMembersView( + chat: Chat.sampleData, + groupInfo: GroupInfo.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift new file mode 100644 index 0000000000..2ed55d1f28 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -0,0 +1,118 @@ +// +// ChannelRelaysView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 20.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelRelaysView: View { + @ObservedObject var chat: Chat + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var groupRelays: [GroupRelay] = [] + + var body: some View { + let isOwner = groupInfo.isOwner + List { + relaysList(showRelayStatus: isOwner) + } + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + if isOwner { + groupRelays = await apiGetGroupRelays(groupInfo.groupId) + } + } + } + } + + @ViewBuilder private func relaysList(showRelayStatus: Bool) -> some View { + let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay } + if relayMembers.isEmpty { + Section { + Text("No chat relays") + .foregroundColor(theme.colors.secondary) + } + } else { + Section { + ForEach(relayMembers) { member in + NavigationLink { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: member, + scrollToItemId: Binding.constant(nil), + groupRelay: groupRelays.first(where: { $0.groupMemberId == member.wrapped.groupMemberId }) + ) + .navigationBarHidden(false) + } label: { + relayMemberRow(member.wrapped, relayStatus: showRelayStatus ? relayStatusForMember(member.wrapped) : nil) + } + } + } footer: { + Text("Chat relays forward messages to channel subscribers.") + } + } + } + + private func relayStatusForMember(_ member: GroupMember) -> RelayStatus? { + groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus + } + + private func relayMemberRow(_ member: GroupMember, relayStatus: RelayStatus?) -> some View { + HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + Text(member.chatViewName) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + Text(relayStatus?.text ?? relayConnStatusText(member)) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + } + } + + private func relayConnStatusText(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + relayConnStatus(member).text + } + } +} + + +func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) { + switch member.activeConn?.connStatus { + case .ready: ("connected", .green) + case .deleted: ("deleted", .red) + default: ("connecting", .yellow) + } +} + +func hostFromRelayLink(_ link: String) -> String { + if let ft = parseSimpleXMarkdown(link) { + for f in ft { + if case let .simplexLink(_, _, _, smpHosts) = f.format, + let host = smpHosts.first { + return host + } + } + } + return link +} + +#Preview { + ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData) +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 257d5aac93..a30e54ef79 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -90,22 +90,46 @@ struct GroupChatInfoView: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - Section { - if groupInfo.canAddMembers && groupInfo.businessChat == nil { - groupLinkButton() + if groupInfo.useRelays { + Section { + // TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership) + if groupInfo.isOwner && groupLink != nil { + channelLinkButton() + } else if let link = groupInfo.groupProfile.groupLink { + SimpleXLinkQRCode(uri: link) + Button { + showShareSheet(items: [simplexChatLink(link)]) + } label: { + Label("Share link", systemImage: "square.and.arrow.up") + } + } + if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { + channelMembersButton() + } + } footer: { + if !groupInfo.isOwner && groupInfo.groupProfile.groupLink != nil { + Text("You can share a link or a QR code - anybody will be able to join the channel.") + .foregroundColor(theme.colors.secondary) + } } - if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { - memberSupportButton() + } else { + Section { + if groupInfo.canAddMembers && groupInfo.businessChat == nil { + groupLinkButton() + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + if groupInfo.membership.memberActive + && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + } header: { + Text("") } - if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - if groupInfo.membership.memberActive - && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { - UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - } header: { - Text("") } Section { @@ -115,22 +139,28 @@ struct GroupChatInfoView: View { if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { addOrEditWelcomeMessage() } - GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + if !groupInfo.useRelays { + GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + } } footer: { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Only group owners can change group preferences." - : "Only chat owners can change preferences." - ) - Text(label) - .foregroundColor(theme.colors.secondary) + if !groupInfo.useRelays { + let label: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "Only group owners can change group preferences." + : "Only chat owners can change preferences." + ) + Text(label) + .foregroundColor(theme.colors.secondary) + } } Section { - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() + if !groupInfo.useRelays { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } } NavigationLink { ChatWallpaperEditorSheet(chat: chat) @@ -142,7 +172,7 @@ struct GroupChatInfoView: View { Text("Delete chat messages from your device.") } - if !groupInfo.nextConnectPrepared { + if !groupInfo.nextConnectPrepared && !groupInfo.useRelays { Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { @@ -174,12 +204,18 @@ struct GroupChatInfoView: View { } Section { + if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) { + channelRelaysButton() + } clearChatButton() if groupInfo.canDelete { deleteGroupButton() } if groupInfo.membership.memberCurrentOrPending { - leaveGroupButton() + if !groupInfo.useRelays || !groupInfo.isOwner + || members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) { + leaveGroupButton() + } } } @@ -220,13 +256,15 @@ struct GroupChatInfoView: View { sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups } sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) - do { - if let gLink = try apiGetGroupLink(groupInfo.groupId) { - groupLink = gLink - groupLinkMemberRole = gLink.acceptMemberRole + if !groupInfo.useRelays || groupInfo.isOwner { + do { + if let gLink = try apiGetGroupLink(groupInfo.groupId) { + groupLink = gLink + groupLinkMemberRole = gLink.acceptMemberRole + } + } catch let error { + logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } - } catch let error { - logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } } } @@ -299,7 +337,9 @@ struct GroupChatInfoView: View { let buttonWidth = g.size.width / 4 HStack(alignment: .center, spacing: 8) { searchButton(width: buttonWidth) - if groupInfo.canAddMembers { + if groupInfo.useRelays && groupInfo.isOwner { + channelLinkActionButton(width: buttonWidth) + } else if !groupInfo.useRelays && groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } if let nextNtfMode = chat.chatInfo.nextNtfMode { @@ -360,6 +400,23 @@ struct GroupChatInfoView: View { .disabled(!groupInfo.ready) } + private func channelLinkActionButton(width: CGFloat) -> some View { + ZStack { + InfoViewButton(image: "link", title: "link", width: width) { + groupLinkNavLinkActive = true + } + + NavigationLink(isActive: $groupLinkNavLinkActive) { + groupLinkDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + .disabled(!groupInfo.ready) + } + private func addMembersButton() -> some View { let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType { case .customer: "Add team members" @@ -545,19 +602,51 @@ struct GroupChatInfoView: View { } } + private func channelLinkButton() -> some View { + NavigationLink { + groupLinkDestinationView() + } label: { + Label("Channel link", systemImage: "link") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole, showTitle: false, - creatingGroup: false + creatingGroup: false, + isChannel: groupInfo.useRelays ) - .navigationBarTitle("Group link") + .navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } + private func channelMembersButton() -> some View { + let label: LocalizedStringKey = groupInfo.isOwner ? "Owners & subscribers" : "Owners" + return NavigationLink { + ChannelMembersView(chat: chat, groupInfo: groupInfo) + .navigationTitle(label) + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(label, systemImage: "person.2") + } + } + + private func channelRelaysButton() -> some View { + NavigationLink { + ChannelRelaysView(chat: chat, groupInfo: groupInfo) + .navigationTitle("Chat relays") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Chat relays", systemImage: "externaldrive.connected.to.line.below") + } + } + struct UserSupportChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme @@ -652,7 +741,7 @@ struct GroupChatInfoView: View { groupProfile: groupInfo.groupProfile ) } label: { - Label("Edit group profile", systemImage: "pencil") + Label(groupInfo.useRelays ? "Edit channel profile" : "Edit group profile", systemImage: "pencil") } } @@ -674,7 +763,7 @@ struct GroupChatInfoView: View { } @ViewBuilder private func deleteGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat" Button(role: .destructive) { alert = .deleteGroupAlert } label: { @@ -693,7 +782,7 @@ struct GroupChatInfoView: View { } private func leaveGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" + let label: LocalizedStringKey = groupInfo.useRelays ? "Leave channel" : groupInfo.businessChat == nil ? "Leave group" : "Leave chat" return Button(role: .destructive) { alert = .leaveGroupAlert } label: { @@ -704,7 +793,7 @@ struct GroupChatInfoView: View { // TODO reuse this and clearChatAlert with ChatInfoView private func deleteGroupAlert() -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: deleteGroupAlertMessage(groupInfo), @@ -741,9 +830,11 @@ struct GroupChatInfoView: View { } private func leaveGroupAlert() -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "You will stop receiving messages from this channel. Chat history will be preserved." + : groupInfo.businessChat == nil ? "You will stop receiving messages from this group. Chat history will be preserved." : "You will stop receiving messages from this chat. Chat history will be preserved." ) @@ -794,9 +885,13 @@ struct GroupChatInfoView: View { func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { showAlert( - NSLocalizedString("Remove member?", comment: "alert title"), + groupInfo.useRelays + ? NSLocalizedString("Remove subscriber?", comment: "alert title") + : NSLocalizedString("Remove member?", comment: "alert title"), message: - groupInfo.businessChat == nil + groupInfo.useRelays + ? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message") + : groupInfo.businessChat == nil ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), actions: {[ @@ -838,10 +933,18 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool } func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { - groupInfo.businessChat == nil ? ( - groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") + groupInfo.useRelays ? ( + groupInfo.membership.memberCurrent + ? Text("Channel will be deleted for all subscribers - this cannot be undone!") + : Text("Channel will be deleted for you - this cannot be undone!") + ) : groupInfo.businessChat == nil ? ( + groupInfo.membership.memberCurrent + ? Text("Group will be deleted for all members - this cannot be undone!") + : Text("Group will be deleted for you - this cannot be undone!") ) : ( - groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!") + groupInfo.membership.memberCurrent + ? Text("Chat will be deleted for all members - this cannot be undone!") + : Text("Chat will be deleted for you - this cannot be undone!") ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index 43bc26e8f8..13b6c0e682 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -17,6 +17,7 @@ struct GroupLinkView: View { @Binding var groupLinkMemberRole: GroupMemberRole var showTitle: Bool = false var creatingGroup: Bool = false + var isChannel: Bool = false var linkCreatedCb: (() -> Void)? = nil @State private var showShortLink = true @State private var creatingLink = false @@ -60,12 +61,16 @@ struct GroupLinkView: View { List { Group { if showTitle { - Text("Group link") + Text(isChannel ? "Channel link" : "Group link") .font(.largeTitle) .bold() .fixedSize(horizontal: false, vertical: true) } - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + if isChannel { + Text("You can share a link or a QR code - anybody will be able to join the channel.") + } else { + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } } .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -73,15 +78,17 @@ struct GroupLinkView: View { Section { if let groupLink = groupLink { - Picker("Initial role", selection: $groupLinkMemberRole) { - ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in - Text(role.text) + if !isChannel { + Picker("Initial role", selection: $groupLinkMemberRole) { + ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in + Text(role.text) + } } + .frame(height: 36) } - .frame(height: 36) SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink) .id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))") - if groupLink.shouldBeUpgraded { + if !isChannel && groupLink.shouldBeUpgraded { Button { upgradeAndShareLinkAlert() } label: { @@ -89,7 +96,7 @@ struct GroupLinkView: View { } } Button { - if groupLink.shouldBeUpgraded { + if !isChannel && groupLink.shouldBeUpgraded { upgradeAndShareLinkAlert(groupLink: groupLink) } else { groupLink.shareAddress(short: showShortLink) @@ -98,7 +105,8 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - if !creatingGroup { + // TODO [relays] review: channel link deletion is only possible together with deleting the channel + if !creatingGroup && !isChannel { Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash") } @@ -110,7 +118,7 @@ struct GroupLinkView: View { .disabled(creatingLink) } } header: { - if let groupLink, groupLink.connLinkContact.connShortLink != nil { + if !isChannel, let groupLink, groupLink.connLinkContact.connShortLink != nil { ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 17a05ffca4..6631fc23c5 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -20,6 +20,7 @@ struct GroupMemberInfoView: View { @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false var openedFromSupportChat: Bool = false + var groupRelay: GroupRelay? = nil @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @State private var connectionLoaded: Bool = false @@ -32,6 +33,26 @@ struct GroupMemberInfoView: View { @State private var justOpened = true @State private var progressIndicator = false + private var channelMemberSectionHeader: LocalizedStringKey { + if groupInfo.useRelays { + switch groupMember.wrapped.memberRole { + case .relay: "Relay" + case .owner: "Owner" + default: "Subscriber" + } + } else { + "Member" + } + } + + private var relaySectionFooter: LocalizedStringKey { + if groupInfo.isOwner { + "Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." + } else { + "You connected to the channel via this relay link." + } + } + enum GroupMemberInfoViewAlert: Identifiable { case blockMemberAlert(mem: GroupMember) case unblockMemberAlert(mem: GroupMember) @@ -89,13 +110,15 @@ struct GroupMemberInfoView: View { .listRowSeparator(.hidden) .padding(.bottom, 18) - infoActionButtons(member) - .padding(.horizontal) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + if !groupInfo.useRelays { + infoActionButtons(member) + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } if connectionLoaded { @@ -103,10 +126,14 @@ struct GroupMemberInfoView: View { Section { if !openedFromSupportChat && groupInfo.membership.memberRole >= .moderator + && member.memberRole != .relay && (member.memberRole < .moderator || member.supportChat != nil) { MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } - if let code = connectionCode { verifyCodeButton(code) } + if let code = connectionCode, + !(groupInfo.useRelays && member.memberRole == .relay) { + verifyCodeButton(code) + } if let connStats = connectionStats, connStats.ratchetSyncAllowed { synchronizeConnectionButton() @@ -141,11 +168,12 @@ struct GroupMemberInfoView: View { } } - Section(header: Text("Member").foregroundColor(theme.colors.secondary)) { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat" + Section { + let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat" infoRow(label, groupInfo.displayName) - if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { + // TODO [relays] review: role changing is not supported for channels currently + if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) { Picker("Change role", selection: $newRole) { ForEach(roles) { role in Text(role.text) @@ -155,6 +183,23 @@ struct GroupMemberInfoView: View { } else { infoRow("Role", member.memberRole.text) } + if let link = member.relayLink { + infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link))) + } + if let address = groupRelay?.userChatRelay.address { + infoRow("Relay address", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(address))) + Button { + showShareSheet(items: [simplexChatLink(address)]) + } label: { + Label("Share relay address", systemImage: "square.and.arrow.up") + } + } + } header: { + Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary) + } footer: { + if groupInfo.useRelays && member.memberRole == .relay { + Text(relaySectionFooter).foregroundColor(theme.colors.secondary) + } } if let connStats = connectionStats { @@ -191,7 +236,7 @@ struct GroupMemberInfoView: View { if groupInfo.membership.memberRole >= .moderator { adminDestructiveSection(member) - } else { + } else if !groupInfo.useRelays { nonAdminBlockSection(member) } @@ -203,16 +248,18 @@ struct GroupMemberInfoView: View { let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel) infoRow("Connection", connLevelDesc) } - Button ("Debug delivery") { - Task { - do { - if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) { - await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) } + if !groupInfo.useRelays || member.memberRole == .relay { + Button ("Debug delivery") { + Task { + do { + if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) { + await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) } + } + } catch let e { + logger.error("apiContactQueueInfo error: \(responseError(e))") + let a = getErrorAlert(e, "Error") + await MainActor.run { alert = .error(title: a.title, error: a.message) } } - } catch let e { - logger.error("apiContactQueueInfo error: \(responseError(e))") - let a = getErrorAlert(e, "Error") - await MainActor.run { alert = .error(title: a.title, error: a.message) } } } } @@ -576,7 +623,9 @@ struct GroupMemberInfoView: View { blockForAllButton(mem) } } - if canRemove { + // TODO [relays] removing relay should also remove its link from group link data; + // removing last relay should be prohibited or show warning + if canRemove && mem.memberRole != .relay { if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft { deleteMemberMessagesButton(mem) } else { @@ -638,7 +687,7 @@ struct GroupMemberInfoView: View { Button(role: .destructive) { showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss) } label: { - Label("Remove member", systemImage: "trash") + Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash") .foregroundColor(.red) } } @@ -818,7 +867,7 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( - title: Text("Block member for all?"), + title: Text(gInfo.useRelays ? "Block subscriber for all?" : "Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { blockMemberForAll(gInfo, mem, true) @@ -829,7 +878,7 @@ func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( - title: Text("Unblock member for all?"), + title: Text(gInfo.useRelays ? "Unblock subscriber for all?" : "Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { blockMemberForAll(gInfo, mem, false) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 381057db5b..b4590fc124 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -244,7 +244,7 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing) { tagChatButton(chat) - if (groupInfo.membership.memberCurrentOrPending) { + if groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { @@ -269,7 +269,7 @@ struct ChatListNavLink: View { let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator let showClearButton = !chat.chatItems.isEmpty let showDeleteGroup = groupInfo.canDelete - let showLeaveGroup = groupInfo.membership.memberCurrentOrPending + let showLeaveGroup = groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) if showClearButton && totalNumberOfButtons <= 3 { @@ -565,7 +565,7 @@ struct ChatListNavLink: View { } private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: deleteGroupAlertMessage(groupInfo), @@ -620,9 +620,11 @@ struct ChatListNavLink: View { } private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "You will stop receiving messages from this channel. Chat history will be preserved." + : groupInfo.businessChat == nil ? "You will stop receiving messages from this group. Chat history will be preserved." : "You will stop receiving messages from this chat. Chat history will be preserved." ) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d84fa29c81..3050b0d4cd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -64,13 +64,14 @@ enum ActiveFilter: Identifiable, Equatable { } class SaveableSettings: ObservableObject { - @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: []) + @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: []) } struct ServerSettings { public var currUserServers: [UserOperatorServers] public var userServers: [UserOperatorServers] public var serverErrors: [UserServersError] + public var serverWarnings: [UserServersWarning] } struct UserPickerSheetView: View { diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index 85ef85c611..902a3f95d7 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -17,6 +17,15 @@ extension View { self } } + + @inline(__always) + @ViewBuilder func compactSectionSpacing() -> some View { + if #available(iOS 17, *) { + self.listSectionSpacing(.compact) + } else { + self + } + } } extension Notification.Name { diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift new file mode 100644 index 0000000000..15be6aa969 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -0,0 +1,396 @@ +// +// AddChannelView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 23.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct AddChannelView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @StateObject private var channelRelaysModel = ChannelRelaysModel.shared + @StateObject private var ss = SaveableSettings() + @State private var profile = GroupProfile(displayName: "", fullName: "") + @FocusState private var focusDisplayName: Bool + @State private var showChooseSource = false + @State private var showImagePicker = false + @State private var showTakePhoto = false + @State private var chosenImage: UIImage? = nil + @State private var hasRelays = true + @State private var groupInfo: GroupInfo? = nil + @State private var groupLink: GroupLink? = nil + @State private var groupRelays: [GroupRelay] = [] + @State private var creationInProgress = false + @State private var showLinkStep = false + @State private var relayListExpanded = false + + var body: some View { + Group { + if showLinkStep, let gInfo = groupInfo { + linkStepView(gInfo) + } else if let gInfo = groupInfo { + progressStepView(gInfo) + } else { + profileStepView() + } + } + } + + // MARK: - Step 1: Profile + + private func profileStepView() -> some View { + List { + Group { + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, size: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } + } + } + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + + Section { + channelNameTextField() + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + .environmentObject(ss) + } label: { + let color: Color = hasRelays ? .accentColor : .orange + settingsRow("externaldrive.connected.to.line.below", color: color) { + Text("Configure relays").foregroundColor(color) + } + } + let canCreate = canCreateProfile() && hasRelays && !creationInProgress + Button(action: createChannel) { + settingsRow("checkmark", color: canCreate ? theme.colors.primary : theme.colors.secondary) { Text("Create channel") } + } + .disabled(!canCreate) + } footer: { + if !hasRelays { + ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning")) + } else { + Text("Your profile will be shared with chat relays and subscribers.") + .foregroundColor(theme.colors.secondary) + } + } + .compactSectionSpacing() + } + .onAppear { + Task { hasRelays = await checkHasRelays() } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + focusDisplayName = true + } + } + .confirmationDialog("Channel image", isPresented: $showChooseSource, titleVisibility: .visible) { + Button("Take picture") { showTakePhoto = true } + Button("Choose from library") { showImagePicker = true } + } + .fullScreenCover(isPresented: $showTakePhoto) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + CameraImagePicker(image: $chosenImage) + } + } + .sheet(isPresented: $showImagePicker) { + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { showImagePicker = false } + } + } + .onChange(of: chosenImage) { image in + Task { + let resized: String? = if let image { + await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) + } else { + nil + } + await MainActor.run { profile.image = resized } + } + } + .modifier(ThemedBackground(grouped: true)) + } + + private func channelNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidChannelNameAlert() + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(theme.colors.secondary) + } + TextField("Enter channel name…", text: $profile.displayName) + .padding(.leading, 36) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() && hasRelays { createChannel() } + } + } + } + + private func canCreateProfile() -> Bool { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + return name != "" && validDisplayName(name) + } + + private func createChannel() { + focusDisplayName = false + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) + profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on)) + creationInProgress = true + Task { + do { + let enabledRelays = try await getEnabledRelays() + let relayIds = enabledRelays.compactMap { $0.chatRelayId } + guard !relayIds.isEmpty else { + await MainActor.run { + creationInProgress = false + hasRelays = false + } + return + } + guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup( + incognito: false, relayIds: relayIds, groupProfile: profile + ) else { + await MainActor.run { creationInProgress = false } + return + } + await MainActor.run { + m.updateGroup(gInfo) + groupInfo = gInfo + groupLink = gLink + groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } + channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays) + creationInProgress = false + } + } catch { + await MainActor.run { + creationInProgress = false + showAlert( + NSLocalizedString("Error creating channel", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + // TODO [relays] move random relay selection to backend; prefer selecting relays from different operators + private func getEnabledRelays() async throws -> [UserChatRelay] { + let servers = try await getUserServers() + let all = servers.flatMap { op in + op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + } + return Array(all.shuffled().prefix(3)) + } + + private func checkHasRelays() async -> Bool { + guard let servers = try? await getUserServers() else { return false } + return servers.contains { op in + op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + } + } + + // MARK: - Step 2: Progress + + private func progressStepView(_ gInfo: GroupInfo) -> some View { + let activeCount = groupRelays.filter { $0.relayStatus == .rsActive }.count + let total = groupRelays.count + return List { + Group { + ProfileImage(imageStr: gInfo.groupProfile.image, size: 128) + .frame(maxWidth: .infinity, alignment: .center) + + Text(gInfo.groupProfile.displayName) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .center) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + + Section { + Button { + withAnimation { relayListExpanded.toggle() } + } label: { + HStack(spacing: 8) { + if activeCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + Spacer() + Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(theme.colors.secondary) + } + } + .foregroundColor(theme.colors.onBackground) + + if relayListExpanded { + ForEach(groupRelays) { relay in + HStack { + Text(relayDisplayName(relay)) + Spacer() + relayStatusIndicator(relay.relayStatus) + } + } + } + } + .compactSectionSpacing() + + Section { + Button("Channel link") { + if activeCount >= total { + showLinkStep = true + } else if activeCount > 0 { + showAlert( + NSLocalizedString("Not all relays connected", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total), + actions: {[ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } + ]} + ) + } + } + .disabled(activeCount == 0) + } + } + .navigationTitle("Creating channel") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { cancelChannelCreation(gInfo) } + } + } + .onChange(of: channelRelaysModel.groupRelays) { relays in + guard channelRelaysModel.groupId == gInfo.groupId else { return } + groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } + if relays.allSatisfy({ $0.relayStatus == .rsActive }) { + showLinkStep = true + channelRelaysModel.reset() + } + } + } + + // MARK: - Step 3: Link + + private func linkStepView(_ gInfo: GroupInfo) -> some View { + GroupLinkView( + groupId: gInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays + showTitle: false, + creatingGroup: true, + isChannel: true + ) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(gInfo.id) + } + } + } + .navigationBarTitle("Channel link") + } + + private func cancelChannelCreation(_ gInfo: GroupInfo) { + channelRelaysModel.reset() + dismissAllSheets(animated: true) + Task { + do { + try await apiDeleteChat(type: .group, id: gInfo.apiId) + await MainActor.run { m.removeChat(gInfo.id) } + } catch { + logger.error("cancelChannelCreation error: \(responseError(error))") + } + } + } + + // MARK: - Helpers + + private func showInvalidChannelNameAlert() { + let validName = mkValidName(profile.displayName) + if validName == "" { + showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) + } else { + showAlert( + NSLocalizedString("Invalid name!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in + profile.displayName = validName + }, + cancelAlertAction + ]} + ) + } + } + +} + +func relayDisplayName(_ relay: GroupRelay) -> String { + if !relay.userChatRelay.name.isEmpty { return relay.userChatRelay.name } + if let domain = relay.userChatRelay.domains.first { return domain } + if let link = relay.relayLink { return hostFromRelayLink(link) } + return "relay \(relay.groupRelayId)" +} + +func relayStatusIndicator(_ status: RelayStatus) -> some View { + HStack(spacing: 4) { + Circle() + .fill(status == .rsActive ? .green : status == .rsNew ? .red : .orange) + .frame(width: 8, height: 8) + Text(status.text) + .font(.caption) + .foregroundStyle(.secondary) + } +} + +struct RelayProgressIndicator: View { + var active: Int + var total: Int + + var body: some View { + if active == 0 { + ProgressView() + .frame(width: 20, height: 20) + } else { + ZStack { + Circle() + .stroke(Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 2.5)) + Circle() + .trim(from: 0, to: Double(active) / Double(max(total, 1))) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 20, height: 20) + } + } +} + +#Preview { + AddChannelView() +} diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 901b2deeab..c74e016974 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -88,7 +88,7 @@ struct AddGroupView: View { } .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) Section { groupNameTextField() @@ -108,6 +108,7 @@ struct AddGroupView: View { focusDisplayName = false } } + .compactSectionSpacing() } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 7adb04cb7e..a1cf1007e0 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -125,6 +125,14 @@ struct NewChatSheet: View { } label: { Label("Create group", systemImage: "person.2.circle.fill") } + NavigationLink { + AddChannelView() + .navigationTitle("Create channel") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Create channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill") + } } if (showArchive) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 71a155949b..95f7fa2e9d 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -990,42 +990,67 @@ private func showOwnGroupLinkConfirmConnectSheet( dismiss: Bool, cleanup: (() -> Void)? ) { - showSheet( - String.localizedStringWithFormat( - NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"), - groupInfo.displayName - ), - actions: {[ - UIAlertAction( - title: NSLocalizedString("Open group", comment: "new chat action"), - style: .default, - handler: { _ in - openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) - } + if groupInfo.useRelays { + showSheet( + String.localizedStringWithFormat( + NSLocalizedString("This is your link for channel %@!", comment: "new chat action"), + groupInfo.displayName ), - UIAlertAction( - title: NSLocalizedString("Use current profile", comment: "new chat action"), - style: .destructive, - handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) - } + actions: {[ + UIAlertAction( + title: NSLocalizedString("Open channel", comment: "new chat action"), + style: .default, + handler: { _ in + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "new chat action"), + style: .default, + handler: { _ in + cleanup?() + } + ) + ]} + ) + } else { + showSheet( + String.localizedStringWithFormat( + NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"), + groupInfo.displayName ), - UIAlertAction( - title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), - style: .destructive, - handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) - } - ), - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "new chat action"), - style: .default, - handler: { _ in - cleanup?() - } - ) - ]} - ) + actions: {[ + UIAlertAction( + title: NSLocalizedString("Open group", comment: "new chat action"), + style: .default, + handler: { _ in + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Use current profile", comment: "new chat action"), + style: .destructive, + handler: { _ in + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), + style: .destructive, + handler: { _ in + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "new chat action"), + style: .default, + handler: { _ in + cleanup?() + } + ) + ]} + ) + } } private func showPrepareContactAlert( @@ -1074,30 +1099,45 @@ private func showPrepareContactAlert( private func showPrepareGroupAlert( connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? ) { + let isChannel = !(groupShortLinkInfo?.direct ?? true) showOpenChatAlert( profileName: groupShortLinkData.groupProfile.displayName, profileFullName: groupShortLinkData.groupProfile.fullName, - profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize), + profileImage: + ProfileImage( + imageStr: groupShortLinkData.groupProfile.image, + iconName: isChannel + ? "antenna.radiowaves.left.and.right.circle.fill" + : "person.2.circle.fill", + size: alertProfileImageSize + ), theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), - confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"), + confirmTitle: isChannel + ? NSLocalizedString("Open new channel", comment: "new chat action") + : NSLocalizedString("Open new group", comment: "new chat action"), onCancel: { cleanup?() }, onConfirm: { Task { do { - let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData) + let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData) await MainActor.run { + if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty, + case let .group(gInfo, _) = chat.chatInfo { + ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays + } ChatModel.shared.addChat(Chat(chat)) openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) } } catch let error { logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)") - showAlert(NSLocalizedString("Error opening group", comment: ""), message: responseError(error)) + showAlert(NSLocalizedString(isChannel ? "Error opening channel" : "Error opening group", comment: "alert title"), message: responseError(error)) await MainActor.run { cleanup?() } @@ -1150,7 +1190,12 @@ private func showOpenKnownGroupAlert( theme: theme, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: - groupInfo.businessChat == nil + groupInfo.useRelays + ? ( groupInfo.nextConnectPrepared + ? NSLocalizedString("Open new channel", comment: "new chat action") + : NSLocalizedString("Open channel", comment: "new chat action") + ) + : groupInfo.businessChat == nil ? ( groupInfo.nextConnectPrepared ? NSLocalizedString("Open new group", comment: "new chat action") : NSLocalizedString("Open group", comment: "new chat action") @@ -1174,6 +1219,14 @@ func planAndConnect( filterKnownContact: ((Contact) -> Void)? = nil, filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { + if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + cleanup?() + return + } ConnectProgressManager.shared.cancelConnectProgress() let inProgress = BoxedValue(true) connectTask(inProgress) @@ -1332,12 +1385,13 @@ func planAndConnect( } case let .groupLink(glp): switch glp { - case let .ok(groupSLinkData_): + case let .ok(groupShortLinkInfo_, groupSLinkData_): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { showPrepareGroupAlert( connectionLink: connectionLink, + groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, theme: theme, dismiss: dismiss, diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift new file mode 100644 index 0000000000..790edb9be7 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -0,0 +1,323 @@ +// +// ChatRelayView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 23.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// +// Spec: spec/architecture.md + +import SwiftUI +import SimpleXChat + +@ViewBuilder func showRelayTestStatus(relay: UserChatRelay) -> some View { + switch relay.tested { + case .some(true): Image(systemName: "checkmark").foregroundColor(.green) + case .some(false): Image(systemName: "multiply").foregroundColor(.red) + case .none: Color.clear + } +} + +func validRelayName(_ name: String) -> Bool { + name != "" && validDisplayName(name) +} + +func showInvalidRelayNameAlert(_ name: Binding) { + let validName = mkValidName(name.wrappedValue) + if validName == "" { + showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) + } else { + showAlert( + NSLocalizedString("Invalid name!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in + name.wrappedValue = validName + }, + cancelAlertAction + ]} + ) + } +} + +func validRelayAddress(_ address: String) -> Bool { + if let parsedMd = parseSimpleXMarkdown(address), + parsedMd.count == 1, + case .simplexLink(_, .relay, _, _) = parsedMd.first?.format { + true + } else { + false + } +} + +// TODO [relays] TBC matching relay to operator by domain (relay address can be hosted on operator server) +func addChatRelay( + _ relay: UserChatRelay, + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, + _ dismiss: DismissAction +) { + let nameEmpty = relay.name.trimmingCharacters(in: .whitespaces).isEmpty + let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty + if nameEmpty && addressEmpty { + dismiss() + } else if !validRelayName(relay.name) { + dismiss() + showAlert( + NSLocalizedString("Invalid relay name!", comment: "alert title"), + message: NSLocalizedString("Check relay name and try again.", comment: "alert message") + ) + } else if !validRelayAddress(relay.address) { + dismiss() + showAlert( + NSLocalizedString("Invalid relay address!", comment: "alert title"), + message: NSLocalizedString("Check relay address and try again.", comment: "alert message") + ) + } else if let i = userServers.wrappedValue.firstIndex(where: { $0.operator == nil }) { + userServers[i].wrappedValue.chatRelays.append(relay) + validateServers_(userServers, serverErrors, serverWarnings) + dismiss() + } else { // Shouldn't happen + dismiss() + showAlert(NSLocalizedString("Error adding relay", comment: "alert title")) + } +} + +struct ChatRelayView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @Binding var relay: UserChatRelay + @State var relayToEdit: UserChatRelay + var backLabel: LocalizedStringKey + + var body: some View { + let validName = validRelayName(relayToEdit.name) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + if relay.preset { + presetRelay() + } else { + customRelay(validName: validName, validAddress: validAddress) + } + } + .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { + if validName && validAddress { + relay = relayToEdit + validateServers_($userServers, $serverErrors, $serverWarnings) + dismiss() + } else if !validName { + dismiss() + showAlert( + NSLocalizedString("Invalid relay name!", comment: "alert title"), + message: NSLocalizedString("Check relay name and try again.", comment: "alert message") + ) + } else { + dismiss() + showAlert( + NSLocalizedString("Invalid relay address!", comment: "alert title"), + message: NSLocalizedString("Check relay address and try again.", comment: "alert message") + ) + } + }) + } + + private func relayNameHeader(validName: Bool) -> some View { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.name) } + } + } + } + + private func presetRelay() -> some View { + List { + Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.name) + } + Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.address) + .textSelection(.enabled) + } + useRelaySection() + } + } + + private func customRelay(validName: Bool, validAddress: Bool) -> some View { + List { + Section { + TextField("Enter relay name…", text: $relayToEdit.name) + .autocorrectionDisabled(true) + } header: { + relayNameHeader(validName: validName) + } + Section { + TextEditor(text: $relayToEdit.address) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your relay address") + .foregroundColor(theme.colors.secondary) + if !validAddress { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + useRelaySection(valid: validAddress) + Section { + Button(role: .destructive) { + relay.deleted = true + validateServers_($userServers, $serverErrors, $serverWarnings) + dismiss() + } label: { + Label("Delete relay", systemImage: "trash") + .foregroundColor(.red) + } + } + } + } + + private func useRelaySection(valid: Bool = true) -> some View { + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + showAlert( + NSLocalizedString("Not implemented", comment: "alert title"), + message: NSLocalizedString("Relay testing is not yet available.", comment: "alert message") + ) + } + .disabled(!valid) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } +} + +struct ChatRelayViewLink: View { + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @Binding var relay: UserChatRelay + var backLabel: LocalizedStringKey + @Binding var selectedServer: String? + + var body: some View { + NavigationLink(tag: relay.id, selection: $selectedServer) { + ChatRelayView( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: $relay, + relayToEdit: relay, + backLabel: backLabel + ) + .navigationBarTitle("Chat relay") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Group { + if !relay.enabled { + Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) + } else { + showRelayTestStatus(relay: relay) + } + } + .frame(width: 16, alignment: .center) + .padding(.trailing, 4) + + let displayName = !relay.name.isEmpty ? relay.name : relay.domains.first ?? relay.address + let v = Text(displayName).lineLimit(1) + if relay.enabled { + v + } else { + v.foregroundColor(theme.colors.secondary) + } + } + } + } +} + +struct NewChatRelayView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @State private var relayToEdit = UserChatRelay( + chatRelayId: nil, address: "", name: "", domains: [], + preset: false, tested: nil, enabled: true, deleted: false + ) + + var body: some View { + let validName = validRelayName(relayToEdit.name) + let validAddress = validRelayAddress(relayToEdit.address) + List { + Section { + TextField("Enter relay name…", text: $relayToEdit.name) + .autocorrectionDisabled(true) + } header: { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.name) } + } + } + } + Section { + TextEditor(text: $relayToEdit.address) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your relay address") + .foregroundColor(theme.colors.secondary) + if !validAddress { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + showAlert( + NSLocalizedString("Not implemented", comment: "alert title"), + message: NSLocalizedString("Relay testing is not yet available.", comment: "alert message") + ) + } + .disabled(!validAddress) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) + }) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 64e3d15de0..3ff1a2ee68 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -78,6 +78,7 @@ struct NetworkAndServers: View { YourServersView( userServers: $ss.servers.userServers, serverErrors: $ss.servers.serverErrors, + serverWarnings: $ss.servers.serverWarnings, operatorIndex: idx ) .navigationTitle("Your servers") @@ -115,6 +116,9 @@ struct NetworkAndServers: View { } else if !ss.servers.serverErrors.isEmpty { ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) } + if let warnStr = globalServersWarning(ss.servers.serverWarnings) { + ServersWarningView(warnStr: warnStr) + } } Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { @@ -143,6 +147,8 @@ struct NetworkAndServers: View { ss.servers.currUserServers = try await getUserServers() ss.servers.userServers = ss.servers.currUserServers ss.servers.serverErrors = [] + ss.servers.serverWarnings = [] + validateServers_($ss.servers.userServers, $ss.servers.serverErrors, $ss.servers.serverWarnings) } catch let error { await MainActor.run { showAlert( @@ -186,6 +192,7 @@ struct NetworkAndServers: View { currUserServers: $ss.servers.currUserServers, userServers: $ss.servers.userServers, serverErrors: $ss.servers.serverErrors, + serverWarnings: $ss.servers.serverWarnings, operatorIndex: operatorIndex, useOperator: serverOperator.enabled ) @@ -360,13 +367,18 @@ struct SimpleConditionsView: View { } } -func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { +func validateServers_( + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil +) { let userServersToValidate = userServers.wrappedValue Task { do { - let errs = try await validateServers(userServers: userServersToValidate) + let (errs, warns) = try await validateServers(userServers: userServersToValidate) await MainActor.run { serverErrors.wrappedValue = errs + serverWarnings?.wrappedValue = warns } } catch let error { logger.error("validateServers error: \(responseError(error))") @@ -396,6 +408,20 @@ struct ServersErrorView: View { } } +struct ServersWarningView: View { + @EnvironmentObject var theme: AppTheme + var warnStr: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(warnStr) + .foregroundColor(theme.colors.secondary) + } + } +} + func globalServersError(_ serverErrors: [UserServersError]) -> String? { for err in serverErrors { if let errStr = err.globalError { @@ -405,6 +431,29 @@ func globalServersError(_ serverErrors: [UserServersError]) -> String? { return nil } +func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? { + for warn in serverWarnings { + switch warn { + case let .noChatRelays(user): + let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning") + if let user = user { + return String.localizedStringWithFormat( + NSLocalizedString("For chat profile %@:", comment: "servers warning"), + user.localDisplayName + ) + " " + text + } else { return text } + } + } + return nil +} + +func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> { + Binding( + get: { userServers[opIndex].wrappedValue.chatRelays }, + set: { userServers[opIndex].wrappedValue.chatRelays = $0 } + ) +} + func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? { for err in serverErrors { if let errStr = err.globalSMPError { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index b44271bd89..0a3c82b4dd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -15,6 +15,7 @@ struct NewServerView: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] @State private var serverToEdit: UserServer = .empty @State private var showTestFailure = false @State private var testing = false @@ -28,7 +29,7 @@ struct NewServerView: View { } } .modifier(BackButton(disabled: Binding.constant(false)) { - addServer(serverToEdit, $userServers, $serverErrors, dismiss) + addServer(serverToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) }) .alert(isPresented: $showTestFailure) { Alert( @@ -118,6 +119,7 @@ func addServer( _ server: UserServer, _ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, _ dismiss: DismissAction ) { if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) { @@ -126,7 +128,7 @@ func addServer( case .smp: userServers[i].wrappedValue.smpServers.append(server) case .xftp: userServers[i].wrappedValue.xftpServers.append(server) } - validateServers_(userServers, serverErrors) + validateServers_(userServers, serverErrors, serverWarnings) dismiss() if let op = matchingOperator { showAlert( @@ -152,6 +154,7 @@ func addServer( #Preview { NewServerView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]) + serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]) ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index abd8be03b9..f8b66d3697 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -19,6 +19,7 @@ struct OperatorView: View { @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int @State var useOperator: Bool @State private var useOperatorToggleReset: Bool = false @@ -52,6 +53,8 @@ struct OperatorView: View { } footer: { if let errStr = globalServersError(serverErrors) { ServersErrorView(errStr: errStr) + } else if let warnStr = globalServersWarning(serverWarnings) { + ServersWarningView(warnStr: warnStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { case let .accepted(acceptedAt, _): @@ -69,15 +72,36 @@ struct OperatorView: View { } if userServers[operatorIndex].operator_.enabled { + if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { + Section { + ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in + if !relay.wrappedValue.deleted { + ChatRelayViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: relay, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { EmptyView() } + } + } header: { + Text("Chat relays").foregroundColor(theme.colors.secondary) + } footer: { + Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary) + } + } + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage) .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy) .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Use for messages") @@ -97,6 +121,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -128,6 +153,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -140,7 +166,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added message servers") @@ -152,7 +178,7 @@ struct OperatorView: View { Section { Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage) .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Use for files") @@ -172,6 +198,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -203,6 +230,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -215,7 +243,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added media & file servers") @@ -246,6 +274,7 @@ struct OperatorView: View { currUserServers: $currUserServers, userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, operatorIndex: operatorIndex ) .modifier(ThemedBackground(grouped: true)) @@ -276,18 +305,18 @@ struct OperatorView: View { switch userServers[operatorIndex].operator_.conditionsAcceptance { case .accepted: userServers[operatorIndex].operator_.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) case let .required(deadline): if deadline == nil { showConditionsSheet = true } else { userServers[operatorIndex].operator_.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } } else { userServers[operatorIndex].operator_.enabled = false - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } } @@ -424,6 +453,7 @@ struct SingleOperatorUsageConditionsView: View { @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int var body: some View { @@ -526,7 +556,7 @@ struct SingleOperatorUsageConditionsView: View { updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) updateOperatorsConditionsAcceptance($userServers, r.serverOperators) userServers[operatorIndexToEnable].operator?.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } } catch let error { @@ -581,6 +611,7 @@ func conditionsLinkButton() -> some View { currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), operatorIndex: 1, useOperator: ServerOperator.sampleData1.enabled ) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 97bf9ebc93..5299b7d415 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -15,6 +15,7 @@ struct ProtocolServerView: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] @Binding var server: UserServer @State var serverToEdit: UserServer var backLabel: LocalizedStringKey @@ -50,7 +51,7 @@ struct ProtocolServerView: View { ) } else { server = serverToEdit - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } } else { @@ -202,6 +203,7 @@ struct ProtocolServerView_Previews: PreviewProvider { ProtocolServerView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), server: Binding.constant(UserServer.sampleData.custom), serverToEdit: UserServer.sampleData.custom, backLabel: "Your SMP servers" diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index 49e1ff79ea..e521c7ea26 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -19,10 +19,12 @@ struct YourServersView: View { @Environment(\.editMode) private var editMode @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int @State private var selectedServer: String? = nil @State private var showAddServer = false @State private var newServerNavLinkActive = false + @State private var newChatRelayNavLinkActive = false @State private var showScanProtoServer = false @State private var testing = false @@ -42,6 +44,31 @@ struct YourServersView: View { private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) return List { + if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { + Section { + ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in + if !relay.wrappedValue.deleted { + ChatRelayViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: relay, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { EmptyView() } + } + .onDelete { indexSet in + deleteChatRelay($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors, $serverWarnings) + } + } header: { + Text("Chat relays").foregroundColor(theme.colors.secondary) + } footer: { + Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary) + } + } + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { ForEach($userServers[operatorIndex].smpServers) { srv in @@ -49,6 +76,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -61,7 +89,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Message servers") @@ -84,6 +112,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -96,7 +125,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Media & file servers") @@ -125,10 +154,23 @@ struct YourServersView: View { } .frame(width: 1, height: 1) .hidden() + + NavigationLink(isActive: $newChatRelayNavLinkActive) { + NewChatRelayView(userServers: $userServers, serverErrors: $serverErrors, serverWarnings: $serverWarnings) + .navigationTitle("New chat relay") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } footer: { if let errStr = globalServersError(serverErrors) { ServersErrorView(errStr: errStr) + } else if let warnStr = globalServersWarning(serverWarnings) { + ServersWarningView(warnStr: warnStr) } } @@ -144,7 +186,8 @@ struct YourServersView: View { .toolbar { if ( !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty || - !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty + !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty || + !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty ) { EditButton() } @@ -152,11 +195,13 @@ struct YourServersView: View { .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { Button("Enter server manually") { newServerNavLinkActive = true } Button("Scan server QR code") { showScanProtoServer = true } + Button("Chat relay") { newChatRelayNavLinkActive = true } } .sheet(isPresented: $showScanProtoServer) { ScanProtocolServer( userServers: $userServers, - serverErrors: $serverErrors + serverErrors: $serverErrors, + serverWarnings: $serverWarnings ) .modifier(ThemedBackground(grouped: true)) } @@ -165,7 +210,8 @@ struct YourServersView: View { private func newServerDestinationView() -> some View { NewServerView( userServers: $userServers, - serverErrors: $serverErrors + serverErrors: $serverErrors, + serverWarnings: $serverWarnings ) .navigationTitle("New server") .navigationBarTitleDisplayMode(.large) @@ -190,6 +236,7 @@ struct ProtocolServerViewLink: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var duplicateHosts: Set @Binding var server: UserServer var serverProtocol: ServerProtocol @@ -203,6 +250,7 @@ struct ProtocolServerViewLink: View { ProtocolServerView( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, server: $server, serverToEdit: server, backLabel: backLabel @@ -280,6 +328,23 @@ func deleteXFTPServer( } } +func deleteChatRelay( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let relay = userServers[operatorServersIndex].wrappedValue.chatRelays[idx] + if relay.chatRelayId == nil { + userServers[operatorServersIndex].wrappedValue.chatRelays.remove(at: idx) + } else { + var updatedRelay = relay + updatedRelay.deleted = true + userServers[operatorServersIndex].wrappedValue.chatRelays[idx] = updatedRelay + } + } +} + struct TestServersButton: View { @Binding var smpServers: [UserServer] @Binding var xftpServers: [UserServer] @@ -354,6 +419,7 @@ struct YourServersView_Previews: PreviewProvider { YourServersView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), operatorIndex: 1 ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift index fd29fd906e..b2b4a64f4e 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift @@ -15,6 +15,7 @@ struct ScanProtocolServer: View { @Environment(\.dismiss) var dismiss: DismissAction @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var body: some View { VStack(alignment: .leading) { @@ -36,7 +37,7 @@ struct ScanProtocolServer: View { case let .success(r): var server: UserServer = .empty server.server = r.string - addServer(server, $userServers, $serverErrors, dismiss) + addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss) case let .failure(e): logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)") dismiss() @@ -48,7 +49,8 @@ struct ScanProtocolServer_Previews: PreviewProvider { static var previews: some View { ScanProtocolServer( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]) + serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]) ) } } diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 6495d09b03..f13401d437 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -68,6 +68,7 @@ func apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, scope: chatInfo.groupChatScope(), + sendAsGroup: chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, live: false, ttl: nil, composedMessages: composedMessages @@ -124,7 +125,7 @@ enum SEChatCommand: ChatCmdProtocol { case apiSetEncryptLocalFiles(enable: Bool) case apiGetChats(userId: Int64) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) var cmdString: String { switch self { @@ -140,10 +141,11 @@ enum SEChatCommand: ChatCmdProtocol { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" - case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" } } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 314f1c072c..cd03ae150a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -162,9 +162,13 @@ 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; }; 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; + 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; + 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; + 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; + 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; }; @@ -528,10 +532,14 @@ 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; }; 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; + 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; + 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; + 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; @@ -959,6 +967,7 @@ 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */, 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */, + 647B15E72F4C8D2500EB431E /* AddChannelView.swift */, ); path = NewChat; sourceTree = ""; @@ -1122,6 +1131,7 @@ 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */, + 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */, ); path = NetworkAndServers; sourceTree = ""; @@ -1141,6 +1151,8 @@ 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */, 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */, 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, + 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, + 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, ); path = Group; sourceTree = ""; @@ -1470,6 +1482,7 @@ 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, + 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, @@ -1572,6 +1585,8 @@ 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */, E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */, 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, + 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, + 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, @@ -1611,6 +1626,7 @@ 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, + 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */, 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, 1841538E296606C74533367C /* UserPicker.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b31a799e68..5ef5c5d14b 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -727,6 +727,7 @@ public enum ChatErrorType: Decodable, Hashable { case userUnknown case activeUserExists case userExists + case chatRelayExists case invalidDisplayName case differentActiveUser(commandUserId: Int64, activeUserId: Int64) case cantDeleteActiveUser(userId: Int64) @@ -801,6 +802,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) + case relayUserNotFound case userNotFoundByName(contactName: ContactName) case userNotFoundByContactId(contactId: Int64) case userNotFoundByGroupId(groupId: Int64) @@ -825,6 +827,7 @@ public enum StoreError: Decodable, Hashable { case memberContactGroupMemberNotFound(contactId: Int64) case groupWithoutUser case duplicateGroupMember + case duplicateMemberId case groupAlreadyJoined case groupInvitationNotFound case sndFileNotFound(fileId: Int64) @@ -859,6 +862,9 @@ public enum StoreError: Decodable, Hashable { case hostMemberIdNotFound(groupId: Int64) case contactNotFoundByFileId(fileId: Int64) case noGroupSndStatus(itemId: Int64, groupMemberId: Int64) + case userChatRelayNotFound(chatRelayId: Int64) + case groupRelayNotFound(groupRelayId: Int64) + case groupRelayNotFoundByMemberId(groupMemberId: Int64) case dBException(message: String) } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0b15666d2..59e1be5c2e 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -43,6 +43,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var autoAcceptMemberContacts: Bool public var viewPwdHash: UserPwdHash? public var uiThemes: ThemeModeOverrides? + public var userChatRelay: Bool public var id: Int64 { userId } @@ -68,7 +69,8 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { showNtfs: true, sendRcptsContacts: true, sendRcptsSmallGroups: false, - autoAcceptMemberContacts: false + autoAcceptMemberContacts: false, + userChatRelay: false ) } @@ -1577,7 +1579,9 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { switch(groupChatScope) { case .none: if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") } - if groupInfo.membership.memberRole == .observer { return ("you are observer", "Please contact group admin.") } + if groupInfo.membership.memberRole == .observer { + return groupInfo.useRelays ? ("you are subscriber", nil) : ("you are observer", "Please contact group admin.") + } return nil case let .some(.memberSupport(groupMember_: .some(supportMember))): if supportMember.versionRange.maxVersion < GROUP_KNOCKING_VERSION && !supportMember.memberPending { @@ -2336,6 +2340,8 @@ public struct Group: Decodable, Hashable { public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupId: Int64 + public var useRelays: Bool + public var relayOwnStatus: RelayStatus? = nil var localDisplayName: GroupName public var groupProfile: GroupProfile public var businessChat: BusinessChatInfo? @@ -2379,15 +2385,20 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { } public var chatIconName: String { - switch businessChat?.chatType { - case .none: "person.2.circle.fill" - case .business: "briefcase.circle.fill" - case .customer: "person.crop.circle.fill" + if useRelays { + "antenna.radiowaves.left.and.right.circle.fill" + } else { + switch businessChat?.chatType { + case .none: "person.2.circle.fill" + case .business: "briefcase.circle.fill" + case .customer: "person.crop.circle.fill" + } } } public static let sampleData = GroupInfo( groupId: 1, + useRelays: false, localDisplayName: "team", groupProfile: GroupProfile.sampleData, fullGroupPreferences: FullGroupPreferences.sampleData, @@ -2419,6 +2430,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { shortDescr: String? = nil, description: String? = nil, image: String? = nil, + groupLink: String? = nil, groupPreferences: GroupPreferences? = nil, memberAdmission: GroupMemberAdmission? = nil ) { @@ -2427,6 +2439,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { self.shortDescr = shortDescr self.description = description self.image = image + self.groupLink = groupLink self.groupPreferences = groupPreferences self.memberAdmission = memberAdmission } @@ -2436,6 +2449,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var shortDescr: String? public var description: String? public var image: String? + public var groupLink: String? public var groupPreferences: GroupPreferences? public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } @@ -2489,6 +2503,75 @@ public struct GroupShortLinkData: Codable, Hashable { public var groupProfile: GroupProfile } +public enum RelayStatus: String, Decodable, Equatable, Hashable { + case rsNew = "new" + case rsInvited = "invited" + case rsAccepted = "accepted" + case rsActive = "active" +} + +public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable { + public var chatRelayId: Int64? + public var address: String + public var name: String + public var domains: [String] + public var preset: Bool + public var tested: Bool? + public var enabled: Bool + public var deleted: Bool + public var createdAt = Date() + + public init(chatRelayId: Int64? = nil, address: String, name: String, domains: [String], preset: Bool, tested: Bool? = nil, enabled: Bool, deleted: Bool, createdAt: Date = Date()) { + self.chatRelayId = chatRelayId + self.address = address + self.name = name + self.domains = domains + self.preset = preset + self.tested = tested + self.enabled = enabled + self.deleted = deleted + self.createdAt = createdAt + } + + public static func == (l: UserChatRelay, r: UserChatRelay) -> Bool { + l.chatRelayId == r.chatRelayId && l.address == r.address && l.name == r.name && l.domains == r.domains && + l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled && l.deleted == r.deleted + } + + public var id: String { "\(address) \(createdAt)" } + + public enum CodingKeys: CodingKey { + case chatRelayId + case address + case name + case domains + case preset + case tested + case enabled + case deleted + } +} + +public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { + public var groupRelayId: Int64 + public var groupMemberId: Int64 + public var userChatRelay: UserChatRelay + public var relayStatus: RelayStatus + public var relayLink: String? + public var id: Int64 { groupRelayId } +} + +extension RelayStatus { + public var text: LocalizedStringKey { + switch self { + case .rsNew: "New" + case .rsInvited: "Invited" + case .rsAccepted: "Accepted" + case .rsActive: "Active" + } + } +} + public struct BusinessChatInfo: Decodable, Hashable { public var chatType: BusinessChatType public var businessId: String @@ -2517,6 +2600,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var activeConn: Connection? public var supportChat: GroupSupportChat? public var memberChatVRange: VersionRange + public var relayLink: String? public var id: String { "#\(groupId) @\(groupMemberId)" } public var ready: Bool { get { activeConn?.connStatus == .ready } } @@ -2642,14 +2726,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { - if !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } + if memberRole == .relay || !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } let userRole = groupInfo.membership.memberRole return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberRole < .moderator + return memberRole != .relay && memberRole < .moderator && userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2720,6 +2804,7 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { + case relay case observer case author case member @@ -2733,6 +2818,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod public var text: String { switch self { + case .relay: return NSLocalizedString("relay", comment: "member role") case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") @@ -2744,12 +2830,13 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: 0 - case .author: 1 - case .member: 2 - case .moderator: 3 - case .admin: 4 - case .owner: 5 + case .relay: 0 + case .observer: 1 + case .author: 2 + case .member: 3 + case .moderator: 4 + case .admin: 5 + case .owner: 6 } } @@ -3217,6 +3304,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership return m.memberRole >= .moderator ? (groupInfo, nil) : nil + case (.group, .channelRcv): + return nil default: return nil } } @@ -3437,6 +3526,7 @@ public enum CIDirection: Decodable, Hashable { case directRcv case groupSnd case groupRcv(groupMember: GroupMember) + case channelRcv case localSnd case localRcv @@ -3447,6 +3537,7 @@ public enum CIDirection: Decodable, Hashable { case .directRcv: return false case .groupSnd: return true case .groupRcv: return false + case .channelRcv: return false case .localSnd: return true case .localRcv: return false } @@ -3456,6 +3547,7 @@ public enum CIDirection: Decodable, Hashable { public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId + case (.channelRcv, .channelRcv): true default: sent == dir.sent } } @@ -4047,6 +4139,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { case .directRcv: return nil case .groupSnd: return membership?.displayName ?? "you" case let .groupRcv(member): return member.displayName + case .channelRcv: return nil case .localSnd: return "you" case .localRcv: return nil case nil: return nil @@ -4689,7 +4782,7 @@ public enum SimplexLinkType: String, Decodable, Hashable { case .invitation: return NSLocalizedString("SimpleX one-time invitation", comment: "simplex link type") case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type") case .channel: return NSLocalizedString("SimpleX channel link", comment: "simplex link type") - case .relay: return NSLocalizedString("SimpleX relay link", comment: "simplex link type") + case .relay: return NSLocalizedString("SimpleX relay address", comment: "simplex link type") } } } diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index a60fe98cbb..3fa722d47a 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -49,6 +49,7 @@ This document provides a structured mapping between product-level concepts, thei | 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` | | 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) | | 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | +| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) | --- diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md index 7b9c8ee304..05051141f9 100644 --- a/apps/ios/product/flows/connection.md +++ b/apps/ios/product/flows/connection.md @@ -58,7 +58,7 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi ### 3. Prepared Contact/Group Flow (Short Links) 1. For short links with embedded profile data, the app uses a two-phase flow. -2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:groupShortLinkData:)` creates a local prepared chat. +2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` creates a local prepared chat. `directLink` is `true` for standard group links, `false` for channel relay links. 3. Returns `ChatData` with the prepared contact/group shown in UI before connecting. 4. User can switch profiles or set incognito before committing. 5. `apiConnectPreparedContact(contactId:incognito:msg:)` finalizes the connection. @@ -101,6 +101,23 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi 6. User must accept each incoming contact request individually. 7. To delete: `apiDeleteUserAddress()` removes the address and associated SMP queues. +### 7a. Relay Link Rejection + +1. User scans, pastes, or opens a relay address link (URL path `/r` or `SimplexLinkType.relay`). +2. In `ContentView.connectViaUrl_()`: early return with alert "Relay address" / "This is a chat relay address, it cannot be used to connect." +3. In `NewChatView.planAndConnect()`: `.simplexLink(_, .relay, _, _)` pattern triggers the same alert. +4. The link is NOT processed further. No connection is attempted. + +### 7b. Channel Prepared Group Flow + +1. When connecting to a channel link (`GroupShortLinkInfo.direct == false`): +2. `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` is called with `directLink: false`, preparing the channel locally. +3. `groupShortLinkInfo.groupRelays` (hostnames) stored in `ChatModel.shared.channelRelayHostnames[groupId]`. +4. Pre-join UI shows channel icon and "Open new channel" (not "Open new group"). +5. `apiConnectPreparedGroup(groupId:incognito:msg:)` returns `(GroupInfo, [RelayConnectionResult])`. +6. `RelayConnectionResult` contains `relayMember: GroupMember` and optional `relayError: ChatError?` per relay. +7. Relay members are upserted to `chatModel.groupMembers`; `channelRelayHostnames` entry is cleared. + ### 7. Incognito Connection 1. Before connecting, user toggles "Incognito" in the connection UI. @@ -121,6 +138,8 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi | `Contact` | `SimpleXChat/ChatTypes.swift` | Full contact model with profile, connection status, preferences | | `UserContactRequest` | `SimpleXChat/ChatTypes.swift` | Incoming contact request awaiting acceptance | | `ChatType` | `SimpleXChat/ChatTypes.swift` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | +| `GroupShortLinkInfo` | `Shared/Model/AppAPITypes.swift` | Contains `direct: Bool`, `groupRelays: [String]`, `sharedGroupId: String?`; transient data returned by prepare | +| `RelayConnectionResult` | `Shared/Model/AppAPITypes.swift` | Contains `relayMember: GroupMember`, `relayError: ChatError?`; per-relay join outcome | ## Error Cases diff --git a/apps/ios/product/flows/group-lifecycle.md b/apps/ios/product/flows/group-lifecycle.md index 78d4f28738..e102fa982a 100644 --- a/apps/ios/product/flows/group-lifecycle.md +++ b/apps/ios/product/flows/group-lifecycle.md @@ -29,6 +29,18 @@ Complete group management in SimpleX Chat iOS: creating groups, inviting members 8. User is navigated to `AddGroupMembersView` to optionally invite contacts. 9. User can also create a group link at this stage. +### 1a. Create Public Group (Channel) + +1. Alternative to standard group creation for relay-backed channels. +2. Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`: + ```swift + func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay]) + ``` +3. Sends `ChatCommand.apiNewPublicGroup(userId:incognito:relayIds:groupProfile:)` to core. +4. Core returns `ChatResponse2.publicGroupCreated(user, groupInfo, groupLink, groupRelays)`. +5. The resulting `GroupInfo` has `useRelays == true` and includes a group link. +6. Channel relay members (with role `.relay`) are managed by the core. + ### 2. Invite Members 1. From `GroupChatInfoView`, user taps "Add members" -> `AddGroupMembersView`. @@ -46,7 +58,7 @@ Complete group management in SimpleX Chat iOS: creating groups, inviting members 1. User receives a group link (scanned or pasted). 2. `apiConnectPlan` validates the link and identifies it as a group link. -3. For prepared groups (short links): `apiPrepareGroup(connLink:groupShortLinkData:)` shows group info before joining. +3. For prepared groups (short links): `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` shows group info before joining. `directLink` is `true` for standard group links, `false` for channel relay links. 4. `apiConnectPreparedGroup(groupId:incognito:msg:)` or `apiConnect(incognito:connLink:)` initiates joining. 5. Core processes the join request. Depending on group admission settings: - **Auto-join**: Member is added immediately. @@ -173,7 +185,7 @@ Complete group management in SimpleX Chat iOS: creating groups, inviting members | `GroupInfo` | `SimpleXChat/ChatTypes.swift` | Full group model: ID, profile, membership, preferences, business chat info | | `GroupProfile` | `SimpleXChat/ChatTypes.swift` | Name, full name, image, description, preferences | | `GroupMember` | `SimpleXChat/ChatTypes.swift` | Member model: role, status, profile, connection info | -| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer` | +| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer`, `.relay` | | `GroupMemberStatus` | `SimpleXChat/ChatTypes.swift` | Member lifecycle: `.invited`, `.accepted`, `.connected`, `.complete`, etc. | | `GroupLink` | `Shared/Model/AppAPITypes.swift` | Group link with URI, member role, and short link data | | `BusinessChatInfo` | `SimpleXChat/ChatTypes.swift` | Business chat metadata for commercial group chats | diff --git a/apps/ios/product/flows/messaging.md b/apps/ios/product/flows/messaging.md index 527079995c..d37fefdd7d 100644 --- a/apps/ios/product/flows/messaging.md +++ b/apps/ios/product/flows/messaging.md @@ -29,7 +29,7 @@ Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, e mentions: [:] ) ``` -6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:)`. +6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:sendAsGroup:)` (where `sendAsGroup` defaults to `false`; set to `true` when a channel owner sends as the channel identity). 7. Internally dispatches `ChatCommand.apiSendMessages(...)` to the Haskell core. 8. Core encrypts, queues via SMP, and returns `ChatResponse1.newChatItems(user, aChatItems)`. 9. `processSendMessageCmd` extracts `[ChatItem]` from response. @@ -104,7 +104,7 @@ Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, e 2. `ChatItemForwardingView` is presented for destination chat selection. 3. `apiPlanForwardChatItems(type:id:scope:itemIds:)` validates what can be forwarded, returns `([Int64], ForwardConfirmation?)`. 4. User confirms and selects destination chat. -5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:)`. +5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:sendAsGroup:)` (where `sendAsGroup` defaults to `false`). 6. Core returns `ChatResponse1.newChatItems(...)` with the forwarded items in the destination chat. ### 9. Voice Message @@ -136,7 +136,7 @@ Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, e | `MsgContent` | `SimpleXChat/ChatTypes.swift` | Enum: `.text`, `.link`, `.image`, `.video`, `.voice`, `.file` | | `CIContent` | `SimpleXChat/ChatTypes.swift` | Chat item content wrapper with sent/received variants | | `CIStatus` | `SimpleXChat/ChatTypes.swift` | Delivery status: sndNew, sndSent, sndError, rcvNew, rcvRead | -| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)` | +| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)`, `.channelRcv` | | `ChatItem` | `SimpleXChat/ChatTypes.swift` | Full message model: content, meta, status, direction, quotedItem | | `ChatItemDeletion` | `SimpleXChat/ChatTypes.swift` | Deleted item info with old/new item pairs | | `CIDeleteMode` | `SimpleXChat/ChatTypes.swift` | `.cidmInternal` (local) or `.cidmBroadcast` (for everyone) | diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md index 04cf97a6a7..50d6bf2938 100644 --- a/apps/ios/product/gaps.md +++ b/apps/ios/product/gaps.md @@ -59,3 +59,6 @@ While the double-ratchet protocol provides forward secrecy, there is no UI indic The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages.hs`, etc.) are referenced by function name but not fully specified with parameter types and return types. **REC:** Expand database spec with key Store function signatures as the specification matures. + +--- + diff --git a/apps/ios/product/glossary.md b/apps/ios/product/glossary.md index 0353c8f606..7b2e227ffa 100644 --- a/apps/ios/product/glossary.md +++ b/apps/ios/product/glossary.md @@ -88,7 +88,7 @@ The lifecycle state of a Connection: ConnNew (created, awaiting join), ConnJoine The status of a contact record: CSActive (normal), CSDeleted (deleted by contact), CSDeletedByUser (deleted by user). *See: `../../src/Simplex/Chat/Types.hs` (data ContactStatus)* ### GroupMemberRole -Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver. Roles determine permissions for sending messages, managing members, and moderating content. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole)* +Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver, GRRelay. Roles determine permissions for sending messages, managing members, and moderating content. The `.relay` role is below `.observer` and is used for relay members in channels. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole), `SimpleXChat/ChatTypes.swift` L2806* ### GroupMemberStatus The lifecycle state of a group member: GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown, GSMemInvited, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator, GSMemPendingReview, GSMemPendingApproval. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMemberStatus)* @@ -99,6 +99,24 @@ Represents an in-progress or completed file transfer. Variants: FTSnd (sending, ### ChatTag A user-defined label for organizing conversations in the chat list. Each tag has a text label and optional emoji. Chats can have multiple tags, and the chat list can be filtered by tag. *See: `../../src/Simplex/Chat/Types.hs` (data ChatTag), `Shared/Views/ChatList/TagListView.swift`* +### Channel +A group that uses relay infrastructure for message delivery (`groupInfo.useRelays == true`). Channels decouple the message sender from direct group membership connections, routing messages through relay members instead. Channels display the `antenna.radiowaves.left.and.right` SF Symbol as their icon and render received messages with the group avatar and "channel" role label. *See: [spec/state.md](../spec/state.md) (Relay-Related Data Model), [spec/client/chat-view.md](../spec/client/chat-view.md) (Channel Message Rendering), `SimpleXChat/ChatTypes.swift` (GroupInfo.useRelays, GroupInfo.chatIconName)* + +### RelayStatus +The lifecycle state of a relay member in a channel: `.rsNew` (created), `.rsInvited` (invitation sent), `.rsAccepted` (accepted by relay), `.rsActive` (fully operational). *See: `SimpleXChat/ChatTypes.swift` L2506* + +### GroupRelay +A struct representing a relay instance for a group. Contains the relay's database ID (`groupRelayId`), associated group member ID, user chat relay ID, relay status, and optional relay link (per-group link for subscribers). *See: `SimpleXChat/ChatTypes.swift` L2555* + +### UserChatRelay +A struct representing a user's chat relay configuration. Contains the relay's database ID (`chatRelayId`), SMP server address, name, domains, and flags for preset/tested/enabled/deleted status. *See: `SimpleXChat/ChatTypes.swift` L2513* + +### GroupShortLinkInfo +Information about a group's short link including whether it's a direct link, associated relay hostnames, and shared group identifier. Transient data returned by `APIConnectPreparedGroup` — not persisted on GroupInfo. *See: `Shared/Model/AppAPITypes.swift` L1352* + +### CIDirection.channelRcv +A chat item direction case for messages received via a channel relay, as opposed to `.groupRcv` for standard group messages. *See: `SimpleXChat/ChatTypes.swift` L3529* + --- ## Commands & Events diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md index b41792898b..0cb3f8e96a 100644 --- a/apps/ios/product/rules.md +++ b/apps/ios/product/rules.md @@ -106,6 +106,35 @@ --- +## Channel Integrity + +### RULE-19: Channel owner cannot leave own channel +**Rule:** A channel owner (`groupInfo.useRelays && groupInfo.isOwner`) who is the sole owner MUST NOT be able to leave the channel. The leave button is hidden in both swipe actions and context menu. +**Enforced by:** `ChatListNavLink.swift` (swipe/context menu guards), `GroupChatInfoView.swift` (leave button conditional). +**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) + +### RULE-20: Relay members cannot be removed +**Rule:** Members with role `.relay` MUST NOT be removable through the member info UI. The remove button is hidden for relay members. +**Enforced by:** `GroupMemberInfoView.swift` (`mem.memberRole != .relay` guard on remove button). +**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) + +### RULE-21: Relay links cannot be used to connect +**Rule:** SimpleX links with path `/r` (relay addresses) MUST be rejected when users attempt to connect. An explanatory alert is shown instead. +**Enforced by:** `ContentView.swift` (`connectViaUrl_` early return for `/r` path), `NewChatView.swift` (`planAndConnect` guard for `.simplexLink(_, .relay, _, _)`). +**Spec:** [spec/client/navigation.md](../spec/client/navigation.md) + +### RULE-22: Channel subscribers default to observer role +**Rule:** Members joining a channel via its link MUST receive the `.observer` role. The initial role picker is hidden for channels. +**Enforced by:** `AddChannelView.swift` (`groupLinkMemberRole: .observer` hardcoded), `GroupLinkView.swift` (role picker hidden when `isChannel`). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-23: Channels default to history enabled +**Rule:** Newly created channels MUST have message history enabled by default (`GroupPreference(enable: .on)`). +**Enforced by:** `AddChannelView.swift` (`createChannel()` sets history preference). +**Spec:** [spec/api.md](../spec/api.md) + +--- + ## Call Integrity ### RULE-17: Call encryption key exchange diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md index 6c2d868d64..04d19bef9e 100644 --- a/apps/ios/product/views/chat-list.md +++ b/apps/ios/product/views/chat-list.md @@ -66,6 +66,23 @@ Each row rendered by `ChatPreviewView` inside `ChatListNavLink`: | Incognito indicator | Shows when connected via incognito profile | | Connection status | Shows connecting/pending state for incomplete connections | +### Channel Adaptations + +When a group has `groupInfo.useRelays == true` (channel): + +| Element | Channel behavior | +|---|---| +| Chat icon | Antenna icon (`antenna.radiowaves.left.and.right.circle.fill`) instead of group icon | +| Swipe "Leave" | Hidden for channel owners (`useRelays && isOwner`) | +| Context menu "Leave" | Hidden for channel owners | +| Delete alert | "Delete channel?" (not "Delete group?") | +| Leave alert title | "Leave channel?" (not "Leave group?") | +| Leave alert message | "You will stop receiving messages from this channel. Chat history will be preserved." | + +### Relay URL Handling + +When a relay address link (`/r` path) is opened via URL deep link, `ContentView.connectViaUrl_()` intercepts it and shows an alert: "Relay address" / "This is a chat relay address, it cannot be used to connect." The link is not processed further. + ### Swipe Actions - **Trailing swipe**: Mute/unmute, pin/unpin, tag management diff --git a/apps/ios/product/views/chat.md b/apps/ios/product/views/chat.md index 57202846eb..bf84bf4feb 100644 --- a/apps/ios/product/views/chat.md +++ b/apps/ios/product/views/chat.md @@ -102,6 +102,15 @@ Emoji reactions bar displayed below messages with reaction counts. | Group mentions | `GroupMentionsView` autocomplete popup when typing `@` in groups | | Profile picker | `ContextProfilePickerView` for choosing incognito/main profile | +### Channel Messages + +In channel conversations (`groupInfo.useRelays == true`), received messages (`.channelRcv` direction) display with: +- The **channel icon** (`antenna.radiowaves.left.and.right`) instead of the standard group icon +- The **channel name** as sender, with "channel" as the role label +- The **group profile image** as the avatar (tapping opens group info, not member info) +- Consecutive channel messages are grouped without repeating the avatar +- Channel messages cannot be moderated per-member (no member identity) + ### Member Support Chat (Groups) For groups with member support enabled: diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md index 9291b3ed2f..3ec322ec0e 100644 --- a/apps/ios/product/views/group-info.md +++ b/apps/ios/product/views/group-info.md @@ -128,6 +128,101 @@ Shown when `developerTools` is enabled: | `blockMemberAlert` / `unblockMemberAlert` | Block/unblock member actions | | `blockForAllAlert` / `unblockForAllAlert` | Moderator block/unblock for all members | +## Channel Adaptations + +When `groupInfo.useRelays == true`, the group info view adapts to channel semantics. All sections below describe differences from the standard group behavior above. + +### Channel Info Layout + +The top section splits into a channel-specific branch: + +| Element | Owner | Non-owner | +|---|---|---| +| Channel link | NavigationLink "Channel link" to `GroupLinkView` | Inline QR code (`SimpleXLinkQRCode`) + "Share link" button (if `groupProfile.groupLink` exists) | +| Members | NavigationLink "Owners & subscribers" to `ChannelMembersView` | NavigationLink "Owners" to `ChannelMembersView` | +| Relays | NavigationLink "Chat relays" to `ChannelRelaysView` | NavigationLink "Chat relays" to `ChannelRelaysView` | + +### Channel Action Bar + +| Button | Channel behavior | +|---|---| +| Link button | Replaces "Add members" for channel owners; navigates to `GroupLinkView` | +| Add members | Hidden for channels | + +### Hidden Sections for Channels + +The following are hidden when `groupInfo.useRelays == true`: + +- Group preferences button and footer +- Send receipts toggle +- Member list section (replaced by ChannelMembersView navigation) +- Non-admin block section (in GroupMemberInfoView) + +### Channel Leave/Delete Rules + +- Sole channel owner cannot leave (button hidden when `isOwner && no other owners`) +- "Leave group" -> "Leave channel"; "Delete group" -> "Delete channel"; "Edit group profile" -> "Edit channel profile" +- `deleteGroupAlert`: "Delete channel?" / "Channel will be deleted for all subscribers - this cannot be undone!" (current member) or "Channel will be deleted for you - this cannot be undone!" (non-current member) +- `leaveGroupAlert`: "Leave channel?" / "You will stop receiving messages from this channel. Chat history will be preserved." +- `showRemoveMemberAlert`: "Remove subscriber?" / "Subscriber will be removed from channel - this cannot be undone!" + +### Channel Members View (`ChannelMembersView`) + +New view accessible from channel info, showing: + +| Section | Content | Visibility | +|---|---|---| +| Owners | Members with role >= `.owner`, plus current user if owner | Always | +| Subscribers | Members with role < `.owner` and != `.relay` | Owner only | + +- Excludes `memLeft`, `memRemoved`, and current user from member list +- Each row: profile image, verified badge, name; taps navigate to `GroupMemberInfoView` +- Empty state: "No subscribers" when subscriber list is empty + +### Channel Relays View (`ChannelRelaysView`) + +New view accessible from channel info, showing relay members (role == `.relay`): + +| Element | Description | +|---|---| +| Relay list | Filtered from `chatModel.groupMembers` by `.relay` role | +| Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) | +| Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter | +| Empty state | "No chat relays" | +| Footer | "Chat relays forward messages to channel subscribers." | + +Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only. + +### Channel Link View (`GroupLinkView` with `isChannel: true`) + +| Change | Channel behavior | +|---|---| +| Title | "Channel link" (not "Group link") | +| Description | "Anybody will be able to join the channel" (omits "You won't lose members...") | +| Initial role picker | Hidden | +| Upgrade link button | Hidden | +| Delete link button | Hidden (channel link deletion only via channel deletion) | +| Short/full link toggle | Hidden | +| Share button | Shares directly (no upgrade-and-share alert) | + +### Channel Member Info (`GroupMemberInfoView` adaptations) + +| Change | Channel behavior | +|---|---| +| Section header | "Relay" / "Owner" / "Subscriber" (based on member role) instead of "Member" | +| Group label | "Channel" instead of "Group" / "Chat" | +| Action buttons | Hidden (message/audio/video/search) | +| Role change picker | Hidden | +| Verify code button | Hidden for relay members | +| Block section | Hidden for non-moderator users | +| Remove button | Hidden for relay members | +| "Remove member" label | "Remove subscriber" | +| "Block for all?" alert | "Block subscriber for all?" | +| "Unblock for all?" alert | "Unblock subscriber for all?" | +| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | +| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." | + ## Related Specs - `spec/api.md` -- Group API commands (create, update, add/remove members, roles, links) @@ -145,3 +240,5 @@ Shown when `developerTools` is enabled: - `Shared/Views/Chat/Group/MemberAdmissionView.swift` -- Member admission policy settings - `Shared/Views/Chat/Group/GroupMemberInfoView.swift` -- Individual member info and actions - `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention support in groups +- `Shared/Views/Chat/Group/ChannelMembersView.swift` -- Channel owners/subscribers list +- `Shared/Views/Chat/Group/ChannelRelaysView.swift` -- Channel relay status list diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md index e53659e622..2ab5f9ba8f 100644 --- a/apps/ios/product/views/new-chat.md +++ b/apps/ios/product/views/new-chat.md @@ -79,16 +79,66 @@ Accessed via `NewChatMenuButton` dropdown: | Connection in progress | Chat list shows pending connection entry | | Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options | +## Create Channel (`AddChannelView`) + +Accessed via `NewChatMenuButton` dropdown: "Create channel (BETA)" with antenna icon (`antenna.radiowaves.left.and.right.circle.fill`). + +### Three-Step Channel Creation Wizard + +| Step | View | Description | +|---|---|---| +| 1. Profile | `profileStepView()` | Channel name input with validation, optional profile image. "Configure relays" link navigates to `NetworkAndServers`. Warning footer if no relays enabled. | +| 2. Progress | `progressStepView(_:)` | Relay connection progress: circular indicator (active/total), expandable relay list with status indicators (green=active, orange=invited/accepted, red=new). Cancel button deletes channel. | +| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` showing the channel link for sharing. | + +### Channel Creation Defaults + +- History preference auto-enabled (`GroupPreference(enable: .on)`) +- Group link member role hardcoded to `.observer` +- Up to 3 random enabled relays selected from user's configured relays + +### Channel Creation API + +Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)` which returns `publicGroupCreated` response with group info, link, and relay list. On cancel, `apiDeleteChat` deletes the channel. + +### Relay Validation + +- `checkHasRelays()`: validates at least one enabled, non-deleted relay exists +- Warning footer: "Enable at least one chat relay in Network & Servers." +- `getEnabledRelays()`: filters enabled/non-deleted relays from user's server config + +## Channel-Specific Connection Behavior + +### Relay Link Blocking + +When `planAndConnect` encounters a `.simplexLink(_, .relay, _, _)`, it shows a "Relay address" alert: "This is a chat relay address, it cannot be used to connect." Connection is blocked. + +### Channel Prepare/Join Alerts + +| Context | Channel behavior | Group behavior | +|---|---|---| +| Prepare alert icon | `antenna.radiowaves.left.and.right.circle.fill` | `person.2.circle.fill` | +| Prepare alert title | "Open new channel" | "Open new group" | +| Error text | "Error opening channel" | "Error opening group" | +| Own-link confirm | "This is your link for channel" with only "Open channel" + "Cancel" (no incognito/profile options) | Full incognito/profile selection | +| Known group alert | "Open channel" / "Open new channel" | "Open group" / "Open new group" | + +### Pre-Join Relay Info + +When preparing a channel link, `groupShortLinkInfo.groupRelays` (hostnames) are stored in `ChatModel.shared.channelRelayHostnames` for display in the subscriber relay bar before joining. + ## Related Specs - `spec/api.md` -- API commands: `APIAddContact`, `APIConnect`, `APICreateUserAddress` +- `spec/client/navigation.md` -- Navigation architecture for channel creation flow - [Chat List](chat-list.md) -- Parent view that presents this sheet - [Chat](chat.md) -- Navigated to after successful connection ## Source Files - `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation -- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group) +- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group, create channel) - `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display - `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form +- `Shared/Views/NewChat/AddChannelView.swift` -- Channel creation wizard (3 steps) - `Shared/Views/NewChat/AddContactLearnMore.swift` -- Info sheet explaining connection process diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md index 58507ce52b..3cc4da5d2b 100644 --- a/apps/ios/product/views/settings.md +++ b/apps/ios/product/views/settings.md @@ -47,7 +47,35 @@ All rows disabled when `chatModel.chatRunning != true`. Appearance row only show | Show sent via proxy | Toggle to show proxy indicator on sent messages | | Show subscription % | Toggle to show server subscription percentage | -Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift` +Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift`, `ChatRelayView.swift` + +##### Chat Relays + +Chat relays forward messages to channel subscribers. They appear in two locations: + +- **Operator View** (`OperatorView`): "Chat relays" section lists relays for each operator with `ChatRelayViewLink` rows. Footer: "Chat relays forward messages in channels you create." +- **Your Servers** (`YourServersView` in `ProtocolServersView`): "Chat relays" section for non-operator relays. "Add server" dialog includes a "Chat relay" option. + +Each relay is managed via `ChatRelayView`: + +| Element | Preset relay | Custom relay | +|---|---|---| +| Name | Read-only display | Editable text field | +| Address | Read-only display | Editable text field (validates as `.simplexLink(_, .relay, _, _)`) | +| Test button | "Test relay" (shows "Not implemented" alert) | Same | +| Enable toggle | "Use for new channels" | Same | +| Delete | Not available | "Delete relay" button | + +Adding a relay: `NewChatRelayView` form with name, address, test, and enable toggle. Back-button validates name/address and shows alerts for invalid input. + +##### Server Warnings + +`ServersWarningView` displays an orange exclamation triangle with warning text when `UserServersWarning.noChatRelays` is detected. Appears in: +- Network & Servers footer (`globalServersWarning`) +- Operator view footer +- Your servers footer + +Server validation (`validateServers_`) now returns both errors and warnings. #### Privacy & Security (`PrivacySettings`) @@ -169,4 +197,5 @@ Key `UserDefaults` / `AppStorage` keys managed by settings: - `Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift` -- Add new server - `Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift` -- Scan server QR code - `Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift` -- Server operator configuration +- `Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` -- Chat relay detail/edit/add views - `Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift` -- Operator conditions display diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index fe40a4c4ec..45a06c371f 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -32,10 +32,10 @@ The iOS app communicates with the Haskell core exclusively through a command/res 5. Async events arrive separately via `chat_recv_msg_wait`, decoded as `ChatEvent` **Source files**: -- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L14](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L647](../Shared/Model/AppAPITypes.swift#L649)), `ChatResponse1` ([L768](../Shared/Model/AppAPITypes.swift#L771)), `ChatResponse2` ([L907](../Shared/Model/AppAPITypes.swift#L911)), `ChatEvent` ([L1050](../Shared/Model/AppAPITypes.swift#L1055)) enums -- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult` ([L26](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L63](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L695](../SimpleXChat/APITypes.swift#L699)) -- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L117](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L230](../Shared/Model/SimpleXAPI.swift#L237)) -- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L114](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L136](../SimpleXChat/API.swift#L137)) +- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L15](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L657](../Shared/Model/AppAPITypes.swift#L657)), `ChatResponse1` ([L779](../Shared/Model/AppAPITypes.swift#L779)), `ChatResponse2` ([L919](../Shared/Model/AppAPITypes.swift#L919)), `ChatEvent` ([L1069](../Shared/Model/AppAPITypes.swift#L1069)) enums +- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult` ([L27](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L65](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L699](../SimpleXChat/APITypes.swift#L699)) +- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L121](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L237](../Shared/Model/SimpleXAPI.swift#L237)) +- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L115](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L137](../SimpleXChat/API.swift#L137)) - `SimpleXChat/ChatTypes.swift` -- Data types used in commands/responses (User, Contact, GroupInfo, ChatItem, etc.) - `../../src/Simplex/Chat/Controller.hs` -- Haskell controller (function `chat_send_cmd_retry`, `chat_recv_msg_wait`) @@ -43,248 +43,252 @@ The iOS app communicates with the Haskell core exclusively through a command/res ## 2. Command Categories -The `ChatCommand` enum ([`AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area. +The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area. ### 2.1 User Management | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `showActiveUser` | -- | Get current active user | [L15](../Shared/Model/AppAPITypes.swift#L16) | -| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L16](../Shared/Model/AppAPITypes.swift#L17) | -| `listUsers` | -- | List all user profiles | [L17](../Shared/Model/AppAPITypes.swift#L18) | -| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L18](../Shared/Model/AppAPITypes.swift#L19) | -| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L23](../Shared/Model/AppAPITypes.swift#L24) | -| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L24](../Shared/Model/AppAPITypes.swift#L25) | -| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L25](../Shared/Model/AppAPITypes.swift#L26) | -| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L27) | -| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L27](../Shared/Model/AppAPITypes.swift#L28) | -| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L138](../Shared/Model/AppAPITypes.swift#L139) | -| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L19](../Shared/Model/AppAPITypes.swift#L20) | -| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L20](../Shared/Model/AppAPITypes.swift#L21) | -| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L22) | -| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L22](../Shared/Model/AppAPITypes.swift#L23) | +| `showActiveUser` | -- | Get current active user | [L16](../Shared/Model/AppAPITypes.swift#L16) | +| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L17](../Shared/Model/AppAPITypes.swift#L17) | +| `listUsers` | -- | List all user profiles | [L18](../Shared/Model/AppAPITypes.swift#L18) | +| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L19](../Shared/Model/AppAPITypes.swift#L19) | +| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L24](../Shared/Model/AppAPITypes.swift#L24) | +| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L25](../Shared/Model/AppAPITypes.swift#L25) | +| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L26) | +| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L27](../Shared/Model/AppAPITypes.swift#L27) | +| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L28](../Shared/Model/AppAPITypes.swift#L28) | +| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L141](../Shared/Model/AppAPITypes.swift#L141) | +| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L20](../Shared/Model/AppAPITypes.swift#L20) | +| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L21) | +| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L22](../Shared/Model/AppAPITypes.swift#L22) | +| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L23](../Shared/Model/AppAPITypes.swift#L23) | ### 2.2 Chat Lifecycle Control | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L28](../Shared/Model/AppAPITypes.swift#L29) | -| `checkChatRunning` | -- | Check if chat is running | [L29](../Shared/Model/AppAPITypes.swift#L30) | -| `apiStopChat` | -- | Stop chat engine | [L30](../Shared/Model/AppAPITypes.swift#L31) | -| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L31](../Shared/Model/AppAPITypes.swift#L32) | -| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L32](../Shared/Model/AppAPITypes.swift#L33) | -| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L33](../Shared/Model/AppAPITypes.swift#L34) | -| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L34](../Shared/Model/AppAPITypes.swift#L35) | +| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L29](../Shared/Model/AppAPITypes.swift#L29) | +| `checkChatRunning` | -- | Check if chat is running | [L30](../Shared/Model/AppAPITypes.swift#L30) | +| `apiStopChat` | -- | Stop chat engine | [L31](../Shared/Model/AppAPITypes.swift#L31) | +| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L32](../Shared/Model/AppAPITypes.swift#L32) | +| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L33](../Shared/Model/AppAPITypes.swift#L33) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L34](../Shared/Model/AppAPITypes.swift#L34) | +| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L35](../Shared/Model/AppAPITypes.swift#L35) | ### 2.3 Chat & Message Operations | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L43](../Shared/Model/AppAPITypes.swift#L44) | -| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L44](../Shared/Model/AppAPITypes.swift#L45) | -| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L45](../Shared/Model/AppAPITypes.swift#L46) | -| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L46](../Shared/Model/AppAPITypes.swift#L47) | -| `apiSendMessages` | `type, id, scope, live, ttl, composedMessages` | Send one or more messages | [L47](../Shared/Model/AppAPITypes.swift#L48) | -| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L53](../Shared/Model/AppAPITypes.swift#L54) | -| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L55](../Shared/Model/AppAPITypes.swift#L56) | -| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L56](../Shared/Model/AppAPITypes.swift#L57) | -| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L57](../Shared/Model/AppAPITypes.swift#L58) | -| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L60](../Shared/Model/AppAPITypes.swift#L61) | -| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L61](../Shared/Model/AppAPITypes.swift#L62) | -| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L62](../Shared/Model/AppAPITypes.swift#L63) | -| `apiForwardChatItems` | `toChatType, toChatId, toScope, from..., itemIds, ttl` | Forward messages | [L63](../Shared/Model/AppAPITypes.swift#L64) | -| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L54](../Shared/Model/AppAPITypes.swift#L55) | -| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L163](../Shared/Model/AppAPITypes.swift#L164) | -| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L164](../Shared/Model/AppAPITypes.swift#L165) | -| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L165](../Shared/Model/AppAPITypes.swift#L166) | +| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L44](../Shared/Model/AppAPITypes.swift#L44) | +| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L45](../Shared/Model/AppAPITypes.swift#L45) | +| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L46](../Shared/Model/AppAPITypes.swift#L46) | +| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L47](../Shared/Model/AppAPITypes.swift#L47) | +| `apiSendMessages` | `type, id, scope, sendAsGroup, live, ttl, composedMessages` | Send one or more messages; `sendAsGroup` sends as channel owner | [L48](../Shared/Model/AppAPITypes.swift#L48) | +| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L54](../Shared/Model/AppAPITypes.swift#L54) | +| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L56](../Shared/Model/AppAPITypes.swift#L56) | +| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L57](../Shared/Model/AppAPITypes.swift#L57) | +| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L58](../Shared/Model/AppAPITypes.swift#L58) | +| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L61](../Shared/Model/AppAPITypes.swift#L61) | +| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L62](../Shared/Model/AppAPITypes.swift#L62) | +| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L63](../Shared/Model/AppAPITypes.swift#L63) | +| `apiForwardChatItems` | `toChatType, toChatId, toScope, sendAsGroup, from..., itemIds, ttl` | Forward messages; `sendAsGroup` forwards as channel owner | [L64](../Shared/Model/AppAPITypes.swift#L64) | +| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L55](../Shared/Model/AppAPITypes.swift#L55) | +| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L166](../Shared/Model/AppAPITypes.swift#L166) | +| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L167](../Shared/Model/AppAPITypes.swift#L167) | +| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L168](../Shared/Model/AppAPITypes.swift#L168) | ### 2.4 Contact Management | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiAddContact` | `userId, incognito` | Create invitation link | [L123](../Shared/Model/AppAPITypes.swift#L124) | -| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L133](../Shared/Model/AppAPITypes.swift#L134) | -| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L126](../Shared/Model/AppAPITypes.swift#L127) | -| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L127](../Shared/Model/AppAPITypes.swift#L128) | -| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L131](../Shared/Model/AppAPITypes.swift#L132) | -| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L134](../Shared/Model/AppAPITypes.swift#L135) | -| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L151](../Shared/Model/AppAPITypes.swift#L152) | -| `apiRejectContact` | `contactReqId` | Reject contact request | [L152](../Shared/Model/AppAPITypes.swift#L153) | -| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L135](../Shared/Model/AppAPITypes.swift#L136) | -| `apiClearChat` | `type, id` | Clear conversation history | [L136](../Shared/Model/AppAPITypes.swift#L137) | -| `apiListContacts` | `userId` | List all contacts | [L137](../Shared/Model/AppAPITypes.swift#L138) | -| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L139](../Shared/Model/AppAPITypes.swift#L140) | -| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L140](../Shared/Model/AppAPITypes.swift#L141) | -| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L142](../Shared/Model/AppAPITypes.swift#L143) | -| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L109](../Shared/Model/AppAPITypes.swift#L110) | -| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L124](../Shared/Model/AppAPITypes.swift#L125) | +| `apiAddContact` | `userId, incognito` | Create invitation link | [L126](../Shared/Model/AppAPITypes.swift#L126) | +| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L136](../Shared/Model/AppAPITypes.swift#L136) | +| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L129](../Shared/Model/AppAPITypes.swift#L129) | +| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L130](../Shared/Model/AppAPITypes.swift#L130) | +| `apiPrepareGroup` | `userId, connLink, directLink, groupShortLinkData` | Prepare group from link; `directLink` (required, no default) indicates whether link is a direct (non-relay) group link | [L131](../Shared/Model/AppAPITypes.swift#L131) | +| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L134](../Shared/Model/AppAPITypes.swift#L134) | +| `apiConnectPreparedGroup` | `groupId, incognito, msg` | Connect to a prepared group/channel; returns `(GroupInfo, [RelayConnectionResult])?` | [L135](../Shared/Model/AppAPITypes.swift#L135) | +| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L137](../Shared/Model/AppAPITypes.swift#L137) | +| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L154](../Shared/Model/AppAPITypes.swift#L154) | +| `apiRejectContact` | `contactReqId` | Reject contact request | [L155](../Shared/Model/AppAPITypes.swift#L155) | +| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L138](../Shared/Model/AppAPITypes.swift#L138) | +| `apiClearChat` | `type, id` | Clear conversation history | [L139](../Shared/Model/AppAPITypes.swift#L139) | +| `apiListContacts` | `userId` | List all contacts | [L140](../Shared/Model/AppAPITypes.swift#L140) | +| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L142](../Shared/Model/AppAPITypes.swift#L142) | +| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L143](../Shared/Model/AppAPITypes.swift#L143) | +| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L145](../Shared/Model/AppAPITypes.swift#L145) | +| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L112](../Shared/Model/AppAPITypes.swift#L112) | +| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L127](../Shared/Model/AppAPITypes.swift#L127) | ### 2.5 Group Management | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L71](../Shared/Model/AppAPITypes.swift#L72) | -| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L72](../Shared/Model/AppAPITypes.swift#L73) | -| `apiJoinGroup` | `groupId` | Accept group invitation | [L73](../Shared/Model/AppAPITypes.swift#L74) | -| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L74](../Shared/Model/AppAPITypes.swift#L75) | -| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L78](../Shared/Model/AppAPITypes.swift#L79) | -| `apiLeaveGroup` | `groupId` | Leave group | [L79](../Shared/Model/AppAPITypes.swift#L80) | -| `apiListMembers` | `groupId` | List group members | [L80](../Shared/Model/AppAPITypes.swift#L81) | -| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L81](../Shared/Model/AppAPITypes.swift#L82) | -| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L76](../Shared/Model/AppAPITypes.swift#L77) | -| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L77](../Shared/Model/AppAPITypes.swift#L78) | -| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L82](../Shared/Model/AppAPITypes.swift#L83) | -| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L83](../Shared/Model/AppAPITypes.swift#L84) | -| `apiDeleteGroupLink` | `groupId` | Delete group link | [L84](../Shared/Model/AppAPITypes.swift#L85) | -| `apiGetGroupLink` | `groupId` | Get existing group link | [L85](../Shared/Model/AppAPITypes.swift#L86) | -| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L86](../Shared/Model/AppAPITypes.swift#L87) | -| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L87](../Shared/Model/AppAPITypes.swift#L88) | -| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L88](../Shared/Model/AppAPITypes.swift#L89) | -| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L110](../Shared/Model/AppAPITypes.swift#L111) | -| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L75](../Shared/Model/AppAPITypes.swift#L76) | -| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L108](../Shared/Model/AppAPITypes.swift#L109) | -| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L141](../Shared/Model/AppAPITypes.swift#L142) | +| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L72](../Shared/Model/AppAPITypes.swift#L72) | +| `apiNewPublicGroup` | `userId, incognito, relayIds, groupProfile` | Create new public group (channel) with chat relays | [L73](../Shared/Model/AppAPITypes.swift#L73) | +| `apiGetGroupRelays` | `groupId` | Get group relay list with status (owner only) | [L74](../Shared/Model/AppAPITypes.swift#L74) | +| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L75](../Shared/Model/AppAPITypes.swift#L75) | +| `apiJoinGroup` | `groupId` | Accept group invitation | [L76](../Shared/Model/AppAPITypes.swift#L76) | +| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L77](../Shared/Model/AppAPITypes.swift#L77) | +| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L81](../Shared/Model/AppAPITypes.swift#L81) | +| `apiLeaveGroup` | `groupId` | Leave group | [L82](../Shared/Model/AppAPITypes.swift#L82) | +| `apiListMembers` | `groupId` | List group members | [L83](../Shared/Model/AppAPITypes.swift#L83) | +| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L84](../Shared/Model/AppAPITypes.swift#L84) | +| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L79](../Shared/Model/AppAPITypes.swift#L79) | +| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L80](../Shared/Model/AppAPITypes.swift#L80) | +| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L85](../Shared/Model/AppAPITypes.swift#L85) | +| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L86](../Shared/Model/AppAPITypes.swift#L86) | +| `apiDeleteGroupLink` | `groupId` | Delete group link | [L87](../Shared/Model/AppAPITypes.swift#L87) | +| `apiGetGroupLink` | `groupId` | Get existing group link | [L88](../Shared/Model/AppAPITypes.swift#L88) | +| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L89](../Shared/Model/AppAPITypes.swift#L89) | +| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L90](../Shared/Model/AppAPITypes.swift#L90) | +| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L91](../Shared/Model/AppAPITypes.swift#L91) | +| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L113](../Shared/Model/AppAPITypes.swift#L113) | +| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L78](../Shared/Model/AppAPITypes.swift#L78) | +| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L111](../Shared/Model/AppAPITypes.swift#L111) | +| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L144](../Shared/Model/AppAPITypes.swift#L144) | ### 2.6 Chat Tags | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiGetChatTags` | `userId` | Get all user tags | [L42](../Shared/Model/AppAPITypes.swift#L43) | -| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L48](../Shared/Model/AppAPITypes.swift#L49) | -| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L49](../Shared/Model/AppAPITypes.swift#L50) | -| `apiDeleteChatTag` | `tagId` | Delete a tag | [L50](../Shared/Model/AppAPITypes.swift#L51) | -| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L51](../Shared/Model/AppAPITypes.swift#L52) | -| `apiReorderChatTags` | `tagIds` | Reorder tags | [L52](../Shared/Model/AppAPITypes.swift#L53) | +| `apiGetChatTags` | `userId` | Get all user tags | [L43](../Shared/Model/AppAPITypes.swift#L43) | +| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L49](../Shared/Model/AppAPITypes.swift#L49) | +| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L50](../Shared/Model/AppAPITypes.swift#L50) | +| `apiDeleteChatTag` | `tagId` | Delete a tag | [L51](../Shared/Model/AppAPITypes.swift#L51) | +| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L52](../Shared/Model/AppAPITypes.swift#L52) | +| `apiReorderChatTags` | `tagIds` | Reorder tags | [L53](../Shared/Model/AppAPITypes.swift#L53) | ### 2.7 File Operations | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L166](../Shared/Model/AppAPITypes.swift#L167) | -| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L167](../Shared/Model/AppAPITypes.swift#L168) | -| `cancelFile` | `fileId` | Cancel file transfer | [L168](../Shared/Model/AppAPITypes.swift#L169) | -| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L178](../Shared/Model/AppAPITypes.swift#L179) | -| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L179](../Shared/Model/AppAPITypes.swift#L180) | -| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L180](../Shared/Model/AppAPITypes.swift#L181) | +| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L169](../Shared/Model/AppAPITypes.swift#L169) | +| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L170](../Shared/Model/AppAPITypes.swift#L170) | +| `cancelFile` | `fileId` | Cancel file transfer | [L171](../Shared/Model/AppAPITypes.swift#L171) | +| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L181](../Shared/Model/AppAPITypes.swift#L181) | +| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L182](../Shared/Model/AppAPITypes.swift#L182) | +| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L183](../Shared/Model/AppAPITypes.swift#L183) | ### 2.8 WebRTC Call Operations | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L154](../Shared/Model/AppAPITypes.swift#L155) | -| `apiRejectCall` | `contact` | Reject incoming call | [L155](../Shared/Model/AppAPITypes.swift#L156) | -| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L156](../Shared/Model/AppAPITypes.swift#L157) | -| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L157](../Shared/Model/AppAPITypes.swift#L158) | -| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L158](../Shared/Model/AppAPITypes.swift#L159) | -| `apiEndCall` | `contact` | End active call | [L159](../Shared/Model/AppAPITypes.swift#L160) | -| `apiGetCallInvitations` | -- | Get pending call invitations | [L160](../Shared/Model/AppAPITypes.swift#L161) | -| `apiCallStatus` | `contact, callStatus` | Report call status change | [L161](../Shared/Model/AppAPITypes.swift#L162) | +| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L157](../Shared/Model/AppAPITypes.swift#L157) | +| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | +| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | +| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | +| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | +| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | ### 2.9 Push Notifications | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiGetNtfToken` | -- | Get current notification token | [L64](../Shared/Model/AppAPITypes.swift#L65) | -| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L65](../Shared/Model/AppAPITypes.swift#L66) | -| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L66](../Shared/Model/AppAPITypes.swift#L67) | -| `apiCheckToken` | `token` | Check token status | [L67](../Shared/Model/AppAPITypes.swift#L68) | -| `apiDeleteToken` | `token` | Unregister token | [L68](../Shared/Model/AppAPITypes.swift#L69) | -| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L69](../Shared/Model/AppAPITypes.swift#L70) | -| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L71) | +| `apiGetNtfToken` | -- | Get current notification token | [L65](../Shared/Model/AppAPITypes.swift#L65) | +| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L66](../Shared/Model/AppAPITypes.swift#L66) | +| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L67](../Shared/Model/AppAPITypes.swift#L67) | +| `apiCheckToken` | `token` | Check token status | [L68](../Shared/Model/AppAPITypes.swift#L68) | +| `apiDeleteToken` | `token` | Unregister token | [L69](../Shared/Model/AppAPITypes.swift#L69) | +| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L70) | +| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L71](../Shared/Model/AppAPITypes.swift#L71) | ### 2.10 Settings & Configuration | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L40](../Shared/Model/AppAPITypes.swift#L41) | -| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L41](../Shared/Model/AppAPITypes.swift#L42) | -| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L107](../Shared/Model/AppAPITypes.swift#L108) | -| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L99](../Shared/Model/AppAPITypes.swift#L100) | -| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L100](../Shared/Model/AppAPITypes.swift#L101) | -| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L101](../Shared/Model/AppAPITypes.swift#L102) | -| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L102](../Shared/Model/AppAPITypes.swift#L103) | -| `apiGetNetworkConfig` | -- | Get network configuration | [L103](../Shared/Model/AppAPITypes.swift#L104) | -| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L104](../Shared/Model/AppAPITypes.swift#L105) | -| `reconnectAllServers` | -- | Force reconnect all servers | [L105](../Shared/Model/AppAPITypes.swift#L106) | -| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L106](../Shared/Model/AppAPITypes.swift#L107) | +| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L41](../Shared/Model/AppAPITypes.swift#L41) | +| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L42](../Shared/Model/AppAPITypes.swift#L42) | +| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L110](../Shared/Model/AppAPITypes.swift#L110) | +| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L102](../Shared/Model/AppAPITypes.swift#L102) | +| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L103](../Shared/Model/AppAPITypes.swift#L103) | +| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L104](../Shared/Model/AppAPITypes.swift#L104) | +| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L105](../Shared/Model/AppAPITypes.swift#L105) | +| `apiGetNetworkConfig` | -- | Get network configuration | [L106](../Shared/Model/AppAPITypes.swift#L106) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L107](../Shared/Model/AppAPITypes.swift#L107) | +| `reconnectAllServers` | -- | Force reconnect all servers | [L108](../Shared/Model/AppAPITypes.swift#L108) | +| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L109](../Shared/Model/AppAPITypes.swift#L109) | ### 2.11 Database & Storage | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L38](../Shared/Model/AppAPITypes.swift#L39) | -| `testStorageEncryption` | `key: String` | Test encryption key | [L39](../Shared/Model/AppAPITypes.swift#L40) | -| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L35](../Shared/Model/AppAPITypes.swift#L36) | -| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L36](../Shared/Model/AppAPITypes.swift#L37) | -| `apiDeleteStorage` | -- | Delete all storage | [L37](../Shared/Model/AppAPITypes.swift#L38) | +| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L39](../Shared/Model/AppAPITypes.swift#L39) | +| `testStorageEncryption` | `key: String` | Test encryption key | [L40](../Shared/Model/AppAPITypes.swift#L40) | +| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L36](../Shared/Model/AppAPITypes.swift#L36) | +| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L37](../Shared/Model/AppAPITypes.swift#L37) | +| `apiDeleteStorage` | -- | Delete all storage | [L38](../Shared/Model/AppAPITypes.swift#L38) | ### 2.12 Server Operations | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiGetServerOperators` | -- | Get server operators | [L91](../Shared/Model/AppAPITypes.swift#L92) | -| `apiSetServerOperators` | `operators` | Set server operators | [L92](../Shared/Model/AppAPITypes.swift#L93) | -| `apiGetUserServers` | `userId` | Get user's configured servers | [L93](../Shared/Model/AppAPITypes.swift#L94) | -| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L94](../Shared/Model/AppAPITypes.swift#L95) | -| `apiValidateServers` | `userId, userServers` | Validate server configuration | [L95](../Shared/Model/AppAPITypes.swift#L96) | -| `apiGetUsageConditions` | -- | Get usage conditions | [L96](../Shared/Model/AppAPITypes.swift#L97) | -| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L98](../Shared/Model/AppAPITypes.swift#L99) | -| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L90](../Shared/Model/AppAPITypes.swift#L91) | +| `apiGetServerOperators` | -- | Get server operators | [L94](../Shared/Model/AppAPITypes.swift#L94) | +| `apiSetServerOperators` | `operators` | Set server operators | [L95](../Shared/Model/AppAPITypes.swift#L95) | +| `apiGetUserServers` | `userId` | Get user's configured servers | [L96](../Shared/Model/AppAPITypes.swift#L96) | +| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L97](../Shared/Model/AppAPITypes.swift#L97) | +| `apiValidateServers` | `userId, userServers` | Validate server configuration; returns errors and warnings | [L98](../Shared/Model/AppAPITypes.swift#L98) | +| `apiGetUsageConditions` | -- | Get usage conditions | [L99](../Shared/Model/AppAPITypes.swift#L99) | +| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L101](../Shared/Model/AppAPITypes.swift#L101) | +| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L93](../Shared/Model/AppAPITypes.swift#L93) | ### 2.13 Theme & UI | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L143](../Shared/Model/AppAPITypes.swift#L144) | -| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L144](../Shared/Model/AppAPITypes.swift#L145) | +| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L146](../Shared/Model/AppAPITypes.swift#L146) | +| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L147](../Shared/Model/AppAPITypes.swift#L147) | ### 2.14 Remote Desktop | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L170](../Shared/Model/AppAPITypes.swift#L171) | -| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L171](../Shared/Model/AppAPITypes.swift#L172) | -| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L172](../Shared/Model/AppAPITypes.swift#L173) | -| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L173](../Shared/Model/AppAPITypes.swift#L174) | -| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L174](../Shared/Model/AppAPITypes.swift#L175) | -| `listRemoteCtrls` | -- | List known remote controllers | [L175](../Shared/Model/AppAPITypes.swift#L176) | -| `stopRemoteCtrl` | -- | Stop remote session | [L176](../Shared/Model/AppAPITypes.swift#L177) | -| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L177](../Shared/Model/AppAPITypes.swift#L178) | +| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L173](../Shared/Model/AppAPITypes.swift#L173) | +| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L174](../Shared/Model/AppAPITypes.swift#L174) | +| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L175](../Shared/Model/AppAPITypes.swift#L175) | +| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L176](../Shared/Model/AppAPITypes.swift#L176) | +| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L177](../Shared/Model/AppAPITypes.swift#L177) | +| `listRemoteCtrls` | -- | List known remote controllers | [L178](../Shared/Model/AppAPITypes.swift#L178) | +| `stopRemoteCtrl` | -- | Stop remote session | [L179](../Shared/Model/AppAPITypes.swift#L179) | +| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L180](../Shared/Model/AppAPITypes.swift#L180) | ### 2.15 Diagnostics | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `showVersion` | -- | Get core version info | [L182](../Shared/Model/AppAPITypes.swift#L183) | -| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L183](../Shared/Model/AppAPITypes.swift#L184) | -| `getAgentServersSummary` | `userId` | Get server summary stats | [L184](../Shared/Model/AppAPITypes.swift#L185) | -| `resetAgentServersStats` | -- | Reset server statistics | [L185](../Shared/Model/AppAPITypes.swift#L186) | +| `showVersion` | -- | Get core version info | [L185](../Shared/Model/AppAPITypes.swift#L185) | +| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L186](../Shared/Model/AppAPITypes.swift#L186) | +| `getAgentServersSummary` | `userId` | Get server summary stats | [L187](../Shared/Model/AppAPITypes.swift#L187) | +| `resetAgentServersStats` | -- | Reset server statistics | [L188](../Shared/Model/AppAPITypes.swift#L188) | ### 2.16 Address Management | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L145](../Shared/Model/AppAPITypes.swift#L146) | -| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L146](../Shared/Model/AppAPITypes.swift#L147) | -| `apiShowMyAddress` | `userId` | Show current address | [L147](../Shared/Model/AppAPITypes.swift#L148) | -| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L148](../Shared/Model/AppAPITypes.swift#L149) | -| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L149](../Shared/Model/AppAPITypes.swift#L150) | -| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L150](../Shared/Model/AppAPITypes.swift#L151) | +| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L148](../Shared/Model/AppAPITypes.swift#L148) | +| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L149](../Shared/Model/AppAPITypes.swift#L149) | +| `apiShowMyAddress` | `userId` | Show current address | [L150](../Shared/Model/AppAPITypes.swift#L150) | +| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L151](../Shared/Model/AppAPITypes.swift#L151) | +| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L152](../Shared/Model/AppAPITypes.swift#L152) | +| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L153](../Shared/Model/AppAPITypes.swift#L153) | ### 2.17 Connection Security | Command | Parameters | Description | Source | |---------|-----------|-------------|--------| -| `apiGetContactCode` | `contactId` | Get verification code | [L119](../Shared/Model/AppAPITypes.swift#L120) | -| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L120](../Shared/Model/AppAPITypes.swift#L121) | -| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L121](../Shared/Model/AppAPITypes.swift#L122) | -| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L122](../Shared/Model/AppAPITypes.swift#L123) | -| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L113](../Shared/Model/AppAPITypes.swift#L114) | -| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L114](../Shared/Model/AppAPITypes.swift#L115) | -| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L115](../Shared/Model/AppAPITypes.swift#L116) | -| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L116](../Shared/Model/AppAPITypes.swift#L117) | -| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L117](../Shared/Model/AppAPITypes.swift#L118) | -| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L118](../Shared/Model/AppAPITypes.swift#L119) | +| `apiGetContactCode` | `contactId` | Get verification code | [L122](../Shared/Model/AppAPITypes.swift#L122) | +| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L123](../Shared/Model/AppAPITypes.swift#L123) | +| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L124](../Shared/Model/AppAPITypes.swift#L124) | +| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L125](../Shared/Model/AppAPITypes.swift#L125) | +| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L116](../Shared/Model/AppAPITypes.swift#L116) | +| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L117](../Shared/Model/AppAPITypes.swift#L117) | +| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L118](../Shared/Model/AppAPITypes.swift#L118) | +| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L119](../Shared/Model/AppAPITypes.swift#L119) | +| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L120](../Shared/Model/AppAPITypes.swift#L120) | +| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L121](../Shared/Model/AppAPITypes.swift#L121) | --- @@ -294,173 +298,178 @@ Responses are split across three enums due to Swift enum size limitations: ### ChatResponse0 -Synchronous query responses ([`AppAPITypes.swift` L647](../Shared/Model/AppAPITypes.swift#L649)): +Synchronous query responses ([`AppAPITypes.swift` L657](../Shared/Model/AppAPITypes.swift#L657)): | Response | Key Fields | Description | Source | |----------|-----------|-------------|--------| -| `activeUser` | `user: User` | Current active user | [L648](../Shared/Model/AppAPITypes.swift#L650) | -| `usersList` | `users: [UserInfo]` | All user profiles | [L649](../Shared/Model/AppAPITypes.swift#L651) | -| `chatStarted` | -- | Chat engine started | [L650](../Shared/Model/AppAPITypes.swift#L652) | -| `chatRunning` | -- | Chat is already running | [L651](../Shared/Model/AppAPITypes.swift#L653) | -| `chatStopped` | -- | Chat engine stopped | [L652](../Shared/Model/AppAPITypes.swift#L654) | -| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L653](../Shared/Model/AppAPITypes.swift#L655) | -| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L654](../Shared/Model/AppAPITypes.swift#L656) | -| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L656](../Shared/Model/AppAPITypes.swift#L658) | -| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L657](../Shared/Model/AppAPITypes.swift#L659) | -| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L658](../Shared/Model/AppAPITypes.swift#L660) | -| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L664](../Shared/Model/AppAPITypes.swift#L666) | -| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L665](../Shared/Model/AppAPITypes.swift#L667) | -| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L666](../Shared/Model/AppAPITypes.swift#L668) | -| `connectionVerified` | `verified, expectedCode` | Verification result | [L676](../Shared/Model/AppAPITypes.swift#L678) | -| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L677](../Shared/Model/AppAPITypes.swift#L679) | +| `activeUser` | `user: User` | Current active user | [L658](../Shared/Model/AppAPITypes.swift#L658) | +| `usersList` | `users: [UserInfo]` | All user profiles | [L659](../Shared/Model/AppAPITypes.swift#L659) | +| `chatStarted` | -- | Chat engine started | [L660](../Shared/Model/AppAPITypes.swift#L660) | +| `chatRunning` | -- | Chat is already running | [L661](../Shared/Model/AppAPITypes.swift#L661) | +| `chatStopped` | -- | Chat engine stopped | [L662](../Shared/Model/AppAPITypes.swift#L662) | +| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L663](../Shared/Model/AppAPITypes.swift#L663) | +| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L664](../Shared/Model/AppAPITypes.swift#L664) | +| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L666](../Shared/Model/AppAPITypes.swift#L666) | +| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L667](../Shared/Model/AppAPITypes.swift#L667) | +| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L668](../Shared/Model/AppAPITypes.swift#L668) | +| `userServersValidation` | `user, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]` | Server validation result with errors and warnings | [L671](../Shared/Model/AppAPITypes.swift#L671) | +| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L674](../Shared/Model/AppAPITypes.swift#L674) | +| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L675](../Shared/Model/AppAPITypes.swift#L675) | +| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L676](../Shared/Model/AppAPITypes.swift#L676) | +| `connectionVerified` | `verified, expectedCode` | Verification result | [L686](../Shared/Model/AppAPITypes.swift#L686) | +| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L687](../Shared/Model/AppAPITypes.swift#L687) | ### ChatResponse1 -Contact, message, and profile responses ([`AppAPITypes.swift` L768](../Shared/Model/AppAPITypes.swift#L771)): +Contact, message, and profile responses ([`AppAPITypes.swift` L779](../Shared/Model/AppAPITypes.swift#L779)): | Response | Key Fields | Description | Source | |----------|-----------|-------------|--------| -| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L769](../Shared/Model/AppAPITypes.swift#L772) | -| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L772](../Shared/Model/AppAPITypes.swift#L775) | -| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L773](../Shared/Model/AppAPITypes.swift#L776) | -| `contactDeleted` | `user, contact` | Contact deleted | [L782](../Shared/Model/AppAPITypes.swift#L785) | -| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L800](../Shared/Model/AppAPITypes.swift#L803) | -| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L803](../Shared/Model/AppAPITypes.swift#L806) | -| `chatItemReaction` | `user, added, reaction` | Reaction change | [L805](../Shared/Model/AppAPITypes.swift#L808) | -| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L807](../Shared/Model/AppAPITypes.swift#L810) | -| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L808](../Shared/Model/AppAPITypes.swift#L811) | -| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L788](../Shared/Model/AppAPITypes.swift#L791) | -| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L796](../Shared/Model/AppAPITypes.swift#L799) | -| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L802](../Shared/Model/AppAPITypes.swift#L805) | -| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L801](../Shared/Model/AppAPITypes.swift#L804) | +| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L780](../Shared/Model/AppAPITypes.swift#L780) | +| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L783](../Shared/Model/AppAPITypes.swift#L783) | +| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L784](../Shared/Model/AppAPITypes.swift#L784) | +| `startedConnectionToGroup` | `user, groupInfo, relayResults: [RelayConnectionResult]` | Group/channel join initiated; relay results indicate per-relay connection success/failure | [L790](../Shared/Model/AppAPITypes.swift#L790) | +| `contactDeleted` | `user, contact` | Contact deleted | [L793](../Shared/Model/AppAPITypes.swift#L793) | +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L811](../Shared/Model/AppAPITypes.swift#L811) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L814](../Shared/Model/AppAPITypes.swift#L814) | +| `chatItemReaction` | `user, added, reaction` | Reaction change | [L816](../Shared/Model/AppAPITypes.swift#L816) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L818](../Shared/Model/AppAPITypes.swift#L818) | +| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L819](../Shared/Model/AppAPITypes.swift#L819) | +| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L799](../Shared/Model/AppAPITypes.swift#L799) | +| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L807](../Shared/Model/AppAPITypes.swift#L807) | +| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L813](../Shared/Model/AppAPITypes.swift#L813) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L812](../Shared/Model/AppAPITypes.swift#L812) | ### ChatResponse2 -Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L907](../Shared/Model/AppAPITypes.swift#L911)): +Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L919](../Shared/Model/AppAPITypes.swift#L919)): | Response | Key Fields | Description | Source | |----------|-----------|-------------|--------| -| `groupCreated` | `user, groupInfo` | New group created | [L909](../Shared/Model/AppAPITypes.swift#L913) | -| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L910](../Shared/Model/AppAPITypes.swift#L914) | -| `groupMembers` | `user, group: Group` | Group member list | [L914](../Shared/Model/AppAPITypes.swift#L918) | -| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L918](../Shared/Model/AppAPITypes.swift#L922) | -| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L920](../Shared/Model/AppAPITypes.swift#L924) | -| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L921](../Shared/Model/AppAPITypes.swift#L925) | -| `rcvFileAccepted` | `user, chatItem` | File download started | [L928](../Shared/Model/AppAPITypes.swift#L932) | -| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L937](../Shared/Model/AppAPITypes.swift#L941) | -| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L940](../Shared/Model/AppAPITypes.swift#L944) | -| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L948](../Shared/Model/AppAPITypes.swift#L952) | -| `cmdOk` | `user_` | Generic success | [L949](../Shared/Model/AppAPITypes.swift#L953) | -| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L953](../Shared/Model/AppAPITypes.swift#L957) | -| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L954](../Shared/Model/AppAPITypes.swift#L958) | -| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L955](../Shared/Model/AppAPITypes.swift#L959) | +| `groupCreated` | `user, groupInfo` | New group created | [L921](../Shared/Model/AppAPITypes.swift#L921) | +| `publicGroupCreated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | New public group (channel) created with relay info | [L922](../Shared/Model/AppAPITypes.swift#L922) | +| `groupRelays` | `user, groupInfo, groupRelays: [GroupRelay]` | Group relay list (owner API response) | [L923](../Shared/Model/AppAPITypes.swift#L923) | +| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L924](../Shared/Model/AppAPITypes.swift#L924) | +| `groupMembers` | `user, group: Group` | Group member list | [L928](../Shared/Model/AppAPITypes.swift#L928) | +| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L932](../Shared/Model/AppAPITypes.swift#L932) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L934](../Shared/Model/AppAPITypes.swift#L934) | +| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L935](../Shared/Model/AppAPITypes.swift#L935) | +| `rcvFileAccepted` | `user, chatItem` | File download started | [L942](../Shared/Model/AppAPITypes.swift#L942) | +| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L951](../Shared/Model/AppAPITypes.swift#L951) | +| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L954](../Shared/Model/AppAPITypes.swift#L954) | +| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L962](../Shared/Model/AppAPITypes.swift#L962) | +| `cmdOk` | `user_` | Generic success | [L963](../Shared/Model/AppAPITypes.swift#L963) | +| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L967](../Shared/Model/AppAPITypes.swift#L967) | +| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L968](../Shared/Model/AppAPITypes.swift#L968) | +| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L969](../Shared/Model/AppAPITypes.swift#L969) | --- ## 4. Event Types -The `ChatEvent` enum ([`AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands. +The `ChatEvent` enum ([`AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands. -Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266) in `SimpleXAPI.swift`. +Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2282) in `SimpleXAPI.swift`. ### Connection Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1057](../Shared/Model/AppAPITypes.swift#L1062) | -| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1058](../Shared/Model/AppAPITypes.swift#L1063) | -| `contactSndReady` | `user, contact` | Ready to send to contact | [L1059](../Shared/Model/AppAPITypes.swift#L1064) | -| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1056](../Shared/Model/AppAPITypes.swift#L1061) | -| `contactUpdated` | `user, toContact` | Contact profile updated | [L1061](../Shared/Model/AppAPITypes.swift#L1066) | -| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1060](../Shared/Model/AppAPITypes.swift#L1065) | -| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1063](../Shared/Model/AppAPITypes.swift#L1068) | +| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1076](../Shared/Model/AppAPITypes.swift#L1076) | +| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `contactSndReady` | `user, contact` | Ready to send to contact | [L1078](../Shared/Model/AppAPITypes.swift#L1078) | +| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1075](../Shared/Model/AppAPITypes.swift#L1075) | +| `contactUpdated` | `user, toContact` | Contact profile updated | [L1080](../Shared/Model/AppAPITypes.swift#L1080) | +| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1079](../Shared/Model/AppAPITypes.swift#L1079) | +| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1082](../Shared/Model/AppAPITypes.swift#L1082) | ### Message Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1065](../Shared/Model/AppAPITypes.swift#L1070) | -| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1067](../Shared/Model/AppAPITypes.swift#L1072) | -| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1068](../Shared/Model/AppAPITypes.swift#L1073) | -| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1069](../Shared/Model/AppAPITypes.swift#L1074) | -| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1066](../Shared/Model/AppAPITypes.swift#L1071) | -| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1071](../Shared/Model/AppAPITypes.swift#L1076) | -| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1064](../Shared/Model/AppAPITypes.swift#L1069) | +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1084](../Shared/Model/AppAPITypes.swift#L1084) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1086](../Shared/Model/AppAPITypes.swift#L1086) | +| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1087](../Shared/Model/AppAPITypes.swift#L1087) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1088](../Shared/Model/AppAPITypes.swift#L1088) | +| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1085](../Shared/Model/AppAPITypes.swift#L1085) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1090](../Shared/Model/AppAPITypes.swift#L1090) | +| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1083](../Shared/Model/AppAPITypes.swift#L1083) | ### Group Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1072](../Shared/Model/AppAPITypes.swift#L1077) | -| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1073](../Shared/Model/AppAPITypes.swift#L1078) | -| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1074](../Shared/Model/AppAPITypes.swift#L1079) | -| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1076](../Shared/Model/AppAPITypes.swift#L1081) | -| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1078](../Shared/Model/AppAPITypes.swift#L1083) | -| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1079](../Shared/Model/AppAPITypes.swift#L1084) | -| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1080](../Shared/Model/AppAPITypes.swift#L1085) | -| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1081](../Shared/Model/AppAPITypes.swift#L1086) | -| `leftMember` | `user, groupInfo, member` | Member left | [L1082](../Shared/Model/AppAPITypes.swift#L1087) | -| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1083](../Shared/Model/AppAPITypes.swift#L1088) | -| `userJoinedGroup` | `user, groupInfo` | Successfully joined | [L1084](../Shared/Model/AppAPITypes.swift#L1089) | -| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1085](../Shared/Model/AppAPITypes.swift#L1090) | -| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1086](../Shared/Model/AppAPITypes.swift#L1091) | -| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1087](../Shared/Model/AppAPITypes.swift#L1092) | -| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1062](../Shared/Model/AppAPITypes.swift#L1067) | +| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1091](../Shared/Model/AppAPITypes.swift#L1091) | +| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1092](../Shared/Model/AppAPITypes.swift#L1092) | +| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1093](../Shared/Model/AppAPITypes.swift#L1093) | +| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1095](../Shared/Model/AppAPITypes.swift#L1095) | +| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1097](../Shared/Model/AppAPITypes.swift#L1097) | +| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1098](../Shared/Model/AppAPITypes.swift#L1098) | +| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1100](../Shared/Model/AppAPITypes.swift#L1100) | +| `leftMember` | `user, groupInfo, member` | Member left | [L1101](../Shared/Model/AppAPITypes.swift#L1101) | +| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1102](../Shared/Model/AppAPITypes.swift#L1102) | +| `userJoinedGroup` | `user, groupInfo, hostMember` | Successfully joined; `hostMember` is upserted into group members | [L1103](../Shared/Model/AppAPITypes.swift#L1103) | +| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1104](../Shared/Model/AppAPITypes.swift#L1104) | +| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1105](../Shared/Model/AppAPITypes.swift#L1105) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) | +| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) | +| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) | ### File Transfer Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `rcvFileStart` | `user, chatItem` | Download started | [L1092](../Shared/Model/AppAPITypes.swift#L1097) | -| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1093](../Shared/Model/AppAPITypes.swift#L1098) | -| `rcvFileComplete` | `user, chatItem` | Download complete | [L1094](../Shared/Model/AppAPITypes.swift#L1099) | -| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1096](../Shared/Model/AppAPITypes.swift#L1101) | -| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1097](../Shared/Model/AppAPITypes.swift#L1102) | -| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1100](../Shared/Model/AppAPITypes.swift#L1105) | -| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1101](../Shared/Model/AppAPITypes.swift#L1106) | -| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1103](../Shared/Model/AppAPITypes.swift#L1108) | -| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1105](../Shared/Model/AppAPITypes.swift#L1110) | -| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1107](../Shared/Model/AppAPITypes.swift#L1112) | +| `rcvFileStart` | `user, chatItem` | Download started | [L1112](../Shared/Model/AppAPITypes.swift#L1112) | +| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1113](../Shared/Model/AppAPITypes.swift#L1113) | +| `rcvFileComplete` | `user, chatItem` | Download complete | [L1114](../Shared/Model/AppAPITypes.swift#L1114) | +| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1116](../Shared/Model/AppAPITypes.swift#L1116) | +| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1117](../Shared/Model/AppAPITypes.swift#L1117) | +| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1120](../Shared/Model/AppAPITypes.swift#L1120) | +| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1121](../Shared/Model/AppAPITypes.swift#L1121) | +| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1123](../Shared/Model/AppAPITypes.swift#L1123) | +| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1125](../Shared/Model/AppAPITypes.swift#L1125) | +| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1127](../Shared/Model/AppAPITypes.swift#L1127) | ### Call Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1110](../Shared/Model/AppAPITypes.swift#L1115) | -| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1111](../Shared/Model/AppAPITypes.swift#L1116) | -| `callAnswer` | `user, contact, answer` | SDP answer received | [L1112](../Shared/Model/AppAPITypes.swift#L1117) | -| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1113](../Shared/Model/AppAPITypes.swift#L1118) | -| `callEnded` | `user, contact` | Call ended by remote | [L1114](../Shared/Model/AppAPITypes.swift#L1119) | +| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1130](../Shared/Model/AppAPITypes.swift#L1130) | +| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1131](../Shared/Model/AppAPITypes.swift#L1131) | +| `callAnswer` | `user, contact, answer` | SDP answer received | [L1132](../Shared/Model/AppAPITypes.swift#L1132) | +| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1133](../Shared/Model/AppAPITypes.swift#L1133) | +| `callEnded` | `user, contact` | Call ended by remote | [L1134](../Shared/Model/AppAPITypes.swift#L1134) | ### Connection Security Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1052](../Shared/Model/AppAPITypes.swift#L1057) | -| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1053](../Shared/Model/AppAPITypes.swift#L1058) | -| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1054](../Shared/Model/AppAPITypes.swift#L1059) | -| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1055](../Shared/Model/AppAPITypes.swift#L1060) | +| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1071](../Shared/Model/AppAPITypes.swift#L1071) | +| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | ### System Events | Event | Key Fields | Description | Source | |-------|-----------|-------------|--------| -| `chatSuspended` | -- | Core suspended | [L1051](../Shared/Model/AppAPITypes.swift#L1056) | +| `chatSuspended` | -- | Core suspended | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | --- ## 5. Error Types -Defined in [`SimpleXChat/APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699): +Defined in [`SimpleXChat/APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699): ```swift -public enum ChatError: Decodable, Hashable { +public enum ChatError: Decodable, Hashable, Error { case error(errorType: ChatErrorType) case errorAgent(agentError: AgentErrorType) case errorStore(storeError: StoreError) case errorDatabase(databaseError: DatabaseError) case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) - case invalidJSON(json: String) + case invalidJSON(json: Data?) case unexpectedResult(type: String) } ``` @@ -469,13 +478,13 @@ public enum ChatError: Decodable, Hashable { | Category | Enum | Description | Source | |----------|------|-------------|--------| -| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied) | [`APITypes.swift` L717](../SimpleXChat/APITypes.swift#L722) | -| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L873](../SimpleXChat/APITypes.swift#L878) | -| Database store | `StoreError` | SQLite query/constraint errors | [`APITypes.swift` L796](../SimpleXChat/APITypes.swift#L801) | -| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L860](../SimpleXChat/APITypes.swift#L865) | -| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1043](../SimpleXChat/APITypes.swift#L1048) | -| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) | -| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L695](../SimpleXChat/APITypes.swift#L699) | +| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied, `chatRelayExists`) | [`APITypes.swift` L722](../SimpleXChat/APITypes.swift#L722) | +| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L884](../SimpleXChat/APITypes.swift#L884) | +| Database store | `StoreError` | SQLite query/constraint errors (includes relay-related: `relayUserNotFound`, `duplicateMemberId`, `userChatRelayNotFound`, `groupRelayNotFound`, `groupRelayNotFoundByMemberId`) | [`APITypes.swift` L802](../SimpleXChat/APITypes.swift#L802) | +| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L871](../SimpleXChat/APITypes.swift#L871) | +| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1054](../SimpleXChat/APITypes.swift#L1054) | +| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) | +| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) | --- @@ -487,7 +496,7 @@ Defined in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift): ```swift // Throws on error, returns typed result -func chatSendCmdSync( // SimpleXAPI.swift L91 +func chatSendCmdSync( // SimpleXAPI.swift L93 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -496,7 +505,7 @@ func chatSendCmdSync( // SimpleXAPI.swift L91 ) throws -> R // Returns APIResult (caller handles error) -func chatApiSendCmdSync( // SimpleXAPI.swift L96 +func chatApiSendCmdSync( // SimpleXAPI.swift L99 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -510,7 +519,7 @@ func chatApiSendCmdSync( // SimpleXAPI.swift L96 ```swift // Throws on error, returns typed result -func chatSendCmd( // SimpleXAPI.swift L117 +func chatSendCmd( // SimpleXAPI.swift L121 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -519,7 +528,7 @@ func chatSendCmd( // SimpleXAPI.swift L117 ) async throws -> R // Returns APIResult with optional retry on network errors -func chatApiSendCmdWithRetry( // SimpleXAPI.swift L122 +func chatApiSendCmdWithRetry( // SimpleXAPI.swift L127 _ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, @@ -543,12 +552,12 @@ public func sendSimpleXCmd( // API.swift L115 ```swift // Polls for async events from the Haskell core -func chatRecvMsg( // SimpleXAPI.swift L230 +func chatRecvMsg( // SimpleXAPI.swift L237 _ ctrl: chat_ctrl? = nil ) async -> APIResult? // Processes a received event and updates app state -func processReceivedMsg( // SimpleXAPI.swift L2248 +func processReceivedMsg( // SimpleXAPI.swift L2282 _ res: ChatEvent ) async ``` @@ -557,7 +566,7 @@ func processReceivedMsg( // SimpleXAPI.swift L2248 ## 7. Result Type -Defined in [`SimpleXChat/APITypes.swift` L26](../SimpleXChat/APITypes.swift#L27): +Defined in [`SimpleXChat/APITypes.swift` L27](../SimpleXChat/APITypes.swift#L27): ```swift public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { @@ -569,14 +578,14 @@ public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { public var unexpected: ChatError { ... } } -public protocol ChatAPIResult: Decodable { // APITypes.swift L63 +public protocol ChatAPIResult: Decodable { // APITypes.swift L65 var responseType: String { get } var details: String { get } static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? } ``` -The `decodeAPIResult` function ([`APITypes.swift` L83](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: +The `decodeAPIResult` function ([`APITypes.swift` L86](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: 1. Try standard `JSONDecoder.decode(APIResult.self, from: data)` 2. If that fails, try manual JSON parsing via `JSONSerialization` 3. Check for `"error"` key -- return `.error` @@ -589,10 +598,10 @@ The `decodeAPIResult` function ([`APITypes.swift` L83](../SimpleXChat/APIType | File | Path | |------|------| -| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L14](../Shared/Model/AppAPITypes.swift#L15) | -| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L647, L768, L907](../Shared/Model/AppAPITypes.swift#L649) | -| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1050](../Shared/Model/AppAPITypes.swift#L1055) | -| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L26, L695](../SimpleXChat/APITypes.swift#L27) | +| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15) | +| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L657, L779, L919](../Shared/Model/AppAPITypes.swift#L657) | +| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069) | +| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L27, L699](../SimpleXChat/APITypes.swift#L27) | | FFI bridge functions | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | | Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) | | Data types | `SimpleXChat/ChatTypes.swift` | diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md index 84d9d3269d..9ab3eb1fd2 100644 --- a/apps/ios/spec/architecture.md +++ b/apps/ios/spec/architecture.md @@ -266,6 +266,55 @@ Optional desktop pairing allows controlling the mobile app from a desktop client --- +## 8. Chat Relay Management + +### Overview + +Chat relays are SMP servers that forward messages to channel subscribers. They are configured in the Network & Servers settings and selected during channel creation. + +### Data Model + +| Type | Location | Description | +|------|----------|-------------| +| `UserChatRelay` | `ChatTypes.swift` | Relay server config: chatRelayId, address, name, domains, preset, tested, enabled, deleted | +| `UserOperatorServers.chatRelays` | `AppAPITypes.swift` | Array of `UserChatRelay` per operator | +| `UserServersWarning` | `AppAPITypes.swift` | Enum with `.noChatRelays(user:)` case | +| `ServerSettings.serverWarnings` | `ChatListView.swift` | `[UserServersWarning]` field on `ServerSettings` struct (exposed via `SaveableSettings.servers`) | + +### Relay Management Views + +| View | File | Description | +|------|------|-------------| +| `ChatRelayView` | `ChatRelayView.swift` | Edit/view relay: name, address, test, enable toggle, delete | +| `ChatRelayViewLink` | `ChatRelayView.swift` | NavigationLink row showing relay status icon + display name | +| `NewChatRelayView` | `ChatRelayView.swift` | Form to add new relay (name + address + test + enable toggle) | +| `ServersWarningView` | `NetworkAndServers.swift` | Orange exclamation triangle + warning text | + +### Key Functions + +| Function | File | Description | +|----------|------|-------------| +| `addChatRelay(...)` | `ChatRelayView.swift` | Validates name/address, appends to `userServers[nil operator].chatRelays`, calls `validateServers_` | +| `deleteChatRelay(...)` | `ProtocolServersView.swift` | Marks relay as deleted or removes if no `chatRelayId` | +| `validRelayName(_:)` | `ChatRelayView.swift` | Non-empty + valid display name check | +| `validRelayAddress(_:)` | `ChatRelayView.swift` | Parses via `parseSimpleXMarkdown`, validates `.simplexLink(_, .relay, _, _)` | +| `showRelayTestStatus(relay:)` | `ChatRelayView.swift` | ViewBuilder returning checkmark/multiply/clear icons | +| `validateServers_` | `NetworkAndServers.swift` | Extended signature: now accepts optional `Binding<[UserServersWarning]>?`; calls `validateServers` which returns `([UserServersError], [UserServersWarning])` tuple | +| `globalServersWarning(_:)` | `NetworkAndServers.swift` | Extracts `.noChatRelays` warning text for display | +| `bindingForChatRelays(_:_:)` | `NetworkAndServers.swift` | Creates binding for `chatRelays` at operator index | + +### Relay Sections in Settings + +"Chat relays" sections appear in: +- `OperatorView`: lists relays for the operator, with header and footer +- `YourServersView` (in `ProtocolServersView`): lists relays for non-operator servers, with delete support and "Add server" -> "Chat relay" option + +### serverWarnings Plumbing + +`Binding<[UserServersWarning]>` is threaded through: `NetworkAndServers` -> `OperatorView` -> `ProtocolServersView` -> `ProtocolServerView` / `NewServerView` / `ScanProtocolServer`. All `validateServers_` calls pass the warnings binding. + +--- + ## Source Files | File | Path | Line | diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md index 0eb3cd75f7..d35de1f80a 100644 --- a/apps/ios/spec/client/chat-list.md +++ b/apps/ios/spec/client/chat-list.md @@ -134,6 +134,22 @@ Wraps `ChatPreviewView` in a navigation link with tap and swipe behavior: - Sets `ChatModel.chatId` to trigger navigation - `ItemsModel.loadOpenChat()` loads messages with a 250ms navigation delay for smooth animation +### Channel Adaptations in ChatListNavLink + +When `groupInfo.useRelays == true`: + +| Change | Behavior | +|--------|----------| +| Swipe "Leave" | Hidden when `useRelays && isOwner` | +| Context menu "Leave" | Hidden under same condition | +| `deleteGroupAlert` label | "Delete channel?" | +| `leaveGroupAlert` title | "Leave channel?" | +| `leaveGroupAlert` message | "You will stop receiving messages from this channel. Chat history will be preserved." | + +### ServerSettings + +`ServerSettings` struct (defined in `ChatListView.swift`) includes `serverWarnings: [UserServersWarning]` field, initialized to `[]`. This field stores validation warnings from `validateServers` and is consumed by NetworkAndServers views. + --- ## 5. Filtering & Tags diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index b913287746..111b8ec1f4 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -5,7 +5,7 @@ > Related specs: [Compose Module](compose.md) | [State Management](../state.md) | [API Reference](../api.md) | [README](../README.md) > Related product: [Chat View](../../product/views/chat.md) -**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) +**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) | [`ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [`ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) --- @@ -52,7 +52,7 @@ ChatView --- -## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3135) +## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3210) **File**: [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) @@ -84,33 +84,33 @@ The main conversation view. Key responsibilities: | Function | Line | Description | |----------|------|-------------| -| [`body`](../../Shared/Views/Chat/ChatView.swift#L76) | L74 | Main view body | -| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L675) | L672 | Initializes chat view state on appear | -| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L821) | L814 | Builds the scrollable message list | -| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L735) | L731 | Scrolls to a specific message by ID | -| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L769) | L764 | In-chat search toolbar UI | -| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1087 | Handles search query changes | -| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1519 | Loads chat items with pagination | -| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L807) | L801 | Filters items by content type | -| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1264 | Audio/video call toolbar button | -| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1284 | Search toggle toolbar button | -| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1352 | Group add-members toolbar button | -| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1409 | Forwards batch-selected messages | -| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1401 | Deletes batch-selected messages | -| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1559 | Reacts to chat items model changes | -| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1292 | Content filter dropdown menu | +| [`body`](../../Shared/Views/Chat/ChatView.swift#L75) | L75 | Main view body | +| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L660) | L660 | Initializes chat view state on appear | +| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L817) | L817 | Builds the scrollable message list | +| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L731) | L731 | Scrolls to a specific message by ID | +| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L765) | L765 | In-chat search toolbar UI | +| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1095 | Handles search query changes | +| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1531 | Loads chat items with pagination | +| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L803) | L803 | Filters items by content type | +| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1273 | Audio/video call toolbar button | +| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1293 | Search toggle toolbar button | +| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1361 | Group add-members toolbar button | +| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1420 | Forwards batch-selected messages | +| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1411 | Deletes batch-selected messages | +| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1572 | Reacts to chat items model changes | +| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1301 | Content filter dropdown menu | ### Supporting Types | Type | Line | Description | |------|------|-------------| -| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1586 | Wraps each chat item with context menu | -| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2712) | L2697 | Manages scroll-to-bottom button state | -| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2899) | L2882 | Reaction picker context menu | -| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L2997) | L2980 | Mute/unmute notifications button | -| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3049) | L3031 | Enum for message content filter types | -| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2795) | L2779 | Deletes messages with confirmation | -| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2842) | L2826 | Archives report messages | +| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1600 | Wraps each chat item with context menu | +| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2787) | L2787 | Manages scroll-to-bottom button state | +| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2974) | L2974 | Reaction picker context menu | +| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L3072) | L3072 | Mute/unmute notifications button | +| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3124) | L3124 | Enum for message content filter types | +| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2870) | L2870 | Deletes messages with confirmation | +| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2917) | L2917 | Archives report messages | --- @@ -124,17 +124,17 @@ Routes each `ChatItem` to the appropriate renderer based on its `CIContent` type | Content Type | Renderer | Line | Description | |-------------|----------|------|-------------| -| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L13 | Standard sent/received text+media message | -| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L13 | Locally deleted message placeholder | +| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L14 | Standard sent/received text+media message | +| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L14 | Locally deleted message placeholder | | `sndCall` / `rcvCall` | [`CICallItemView`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | L13 | Call event (missed, ended, duration) | -| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L13 | Message integrity error | -| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L15 | Decryption failure | -| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L13 | Group invite | -| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Group system event | -| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L13 | Connection event | -| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L13 | Feature toggle event | -| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L13 | Preference change | -| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L13 | Failed to decode | +| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L14 | Message integrity error | +| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L16 | Decryption failure | +| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L14 | Group invite | +| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Group system event | +| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Connection event | +| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L14 | Feature toggle event | +| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L14 | Preference change | +| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L14 | Failed to decode | ### Bubble Direction - Sent messages: aligned right, sender-colored bubble @@ -149,6 +149,19 @@ Each [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) may depe `ChatItemDummyModel.shared.sendUpdate()` forces a re-render of all items when global appearance changes. +### Channel Message Rendering (`.channelRcv`) + +Channel messages (`CIDirection.channelRcv`) are rendered with the group avatar and group name as sender, with "channel" as the role label. This mirrors the `.groupRcv` path's `showGroupAsSender` visual but uses a dedicated code branch in [`chatItemListView()`](../../Shared/Views/Chat/ChatView.swift#L1846). + +Key differences from `.groupRcv`: +- No `prevMember`/`memCount` logic — channels have no per-member identity +- Always shows group avatar (via `ProfileImage` with `groupInfo.image` / `groupInfo.chatIconName`) +- Tapping avatar opens `showChatInfoSheet` (not member info) +- [`shouldShowAvatar()`](../../Shared/Views/Chat/ChatView.swift#L1670) treats consecutive `.channelRcv` items as same sender +- [`getItemSeparation()`](../../Shared/Views/Chat/ChatView.swift#L1649) treats consecutive `.channelRcv` items as `sameMemberAndDirection` +- [`showMemberImage()`](../../Shared/Views/Chat/ChatView.swift#L2116) returns `true` when previous item is `.channelRcv` (different sender type) +- [`memberToModerate()`](../../SimpleXChat/ChatTypes.swift#L3297) returns `nil` for `.channelRcv` (no per-member moderation) + --- ## 4. Message Renderers @@ -301,31 +314,78 @@ Multi-selection mode allows batch operations on messages: --- +## GroupChatInfoView — Channel Adaptations + +When `groupInfo.useRelays == true`, [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) adapts its sections: + +### Section Structure (Channel) + +| Section | Owner | Subscriber | +|---------|-------|-----------| +| 1. Links & Members | Channel link (manage via GroupLinkView), Owners & subscribers | Channel link (read-only QR from `groupProfile.groupLink`), Owners | +| 2. Profile & Welcome | Edit channel profile, Welcome message | Welcome message (if exists) | +| 3. Theme & TTL | Chat theme, Delete messages after | Chat theme, Delete messages after | +| 4. Actions | Chat relays, Clear chat, Delete channel | Chat relays, Clear chat, Leave channel | + +**Hidden for channels:** Member support, group reports, user support chat, send receipts, inline members list, group preferences. + +### Label Replacements + +All "group" labels are replaced with "channel" equivalents via `groupInfo.useRelays ? "Channel..." :` ternary prepended before existing `businessChat` ternary. Affected: delete/leave buttons, delete/leave alerts, remove member alert, edit profile button, group link nav title. Channel link button uses a separate `channelLinkButton()` with hardcoded "Channel link" label. + +### [`channelMembersButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L627) → [`ChannelMembersView`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) + +Navigates to a dedicated members view with two sections: +- **Owners**: current user (if owner) + members with `memberRole >= .owner` +- **Subscribers** (admin+ only): members with `memberRole < .owner` + +Member rows show profile image, display name (with verified shield), connection status, and role badge. Non-user rows link to `GroupMemberInfoView`. + +### Channel Link + +Owner sees [`channelLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L605) (navigates to `GroupLinkView` for full link management), guarded by `groupInfo.isOwner && groupLink != nil` — channel links can only be created during channel creation, not from the info view. A TODO marks the need for protocol changes to allow other owners to manage the same channel link. Non-owner sees read-only QR code displaying `groupProfile.groupLink` via `SimpleXLinkQRCode`. `apiGetGroupLink` is skipped in `onAppear` for non-owner channels. + +Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L593) which supports both "Create group link" and "Group link" labels. + +### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) + +Navigates to relay list view with role-based branches: +- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). +- **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). + +### Leave Button Logic + +Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`. + +--- + ## Source Files | File | Path | Line | |------|------|------| -| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L17](../../Shared/Views/Chat/ChatView.swift#L18) | -| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L41](../../Shared/Views/Chat/ChatItemView.swift#L42) | -| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | -| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) | -| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) | -| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) | -| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) | -| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) | -| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) | +| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L18](../../Shared/Views/Chat/ChatView.swift#L18) | +| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L42](../../Shared/Views/Chat/ChatItemView.swift#L42) | +| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | +| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) | +| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) | +| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) | +| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) | +| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) | +| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) | | Call event | [`Shared/Views/Chat/ChatItem/CICallItemView.swift`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | -| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) | -| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L12](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | -| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | -| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | -| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) | -| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L27](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) | -| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | -| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | -| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L15](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | -| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | -| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) | -| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L10](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) | -| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L15](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) | -| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) | +| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) | +| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L13](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | +| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | +| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) | +| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L28](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) | +| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | +| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | +| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | +| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | +| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) | +| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L11](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) | +| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) | +| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) | +| Channel members | [`Shared/Views/Chat/Group/ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelMembersView.swift#L12) | +| Channel relays | [`Shared/Views/Chat/Group/ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelRelaysView.swift#L12) | diff --git a/apps/ios/spec/client/compose.md b/apps/ios/spec/client/compose.md index 03116ddf6b..f86e323ade 100644 --- a/apps/ios/spec/client/compose.md +++ b/apps/ios/spec/client/compose.md @@ -69,21 +69,21 @@ ComposeView | Function | Line | Description | |----------|------|-------------| -| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L369) | L360 | Main view body | -| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L693) | L683 | Builds the send-message UI | -| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1106) | L1091 | Entry point: initiates send | -| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1115) | L1099 | Async send implementation | -| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1467) | L1446 | Resets compose state after send | -| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L893) | L882 | Adds media attachment | -| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L866) | L856 | Checks link preview before connect | -| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L754) | L744 | Builds commands menu button | +| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L371) | L371 | Main view body | +| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L870) | L870 | Builds the send-message UI | +| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1286) | L1286 | Entry point: initiates send | +| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1295) | L1295 | Async send implementation | +| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1649) | L1649 | Resets compose state after send | +| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1073) | L1073 | Adds media attachment | +| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1046) | L1046 | Checks link preview before connect | +| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L931) | L931 | Builds commands menu button | ### Draft Persistence | Function | Line | Description | |----------|------|-------------| -| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1481) | L1459 | Saves compose state to `ChatModel.draft` | -| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1487) | L1464 | Clears persisted draft | +| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1663) | L1663 | Saves compose state to `ChatModel.draft` | +| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1669) | L1669 | Clears persisted draft | - When navigating away from a chat, compose state is saved to `ChatModel.draft` / `ChatModel.draftChatId` - When returning to the same chat, draft is restored @@ -118,14 +118,14 @@ The compose bar operates as a state machine with these primary states: | Type | Line | Description | |------|------|-------------| -| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L10 | Preview variants (image, voice, file, etc.) | -| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L18 | Context item for reply/quote | -| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L26 | Recording state enum | -| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L40 | Full compose state struct | -| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L93 | Copy compose state with overrides | -| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L113 | Format mention display name | -| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L260 | Build preview from chat item | -| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L280 | Upload content variants | +| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L11 | Preview variants (image, voice, file, etc.) | +| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L20 | Context item for reply/quote | +| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L29 | Recording state enum | +| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L45 | Full compose state struct | +| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L98 | Copy compose state with overrides | +| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L118 | Format mention display name | +| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L266 | Build preview from chat item | +| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L287 | Upload content variants | ### States @@ -253,10 +253,10 @@ Optional feature where the recipient sees typing in real-time. | Function | Line | Description | |----------|------|-------------| -| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L922) | L910 | Initiates a live message | -| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L940) | L927 | Sends incremental live update | -| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L959) | L945 | Determines text diff to send | -| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L964) | L950 | Truncates text at word boundary | +| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1102) | L1102 | Initiates a live message | +| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1120) | L1120 | Sends incremental live update | +| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1139) | L1139 | Determines text diff to send | +| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1144) | L1144 | Truncates text at word boundary | ### API - Initial: `apiSendMessages(live: true, composedMessages: [...])` -- creates live message @@ -279,12 +279,12 @@ Optional feature where the recipient sees typing in real-time. | Function | Line | Description | |----------|------|-------------| -| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1382) | L1365 | Begins audio recording | -| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1423) | L1405 | Stops recording, shows preview | -| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1434) | L1415 | Enables voice messages for contact | -| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1441) | L1422 | Updates state after recording finishes | -| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1453) | L1434 | Cancels in-progress recording | -| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1460) | L1440 | Cancels and cleans up recording file | +| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1564) | L1564 | Begins audio recording | +| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1605) | L1605 | Stops recording, shows preview | +| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1616) | L1616 | Enables voice messages for contact | +| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1623) | L1623 | Updates state after recording finishes | +| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1635) | L1635 | Cancels in-progress recording | +| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1642) | L1642 | Cancels and cleans up recording file | ### Constraints - Maximum duration: `MAX_VOICE_MESSAGE_LENGTH = 300` seconds (5 minutes) @@ -310,12 +310,12 @@ Optional feature where the recipient sees typing in real-time. | Function | Line | Description | |----------|------|-------------| -| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1495) | L1471 | Triggers link preview loading | -| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1515) | L1490 | Extracts URLs from formatted text | -| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) | L1501 | Checks if URL is a SimpleX link | -| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1530) | L1505 | Cancels pending preview | -| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1542) | L1516 | Fetches OpenGraph metadata | -| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1559) | L1533 | Resets preview state | +| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1677) | L1677 | Triggers link preview loading | +| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1697) | L1697 | Extracts URLs from formatted text | +| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1708) | L1708 | Checks if URL is a SimpleX link | +| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1712) | L1712 | Cancels pending preview | +| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1724) | L1724 | Fetches OpenGraph metadata | +| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1741) | L1741 | Resets preview state | ### Behavior - Only the first URL in the message generates a preview @@ -342,12 +342,29 @@ In group chats, typing `@` triggers member name autocomplete: --- +## Channel Compose Behavior + +When `chat.chatInfo.groupInfo?.useRelays == true` (channel mode), compose behaves differently: + +### Owner/Admin Compose +- [`send()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1498) passes `sendAsGroup: true` to `apiSendMessages` when `useRelays && memberRole >= .owner` +- [`forwardItems()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) passes `sendAsGroup: true` to `apiForwardChatItems` under same condition +- Placeholder text shows "Broadcast" instead of "Message" (via `sendMessageView()` `placeholder:` parameter) +- Share Extension ([`ShareAPI.swift`](../../SimpleX%20SE/ShareAPI.swift#L71)) uses the same `sendAsGroup` expression + +### Subscriber Compose +- [`userCantSendReason`](../../SimpleXChat/ChatTypes.swift#L1566) returns `("you are subscriber", nil)` when `useRelays && memberRole == .observer` +- This check is evaluated after `memberPending` (which takes priority) but replaces the `observer` message +- Compose field is disabled; tapping shows "You can't send messages!" alert with no body text + +--- + ## Source Files | File | Path | Struct/Class | Line | |------|------|--------------|------| -| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L321](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) | -| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L14](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) | +| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L329](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) | +| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L15](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) | | Image preview | [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | `ComposeImageView` | [L12](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) | | File preview | [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | `ComposeFileView` | [L11](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) | | Voice preview | [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | `ComposeVoiceView` | [L26](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) | diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index e755115827..22985c6fe1 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -198,7 +198,7 @@ SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Sh } ``` -URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1169). +URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). ### Call Deep Link @@ -293,6 +293,72 @@ Migration state (`ChatModel.migrationState != nil`) takes precedence over onboar --- +## 9. Channel Creation Flow (`AddChannelView`) + +**Source:** [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) + +### Entry Point + +`NewChatMenuButton` includes a NavigationLink "Create channel (BETA)" with antenna icon, navigating to `AddChannelView`. + +### Three-Step Wizard + +| Step | Function | Description | +|------|----------|-------------| +| 1. Profile | `profileStepView()` | Channel name input (`channelNameTextField()`), profile image picker. "Configure relays" link to `NetworkAndServers`. Validates via `canCreateProfile()` (non-empty + valid display name) and `checkHasRelays()`. | +| 2. Progress | `progressStepView(_:)` | Relay connection progress with `RelayProgressIndicator` (circular active/total or spinner). Expandable relay list with `relayStatusIndicator(_:)` (green/red/orange dots). Cancel via `cancelChannelCreation(_:)` which calls `apiDeleteChat`. | +| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` for channel link sharing. | + +### Key Functions + +| Function | Scope | Description | +|----------|-------|-------------| +| `createChannel()` | private | Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`, sets `ChannelRelaysModel` | +| `getEnabledRelays()` | private | Filters enabled/non-deleted relays, selects random 3 | +| `checkHasRelays()` | private | Validates at least one relay exists | +| `relayDisplayName(_:)` | module | name > domain > link host > fallback | +| `relayStatusIndicator(_:)` | module | Green/red/orange dot + status text | +| `RelayProgressIndicator` | module | Circular progress (active/total) or spinner | + +## 10. Relay URL Interception + +**Source:** [`Shared/ContentView.swift`](../../Shared/ContentView.swift#L454) + +In `connectViaUrl_()`, relay address links (URL path `/r`) are intercepted before processing: + +```swift +if path == "/r" { + showAlert(NSLocalizedString("Relay address", ...), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", ...)) + return +} +``` + +Similarly, in `planAndConnect()` (`NewChatView.swift`), `.simplexLink(_, .relay, _, _)` patterns trigger the same alert and block connection. + +## 11. Channel-Specific NewChatView Behavior + +**Source:** [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) + +### Prepared Group Alert (`showPrepareGroupAlert`) + +When `groupShortLinkInfo?.direct == false` (channel relay link), the prepare alert uses: +- Channel icon: `antenna.radiowaves.left.and.right.circle.fill` +- Title: "Open new channel" +- Error: "Error opening channel" +- `apiPrepareGroup` call passes `directLink: false` +- Stores `groupShortLinkInfo.groupRelays` in `ChatModel.shared.channelRelayHostnames` + +### Own Link Confirmation (`showOwnGroupLinkConfirmConnectSheet`) + +For channels: shows "This is your link for channel" with only "Open channel" + "Cancel" buttons. No incognito or profile selection options. + +### Known Group Alert (`showOpenKnownGroupAlert`) + +For channels (`groupInfo.useRelays`): titles become "Open channel" / "Open new channel". + +--- + ## Source Files | File | Path | @@ -304,6 +370,8 @@ Migration state (`ChatModel.migrationState != nil`) takes precedence over onboar | Nav link wrapper | `Shared/Views/ChatList/ChatListNavLink.swift` | | User picker | `Shared/Views/ChatList/UserPicker.swift` | | New chat view | [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | +| Channel creation | [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) | +| New chat menu | [`Shared/Views/NewChat/NewChatMenuButton.swift`](../../Shared/Views/NewChat/NewChatMenuButton.swift) | | Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | | User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) | | Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index 9593419b87..eaf646e7f4 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -40,6 +40,7 @@ | PC28 | Chat Tags | | PC29 | User Address | | PC30 | Member Support Chat | +| PC31 | Channels (Relays) | --- @@ -48,42 +49,46 @@ | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access | -| Shared/SimpleXApp.swift | PC1 through PC30 | High | App entry point — initialization affects everything | +| Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | | Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | | Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | -| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | -| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11 | High | Message composition — send path for all messages | +| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | | Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | | Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification | -| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30, PC31 | High | Group management hub, channel info adaptations | +| Shared/Views/Chat/Group/ChannelMembersView.swift | PC31 | Medium | Channel owners/subscribers list | +| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list | | Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | | Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | | Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management | -| Shared/Views/NewChat/NewChatView.swift | PC12 | High | New connection creation — onramp for all contacts | +| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels | | Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | | Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | | Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle | | Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management | | Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | | Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI | -| Shared/Views/UserSettings/NetworkAndServers/ | PC25 | High | Server configuration — affects connectivity | +| Shared/Views/UserSettings/NetworkAndServers/ | PC25, PC31 | High | Server configuration — affects connectivity and relay validation | | Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management | | Shared/Views/Onboarding/ | PC1 | Medium | First-time setup — affects initial state | | Shared/Views/LocalAuth/ | PC22 | Medium | App lock functionality | | Shared/Views/Database/ | PC23, PC26 | High | Database encryption and export | | Shared/Views/Migration/ | PC26 | High | Device migration — data portability | -| Shared/Model/ChatModel.swift | PC1 through PC30 | High | Central state — all features depend on it | -| Shared/Model/SimpleXAPI.swift | PC1 through PC30 | High | FFI bridge — all commands flow through here | -| Shared/Model/AppAPITypes.swift | PC1 through PC30 | High | Command/response types — all API communication | +| Shared/Model/ChatModel.swift | PC1 through PC31 | High | Central state — all features depend on it | +| Shared/Model/SimpleXAPI.swift | PC1 through PC31 | High | FFI bridge — all commands flow through here | +| Shared/Model/AppAPITypes.swift | PC1 through PC31 | High | Command/response types — all API communication | | Shared/Model/NtfManager.swift | PC18 | High | Notification delivery | | Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling | | Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine | -| SimpleXChat/ChatTypes.swift | PC1 through PC30 | High | Core data types — all features use them | -| SimpleXChat/APITypes.swift | PC1 through PC30 | High | API result types and error handling | +| SimpleXChat/ChatTypes.swift | PC1 through PC31 | High | Core data types — all features use them | +| SimpleXChat/APITypes.swift | PC1 through PC31 | High | API result types and error handling | | SimpleXChat/CallTypes.swift | PC17 | Medium | Call-specific data types | | SimpleXChat/FileUtils.swift | PC10, PC23, PC26 | Medium | File paths and encryption utilities | | SimpleXChat/Notifications.swift | PC18 | Medium | Notification type definitions | | SimpleX NSE/NotificationService.swift | PC18 | High | Push notification decryption and display | +| Shared/Views/Chat/ChatItemsMerger.swift | PC2, PC3, PC31 | Low | Chat item merge categories — added channelRcv hash | +| SimpleX SE/ShareAPI.swift | PC4, PC31 | Medium | Share extension API — sendAsGroup support | --- @@ -91,9 +96,9 @@ | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| src/Simplex/Chat/Controller.hs | PC1 through PC30 | High | Command processor — all API commands | -| src/Simplex/Chat/Types.hs | PC1 through PC30 | High | Core data types shared across all features | -| src/Simplex/Chat/Core.hs | PC1 through PC30 | High | Chat engine lifecycle | +| src/Simplex/Chat/Controller.hs | PC1 through PC31 | High | Command processor — all API commands | +| src/Simplex/Chat/Types.hs | PC1 through PC31 | High | Core data types shared across all features | +| src/Simplex/Chat/Core.hs | PC1 through PC31 | High | Chat engine lifecycle | | src/Simplex/Chat/Protocol.hs | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | | src/Simplex/Chat/Messages.hs | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | | src/Simplex/Chat/Messages/CIContent.hs | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index 68b5f3cbcc..c989547299 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -1,6 +1,6 @@ # SimpleX Chat iOS -- State Management -**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1375) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5284) +**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1404) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5377) > Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage. > @@ -15,10 +15,11 @@ 2. [ChatModel -- Primary App State](#2-chatmodel) 3. [ItemsModel -- Per-Chat Message State](#3-itemsmodel) 4. [ChatTagsModel -- Tag Filtering State](#4-chattagsmodel) -5. [Chat -- Single Conversation State](#5-chat) -6. [ChatInfo -- Conversation Metadata](#6-chatinfo) -7. [State Flow](#7-state-flow) -8. [Preference Storage](#8-preference-storage) +5. [ChannelRelaysModel -- Channel Relay State](#5-channelrelaysmodel) +6. [Chat -- Single Conversation State](#6-chat) +7. [ChatInfo -- Conversation Metadata](#7-chatinfo) +8. [State Flow](#8-state-flow) +9. [Preference Storage](#9-preference-storage) --- @@ -62,122 +63,122 @@ ChatTagsModel (singleton -- filter state) --- -## 2. [ChatModel](../Shared/Model/ChatModel.swift#L337-L1260) +## 2. [ChatModel](../Shared/Model/ChatModel.swift#L353-L1289) **Class**: `final class ChatModel: ObservableObject` **Singleton**: `ChatModel.shared` -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L353) ### Key Published Properties #### App Lifecycle | Property | Type | Description | Line | |----------|------|-------------|------| -| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L331](../Shared/Model/ChatModel.swift#L338) | -| `chatInitialized` | `Bool` | Whether chat has been initialized | [L340](../Shared/Model/ChatModel.swift#L347) | -| `chatRunning` | `Bool?` | Whether chat engine is running | [L341](../Shared/Model/ChatModel.swift#L348) | -| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L342](../Shared/Model/ChatModel.swift#L349) | -| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L343](../Shared/Model/ChatModel.swift#L350) | -| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L344](../Shared/Model/ChatModel.swift#L351) | -| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L345](../Shared/Model/ChatModel.swift#L352) | -| `migrationState` | `MigrationToState?` | Device migration state | [L390](../Shared/Model/ChatModel.swift#L398) | +| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L354](../Shared/Model/ChatModel.swift#L354) | +| `chatInitialized` | `Bool` | Whether chat has been initialized | [L363](../Shared/Model/ChatModel.swift#L363) | +| `chatRunning` | `Bool?` | Whether chat engine is running | [L364](../Shared/Model/ChatModel.swift#L364) | +| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L365](../Shared/Model/ChatModel.swift#L365) | +| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L366](../Shared/Model/ChatModel.swift#L366) | +| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L367](../Shared/Model/ChatModel.swift#L367) | +| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L368](../Shared/Model/ChatModel.swift#L368) | +| `migrationState` | `MigrationToState?` | Device migration state | [L417](../Shared/Model/ChatModel.swift#L417) | #### User State | Property | Type | Description | Line | |----------|------|-------------|------| -| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L334](../Shared/Model/ChatModel.swift#L341) | -| `users` | `[UserInfo]` | All user profiles | [L339](../Shared/Model/ChatModel.swift#L346) | -| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L333](../Shared/Model/ChatModel.swift#L340) | +| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L357](../Shared/Model/ChatModel.swift#L357) | +| `users` | `[UserInfo]` | All user profiles | [L362](../Shared/Model/ChatModel.swift#L362) | +| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L356](../Shared/Model/ChatModel.swift#L356) | #### Chat List | Property | Type | Description | Line | |----------|------|-------------|------| -| `chats` | `[Chat]` (private set) | All conversations for current user | [L351](../Shared/Model/ChatModel.swift#L358) | -| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L352](../Shared/Model/ChatModel.swift#L359) | +| `chats` | `[Chat]` (private set) | All conversations for current user | [L374](../Shared/Model/ChatModel.swift#L374) | +| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L375](../Shared/Model/ChatModel.swift#L375) | #### Active Chat | Property | Type | Description | Line | |----------|------|-------------|------| -| `chatId` | `String?` | Currently open chat ID | [L354](../Shared/Model/ChatModel.swift#L361) | -| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L355](../Shared/Model/ChatModel.swift#L362) | -| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L356](../Shared/Model/ChatModel.swift#L363) | -| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L357](../Shared/Model/ChatModel.swift#L364) | -| `chatToTop` | `String?` | Chat to scroll to top | [L358](../Shared/Model/ChatModel.swift#L365) | -| `groupMembers` | `[GMember]` | Members of active group | [L359](../Shared/Model/ChatModel.swift#L366) | -| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L360](../Shared/Model/ChatModel.swift#L367) | -| `membersLoaded` | `Bool` | Whether members have been loaded | [L361](../Shared/Model/ChatModel.swift#L368) | -| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L408](../Shared/Model/ChatModel.swift#L416) | +| `chatId` | `String?` | Currently open chat ID | [L377](../Shared/Model/ChatModel.swift#L377) | +| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L378](../Shared/Model/ChatModel.swift#L378) | +| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L379](../Shared/Model/ChatModel.swift#L379) | +| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L380](../Shared/Model/ChatModel.swift#L380) | +| `chatToTop` | `String?` | Chat to scroll to top | [L381](../Shared/Model/ChatModel.swift#L381) | +| `groupMembers` | `[GMember]` | Members of active group | [L382](../Shared/Model/ChatModel.swift#L382) | +| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L383](../Shared/Model/ChatModel.swift#L383) | +| `membersLoaded` | `Bool` | Whether members have been loaded | [L384](../Shared/Model/ChatModel.swift#L384) | +| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L435](../Shared/Model/ChatModel.swift#L435) | #### Authentication | Property | Type | Description | Line | |----------|------|-------------|------| -| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L348](../Shared/Model/ChatModel.swift#L355) | -| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L349](../Shared/Model/ChatModel.swift#L356) | +| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L371](../Shared/Model/ChatModel.swift#L371) | +| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L372](../Shared/Model/ChatModel.swift#L372) | #### Notifications | Property | Type | Description | Line | |----------|------|-------------|------| -| `deviceToken` | `DeviceToken?` | Current APNs device token | [L369](../Shared/Model/ChatModel.swift#L376) | -| `savedToken` | `DeviceToken?` | Previously saved token | [L370](../Shared/Model/ChatModel.swift#L377) | -| `tokenRegistered` | `Bool` | Whether token is registered with server | [L371](../Shared/Model/ChatModel.swift#L378) | -| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L373](../Shared/Model/ChatModel.swift#L380) | -| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L374](../Shared/Model/ChatModel.swift#L381) | -| `notificationServer` | `String?` | Notification server URL | [L375](../Shared/Model/ChatModel.swift#L382) | -| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L376](../Shared/Model/ChatModel.swift#L383) | -| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L346](../Shared/Model/ChatModel.swift#L353) | -| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L378](../Shared/Model/ChatModel.swift#L385) | -| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L379](../Shared/Model/ChatModel.swift#L386) | +| `deviceToken` | `DeviceToken?` | Current APNs device token | [L395](../Shared/Model/ChatModel.swift#L395) | +| `savedToken` | `DeviceToken?` | Previously saved token | [L396](../Shared/Model/ChatModel.swift#L396) | +| `tokenRegistered` | `Bool` | Whether token is registered with server | [L397](../Shared/Model/ChatModel.swift#L397) | +| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L399](../Shared/Model/ChatModel.swift#L399) | +| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L400](../Shared/Model/ChatModel.swift#L400) | +| `notificationServer` | `String?` | Notification server URL | [L401](../Shared/Model/ChatModel.swift#L401) | +| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L402](../Shared/Model/ChatModel.swift#L402) | +| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L369](../Shared/Model/ChatModel.swift#L369) | +| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L404](../Shared/Model/ChatModel.swift#L404) | +| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L405](../Shared/Model/ChatModel.swift#L405) | #### Calls | Property | Type | Description | Line | |----------|------|-------------|------| -| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L381](../Shared/Model/ChatModel.swift#L388) | -| `activeCall` | `Call?` | Currently active call | [L382](../Shared/Model/ChatModel.swift#L389) | -| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L383](../Shared/Model/ChatModel.swift#L390) | -| `showCallView` | `Bool` | Whether to show full-screen call UI | [L384](../Shared/Model/ChatModel.swift#L391) | -| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L385](../Shared/Model/ChatModel.swift#L392) | +| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L407](../Shared/Model/ChatModel.swift#L407) | +| `activeCall` | `Call?` | Currently active call | [L408](../Shared/Model/ChatModel.swift#L408) | +| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L409](../Shared/Model/ChatModel.swift#L409) | +| `showCallView` | `Bool` | Whether to show full-screen call UI | [L410](../Shared/Model/ChatModel.swift#L410) | +| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L411](../Shared/Model/ChatModel.swift#L411) | #### Remote Desktop | Property | Type | Description | Line | |----------|------|-------------|------| -| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L387](../Shared/Model/ChatModel.swift#L395) | +| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L414](../Shared/Model/ChatModel.swift#L414) | #### Misc | Property | Type | Description | Line | |----------|------|-------------|------| -| `userAddress` | `UserContactLink?` | User's SimpleX address | [L365](../Shared/Model/ChatModel.swift#L372) | -| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L366](../Shared/Model/ChatModel.swift#L373) | -| `appOpenUrl` | `URL?` | URL opened while app active | [L367](../Shared/Model/ChatModel.swift#L374) | -| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L368](../Shared/Model/ChatModel.swift#L375) | -| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L389](../Shared/Model/ChatModel.swift#L397) | -| `draft` | `ComposeState?` | Saved compose draft | [L393](../Shared/Model/ChatModel.swift#L401) | -| `draftChatId` | `String?` | Chat ID for saved draft | [L394](../Shared/Model/ChatModel.swift#L402) | -| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L395](../Shared/Model/ChatModel.swift#L403) | -| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L397](../Shared/Model/ChatModel.swift#L405) | -| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L392](../Shared/Model/ChatModel.swift#L400) | +| `userAddress` | `UserContactLink?` | User's SimpleX address | [L391](../Shared/Model/ChatModel.swift#L391) | +| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L392](../Shared/Model/ChatModel.swift#L392) | +| `appOpenUrl` | `URL?` | URL opened while app active | [L393](../Shared/Model/ChatModel.swift#L393) | +| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L394](../Shared/Model/ChatModel.swift#L394) | +| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L416](../Shared/Model/ChatModel.swift#L416) | +| `draft` | `ComposeState?` | Saved compose draft | [L420](../Shared/Model/ChatModel.swift#L420) | +| `draftChatId` | `String?` | Chat ID for saved draft | [L421](../Shared/Model/ChatModel.swift#L421) | +| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L422](../Shared/Model/ChatModel.swift#L422) | +| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L424](../Shared/Model/ChatModel.swift#L424) | +| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L419](../Shared/Model/ChatModel.swift#L419) | ### Non-Published Properties | Property | Type | Description | Line | |----------|------|-------------|------| -| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L399](../Shared/Model/ChatModel.swift#L407) | -| `filesToDelete` | `Set` | Files queued for deletion | [L401](../Shared/Model/ChatModel.swift#L409) | -| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L405](../Shared/Model/ChatModel.swift#L413) | +| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) | +| `filesToDelete` | `Set` | Files queued for deletion | [L428](../Shared/Model/ChatModel.swift#L428) | +| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L432](../Shared/Model/ChatModel.swift#L432) | ### Key Methods | Method | Description | Line | |--------|-------------|------| -| `getUser(_ userId:)` | Find user by ID | [L427](../Shared/Model/ChatModel.swift#L436) | -| `updateUser(_ user:)` | Update user in list and current | [L437](../Shared/Model/ChatModel.swift#L447) | -| `removeUser(_ user:)` | Remove user from list | [L446](../Shared/Model/ChatModel.swift#L457) | -| `getChat(_ id:)` | Find chat by ID | [L456](../Shared/Model/ChatModel.swift#L468) | -| `addChat(_ chat:)` | Add chat to list | [L510](../Shared/Model/ChatModel.swift#L523) | -| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L523](../Shared/Model/ChatModel.swift#L537) | -| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L574](../Shared/Model/ChatModel.swift#L589) | -| `removeChat(_ id:)` | Remove chat from list | [L1180](../Shared/Model/ChatModel.swift#L1198) | -| `popChat(_ id:, _ ts:)` | Move chat to top of list | [L1157](../Shared/Model/ChatModel.swift#L1174) | -| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1058](../Shared/Model/ChatModel.swift#L1074) | +| `getUser(_ userId:)` | Find user by ID | [L455](../Shared/Model/ChatModel.swift#L455) | +| `updateUser(_ user:)` | Update user in list and current | [L466](../Shared/Model/ChatModel.swift#L466) | +| `removeUser(_ user:)` | Remove user from list | [L476](../Shared/Model/ChatModel.swift#L476) | +| `getChat(_ id:)` | Find chat by ID | [L487](../Shared/Model/ChatModel.swift#L487) | +| `addChat(_ chat:)` | Add chat to list | [L542](../Shared/Model/ChatModel.swift#L542) | +| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L556](../Shared/Model/ChatModel.swift#L556) | +| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L608](../Shared/Model/ChatModel.swift#L608) | +| `removeChat(_ id:)` | Remove chat from list | [L1217](../Shared/Model/ChatModel.swift#L1217) | +| `popChat(_ id:)` | Move chat to top of list | [L1193](../Shared/Model/ChatModel.swift#L1193) | +| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1093](../Shared/Model/ChatModel.swift#L1093) | --- @@ -192,21 +193,21 @@ ChatTagsModel (singleton -- filter state) | Property | Type | Description | Line | |----------|------|-------------|------| -| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L78](../Shared/Model/ChatModel.swift#L80) | -| `itemAdded` | `Bool` | Flag indicating a new item was added | [L81](../Shared/Model/ChatModel.swift#L83) | -| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L85](../Shared/Model/ChatModel.swift#L87) | -| `isLoading` | `Bool` | Whether messages are currently loading | [L89](../Shared/Model/ChatModel.swift#L91) | -| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L90](../Shared/Model/ChatModel.swift#L92) | -| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L75](../Shared/Model/ChatModel.swift#L77) | -| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L74](../Shared/Model/ChatModel.swift#L76) | +| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L80](../Shared/Model/ChatModel.swift#L80) | +| `itemAdded` | `Bool` | Flag indicating a new item was added | [L83](../Shared/Model/ChatModel.swift#L83) | +| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L87](../Shared/Model/ChatModel.swift#L87) | +| `isLoading` | `Bool` | Whether messages are currently loading | [L91](../Shared/Model/ChatModel.swift#L91) | +| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L92](../Shared/Model/ChatModel.swift#L92) | +| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L77](../Shared/Model/ChatModel.swift#L77) | +| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L76](../Shared/Model/ChatModel.swift#L76) | ### Computed Properties | Property | Type | Description | Line | |----------|------|-------------|------| -| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L95](../Shared/Model/ChatModel.swift#L97) | -| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L154](../Shared/Model/ChatModel.swift#L159) | -| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L162](../Shared/Model/ChatModel.swift#L167) | +| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L97](../Shared/Model/ChatModel.swift#L97) | +| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L159](../Shared/Model/ChatModel.swift#L159) | +| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L167](../Shared/Model/ChatModel.swift#L167) | ### Throttling @@ -225,9 +226,9 @@ Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throt | Method | Description | Line | |--------|-------------|------| -| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L113](../Shared/Model/ChatModel.swift#L117) | -| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L138](../Shared/Model/ChatModel.swift#L143) | -| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L107](../Shared/Model/ChatModel.swift#L110) | +| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L117](../Shared/Model/ChatModel.swift#L117) | +| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L143](../Shared/Model/ChatModel.swift#L143) | +| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L110](../Shared/Model/ChatModel.swift#L110) | ### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70) @@ -252,10 +253,10 @@ enum SecondaryItemsModelFilter { | Property | Type | Description | Line | |----------|------|-------------|------| -| `userTags` | `[ChatTag]` | User-defined tags | [L186](../Shared/Model/ChatModel.swift#L192) | -| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L187](../Shared/Model/ChatModel.swift#L193) | -| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L188](../Shared/Model/ChatModel.swift#L194) | -| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L189](../Shared/Model/ChatModel.swift#L195) | +| `userTags` | `[ChatTag]` | User-defined tags | [L192](../Shared/Model/ChatModel.swift#L192) | +| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L193](../Shared/Model/ChatModel.swift#L193) | +| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L194](../Shared/Model/ChatModel.swift#L194) | +| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L195](../Shared/Model/ChatModel.swift#L195) | ### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52) @@ -269,10 +270,34 @@ enum ActiveFilter { --- -## 5. [Chat](../Shared/Model/ChatModel.swift#L1311-L1323) +## 5. [ChannelRelaysModel](../Shared/Model/ChatModel.swift#L336-L350) + +**Class**: `class ChannelRelaysModel: ObservableObject` +**Singleton**: `ChannelRelaysModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L336) + +Holds runtime relay state for the currently viewed channel. Used by `ChannelRelaysView` to display and manage relays. Reset when the view is dismissed. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `groupId` | `Int64?` | Group ID of the channel whose relays are loaded | [L338](../Shared/Model/ChatModel.swift#L338) | +| `groupRelays` | `[GroupRelay]` | Current relay instances for the channel | [L339](../Shared/Model/ChatModel.swift#L339) | + +### Methods + +| Method | Description | Line | +|--------|-------------|------| +| `set(groupId:groupRelays:)` | Populate all properties at once | [L341](../Shared/Model/ChatModel.swift#L341) | +| `reset()` | Clear all properties to nil/empty | [L346](../Shared/Model/ChatModel.swift#L346) | + +--- + +## 6. [Chat](../Shared/Model/ChatModel.swift#L1301-L1353) **Class**: `final class Chat: ObservableObject, Identifiable, ChatLike` -**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1271) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1301) Represents a single conversation in the chat list. Each `Chat` is an independent observable object. @@ -280,12 +305,12 @@ Represents a single conversation in the chat list. Each `Chat` is an independent | Property | Type | Description | Line | |----------|------|-------------|------| -| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1253](../Shared/Model/ChatModel.swift#L1272) | -| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1254](../Shared/Model/ChatModel.swift#L1273) | -| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1255](../Shared/Model/ChatModel.swift#L1274) | -| `created` | `Date` | Creation timestamp | [L1256](../Shared/Model/ChatModel.swift#L1275) | +| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1302](../Shared/Model/ChatModel.swift#L1302) | +| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1303](../Shared/Model/ChatModel.swift#L1303) | +| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1304](../Shared/Model/ChatModel.swift#L1304) | +| `created` | `Date` | Creation timestamp | [L1305](../Shared/Model/ChatModel.swift#L1305) | -### [ChatStats](../SimpleXChat/ChatTypes.swift#L1877-L1899) +### [ChatStats](../SimpleXChat/ChatTypes.swift#L1881-L1903) ```swift struct ChatStats: Decodable, Hashable { @@ -301,17 +326,17 @@ struct ChatStats: Decodable, Hashable { | Property | Description | Line | |----------|-------------|------| -| `id` | Chat ID from `chatInfo.id` | [L1287](../Shared/Model/ChatModel.swift#L1306) | -| `viewId` | Unique view identity including creation time | [L1289](../Shared/Model/ChatModel.swift#L1308) | -| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1279](../Shared/Model/ChatModel.swift#L1298) | -| `supportUnreadCount` | Unread count for group support scope | [L1291](../Shared/Model/ChatModel.swift#L1310) | +| `id` | Chat ID from `chatInfo.id` | [L1336](../Shared/Model/ChatModel.swift#L1336) | +| `viewId` | Unique view identity including creation time | [L1338](../Shared/Model/ChatModel.swift#L1338) | +| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1328](../Shared/Model/ChatModel.swift#L1328) | +| `supportUnreadCount` | Unread count for group support scope | [L1340](../Shared/Model/ChatModel.swift#L1340) | --- -## 6. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1372-L1852) +## 7. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1374-L1856) **Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable` -**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1372) +**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1374) Represents the type and metadata of a conversation: @@ -348,9 +373,38 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { | `chatSettings` | `ChatSettings?` | Notification/favorite settings | | `chatTags` | `[Int64]?` | Assigned tag IDs | +### Relay-Related Data Model (Channels) + +A **channel** is a group with `groupInfo.useRelays == true`. These types support the relay/channel infrastructure: + +#### New Fields on Existing Types + +| Type | Field | Type | Description | Line | +|------|-------|------|-------------|------| +| `User` | `userChatRelay` | `Bool` | Whether user acts as a chat relay | [L46](../SimpleXChat/ChatTypes.swift#L46) | +| `GroupInfo` | `useRelays` | `Bool` | Whether group uses relay infrastructure (channel mode) | [L2343](../SimpleXChat/ChatTypes.swift#L2343) | +| `GroupInfo` | `relayOwnStatus` | `RelayStatus?` | Current user's relay status in this group | [L2344](../SimpleXChat/ChatTypes.swift#L2344) | +| `GroupProfile` | `groupLink` | `String?` | Group's short link | [L2452](../SimpleXChat/ChatTypes.swift#L2452) | + +#### New Types + +| Type | Kind | Description | Line | +|------|------|-------------|------| +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | +| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) | +| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) | + +#### New Enum Cases + +| Enum | Case | Description | Line | +|------|------|-------------|------| +| `GroupMemberRole` | `.relay` | Role for relay members (below `.observer`) | [L2807](../SimpleXChat/ChatTypes.swift#L2807) | +| `CIDirection` | `.channelRcv` | Message direction for channel-received messages (via relay) | [L3529](../SimpleXChat/ChatTypes.swift#L3529) | + --- -## 7. State Flow +## 8. State Flow ### App Start ``` @@ -400,7 +454,7 @@ User taps send in ComposeView --- -## 8. Preference Storage +## 9. Preference Storage ### UserDefaults (via @AppStorage) @@ -457,7 +511,7 @@ Chat-level preferences stored in the SQLite database (managed by Haskell core): | File | Path | |------|------| -| ChatModel, ItemsModel, Chat, ChatTagsModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) | +| ChatModel, ItemsModel, Chat, ChatTagsModel, ChannelRelaysModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) | | ChatInfo, User, Contact, GroupInfo, ChatItem | [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift) | | ActiveFilter | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift#L52) | | Preference defaults | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift), [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 54ffc977d5..7f6c6c106d 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -31,6 +31,7 @@ This file is generated automatically. - [APIListMembers](#apilistmembers) - [APINewGroup](#apinewgroup) - [APINewPublicGroup](#apinewpublicgroup) +- [APIGetGroupRelays](#apigetgrouprelays) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -983,6 +984,44 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIGetGroupRelays + +Get group relays. + +*Network usage*: no. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_get relays # +``` + +```javascript +'/_get relays #' + groupId // JavaScript +``` + +```python +'/_get relays #' + str(groupId) # Python +``` + +**Responses**: + +GroupRelays: Group relays. +- type: "groupRelays" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ### APIUpdateGroupProfile Update group profile. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 328b923f90..facb2ce444 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -169,6 +169,7 @@ This file is generated automatically. - [UIThemeEntityOverrides](#uithemeentityoverrides) - [UpdatedMessage](#updatedmessage) - [User](#user) +- [UserChatRelay](#userchatrelay) - [UserContact](#usercontact) - [UserContactLink](#usercontactlink) - [UserContactRequest](#usercontactrequest) @@ -2243,6 +2244,7 @@ Known: - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? - memberPubKey: string? +- relayLink: string? --- @@ -2366,7 +2368,7 @@ Known: **Record type**: - groupRelayId: int64 - groupMemberId: int64 -- userChatRelayId: int64 +- userChatRelay: [UserChatRelay](#userchatrelay) - relayStatus: [RelayStatus](#relaystatus) - relayLink: string? @@ -3843,6 +3845,21 @@ Handshake: - userChatRelay: bool +--- + +## UserChatRelay + +**Record type**: +- chatRelayId: int64 +- address: string +- name: string +- domains: [string] +- preset: bool +- tested: bool? +- enabled: bool +- deleted: bool + + --- ## UserContact diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index fa5dc49c9c..dcb2b62a13 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -118,6 +118,7 @@ chatCommandsDocsData = ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), + ("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 321fac1d9c..915496cec0 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -69,6 +69,7 @@ chatResponsesDocsData = ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), ("CRPublicGroupCreated", ""), + ("CRGroupRelays", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 10d8368857..3af99b71c0 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} @@ -31,6 +32,8 @@ import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.Chat.Operators +import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -349,6 +352,7 @@ chatTypesDocsData = (sti @UIThemeEntityOverrides, STRecord, "", [], "", ""), (sti @UpdatedMessage, STRecord, "", [], "", ""), (sti @User, STRecord, "", [], "", ""), + ((sti @UserChatRelay) {typeName = "UserChatRelay"}, STRecord, "", [], "", ""), (sti @UserContact, STRecord, "", [], "", ""), (sti @UserContactLink, STRecord, "", [], "", ""), (sti @UserContactRequest, STRecord, "", [], "", ""), @@ -545,6 +549,7 @@ deriving instance Generic UIThemeEntityOverride deriving instance Generic UIThemeEntityOverrides deriving instance Generic UpdatedMessage deriving instance Generic User +deriving instance Generic (UserChatRelay' 'DBStored) deriving instance Generic UserContact deriving instance Generic UserContactLink deriving instance Generic UserContactRequest diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index 61e2595fa9..37f74e4275 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -167,12 +167,14 @@ toTypeInfo tr = _ -> TIType (simpleType tr) simpleType tr' = primitiveToLower $ case tyConName (typeRepTyCon tr') of "AgentUserId" -> ST TInt64 [] + "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] "Version" -> ST TInt [] "BoolDef" -> ST TBool [] "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] "ACreatedConnLink" -> ST "CreatedConnLink" [] + "UserChatRelay'" -> ST "UserChatRelay" [] "CChatItem" -> ST "ChatItem" [] "FormatColor" -> ST "Color" [] "CustomData" -> ST "JSONObject" [] diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 742f5b8dd2..7711324890 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -358,6 +358,20 @@ export namespace APINewPublicGroup { } } +// Get group relays. +// Network usage: no. +export interface APIGetGroupRelays { + groupId: number // int64 +} + +export namespace APIGetGroupRelays { + export type Response = CR.GroupRelays | CR.ChatCmdError + + export function cmdString(self: APIGetGroupRelays): string { + return '/_get relays #' + self.groupId + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index de80b8666d..ff913fdfa9 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -28,6 +28,7 @@ export type ChatResponse = | CR.GroupLinkDeleted | CR.GroupCreated | CR.PublicGroupCreated + | CR.GroupRelays | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -80,6 +81,7 @@ export namespace CR { | "groupLinkDeleted" | "groupCreated" | "publicGroupCreated" + | "groupRelays" | "groupMembers" | "groupUpdated" | "groupsList" @@ -255,6 +257,13 @@ export namespace CR { groupRelays: T.GroupRelay[] } + export interface GroupRelays extends Interface { + type: "groupRelays" + user: T.User + groupInfo: T.GroupInfo + groupRelays: T.GroupRelay[] + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 0660f0e968..6c316fbaf3 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -2534,6 +2534,7 @@ export interface GroupMember { updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat memberPubKey?: string + relayLink?: string } export interface GroupMemberAdmission { @@ -2617,7 +2618,7 @@ export interface GroupProfile { export interface GroupRelay { groupRelayId: number // int64 groupMemberId: number // int64 - userChatRelayId: number // int64 + userChatRelay: UserChatRelay relayStatus: RelayStatus relayLink?: string } @@ -4549,6 +4550,17 @@ export interface User { userChatRelay: boolean } +export interface UserChatRelay { + chatRelayId: number // int64 + address: string + name: string + domains: string[] + preset: boolean + tested?: boolean + enabled: boolean + deleted: boolean +} + export interface UserContact { userContactLinkId: number // int64 connReqContact: string diff --git a/plans/2026-02-17-ios-channels-product-plan.md b/plans/2026-02-17-ios-channels-product-plan.md new file mode 100644 index 0000000000..de448ec27e --- /dev/null +++ b/plans/2026-02-17-ios-channels-product-plan.md @@ -0,0 +1,506 @@ +# Channels on iOS — Product Plan + +## Contents +1. [Overview](#1-overview) +2. [Screens](#2-screens) + - 2.1 [Chat List](#21-chat-list) + - 2.2 [Channel Messages & Compose](#22-channel-messages--compose) + - 2.3 [Channel Creation](#23-channel-creation) + - 2.4 [Channel Info](#24-channel-info) + - 2.5 [Chat Relay Management (Network & Servers)](#25-chat-relay-management-network--servers) + - 2.6 [Joining a Channel](#26-joining-a-channel) +3. [Implementation Order](#3-implementation-order) + +--- + +## 1. Overview + +### What +Channels are one-to-many broadcast groups where messages flow **owner → chat relays → subscribers**. Unlike regular groups (N-to-N connections), channels use chat relay infrastructure to scale delivery — an owner sends once, chat relays fan out to all subscribers. + +Technically, a channel is a group with `useRelays = true`. All subscribers are observers (read-only). The owner posts as the channel identity. + +### Why +Regular SimpleX groups require direct connections between all members. While there is no hard technical limit, in practice large groups of even several hundred members become very inefficient — group state desynchronizes, delivery becomes inefficient and unreliable, and the experience degrades. Channels solve the broadcast use case: organizations, projects, and individuals publishing to large audiences while preserving SimpleX's privacy model (no user identifiers, relay-mediated delivery). + +### For Whom + +**Channel owners** — creators who want to broadcast to a large audience. They create channels, configure chat relays, post content. Their problem: no way to efficiently reach many people on SimpleX because large groups work badly in practice. + +**Channel subscribers** — readers who want to follow public content. They join via link and receive messages through chat relays. Their problem: can't follow public channels/announcements on SimpleX. + +--- + +## 2. Screens + +### 2.1 Chat List + +New icon (`antenna.radiowaves.left.and.right`) to differentiate channels. + +``` +┌────────────────────────────────────────┐ +│ [👥] Team Chat 3:42 PM │ +│ alice: Hey everyone... ● 1 │ +├────────────────────────────────────────┤ +│ [📡] SimpleX News 3:38 PM │ +│ Latest update about... ● 3 │ +├────────────────────────────────────────┤ +│ [👤] Bob 2:15 PM │ +│ See you tomorrow ✓✓ │ +└────────────────────────────────────────┘ +``` + +Chat header uses channel icon when no profile image, same as groups: + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +└────────────────────────────────────────┘ +``` + +--- + +### 2.2 Channel Messages & Compose + +Messages render with channel avatar + channel name as sender (via existing `showGroupAsSender` path). Consecutive messages group without repeating avatar/name. + +**Subscriber view** — compose disabled with "you are subscriber" label (vs. "you are observer" in groups): + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ [📡] SimpleX News │ +│ ┌──────────────────────────────────┐ │ +│ │ We're excited to announce v7.0! │ │ +│ │ New channel feature allows... │ │ +│ │ 3:42 PM │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Check out the blog post: │ │ +│ │ simplex.chat/blog/v7 │ │ +│ │ 3:45 PM │ │ +│ └──────────────────────────────────┘ │ +│ │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +**Owner view** — compose field shows "Broadcast" placeholder. Always sends `asGroup=true` (MVP). Backend also supports sending "as member" (like in regular groups), but this will not be available in MVP UI. + +``` +├────────────────────────────────────────┤ +│ ┌───────────────────────────────┐ │ +│ 📎 │ Broadcast ➤ │ │ +│ └───────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +**Note**: If all chat relays are removed or stop serving the channel, this won't be visible in the UI in MVP. + +--- + +### 2.3 Channel Creation + +Entry point: "Create channel" in New Chat menu, after "Create group". + +``` +┌────────────────────────────────────────┐ +│ New message │ +├────────────────────────────────────────┤ +│ 🔗 Create 1-time link > │ +│ 📷 Scan / Paste link > │ +│ 👥 Create group > │ +│ 📡 Create channel > │ +├────────────────────────────────────────┤ +│ 📦 Archived contacts > │ +└────────────────────────────────────────┘ +``` + +#### Step 1 — Channel profile + +``` +┌────────────────────────────────────────┐ +│ Cancel Create channel │ +├────────────────────────────────────────┤ +│ [ 📷 ] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Enter channel name... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Configure relays... > │ +│ │ +│ Your profile will be shared with │ +│ chat relays and subscribers. │ +│ Random relays will be selected from │ +│ the list of enabled chat relays. │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Create channel │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +"Configure relays..." opens Network & Servers view (full settings view) where the user can enable/disable chat relays globally. + +There is no explicit relay selection — the app randomly selects from enabled chat relays, same as for SMP/XFTP servers. + +> **API note**: Currently `apiNewPublicGroup` takes an explicit list of chat relay IDs. Either the API should be reworked to select relays automatically (consistent with SMP/XFTP server selection), or the UI should randomly select from enabled relays and pass the IDs. + +"Create channel" disabled when name is invalid or no relays enabled. + +#### Step 2 — Relay connection progress + +After tapping "Create channel", chat relays are selected automatically and `apiNewPublicGroup` sends relay invitations. Progress shown as a progress bar with label. + +``` +┌────────────────────────────────────────┐ +│ Creating channel... │ +├────────────────────────────────────────┤ +│ [ 📷 ] │ +│ SimpleX News │ +│ │ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ 1/3 relays connected │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Channel link │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +Tap progress label to expand relay list: + +``` +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ ▼ 1/3 relays connected │ +│ relay1.simplex.im ✓ Active │ +│ relay2.simplex.im Connecting │ +│ relay3.simplex.im Connecting │ +``` + +"Channel link" button enabled when ≥1 relay is active. If tapped while relays are still connecting, warning alert: "Not all relays have connected yet. Channel will start working with N relays. Proceed?" — Proceed / Wait. + +#### Step 3 — Channel link + +Shown after tapping "Channel link" or auto-transition when all relays active. Standard `GroupLinkView` with QR code + share (same as group creation). + +``` +┌────────────────────────────────────────┐ +│ Back Channel link Continue │ +├────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ │ │ +│ │ [ QR CODE ] │ │ +│ │ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ https://simplex.chat/... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ^ Share link │ +└────────────────────────────────────────┘ +``` + +#### Failure modes (inline on Step 2) + +- **API call fails** (sync — relay invitation send failed): Alert "Error creating channel" + error detail. Retry / Cancel. +- **Partial relay error** (async — some relays don't connect): Progress shows "2/3 relays connected, 1 failed". Expanded view: failed relay with red ● Error. "Channel link" enabled — channel works with fewer relays. +- **All relays error** (async): Progress shows "0/3 relays connected, 3 failed" in red. Alert with Retry / Cancel. + +--- + +### 2.4 Channel Info + +Extends `GroupChatInfoView` with conditional sections for `useRelays = true`. + +**Design rationale:** Owners/subscribers lists live in a sub-view (not inline) to match patterns familiar from other messengers and reduce main info screen clutter. + +#### Owner view + +``` +┌────────────────────────────────────────┐ +│ Done SimpleX News Edit │ +├────────────────────────────────────────┤ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ Set chat name... │ +├────────────────────────────────────────┤ +│ 🔍 Search │ 🔇 Mute │ +├────────────────────────────────────────┤ +│ Channel link > │ +│ Owners & subscribers > │ +├────────────────────────────────────────┤ +│ Edit channel profile > │ +│ Welcome message > │ +├────────────────────────────────────────┤ +│ Chat theme > │ +│ Delete messages after > │ +├────────────────────────────────────────┤ +│ Chat relays > │ +│ Clear chat │ +│ Delete channel │ +└────────────────────────────────────────┘ +``` + +No "Leave channel" for single (last) owner. + +Post-MVP: "Chats with subscribers" navigation link in section 1 for subscriber support. + +TBC: share link button in action buttons row. + +#### Subscriber view + +``` +┌────────────────────────────────────────┐ +│ Done SimpleX News │ +├────────────────────────────────────────┤ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ Set chat name... │ +├────────────────────────────────────────┤ +│ 🔍 Search │ 🔇 Mute │ +├────────────────────────────────────────┤ +│ Channel link > │ +│ Owners > │ +├────────────────────────────────────────┤ +│ Welcome message > │ +├────────────────────────────────────────┤ +│ Chat theme > │ +│ Delete messages after > │ +├────────────────────────────────────────┤ +│ Chat relays > │ +│ Clear chat │ +│ Leave channel │ +└────────────────────────────────────────┘ +``` + +Differences from owner view: +- **Owners & subscribers**: replaced with **Owners** +- **Edit channel profile**: hidden +- **Delete channel**: replaced with **Leave channel** + +#### Owners & subscribers sub-view + +Separate sub-view following familiar channel UI patterns from other messengers to increase adoption. + +**Owner's view** ("Owners & subscribers"): + +``` +┌────────────────────────────────────────┐ +│ < Back Owners & subscribers │ +├────────────────────────────────────────┤ +│ OWNERS │ +│ alice (you) > │ +├────────────────────────────────────────┤ +│ 150 SUBSCRIBERS │ +│ bob > │ +│ charlie > │ +│ ... │ +└────────────────────────────────────────┘ +``` + +**Subscriber's view** ("Owners"): + +``` +┌────────────────────────────────────────┐ +│ < Back Owners │ +├────────────────────────────────────────┤ +│ OWNERS │ +│ alice > │ +└────────────────────────────────────────┘ +``` + +> **Protocol note**: Correct subscriber and owner lists with counts must be implemented for MVP. This requires protocol changes to support relay-reported subscriber counts and subscriber list synchronization. See launch plan §3.3. + +#### Chat relays sub-view + +``` +┌────────────────────────────────────────┐ +│ < Back Chat relays │ +├────────────────────────────────────────┤ +│ relay1.simplex.im ● Active │ +│ relay2.simplex.im ● Active │ +│ relay3.simplex.im ● Active │ +│ │ +│ Chat relays forward messages to │ +│ channel subscribers. │ +└────────────────────────────────────────┘ +``` + +Read-only for MVP. In future, owner will be able to manage (add, remove) relays from this view. + +Relay statuses differ by role: +- **Owner**: based on `RelayStatus` — New, Invited, Accepted, Active +- **Subscriber**: based on connection state — Connecting, Connected, Error (TBC: new type or inferred from connection status) + +--- + +### 2.5 Chat Relay Management (Network & Servers) + +Chat relays follow the same placement pattern as SMP/XFTP servers: preset relays appear inside each operator page, custom relays appear in "Your servers" page. + +#### Operator page (e.g. SimpleX Chat) + +New "Chat relays" section added after "Operator" section, before message and file server sections: + +``` +┌────────────────────────────────────────┐ +│ < Back SimpleX Chat servers │ +├────────────────────────────────────────┤ +│ OPERATOR │ +│ ... │ +├────────────────────────────────────────┤ +│ CHAT RELAYS │ +│ relay1.simplex.im ✓ │ +│ relay2.simplex.im ✓ │ +│ relay3.simplex.im ✓ │ +│ │ +│ Chat relays forward messages in │ +│ channels you create. │ +├────────────────────────────────────────┤ +│ (message server sections) │ +│ (file server sections) │ +├────────────────────────────────────────┤ +│ Test servers │ +└────────────────────────────────────────┘ +``` + +#### Your servers page + +New "Chat relays" section before "Message servers": + +``` +┌────────────────────────────────────────┐ +│ < Back Your servers │ +├────────────────────────────────────────┤ +│ CHAT RELAYS │ +│ myrelay.example.com ✗ │ +│ │ +│ Chat relays forward messages in │ +│ channels you create. │ +├────────────────────────────────────────┤ +│ MESSAGE SERVERS │ +│ ... │ +├────────────────────────────────────────┤ +│ MEDIA & FILE SERVERS │ +│ ... │ +├────────────────────────────────────────┤ +│ Add server... │ +│ Test servers │ +│ How to use your servers > │ +└────────────────────────────────────────┘ +``` + +#### Relay detail view + +Follows `ProtocolServerView` pattern. Preset: read-only address + test + enable toggle. Custom: editable address + test + enable + delete. TBC editable name (present in backend). + +``` +┌────────────────────────────────────────┐ +│ < Back relay1.simplex.im │ +├────────────────────────────────────────┤ +│ RELAY ADDRESS │ +│ ┌──────────────────────────────────┐ │ +│ │ https://relay1.simplex.im/... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Test relay ✓ │ +│ Use for new channels [ON] │ +├────────────────────────────────────────┤ +│ Delete relay │ +└────────────────────────────────────────┘ +``` + +If all relays are disabled: footer warning "No chat relays enabled. Channels require at least one relay." + +--- + +### 2.6 Joining a Channel + +User taps channel link → pre-join view. + +#### Pre-join + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ 3 relays ▶ │ +│ ┌──────────────────────────────────┐ │ +│ │ Join channel │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +Relay count visible (from link data). Tapping "3 relays" expands to show relay hostnames. + +**Why:** Subscriber can decide whether to join based on which relays are used. + +#### Connecting + +After "Join channel", relay connections proceed. Progress bar shown above "you are subscriber" — channel already functions with even a single relay connected. + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ (chat area — welcome message etc.) │ +│ │ +├────────────────────────────────────────┤ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ Connecting... 1/3 relays │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +Tap progress label to expand: + +``` +├────────────────────────────────────────┤ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ ▼ Connecting... 1/3 relays │ +│ relay1.simplex.im ✓ Connected │ +│ relay2.simplex.im Connecting │ +│ relay3.simplex.im Connecting │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +All connected → progress bar disappears. + +#### Failure modes (inline) +- **Sync failure** (all relays fail on connect call): Alert "Failed to join channel" + Retry / Cancel. +- **Partial failure**: "2/3 relays connected, 1 failed". Channel works. Expanded view shows failed relay with red indicator. +- **All relays fail async**: Red error bar "Channel not connected". TBC: programmatic retry, or only failure indication. + +--- + +## 3. Implementation Order + +| # | Screen | Backend Dependency | Complexity | +|---|--------|--------------------|------------| +| 1 | Chat List — channel icon | None | Low | +| 2 | Channel Messages — `CIChannelRcv` rendering | None | Low | +| 3 | Owner Compose — "Broadcast" placeholder + `asGroup` | None | Low | +| 4 | Channel Info — extended `GroupChatInfoView` | Subscriber/owner lists: protocol changes (§3.3) | Medium | +| 5 | Chat Relay Management — Network & Servers | `APITestChatRelay` (launch plan §2.5) | Medium | +| 6 | Channel Creation — 3-step flow | Relay state events (launch plan §3.2) | High | +| 7 | Join Channel — progress bar + relay states | Relay state events (launch plan §3.2) | Medium | + +Items 1–3 have no backend blockers and can start immediately. Item 4 requires protocol changes for subscriber/owner lists and counts. Items 5–7 depend on backend work. diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 0125a75fc1..debe95825c 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -37,6 +37,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Store import Simplex.Chat.Store.Profiles import Simplex.Chat.Types +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Chat.Util (shuffle) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.Messaging.Agent @@ -113,6 +114,7 @@ defaultChatConfig = highlyAvailable = False, deliveryWorkerDelay = 0, deliveryBucketSize = 10000, + channelSubscriberRole = GRObserver, deviceNameForRemote = "", remoteCompression = True, chatHooks = defaultChatHooks diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index a0a8111160..7b44a66d98 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -157,6 +157,7 @@ data ChatConfig = ChatConfig ciExpirationInterval :: Int64, -- microseconds deliveryWorkerDelay :: Int64, -- microseconds deliveryBucketSize :: Int, + channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays highlyAvailable :: Bool, deviceNameForRemote :: Text, remoteCompression :: Bool, @@ -509,8 +510,9 @@ data ChatCommand | ReactToMessage {add :: Bool, reaction :: MsgReaction, chatName :: ChatName, reactToMessage :: Text} | APINewGroup {userId :: UserId, incognito :: IncognitoEnabled, groupProfile :: GroupProfile} | NewGroup IncognitoEnabled GroupProfile - -- TODO [relays] owner: TBC group link's default member role for APINewPublicGroup + -- TODO [relays] starting role should be communicated in protocol from owner to relays (see channelSubscriberRole config) | APINewPublicGroup {userId :: UserId, incognito :: IncognitoEnabled, relayIds :: NonEmpty Int64, groupProfile :: GroupProfile} + | APIGetGroupRelays {groupId :: GroupId} | NewPublicGroup IncognitoEnabled (NonEmpty Int64) GroupProfile | AddMember GroupName ContactName GroupMemberRole | JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter} @@ -638,6 +640,12 @@ allowRemoteCommand = \case ExecAgentStoreSQL _ -> False _ -> True +data RelayConnectionResult = RelayConnectionResult + { relayMember :: GroupMember, + relayError :: Maybe ChatError + } + deriving (Show) + data ChatResponse = CRActiveUser {user :: User} | CRUsersList {users :: [UserInfo]} @@ -687,6 +695,7 @@ data ChatResponse | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} | CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} + | CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]} | CRGroupMembers {user :: User, group :: Group} | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} -- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]} @@ -715,7 +724,7 @@ data ChatResponse | CRSentConfirmation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRStartedConnectionToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} - | CRStartedConnectionToGroup {user :: User, groupInfo :: GroupInfo, customUserProfile :: Maybe Profile} + | CRStartedConnectionToGroup {user :: User, groupInfo :: GroupInfo, customUserProfile :: Maybe Profile, relayResults :: [RelayConnectionResult]} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} | CRItemsReadForChat {user :: User, chatInfo :: AChatInfo} | CRContactDeleted {user :: User, contact :: Contact} @@ -1664,6 +1673,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHSR") ''RemoteHostStopReason) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "TE") ''TerminalEvent) +$(JQ.deriveJSON defaultJSON ''RelayConnectionResult) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CR") ''ChatResponse) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CEvt") ''ChatEvent) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3fd2a42fac..3a22a9a69a 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1880,7 +1880,7 @@ processChatCommand vr nm = \case groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences groupProfile = businessGroupProfile profile groupPreferences gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user groupProfile True ccLink welcomeSharedMsgId False GRMember hostMember <- maybe (throwCmdError "no host member") pure hostMember_ void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = CDGroupRcv gInfo Nothing hostMember @@ -1909,8 +1909,9 @@ processChatCommand vr nm = \case let GroupShortLinkData {groupProfile = gp@GroupProfile {description}} = groupSLinkData welcomeSharedMsgId <- forM description $ \_ -> getSharedMsgId let useRelays = not direct + subRole <- if useRelays then asks $ channelSubscriberRole . config else pure GRMember gVar <- asks random - (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays + (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar vr user gp False ccLink welcomeSharedMsgId useRelays subRole void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing (Just epochStart) let cd = maybe (CDChannelRcv gInfo Nothing) (CDGroupRcv gInfo Nothing) hostMember_ cInfo = GroupChat gInfo Nothing @@ -2017,15 +2018,17 @@ processChatCommand vr nm = \case Just (_, _, Left e) -> throwError e _ -> throwChatError $ CEException "no relay connection results" -- shouldn't happen else do - withFastStore' $ \db -> setPreparedGroupStartedConnection db groupId + gInfo'' <- withFastStore $ \db -> do + liftIO $ setPreparedGroupStartedConnection db groupId + getGroupInfo db vr user groupId -- Async retry failed relays with temporary errors let retryable = [(l, m) | r@(l, m, _) <- failed, isTempErr r] void $ mapConcurrently (uncurry $ retryRelayConnectionAsync gInfo') retryable - -- TODO [relays] member: TBC response type for UI to display state of relays connection - -- TODO - differentiate success, temporary failure, permanent failure - -- TODO - possibly, additional status on relay member record - pure $ CRStartedConnectionToGroup user gInfo' incognitoProfile + let relayResults = [RelayConnectionResult m (leftToMaybe r) | (_, m, r) <- rs] + pure $ CRStartedConnectionToGroup user gInfo'' incognitoProfile relayResults where + leftToMaybe (Left e) = Just e + leftToMaybe _ = Nothing isTempErr = \case (_, _, Left ChatErrorAgent {agentError = e}) -> temporaryOrHostError e _ -> False @@ -2075,14 +2078,17 @@ processChatCommand vr nm = \case forM_ msg_ $ \(sharedMsgId, mc) -> do ci <- createChatItem user (CDGroupSnd gInfo' Nothing) False (CISndMsgContent mc) (Just sharedMsgId) Nothing toView $ CEvtNewChatItems user [ci] - pure $ CRStartedConnectionToGroup user gInfo' customUserProfile + pure $ CRStartedConnectionToGroup user gInfo' customUserProfile [] CVRConnectedContact _ct -> throwChatError $ CEException "contact already exists when connecting to group" APIConnect userId incognito (Just acl) -> withUserId userId $ \user -> case acl of ACCL SCMInvitation ccLink -> do (conn, incognitoProfile) <- connectViaInvitation user incognito ccLink Nothing let pcc = mkPendingContactConnection conn $ Just ccLink pure $ CRSentConfirmation user pcc incognitoProfile - ACCL SCMContact ccLink -> + ACCL SCMContact ccLink@(CCLink _ sLnk) -> do + case sLnk of + Just (CSLContact _ CCTChannel _ _) -> throwChatError $ CECommandError "channel links must be connected via APIConnectPreparedGroup" + _ -> pure () connectViaContact user Nothing incognito ccLink Nothing Nothing >>= \case CVRConnectedContact ct -> pure $ CRContactAlreadyExists user ct CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile @@ -2348,7 +2354,7 @@ processChatCommand vr nm = \case let crClientData = encodeJSON $ CRDataGroup groupLinkId -- prepare link with sharedGroupId as linkEntityId (no server request) ((_, rootPrivKey), ccLink, preparedParams) <- withAgent $ \a -> prepareConnectionLink a (aUserId user) (Just sharedGroupId) True (Just crClientData) - ccLink' <- createdGroupLink <$> shortenCreatedLink ccLink + ccLink' <- createdChannelLink <$> shortenCreatedLink ccLink sLnk <- case toShortLinkContact ccLink' of Just sl -> pure sl Nothing -> throwChatError $ CEException "failed to create relayed group link: no short link" @@ -2362,13 +2368,21 @@ processChatCommand vr nm = \case connId <- withAgent $ \a -> createConnectionForLink a nm (aUserId user) True ccLink preparedParams userLinkData IKPQOff subMode let groupKeys = GroupKeys {sharedGroupId = B64UrlByteString sharedGroupId, groupRootKey = GRKPrivate rootPrivKey, memberPrivKey} setupLink gInfo = do - gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId GRMember subMode + -- TODO [relays] starting role should be communicated in protocol from owner to relays + subRole <- asks $ channelSubscriberRole . config + gLink <- withFastStore $ \db -> createGroupLink db gVar user gInfo connId ccLink' groupLinkId subRole subMode relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds) groupRelays <- addRelays user gInfo sLnk relays pure (gLink, groupRelays) pure (groupProfile', memberId, groupKeys, setupLink) NewPublicGroup incognito relayIds gProfile -> withUser $ \User {userId} -> processChatCommand vr nm $ APINewPublicGroup userId incognito relayIds gProfile + APIGetGroupRelays groupId -> withUser $ \user -> do + (gInfo, relays) <- withFastStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + relays <- liftIO $ getGroupRelays db gInfo + pure (gInfo, relays) + pure $ CRGroupRelays user gInfo relays APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId @@ -3801,10 +3815,7 @@ processChatCommand vr nm = \case CLFull cReq -> do plan <- contactOrGroupRequestPlan user cReq `catchAllErrors` (pure . CPError) pure (ACCL SCMContact $ CCLink cReq Nothing, plan) - CLShort l@(CSLContact _ ct _ _) -> do - let l' = serverShortLink l - con cReq = ACCL SCMContact $ CCLink cReq (Just l') - gPlan (cReq, g) = if memberRemoved (membership g) then Nothing else Just (con cReq, CPGroupLink (GLPKnown g)) + CLShort l@(CSLContact _ ct _ _) -> case ct of CCTContact -> knownLinkPlans >>= \case @@ -3825,7 +3836,14 @@ processChatCommand vr nm = \case getContactViaShortLinkToConnect db vr user l' >>= \case Just (cReq, ct') -> pure $ if contactDeleted ct' then Nothing else Just (con cReq, CPContactAddress (CAPKnown ct')) Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l' - CCTGroup -> + CCTGroup -> groupShortLinkPlan + CCTChannel -> groupShortLinkPlan + CCTRelay -> throwCmdError "chat relay links are not supported in this version" + where + l' = serverShortLink l + con cReq = ACCL SCMContact $ CCLink cReq (Just l') + gPlan (cReq, g) = if memberRemoved (membership g) then Nothing else Just (con cReq, CPGroupLink (GLPKnown g)) + groupShortLinkPlan = knownLinkPlans >>= \case Just r -> pure r Nothing -> do @@ -3840,8 +3858,6 @@ processChatCommand vr nm = \case liftIO (getGroupInfoViaUserShortLink db vr user l') >>= \case Just (cReq, g) -> pure $ Just (con cReq, CPGroupLink (GLPOwnLink g)) Nothing -> (gPlan =<<) <$> getGroupViaShortLinkToConnect db vr user l' - CCTChannel -> throwCmdError "channel links are not supported in this version" - CCTRelay -> throwCmdError "chat relay links are not supported in this version" connectWithPlan :: User -> IncognitoEnabled -> ACreatedConnLink -> ConnectionPlan -> CM ChatResponse connectWithPlan user@User {userId} incognito ccLink plan | connectionPlanProceed plan = do @@ -4762,6 +4778,7 @@ chatCommandP = "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), ("/public group" <|> "/pg") *> (NewPublicGroup <$> incognitoP <* " relays=" <*> strP <* A.space <* char_ '#' <*> groupProfile), "/_public group " *> (APINewPublicGroup <$> A.decimal <*> incognitoOnOffP <*> _strP <* A.space <*> jsonP), + "/_get relays #" *> (APIGetGroupRelays <$> A.decimal), ("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), ("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)), "/accept member " *> char_ '#' *> (AcceptMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 9ca16e299b..3547f1f9fc 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1313,7 +1313,7 @@ setGroupLinkDataAsync user gInfo gLink = do groupLinkData :: GroupInfo -> GroupLink -> [GroupRelay] -> (UserConnLinkData 'CMContact, CRClientData) groupLinkData gInfo@GroupInfo {groupProfile} GroupLink {groupLinkId} groupRelays = let direct = not $ useRelays' gInfo - relays = mapMaybe relayLink groupRelays + relays = mapMaybe (\GroupRelay {relayLink} -> relayLink) groupRelays userData = encodeShortLinkData $ GroupShortLinkData groupProfile userLinkData = UserContactLinkData UserContactData {direct, owners = [], relays, userData} crClientData = encodeJSON $ CRDataGroup groupLinkId @@ -1370,6 +1370,12 @@ createdGroupLink (CCLink cReq shortLink) = CCLink cReq (toShortGroupLink <$> sho toShortGroupLink :: ShortLinkContact -> ShortLinkContact toShortGroupLink (CSLContact sch _ srv k) = CSLContact sch CCTGroup srv k +createdChannelLink :: CreatedLinkContact -> CreatedLinkContact +createdChannelLink (CCLink cReq shortLink) = CCLink cReq (toShortChannelLink <$> shortLink) + +toShortChannelLink :: ShortLinkContact -> ShortLinkContact +toShortChannelLink (CSLContact sch _ srv k) = CSLContact sch CCTChannel srv k + createdRelayLink :: CreatedLinkContact -> CreatedLinkContact createdRelayLink (CCLink cReq shortLink) = CCLink cReq (toShortRelayLink <$> shortLink) diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 10f216940c..2fcec2c8ee 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -62,6 +62,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.RelayRequests import Simplex.Chat.Store.Shared +import Simplex.Chat.Operators import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences @@ -1392,23 +1393,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = Just gli@GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do -- TODO [short links] deduplicate request by xContactId? gInfo <- withStore $ \db -> getGroupInfo db vr user groupId - acceptMember_ <- asks $ acceptMember . chatHooks . config - maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case - Right (acceptance, useRole) - | v < groupFastLinkJoinVersion -> - messageError "processContactConnMessage: chat version range incompatible for accepting group join request" - | otherwise -> do - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo - mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode - (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem - createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing - toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' - Left rjctReason - | v < groupJoinRejectVersion -> - messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" - | otherwise -> do - mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason - toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason + if useRelays' gInfo + then messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): ignored direct join request from " <> displayName <> " (group uses relays)" + else do + acceptMember_ <- asks $ acceptMember . chatHooks . config + maybe (pure $ Right (GAAccepted, gLinkMemRole)) (\am -> liftIO $ am gInfo gli p) acceptMember_ >>= \case + Right (acceptance, useRole) + | v < groupFastLinkJoinVersion -> + messageError "processContactConnMessage: chat version range incompatible for accepting group join request" + | otherwise -> do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo + mem <- acceptGroupJoinRequestAsync user uclId gInfo invId chatVRange p xContactId_ Nothing welcomeMsgId_ acceptance useRole profileMode + (gInfo', mem', scopeInfo) <- mkGroupChatScope gInfo mem + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo mem') (CIRcvGroupEvent RGEInvitedViaGroupLink) Nothing + toView $ CEvtAcceptingGroupJoinRequestMember user gInfo' mem' + Left rjctReason + | v < groupJoinRejectVersion -> + messageWarning $ "processContactConnMessage (group " <> groupName' gInfo <> "): joining of " <> displayName <> " is blocked" + | otherwise -> do + mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason + toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () xGrpRelayInv invId chatVRange groupRelayInv = do (_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange @@ -2892,7 +2896,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = brokerTs | membershipMemId == memId = pure Nothing -- ignore - XGrpMemRestrict can be sent to restricted member for efficiency | otherwise = do - (bm, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memId Nothing + unknownRole <- unknownMemberRole gInfo + (bm, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memId Nothing unknownRole let GroupMember {groupMemberId = bmId, memberRole, blockedByAdmin, memberProfile = bmp} = bm if | blockedByAdmin == mrsBlocked restriction -> pure Nothing @@ -2990,6 +2995,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | useRelays' gInfo = isRelay m | otherwise = memberRole' m >= GRAdmin + unknownMemberRole :: GroupInfo -> CM GroupMemberRole + unknownMemberRole gInfo + | useRelays' gInfo = asks $ channelSubscriberRole . config + | otherwise = pure GRAuthor + xGrpLeave :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpLeave gInfo m msg brokerTs = do deleteMemberConnection m @@ -3138,7 +3148,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (isMemberGrpFwdRelay gInfo m) $ throwChatError (CEGroupContactRole localDisplayName) case memberId_ of Just memberId -> do - (author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName_ + unknownRole <- unknownMemberRole gInfo + (author, unknown) <- withStore $ \db -> getCreateUnknownGMByMemberId db vr user gInfo memberId memberName_ unknownRole when unknown $ toView $ CEvtUnknownMemberCreated user gInfo m author processForwardedMsg (Just author) Nothing -> processForwardedMsg Nothing @@ -3553,9 +3564,10 @@ runRelayRequestWorker a Worker {doWork} = do createRelayLink gi@GroupInfo {groupProfile} = do -- TODO [relays] relay: set relay link data -- TODO - link data: relay key for group, relay identity (profile, certificate, relay identity key) - -- TODO - TBC link's member role - owner to communicate in invitation? + -- TODO - starting role should be communicated in protocol from owner to relays groupLinkId <- GroupLinkId <$> drgRandomBytes 16 subMode <- chatReadVar subscriptionMode + subRole <- asks $ channelSubscriberRole . config let userData = encodeShortLinkData $ GroupShortLinkData groupProfile userLinkData = UserContactLinkData UserContactData {direct = True, owners = [], relays = [], userData} crClientData = encodeJSON $ CRDataGroup groupLinkId @@ -3565,7 +3577,7 @@ runRelayRequestWorker a Worker {doWork} = do Just sl -> pure sl Nothing -> throwChatError $ CEException "failed to create relay link: no short link" gVar <- asks random - void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId GRMember subMode + void $ withFastStore $ \db -> createGroupLink db gVar user gi connId ccLink' groupLinkId subRole subMode pure sLnk acceptOwnerConnection :: RelayRequestData -> GroupInfo -> ShortLinkContact -> CM () acceptOwnerConnection RelayRequestData {relayInvId, reqChatVRange} gi relayLink = do diff --git a/src/Simplex/Chat/Operators.hs b/src/Simplex/Chat/Operators.hs index ac3af273a8..b0dda4aad1 100644 --- a/src/Simplex/Chat/Operators.hs +++ b/src/Simplex/Chat/Operators.hs @@ -47,6 +47,7 @@ import Data.Time.Clock (UTCTime, nominalDay) import Language.Haskell.TH.Syntax (lift) import Simplex.Chat.Operators.Conditions 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.Protocol (sameShortLinkContact) import Simplex.Messaging.Agent.Store.DB (FromField (..), ToField (..), fromTextField_) @@ -271,6 +272,17 @@ data UserChatRelay' s = UserChatRelay } deriving (Show) +deriving instance Eq UserChatRelay + +data GroupRelay = GroupRelay + { groupRelayId :: Int64, + groupMemberId :: Int64, + userChatRelay :: UserChatRelay, + relayStatus :: RelayStatus, + relayLink :: Maybe ShortLinkContact + } + deriving (Eq, Show) + -- for setting chat relays via CLI API data CLINewRelay = CLINewRelay { address :: ShortLinkContact, @@ -585,3 +597,5 @@ instance FromJSON UpdatedUserOperatorServers where $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USE") ''UserServersError) $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "USW") ''UserServersWarning) + +$(JQ.deriveJSON defaultJSON ''GroupRelay) diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0e689267d4..349afcc7aa 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -149,12 +149,12 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index cba800c9de..451ef5d825 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -193,6 +193,7 @@ import Simplex.Chat.Types.UITheme import Simplex.Messaging.Agent.Protocol (ConnId, CreatedConnLink (..), InvitationId, UserId) import Simplex.Messaging.Agent.Store.AgentStore (firstRow, fromOnlyBI, maybeFirstRow) import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) +import Simplex.Messaging.Agent.Store.Entity (DBEntityId) import qualified Simplex.Messaging.Agent.Store.DB as DB import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet (pattern PQEncOff, pattern PQSupportOff) @@ -208,11 +209,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519) +type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. (Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. (Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink @@ -329,7 +330,7 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC -- | creates completely new group with a single member - the current user createNewGroup :: DB.Connection -> VersionRangeChat -> User -> GroupProfile -> Maybe Profile -> Bool -> MemberId -> Maybe GroupKeys -> ExceptT StoreError IO GroupInfo createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays memberId groupKeys = ExceptT $ do - let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile + let GroupProfile {displayName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences currentTs <- getCurrentTime customUserProfileId <- mapM (createIncognitoProfile_ db userId currentTs) incognitoProfile @@ -344,8 +345,14 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) + [sql| + INSERT INTO group_profiles + (display_name, full_name, short_descr, description, image, group_link, + user_id, preferences, member_admission, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ((displayName, fullName, shortDescr, description, image, groupLink) + :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute db @@ -532,7 +539,8 @@ createContactMemberInv_ db User {userId, userContactId} groupId invitedByGroupMe createdAt, updatedAt = createdAt, supportChat = Nothing, - memberPubKey + memberPubKey, + relayLink = Nothing } where memberChatVRange@(VersionRange minV maxV) = vr @@ -581,8 +589,8 @@ deleteContactCardKeepConn db connId Contact {contactId, profile = LocalProfile { DB.execute db "DELETE FROM contacts WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM contact_profiles WHERE contact_profile_id = ?" (Only profileId) -createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) -createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays = do +createPreparedGroup :: DB.Connection -> TVar ChaChaDRG -> VersionRangeChat -> User -> GroupProfile -> Bool -> CreatedLinkContact -> Maybe SharedMsgId -> Bool -> GroupMemberRole -> ExceptT StoreError IO (GroupInfo, Maybe GroupMember) +createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile business connLinkToConnect welcomeSharedMsgId useRelays userMemberRole = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) (groupId, groupLDN) <- createGroup_ db userId groupProfile prepared Nothing useRelays Nothing currentTs @@ -594,7 +602,7 @@ createPreparedGroup db gVar vr user@User {userId, userContactId} groupProfile bu if useRelays then liftIO $ MemberId <$> encodedRandomBytes gVar 12 else pure $ MemberId $ encodeUtf8 groupLDN <> "_user_unknown_id" - let userMember = MemberIdRole userMemberId GRMember + let userMember = MemberIdRole userMemberId userMemberRole -- TODO [member keys] user key must be included here. Should key be added when group is prepared? membership <- createContactMemberInv_ db user groupId hostMemberId_ user userMember GCUserMember GSMemUnknown IBUnknown Nothing Nothing currentTs vr hostMember_ <- forM hostMemberId_ $ getGroupMember db vr user groupId @@ -822,13 +830,19 @@ createGroupViaLink' createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> UTCTime -> ExceptT StoreError IO (GroupId, Text) createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile + let GroupProfile {displayName, fullName, shortDescr, description, image, groupLink, groupPreferences, memberAdmission} = groupProfile withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, preferences, member_admission, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)" - (displayName, fullName, shortDescr, description, image, userId, groupPreferences, memberAdmission, currentTs, currentTs) + [sql| + INSERT INTO group_profiles + (display_name, full_name, short_descr, description, image, group_link, + user_id, preferences, member_admission, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ((displayName, fullName, shortDescr, description, image, groupLink) + :. (userId, groupPreferences, memberAdmission, currentTs, currentTs)) profileId <- insertedRowId db DB.execute db @@ -1072,13 +1086,13 @@ getGroupMemberByMemberId db vr user GroupInfo {groupId} memberId = (groupMemberQuery <> " WHERE m.group_id = ? AND m.member_id = ?") (groupId, memberId) -getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe ContactName -> ExceptT StoreError IO (GroupMember, Bool) -getCreateUnknownGMByMemberId db vr user gInfo memberId memberName = do +getCreateUnknownGMByMemberId :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Maybe ContactName -> GroupMemberRole -> ExceptT StoreError IO (GroupMember, Bool) +getCreateUnknownGMByMemberId db vr user gInfo memberId memberName unknownMemberRole = do liftIO (runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Right m -> pure (m, False) Left (SEGroupMemberNotFoundByMemberId _) -> do let name = fromMaybe (nameFromMemberId memberId) memberName - m <- createNewUnknownGroupMember db vr user gInfo memberId name + m <- createNewUnknownGroupMember db vr user gInfo memberId name unknownMemberRole pure (m, True) Left e -> throwError e @@ -1215,7 +1229,8 @@ createNewContactMember db gVar User {userId, userContactId} GroupInfo {groupId, createdAt, updatedAt = createdAt, supportChat = Nothing, - memberPubKey = Nothing + memberPubKey = Nothing, + relayLink = Nothing } where insertMember_ = do @@ -1256,7 +1271,7 @@ getGroupRelayById db relayId = ExceptT . firstRow toGroupRelay (SEGroupRelayNotFound relayId) $ DB.query db - (groupRelayQuery <> " WHERE group_relay_id = ?") + (groupRelayQuery <> " WHERE gr.group_relay_id = ?") (Only relayId) getGroupRelayByGMId :: DB.Connection -> GroupMemberId -> ExceptT StoreError IO GroupRelay @@ -1264,7 +1279,7 @@ getGroupRelayByGMId db groupMemberId = ExceptT . firstRow toGroupRelay (SEGroupRelayNotFoundByMemberId groupMemberId) $ DB.query db - (groupRelayQuery <> " WHERE group_member_id = ?") + (groupRelayQuery <> " WHERE gr.group_member_id = ?") (Only groupMemberId) getGroupRelays :: DB.Connection -> GroupInfo -> IO [GroupRelay] @@ -1272,19 +1287,23 @@ getGroupRelays db GroupInfo {groupId} = map toGroupRelay <$> DB.query db - (groupRelayQuery <> " WHERE group_id = ?") + (groupRelayQuery <> " WHERE gr.group_id = ?") (Only groupId) groupRelayQuery :: Query groupRelayQuery = [sql| - SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link - FROM group_relays + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, + gr.relay_status, gr.relay_link + FROM group_relays gr + JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id |] -toGroupRelay :: (Int64, GroupMemberId, Int64, RelayStatus, Maybe ShortLinkContact) -> GroupRelay -toGroupRelay (groupRelayId, groupMemberId, userChatRelayId, relayStatus, relayLink) = - GroupRelay {groupRelayId, groupMemberId, userChatRelayId, relayStatus, relayLink} +toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, BoolInt, Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay +toGroupRelay (groupRelayId, groupMemberId, chatRelayId, address, name, domains, BI preset, tested, BI enabled, BI deleted, relayStatus, relayLink) = + let userChatRelay = UserChatRelay {chatRelayId, address, name, domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted} + in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink} createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {name} = do @@ -1873,7 +1892,8 @@ createNewMember_ updatedAt = createdAt, supportChat = Nothing, -- TODO [member keys] is it used with relay/public groups? - memberPubKey = Nothing + memberPubKey = Nothing, + relayLink = Nothing } checkGroupMemberHasItems :: DB.Connection -> User -> GroupMember -> IO (Maybe ChatItemId) @@ -2754,8 +2774,8 @@ setXGrpLinkMemReceived db mId xGrpLinkMemReceived = do "UPDATE group_members SET xgrplinkmem_received = ?, updated_at = ? WHERE group_member_id = ?" (BI xGrpLinkMemReceived, currentTs, mId) -createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> ExceptT StoreError IO GroupMember -createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName = do +createNewUnknownGroupMember :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> MemberId -> Text -> GroupMemberRole -> ExceptT StoreError IO GroupMember +createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {groupId} memberId memberName unknownMemberRole = do currentTs <- liftIO getCurrentTime let memberProfile = profileFromName memberName (localDisplayName, profileId) <- createNewMemberProfile_ db user memberProfile currentTs @@ -2770,7 +2790,7 @@ createNewUnknownGroupMember db vr user@User {userId, userContactId} GroupInfo {g peer_chat_min_version, peer_chat_max_version) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRAuthor, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, unknownMemberRole, GCPreMember, GSMemUnknown, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 00ab18e939..50bd3eaeae 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -690,7 +690,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -2994,7 +2994,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember @@ -3002,13 +3002,13 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, - rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, - dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index c58099d2b0..79376a38bb 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -921,10 +921,22 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s | deleted -> Nothing <$ DB.execute db "DELETE FROM protocol_servers WHERE user_id = ? AND smp_server_id = ? AND preset = ?" (userId, srvId, BI False) | otherwise -> Just s <$ updateProtocolServer db p ts s upsertOrDeleteCRelay :: AUserChatRelay -> IO (Maybe UserChatRelay) - upsertOrDeleteCRelay (AUCR _ relay@UserChatRelay {chatRelayId, deleted}) = case chatRelayId of + upsertOrDeleteCRelay (AUCR _ relay@UserChatRelay {chatRelayId, address, deleted}) = case chatRelayId of DBNewEntity | deleted -> pure Nothing - | otherwise -> Just <$> insertChatRelay db user ts relay + | otherwise -> do + -- When a relay referenced in group_relays is deleted, it's soft-deleted (deleted=1). + -- On re-add with the same address, un-delete the existing row to preserve group_relays FK. + -- Only address is matched — it's the relay's identity. Name and other settings are updated. + -- Re-adding with same name but different address is a different relay and will fail on UNIQUE constraint. + existing <- maybeFirstRow fromOnly $ DB.query db + "SELECT chat_relay_id FROM chat_relays WHERE user_id = ? AND address = ? AND deleted = 1 LIMIT 1" + (userId, address) + case existing of + Just existingId -> do + undeleteRelay existingId relay + pure $ Just (relay :: NewUserChatRelay) {chatRelayId = DBEntityId existingId} + Nothing -> Just <$> insertChatRelay db user ts relay DBEntityId relayId | deleted -> do -- If relay is referenced in group_relays, mark it as deleted instead of deleting @@ -934,6 +946,17 @@ setUserServers' db user@User {userId} ts UpdatedUserOperatorServers {operator, s else DB.execute db "DELETE FROM chat_relays WHERE user_id = ? AND chat_relay_id = ? AND preset = ?" (userId, relayId, BI False) pure Nothing | otherwise -> Just relay <$ updateChatRelay db ts relay + -- Un-delete soft-deleted relay, updating name and settings but keeping the address unchanged. + undeleteRelay :: Int64 -> NewUserChatRelay -> IO () + undeleteRelay existingId UserChatRelay {name = nm, domains, preset, tested, enabled} = + DB.execute db + [sql| + UPDATE chat_relays + SET name = ?, domains = ?, + preset = ?, tested = ?, enabled = ?, deleted = 0, updated_at = ? + WHERE chat_relay_id = ? + |] + (nm, T.intercalate "," domains, BI preset, BI <$> tested, BI enabled, ts, existingId) createCall :: DB.Connection -> User -> Call -> UTCTime -> IO () createCall db user@User {userId} Call {contactId, callId, callUUID, chatItemId, callState} callTs = do diff --git a/src/Simplex/Chat/Store/RelayRequests.hs b/src/Simplex/Chat/Store/RelayRequests.hs index 04731d8ef3..3858281878 100644 --- a/src/Simplex/Chat/Store/RelayRequests.hs +++ b/src/Simplex/Chat/Store/RelayRequests.hs @@ -18,6 +18,7 @@ import Data.Text (Text) import Data.Time.Clock (getCurrentTime) import Simplex.Chat.Store.Shared import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (InvitationId) import Simplex.Messaging.Agent.Store.AgentStore (getWorkItem, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index ebdf7e1f5c..1b881bd446 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1197,10 +1197,6 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) -Query: UPDATE deleted_snd_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE deleted_snd_chunk_replica_id = ? -Plan: -SEARCH deleted_snd_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) @@ -1209,6 +1205,10 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) +Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? +Plan: +SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) 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 a69485129c..f666ea4b72 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -156,12 +156,12 @@ Query: -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link, -- from GroupMember m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -1010,7 +1010,7 @@ Query: m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -1203,6 +1203,14 @@ SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_mem SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) +Query: + INSERT INTO group_profiles + (display_name, full_name, short_descr, description, image, group_link, + user_id, preferences, member_admission, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO groups (group_profile_id, local_display_name, user_id, enable_ntfs, @@ -1276,7 +1284,7 @@ Query: m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember @@ -1284,13 +1292,13 @@ Query: rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences, rm.created_at, rm.updated_at, - rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences, dbm.created_at, dbm.updated_at, - dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id @@ -1640,6 +1648,15 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) +Query: + UPDATE chat_relays + SET name = ?, domains = ?, + preset = ?, tested = ?, enabled = ?, deleted = 0, updated_at = ? + WHERE chat_relay_id = ? + +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE connections SET via_contact_uri = NULL, via_contact_uri_hash = NULL, xcontact_id = NULL WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( @@ -4992,6 +5009,14 @@ Query: Plan: SEARCH protocol_servers USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE rcv_files + SET to_receive = 1, user_approved_relays = ?, updated_at = ? + WHERE file_id = ? + +Plan: +SEARCH rcv_files USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE remote_controllers SET ctrl_device_name = ?, dh_priv_key = ?, prev_dh_priv_key = dh_priv_key @@ -5097,7 +5122,7 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -5133,7 +5158,7 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -5162,7 +5187,7 @@ Query: mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id @@ -5210,7 +5235,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5237,7 +5262,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5256,7 +5281,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5275,7 +5300,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5294,7 +5319,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5313,7 +5338,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5332,7 +5357,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5351,7 +5376,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5370,7 +5395,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5389,7 +5414,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5408,7 +5433,7 @@ Query: m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -5477,25 +5502,37 @@ SEARCH i USING COVERING INDEX idx_chat_items_note_folder_has_link_created_at (us SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: - SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link - FROM group_relays - WHERE group_id = ? + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, + gr.relay_status, gr.relay_link + FROM group_relays gr + JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id + WHERE gr.group_id = ? Plan: -SEARCH group_relays USING INDEX idx_group_relays_group_id (group_id=?) +SEARCH gr USING INDEX idx_group_relays_group_id (group_id=?) +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link - FROM group_relays - WHERE group_member_id = ? + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, + gr.relay_status, gr.relay_link + FROM group_relays gr + JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id + WHERE gr.group_member_id = ? Plan: -SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH gr USING INDEX idx_group_relays_group_member_id (group_member_id=?) +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT group_relay_id, group_member_id, chat_relay_id, relay_status, relay_link - FROM group_relays - WHERE group_relay_id = ? + SELECT gr.group_relay_id, gr.group_member_id, + cr.chat_relay_id, cr.address, cr.name, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted, + gr.relay_status, gr.relay_link + FROM group_relays gr + JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id + WHERE gr.group_relay_id = ? Plan: -SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gr USING INTEGER PRIMARY KEY (rowid=?) +SEARCH cr USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT m.group_member_id, m.member_id, m.member_role, p.display_name, p.local_alias @@ -6466,6 +6503,10 @@ Query: SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1 Plan: SEARCH settings USING INDEX idx_settings_user_id (user_id=?) +Query: SELECT chat_relay_id FROM chat_relays WHERE user_id = ? AND address = ? AND deleted = 1 LIMIT 1 +Plan: +SEARCH chat_relays USING INDEX idx_chat_relays_user_id_address (user_id=? AND address=?) + Query: SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ? Plan: SEARCH chat_tags_chats USING COVERING INDEX idx_chat_tags_chats_chat_tag_id_contact_id (contact_id=?) @@ -6678,6 +6719,10 @@ Query: UPDATE chat_items SET via_proxy = ? WHERE user_id = ? AND contact_id = ? Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE chat_relays SET deleted = 1, updated_at = ? WHERE chat_relay_id = ? +Plan: +SEARCH chat_relays USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE connections SET auth_err_counter = ?, updated_at = ? WHERE user_id = ? AND connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 90ccd4dedc..273fea1211 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -667,7 +667,7 @@ type GroupKeysRow = (Maybe B64UrlByteString, Maybe C.PrivateKeyEd25519, Maybe C. type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe ShortLinkContact) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow -type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519) +type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact) type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) @@ -697,7 +697,7 @@ toGroupKeys = \case _ -> Nothing toGroupMember :: Int64 -> GroupMemberRow -> GroupMember -toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey)) = +toGroupMember userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. profileRow :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) = let memberProfile = rowToLocalProfile profileRow memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ @@ -724,7 +724,7 @@ groupMemberQuery = m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -767,7 +767,7 @@ groupInfoQueryFields = mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, - mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link |] groupInfoQueryFrom :: Query diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2329c21e74..2ed3298460 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -988,26 +988,11 @@ data GroupMember = GroupMember createdAt :: UTCTime, updatedAt :: UTCTime, supportChat :: Maybe GroupSupportChat, - memberPubKey :: Maybe C.PublicKeyEd25519 - } - deriving (Eq, Show) - -data GroupRelay = GroupRelay - { groupRelayId :: Int64, - groupMemberId :: GroupMemberId, - userChatRelayId :: Int64, -- ID of configured UserChatRelay - relayStatus :: RelayStatus, + memberPubKey :: Maybe C.PublicKeyEd25519, relayLink :: Maybe ShortLinkContact } deriving (Eq, Show) -data RelayStatus - = RSNew -- only for owner - | RSInvited - | RSAccepted - | RSActive - deriving (Eq, Show) - data RelayRequestData = RelayRequestData { relayInvId :: InvitationId, reqGroupLink :: ShortLinkContact, @@ -1015,30 +1000,6 @@ data RelayRequestData = RelayRequestData } deriving (Eq, Show) -relayStatusText :: RelayStatus -> Text -relayStatusText = \case - RSNew -> "new" - RSInvited -> "invited" - RSAccepted -> "accepted" - RSActive -> "active" - -instance TextEncoding RelayStatus where - textEncode = \case - RSNew -> "new" - RSInvited -> "invited" - RSAccepted -> "accepted" - RSActive -> "active" - textDecode = \case - "new" -> Just RSNew - "invited" -> Just RSInvited - "accepted" -> Just RSAccepted - "active" -> Just RSActive - _ -> Nothing - -instance FromField RelayStatus where fromField = fromTextField_ textDecode - -instance ToField RelayStatus where toField = toField . textEncode - data GroupSupportChat = GroupSupportChat { chatTs :: UTCTime, unread :: Int64, @@ -2044,8 +2005,6 @@ $(JQ.deriveJSON defaultJSON ''GroupSupportChat) $(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) -$(JQ.deriveJSON defaultJSON ''GroupRelay) - $(JQ.deriveJSON defaultJSON ''GroupMember) $(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index fafac46da8..f33719a434 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -74,3 +74,34 @@ instance FromJSON GroupAcceptance where instance ToJSON GroupAcceptance where toJSON = strToJSON toEncoding = strToJEncoding + +data RelayStatus + = RSNew -- only for owner + | RSInvited + | RSAccepted + | RSActive + deriving (Eq, Show) + +relayStatusText :: RelayStatus -> Text +relayStatusText = \case + RSNew -> "new" + RSInvited -> "invited" + RSAccepted -> "accepted" + RSActive -> "active" + +instance TextEncoding RelayStatus where + textEncode = \case + RSNew -> "new" + RSInvited -> "invited" + RSAccepted -> "accepted" + RSActive -> "active" + textDecode = \case + "new" -> Just RSNew + "invited" -> Just RSInvited + "accepted" -> Just RSAccepted + "active" -> Just RSActive + _ -> Nothing + +instance FromField RelayStatus where fromField = fromTextField_ textDecode + +instance ToField RelayStatus where toField = toField . textEncode diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5784e9b7e0..197d0de3cf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -179,6 +179,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView + CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] @@ -204,7 +205,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentConfirmation u _ _customUserProfile -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRStartedConnectionToContact u c customUserProfile -> ttyUser u $ viewStartedConnectionToContact c customUserProfile testView - CRStartedConnectionToGroup u g customUserProfile -> ttyUser u $ viewStartedConnectionToGroup g customUserProfile testView + CRStartedConnectionToGroup u g customUserProfile _relayResults -> ttyUser u $ viewStartedConnectionToGroup g customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRItemsReadForChat u _chatId -> ttyUser u ["items read for chat"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] @@ -1161,6 +1162,15 @@ viewReceivedContactRequest c Profile {fullName, shortDescr} = "to reject: " <> highlight ("/rc " <> viewName c) <> " (the sender will NOT be notified)" ] +showRelay :: GroupRelay -> StyledString +showRelay GroupRelay {groupRelayId, relayStatus} = + " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) + +viewGroupRelays :: GroupInfo -> [GroupRelay] -> [StyledString] +viewGroupRelays g relays = + [ttyFullGroup g <> ": group relays:"] + <> map showRelay relays + viewGroupLinkRelaysUpdated :: GroupInfo -> GroupLink -> [GroupRelay] -> [StyledString] viewGroupLinkRelaysUpdated g groupLink relays = [ttyFullGroup g <> ": group link relays updated, current relays:"] @@ -1170,8 +1180,6 @@ viewGroupLinkRelaysUpdated g groupLink relays = plain $ maybe cReqStr strEncode shortLink ] where - showRelay GroupRelay {groupRelayId, relayStatus} = - " - relay id " <> sShow groupRelayId <> ": " <> plain (relayStatusText relayStatus) GroupLink {connLinkContact = CCLink cReq shortLink} = groupLink cReqStr = strEncode $ simplexChatContact cReq diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d018eed4ee..5ea1649a4a 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -38,6 +38,7 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Terminal import Simplex.Chat.Terminal.Output (newChatTerminal) import Simplex.Chat.Types +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.FileTransfer.Description (kb, mb) import Simplex.FileTransfer.Server (runXFTPServerBlocking) import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration) @@ -209,6 +210,7 @@ testCfg = shortLinkPresetServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:7001"], testView = True, tbqSize = 16, + channelSubscriberRole = GRMember, confirmMigrations = MCYesUp } diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 2a45f79a73..606c199a82 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -5,11 +5,12 @@ import ChatTests.DBUtils import ChatTests.Utils import Test.Hspec hiding (it) --- TODO [relays] test deleting relay (from configuration), referenced in group_relays. chatRelayTests :: SpecWith TestParams chatRelayTests = do describe "configure chat relays" $ do it "get and set chat relays" testGetSetChatRelays + it "re-add soft-deleted relay by same address" testReAddRelaySameAddress + it "re-add soft-deleted relay by same name" testReAddRelaySameName testGetSetChatRelays :: HasCallStack => TestParams -> IO () testGetSetChatRelays ps = @@ -48,3 +49,84 @@ testGetSetChatRelays ps = <### [ ConsoleString $ " bob_relay: " <> bobSLink, ConsoleString $ " cath_relay: " <> cathSLink ] + +-- Relay used by a channel is soft-deleted (referenced in group_relays). +-- Re-adding with same address should un-delete it. +testReAddRelaySameAddress :: HasCallStack => TestParams -> IO () +testReAddRelaySameAddress ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Configure bob as relay and create channel (creates group_relays reference) + alice ##> ("/relays name=bob_relay " <> bobSLink) + alice <## "ok" + createChannelWithRelay "team" alice bob + + -- Replace bob_relay with cath_relay (bob_relay is soft-deleted, referenced in group_relays) + alice ##> ("/relays name=cath_relay " <> cathSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" cath_relay: " <> cathSLink) + + -- Re-add with same address but different name - should succeed (un-deletes soft-deleted row by address) + alice ##> ("/relays name=bob_relay2 " <> bobSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" bob_relay2: " <> bobSLink) + +-- Relay used by a channel is soft-deleted (referenced in group_relays). +-- Re-adding with same name and same address should un-delete it. +testReAddRelaySameName :: HasCallStack => TestParams -> IO () +testReAddRelaySameName ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> do + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Configure bob as relay named "my_relay" and create channel + alice ##> ("/relays name=my_relay " <> bobSLink) + alice <## "ok" + createChannelWithRelay "team" alice bob + + -- Replace with cath_relay (my_relay is soft-deleted) + alice ##> ("/relays name=cath_relay " <> cathSLink) + alice <## "ok" + + -- Re-add with same name and same address - should succeed (un-deletes by address match) + alice ##> ("/relays name=my_relay " <> bobSLink) + alice <## "ok" + + alice ##> "/relays" + alice <## "Your servers" + alice <## " Chat relays" + alice <## (" my_relay: " <> bobSLink) + +-- Create a public group with relay=1, wait for relay to join +createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () +createChannelWithRelay gName owner relay = do + owner ##> ("/public group relays=1 #" <> gName) + owner <## ("group #" <> gName <> " is created") + owner <## "wait for selected relay(s) to join, then you can invite members via group link" + concurrentlyN_ + [ do + owner <## ("#" <> gName <> ": group link relays updated, current relays:") + owner <## " - relay id 1: active" + owner <## "group link:" + _ <- getTermLine owner + pure (), + relay <## ("#" <> gName <> ": you joined the group as relay") + ] From 8d15bc27d96f58d37b33b7bc8230498607bab874 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:49:31 +0400 Subject: [PATCH 021/112] core: fix orphan instances --- src/Simplex/Chat/Types.hs | 2 -- src/Simplex/Chat/Types/Shared.hs | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 2ed3298460..ee2ac1447b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -2003,8 +2003,6 @@ $(JQ.deriveJSON defaultJSON ''PendingContactConnection) $(JQ.deriveJSON defaultJSON ''GroupSupportChat) -$(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) - $(JQ.deriveJSON defaultJSON ''GroupMember) $(JQ.deriveJSON (enumJSON $ dropPrefix "MF") ''MsgFilter) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index f33719a434..dfc932c35d 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -1,15 +1,18 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Types.Shared where import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Aeson.TH as JQ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import Data.Text (Text) import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Messaging.Agent.Store.DB (fromTextField_) import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Parsers (dropPrefix, enumJSON) import Simplex.Messaging.Util ((<$?>)) data GroupMemberRole @@ -105,3 +108,5 @@ instance TextEncoding RelayStatus where instance FromField RelayStatus where fromField = fromTextField_ textDecode instance ToField RelayStatus where toField = toField . textEncode + +$(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) From e0dc4366e017a13376f75710b7e9c6ee2fc086c7 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Thu, 5 Mar 2026 20:52:53 +0000 Subject: [PATCH 022/112] website: remove old notice --- website/src/directory.html | 1 - website/src/index.html | 1 - 2 files changed, 2 deletions(-) diff --git a/website/src/directory.html b/website/src/directory.html index 30d6765553..0235583ece 100644 --- a/website/src/directory.html +++ b/website/src/directory.html @@ -260,7 +260,6 @@ active_directory: true app.

    SimpleX Directory is also available as a SimpleX chat bot.

    Read about how to add your community.

    -

    Under maintenance — you can't join these groups until 17:00 UTC, 01/09.

    From 3f4e7f379de77ff9586975da0bb36a77c1096ff5 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:24:55 +0000 Subject: [PATCH 023/112] core, ui: group members permanent connection errors (#6662) --- .../Views/Chat/Group/GroupChatInfoView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 6 + .../Views/Chat/Group/MemberSupportView.swift | 4 +- apps/ios/SimpleXChat/ChatTypes.swift | 25 +- .../chat/simplex/common/model/ChatModel.kt | 42 ++- .../views/chat/group/GroupChatInfoView.kt | 6 +- .../views/chat/group/GroupMemberInfoView.kt | 8 + .../views/chat/group/MemberSupportView.kt | 4 +- .../commonMain/resources/MR/base/strings.xml | 2 + bots/api/TYPES.md | 38 ++- bots/src/API/Docs/Types.hs | 2 +- .../types/typescript/src/types.ts | 72 +++- plans/2026-03-05-members-conn-errors.md | 316 ++++++++++++++++++ src/Simplex/Chat/Library/Internal.hs | 2 +- src/Simplex/Chat/Library/Subscriber.hs | 11 +- src/Simplex/Chat/Store/Connections.hs | 3 + src/Simplex/Chat/Types.hs | 18 +- 17 files changed, 501 insertions(+), 62 deletions(-) create mode 100644 plans/2026-03-05-members-conn-errors.md diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 257d5aac93..4113b75d0a 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -455,7 +455,9 @@ struct GroupChatInfoView: View { } private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { - if member.activeConn?.connDisabled ?? false { + if case .failed = member.activeConn?.connStatus { + return "failed" + } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 17a05ffca4..135efae74f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -189,6 +189,12 @@ struct GroupMemberInfoView: View { } } + if let connFailedErr = member.activeConn?.connFailedErr { + Section { + infoRow("Connection failed", connFailedErr) + } + } + if groupInfo.membership.memberRole >= .moderator { adminDestructiveSection(member) } else { diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 75a6840c4e..3dc27c08f6 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -196,7 +196,9 @@ struct MemberSupportView: View { } private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { - if member.activeConn?.connDisabled ?? false { + if case .failed = member.activeConn?.connStatus { + return "failed" + } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0b15666d2..b2a9611593 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2113,6 +2113,11 @@ public struct Connection: Decodable, Hashable { public var id: ChatId { get { ":\(connId)" } } + public var connFailedErr: String? { + if case let .failed(err) = connStatus { return err } + return nil + } + public var connDisabled: Bool { authErrCounter >= 10 // authErrDisableCount in core } @@ -2298,15 +2303,16 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { } } -public enum ConnStatus: String, Decodable, Hashable { - case new = "new" - case prepared = "prepared" - case joined = "joined" - case requested = "requested" - case accepted = "accepted" - case sndReady = "snd-ready" - case ready = "ready" - case deleted = "deleted" +public enum ConnStatus: Decodable, Hashable { + case new + case prepared + case joined + case requested + case accepted + case sndReady + case ready + case deleted + case failed(connError: String) var initiated: Bool? { get { @@ -2319,6 +2325,7 @@ public enum ConnStatus: String, Decodable, Hashable { case .sndReady: return nil case .ready: return nil case .deleted: return nil + case .failed: return nil } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 01f8197beb..668e18cf6c 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 @@ -1904,6 +1904,12 @@ data class Connection( val connInactive: Boolean get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core + val connFailedErr: String? + get() = when (connStatus) { + is ConnStatus.Failed -> connStatus.connError + else -> null + } + val connPQEnabled: Boolean get() = pqSndEnabled == true && pqRcvEnabled == true @@ -2638,25 +2644,27 @@ class PendingContactConnection( } @Serializable -enum class ConnStatus { - @SerialName("new") New, - @SerialName("prepared") Prepared, - @SerialName("joined") Joined, - @SerialName("requested") Requested, - @SerialName("accepted") Accepted, - @SerialName("snd-ready") SndReady, - @SerialName("ready") Ready, - @SerialName("deleted") Deleted; +sealed class ConnStatus { + @Serializable @SerialName("new") object New: ConnStatus() + @Serializable @SerialName("prepared") object Prepared: ConnStatus() + @Serializable @SerialName("joined") object Joined: ConnStatus() + @Serializable @SerialName("requested") object Requested: ConnStatus() + @Serializable @SerialName("accepted") object Accepted: ConnStatus() + @Serializable @SerialName("sndReady") object SndReady: ConnStatus() + @Serializable @SerialName("ready") object Ready: ConnStatus() + @Serializable @SerialName("deleted") object Deleted: ConnStatus() + @Serializable @SerialName("failed") class Failed(val connError: String): ConnStatus() val initiated: Boolean? get() = when (this) { - New -> true - Prepared -> false - Joined -> false - Requested -> true - Accepted -> true - SndReady -> null - Ready -> null - Deleted -> null + is New -> true + is Prepared -> false + is Joined -> false + is Requested -> true + is Accepted -> true + is SndReady -> null + is Ready -> null + is Deleted -> null + is Failed -> null } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 3f80361249..dd3374d50b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -878,9 +878,11 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr } fun memberConnStatus(): String { - return if (member.activeConn?.connDisabled == true) { - generalGetString(MR.strings.member_info_member_disabled) + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.member_info_member_failed) } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) } else { member.memberStatus.shortText diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index f09d2f44bb..8902a0fd9e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -562,6 +562,14 @@ fun GroupMemberInfoLayout( } } + val connFailedErr = member.activeConn?.connFailedErr + if (connFailedErr != null) { + SectionDividerSpaced() + SectionView { + InfoRow(stringResource(MR.strings.info_row_connection_failed), connFailedErr) + } + } + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModeratorDestructiveSection() } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index e696128288..c3cf954ab6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -162,7 +162,9 @@ private fun ModalData.MemberSupportViewLayout( @Composable fun SupportChatRow(member: GroupMember) { fun memberStatus(): String { - return if (member.activeConn?.connDisabled == true) { + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.member_info_member_failed) + } else if (member.activeConn?.connDisabled == true) { generalGetString(MR.strings.member_info_member_disabled) } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) 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 4d71073dac..8b1eb44249 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -1906,6 +1906,7 @@ Blocked by admin blocked disabled + failed inactive MEMBER Role @@ -1924,6 +1925,7 @@ Group Chat Connection + Connection failed direct indirect (%1$s) Message queue info diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index a66f1b379e..5dcfe81831 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -1473,15 +1473,35 @@ LARGE: ## ConnStatus -**Enum type**: -- "new" -- "prepared" -- "joined" -- "requested" -- "accepted" -- "snd-ready" -- "ready" -- "deleted" +**Discriminated union type**: + +New: +- type: "new" + +Prepared: +- type: "prepared" + +Joined: +- type: "joined" + +Requested: +- type: "requested" + +Accepted: +- type: "accepted" + +SndReady: +- type: "sndReady" + +Ready: +- type: "ready" + +Deleted: +- type: "deleted" + +Failed: +- type: "failed" +- connError: string --- diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 73ad90e91b..21970ce419 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -240,7 +240,7 @@ chatTypesDocsData = (sti @ConnectionErrorType, STUnion, "", [], "", ""), (sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""), (sti @ConnectionPlan, STUnion, "CP", [], "", ""), - (sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""), + (sti @ConnStatus, STUnion, "Conn", [], "", ""), (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""), (sti @Contact, STRecord, "", [], "", ""), (sti @ContactAddressPlan, STUnion, "CAP", [], "", ""), diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index bd03d7d72b..65ce90f647 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1695,15 +1695,69 @@ export interface ComposedMessage { mentions: {[key: string]: number} // string : int64 } -export enum ConnStatus { - New = "new", - Prepared = "prepared", - Joined = "joined", - Requested = "requested", - Accepted = "accepted", - Snd_ready = "snd-ready", - Ready = "ready", - Deleted = "deleted", +export type ConnStatus = + | ConnStatus.New + | ConnStatus.Prepared + | ConnStatus.Joined + | ConnStatus.Requested + | ConnStatus.Accepted + | ConnStatus.SndReady + | ConnStatus.Ready + | ConnStatus.Deleted + | ConnStatus.Failed + +export namespace ConnStatus { + export type Tag = + | "new" + | "prepared" + | "joined" + | "requested" + | "accepted" + | "sndReady" + | "ready" + | "deleted" + | "failed" + + interface Interface { + type: Tag + } + + export interface New extends Interface { + type: "new" + } + + export interface Prepared extends Interface { + type: "prepared" + } + + export interface Joined extends Interface { + type: "joined" + } + + export interface Requested extends Interface { + type: "requested" + } + + export interface Accepted extends Interface { + type: "accepted" + } + + export interface SndReady extends Interface { + type: "sndReady" + } + + export interface Ready extends Interface { + type: "ready" + } + + export interface Deleted extends Interface { + type: "deleted" + } + + export interface Failed extends Interface { + type: "failed" + connError: string + } } export enum ConnType { diff --git a/plans/2026-03-05-members-conn-errors.md b/plans/2026-03-05-members-conn-errors.md new file mode 100644 index 0000000000..b63923771a --- /dev/null +++ b/plans/2026-03-05-members-conn-errors.md @@ -0,0 +1,316 @@ +# Save Permanent Connection Errors for Group Members + +## Context + +When a group member's connection handshake fails with a permanent error (e.g., `CONN NOT_ACCEPTED`, `SMP AUTH`, `AGENT A_VERSION`), the ERR event is logged to the UI event stream and discarded. The member record stays stuck in a "connecting" `GroupMemberStatus` (like `memIntroduced`, `memAccepted`) forever. Users see perpetual "connecting" with no explanation and no way to know whether to wait or re-invite. + +**Root cause**: `agentMsgConnStatus` (Subscriber.hs:376) only maps success events (`CONF`, `INFO`, `JOINED`, `CON`) to status transitions. The ERR handler for group members (Subscriber.hs:1054-1056) only logs to UI and completes the command — no status or error is persisted. + +## Solution Summary + +Add `ConnError {connError :: Text}` constructor to `ConnStatus`. Error text is encoded in the `conn_status TEXT` column as `"error "` via `TextEncoding`, and in JSON via `sumTypeJSON` (following `GSSError`/`CIFileStatus` pattern). No new DB column, no migration. When a non-temporary ERR arrives before connection is ready, transition to `ConnError` and notify UI. Messages are not queued for errored connections. + +## Technical Design + +### Error classification + +Use `temporaryOrHostError` from `Simplex.Messaging.Agent.Client` (simplexmq Client.hs:1486, exported at line 60): +- Returns `True` for NETWORK, TIMEOUT, HOST, TEVersion, INACTIVE, CRITICAL-with-restart → **do not save** +- Returns `False` for AUTH, CONN errors, VERSION, INTERNAL, etc. → **save as permanent error** + +Guard: only save when connection is not `ConnReady` and not already `ConnError`. Post-handshake errors (when `connStatus == ConnReady`) are handled by existing `processConnMERR` (AUTH counters, QUOTA counters). + +### Data flow + +``` +Agent ERR event + → Subscriber.hs processGroupMessage ERR handler + → guard: connStatus is not ConnReady, not ConnError, not temporaryOrHostError + → DB: UPDATE connections SET conn_status = 'error ' + → emit: CEvtGroupMemberUpdated user gInfo m m' + → iOS: upsertGroupMember updates model → UI re-renders +``` + +### DB encoding + +`conn_status TEXT NOT NULL` already exists. `ConnError` encodes as `"error " <> errText` using `TextEncoding` (same as `GSSError`). No migration needed — new text values are valid in the existing column. + +### JSON encoding + +Replace manual `ToJSON`/`FromJSON` instances with `$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)`. This follows the `GroupSndStatus`/`CIFileStatus` pattern — `sumTypeJSON` is already imported in Types.hs (line 60). + +JSON format (platform-dependent via `sumTypeJSON`): +- iOS: `{"error": {"connError": "SMP AUTH"}}` (ObjectWithSingleField) +- Android/Desktop: `{"type": "error", "connError": "SMP AUTH"}` (TaggedObject) +- Nullary cases: `{"ready": {}}` / `{"type": "ready"}` (not plain `"ready"` strings) + +Note: `ConnSndReady` JSON tag changes from `"snd-ready"` to `"sndReady"` (`dropPrefix "Conn"` applies `fstToLower`). This is safe — JSON is core→UI within same build. Swift auto-synthesis matches on `sndReady` case name. + +### Clear on recovery + +When CON event arrives, `agentMsgConnStatus` returns `Just ConnReady`, `updateConnStatus` overwrites `conn_status` to `"ready"`. Error is implicitly cleared — no special cleanup needed. + +### ConnStatus state machine update + +``` +Existing transitions (unchanged): + ConnNew → ConnRequested → ConnAccepted → ConnSndReady → ConnReady + ConnNew → ConnJoined → ConnSndReady → ConnReady + ConnPrepared → ConnJoined → ConnSndReady → ConnReady + Any → ConnDeleted + +New transitions: + Any pre-ready state → ConnError (on permanent ERR) + ConnError → ConnReady (on successful CON — recovery) + ConnError → ConnDeleted (on connection deletion) +``` + +### Pattern match safety audit + +Traced every ConnStatus pattern match across Haskell (10 files), Swift (6 files), Kotlin (3 files). + +**Must update (exhaustive matches):** + +| Location | Change | +|---|---| +| Types.hs textEncode/textDecode (~1703) | Add ConnError encoding/decoding | +| Types.hs ToJSON/FromJSON (~1696) | Replace with `sumTypeJSON` TH splice | +| Swift ConnStatus.initiated | Add `case .error: return nil` | +| Kotlin ConnStatus.initiated | Add `Error -> null` (follow-up) | + +**Must update (behavioral):** + +| Location | Current behavior | Fix | +|---|---|---| +| Internal.hs memberSendAction (line 2041) | ConnError falls to `otherwise -> pendingOrForwarded` — messages queued for permanently errored connections | Add pattern guard `ConnError {} <- connStatus -> Nothing` | + +**Verified safe — no changes needed:** + +| Pattern | Sites | Why safe | +|---|---|---| +| `== ConnReady` / `== ConnSndReady` | 12 sites (connReady, Contact.ready, GroupMember.ready, sndReady, readyMemberConn, xftpSndFileTransfer) | ConnError ≠ these → excluded from "ready" paths | +| `== ConnPrepared` | 8 sites (joinPreparedConn, nextConnectPrepared, isContactCard, contactRequestPlan) | ConnError ≠ ConnPrepared → doesn't trigger join/prepare logic | +| `== ConnNew` | 4 sites (contactConnInitiated, nextAcceptContactRequest, APIPrepareContact) | ConnError ≠ ConnNew → doesn't trigger new-connection logic | +| `!= ConnDeleted` (DB WHERE) | 6 sites (getConnectionEntity, *ConnsToSub) | ConnError ≠ ConnDeleted → errored connections remain findable and subscribable (correct — enables recovery via CON). **Add TODO comments** at each site to consider whether ConnError connections should be excluded. | +| `updateConnectionStatusFromTo` | 3 sites | Compares current to specific `fromStatus` — ConnError won't accidentally match | +| `readyMemberConn` (Internal.hs:2078) | 1 site | `connStatus == ConnReady \|\| == ConnSndReady` — ConnError → `otherwise = Nothing` (correct) | +| `connDisabled`/`connInactive` | 6 sites | Derived from error counters, not connStatus | +| `agentMsgConnStatus` | 1 site | Only produces ConnSndReady/ConnRequested/ConnReady — no ConnError output | + +## Implementation Plan + +### 1. Haskell: ConnStatus type + +**File: `src/Simplex/Chat/Types.hs`** + +**ConnStatus** (~line 1673): Add constructor after `ConnDeleted`: +```haskell + | ConnError {connError :: Text} +``` +Record syntax for `sumTypeJSON` field name in JSON. `deriving (Eq, Show, Read)` unchanged. + +**TextEncoding instance** (~line 1703) — for DB storage: +```haskell + textEncode = \case + ... + ConnError err -> "error " <> err + textDecode s + | Just err <- T.stripPrefix "error " s = Just (ConnError err) + | otherwise = case s of + "new" -> Just ConnNew + ... (existing cases unchanged) + _ -> Nothing +``` + +Note: `textDecode` changes from `\case` to named parameter `s` to support `stripPrefix` guard. + +**JSON instances** (~lines 1696-1701): Replace manual instances with TH splice: +```haskell +-- Remove: +-- instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" +-- instance ToJSON ConnStatus where toJSON = J.String . textEncode; toEncoding = JE.text . textEncode +-- Add: +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus) +``` + +`sumTypeJSON` and `dropPrefix` already imported (line 60). `FromField`/`ToField` instances unchanged — still use `TextEncoding` for DB. + +**`connReady`** (line 1597): No change — `== ConnReady || == ConnSndReady`, `ConnError _` naturally returns `False`. + +### 2. Haskell: Subscriber.hs — save error on permanent ERR + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +Extend existing import (line 74): +```haskell +import Simplex.Messaging.Agent.Client (temporaryOrHostError, getAgentWorker, ...) +``` + +Update ERR handler in `processGroupMessage` (line 1054-1056). Current: +```haskell +ERR err -> do + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () +``` + +New: +```haskell +ERR err -> do + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + let Connection {connStatus = cs} = conn + case cs of + ConnReady -> pure () + ConnError _ -> pure () + _ | temporaryOrHostError err -> pure () + | otherwise -> do + let errText = tshow err + withStore' $ \db -> updateConnectionStatus db conn (ConnError errText) + let conn' = conn {connStatus = ConnError errText} + m' = m {activeConn = Just conn'} + toView $ CEvtGroupMemberUpdated user gInfo m m' +``` + +Note: `let Connection {connStatus = cs} = conn` destructures via pattern binding, avoiding ambiguous `connStatus conn` field selector under `DuplicateRecordFields`. + +No new store function — reuses existing `updateConnectionStatus` (Direct.hs:937) which calls `updateConnectionStatus_` → `textEncode` → stores `"error SMP AUTH"` in `conn_status`. + +### 3. Haskell: memberSendAction — don't queue for errored connections + +**File: `src/Simplex/Chat/Library/Internal.hs`** + +Update `memberSendAction` (line 2040-2044). Current: +```haskell + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded +``` + +Add pattern guard after first guard (can't use `==` with associated data): +```haskell + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | ConnError {} <- connStatus -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded +``` + +### 4. Swift: ConnStatus enum + +**File: `apps/ios/SimpleXChat/ChatTypes.swift`** (~line 2301) + +Change from `String`-backed raw value enum to enum with associated value. Auto-synthesized `Decodable` handles `sumTypeJSON` format (same as `GroupSndStatus`, `CIFileStatus`): + +```swift +public enum ConnStatus: Decodable, Hashable { + case new + case prepared + case joined + case requested + case accepted + case sndReady + case ready + case deleted + case error(connError: String) + + var initiated: Bool? { + switch self { + case .new: return true + case .prepared: return false + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return nil + case .ready: return nil + case .deleted: return nil + case .error: return nil + } + } +} +``` + +No custom `init(from:)` needed. `Hashable`/`Equatable` auto-synthesized. Existing equality checks like `connStatus == .ready` still compile (nullary cases). + +### 5. Swift: Connection computed property + +**File: `apps/ios/SimpleXChat/ChatTypes.swift`** + +Add computed property to `Connection` struct (~line 2092, after `connStatus`): +```swift +public var connError: String? { + if case let .error(err) = connStatus { return err } + return nil +} +``` + +### 6. Swift: Member list status + +**File: `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift`** + +Update `memberConnStatus` function (~line 457). Insert error check FIRST (before `connDisabled`/`connInactive`): +```swift +private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { + if case .error = member.activeConn?.connStatus { + return "connection error" + } else if member.activeConn?.connDisabled ?? false { + return "disabled" + } else if member.activeConn?.connInactive ?? false { + return "inactive" + } else { + return member.memberStatus.shortText + } +} +``` + +**File: `apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift`** + +Update `memberStatus` function (line 198). Insert error check FIRST (before `connDisabled` at line 199): +```swift + if case .error = member.activeConn?.connStatus { + return "connection error" + } else if member.activeConn?.connDisabled ?? false { +``` + +### 7. Swift: Member info error display + +**File: `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`** + +Add error display section after the `connStats` section (~line 190): +```swift +if let connError = member.activeConn?.connError { + Section(header: Text("Connection error").foregroundColor(theme.colors.secondary)) { + Text(connError) + .foregroundColor(theme.colors.secondary) + .font(.callout) + .textSelection(.enabled) + } +} +``` + +## Files Changed Summary + +| Layer | File | Change | +|-------|------|--------| +| Core | `Types.hs` | Add `ConnError {connError :: Text}` to ConnStatus, update TextEncoding, replace JSON with `sumTypeJSON` TH splice | +| Logic | `Subscriber.hs` | Import `temporaryOrHostError`, handle permanent ERR for group members | +| Logic | `Internal.hs` | Add `ConnError` guard to `memberSendAction` → return `Nothing` | +| iOS | `ChatTypes.swift` | ConnStatus: auto-synthesized Decodable with `.error(connError:)`, Connection: `connError` computed property | +| iOS | `GroupChatInfoView.swift` | Show "connection error" in `memberConnStatus` (first check) | +| iOS | `MemberSupportView.swift` | Show "connection error" in `memberStatus` (first check) | +| iOS | `GroupMemberInfoView.swift` | Show error description section | + +## Verification + +1. **Build Haskell**: `cabal build --ghc-options -O0` +2. **Build iOS**: Verify Swift compiles — existing `connStatus == .ready` comparisons still work (nullary cases) +3. **JSON format**: Verify `sumTypeJSON` output matches Swift auto-synthesis expectations (nullary: `{"ready": {}}`, error: `{"error": {"connError": "..."}}`) +4. **Backward compat**: New `"error ..."` values in `conn_status` only appear after code update. Old code cannot parse them (downgrade risk, same as any new enum value). +5. **Recovery**: CON event → `updateConnectionStatus_ ConnReady` → overwrites `"error ..."` with `"ready"` in DB +6. **memberSendAction**: Verify messages are NOT queued for ConnError connections + +## Out of Scope (immediate follow-up) + +**Kotlin/Android/Desktop**: `ConnStatus` enum in `ChatModel.kt:2640` needs custom serializer for `sumTypeJSON` format (TaggedObject: `{"type": "error", "connError": "..."}`) + `Connection` needs `connError` computed property + member status UI. Must be updated before Android/Desktop builds from this commit. Existing bug at `GroupChatInfoView.kt:883` (`connDisabled` checked twice, should be `connInactive` on second check). diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 00fd2f18d9..4dc6445f67 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2038,7 +2038,7 @@ memberSendAction GroupInfo {useRelays, membership} events members m@GroupMember | otherwise = case memberConn m of Nothing -> pendingOrForwarded Just conn@Connection {connStatus} - | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | connDisabled conn || connStatus == ConnDeleted || isConnFailed connStatus || memberStatus == GSMemRejected -> Nothing | connInactive conn -> Just MSAPending | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn | otherwise -> pendingOrForwarded diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 7fcc07640d..4a2a515929 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -71,7 +71,7 @@ import Simplex.FileTransfer.Protocol (FilePartyI) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.FileTransfer.Types (FileErrorType (..), RcvFileId, SndFileId) import Simplex.Messaging.Agent -import Simplex.Messaging.Agent.Client (getAgentWorker, waitForWork, withWork_, withWorkItems) +import Simplex.Messaging.Agent.Client (getAgentWorker, temporaryOrHostError, waitForWork, withWork_, withWorkItems) import Simplex.Messaging.Agent.Env.SQLite (Worker (..)) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) @@ -366,19 +366,20 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processUserContactRequest agentMessage entity conn uc where updateConnStatus :: ConnectionEntity -> CM ConnectionEntity - updateConnStatus acEntity = case agentMsgConnStatus agentMessage of + updateConnStatus acEntity = case agentMsgConnStatus (entityConnection acEntity) agentMessage of Just connStatus -> do let conn = (entityConnection acEntity) {connStatus} withStore' $ \db -> updateConnectionStatus db conn connStatus pure $ updateEntityConnStatus acEntity connStatus Nothing -> pure acEntity - agentMsgConnStatus :: AEvent e -> Maybe ConnStatus - agentMsgConnStatus = \case + agentMsgConnStatus :: Connection -> AEvent e -> Maybe ConnStatus + agentMsgConnStatus Connection {connStatus = cs} = \case JOINED True _ -> Just ConnSndReady CONF {} -> Just ConnRequested INFO {} -> Just ConnSndReady CON _ -> Just ConnReady + ERR err | cs /= ConnReady && not (temporaryOrHostError err) -> Just $ ConnFailed (tshow err) _ -> Nothing processCONFpqSupport :: Connection -> PQSupport -> CM Connection @@ -1054,6 +1055,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = ERR err -> do eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + when (isConnFailed $ connStatus conn) $ + toView $ CEvtGroupMemberUpdated user gInfo m m -- TODO add debugging output _ -> pure () where diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index bbb5b6a8c0..3ae5e257b6 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -71,6 +71,9 @@ getChatLockEntity db agentConnId = do ExceptT . firstRow fromOnly (SEInternalError "group member connection group_id not found") $ DB.query db "SELECT group_id FROM group_members WHERE group_member_id = ?" (Only groupMemberId) +-- TODO consider whether ConnFailed connections should be excluded: +-- - from receiving: getConnectionEntity, getContactConnEntityByConnReqHash +-- - from subscribing: getContactConnsToSub, getUCLConnsToSub, getMemberConnsToSub, getPendingConnsToSub getConnectionEntity :: DB.Connection -> VersionRangeChat -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 44ea46492c..f0145840e2 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -1687,19 +1687,14 @@ data ConnStatus ConnReady | -- | connection deleted ConnDeleted + | -- | connection had a permanent error during handshake + ConnFailed {connError :: Text} deriving (Eq, Show, Read) instance FromField ConnStatus where fromField = fromTextField_ textDecode instance ToField ConnStatus where toField = toField . textEncode -instance FromJSON ConnStatus where - parseJSON = textParseJSON "ConnStatus" - -instance ToJSON ConnStatus where - toJSON = J.String . textEncode - toEncoding = JE.text . textEncode - instance TextEncoding ConnStatus where textDecode = \case "new" -> Just ConnNew @@ -1710,6 +1705,7 @@ instance TextEncoding ConnStatus where "snd-ready" -> Just ConnSndReady "ready" -> Just ConnReady "deleted" -> Just ConnDeleted + s | Just err <- T.stripPrefix "failed " s -> Just (ConnFailed err) _ -> Nothing textEncode = \case ConnNew -> "new" @@ -1720,6 +1716,12 @@ instance TextEncoding ConnStatus where ConnSndReady -> "snd-ready" ConnReady -> "ready" ConnDeleted -> "deleted" + ConnFailed err -> "failed " <> err + +isConnFailed :: ConnStatus -> Bool +isConnFailed = \case + ConnFailed {} -> True + _ -> False data ConnType = ConnContact | ConnMember | ConnUserContact deriving (Eq, Show) @@ -1935,6 +1937,8 @@ $(JQ.deriveJSON defaultJSON ''GroupMemberSettings) $(JQ.deriveJSON defaultJSON ''SecurityCode) +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus) + $(JQ.deriveJSON defaultJSON ''Connection) $(JQ.deriveJSON defaultJSON ''PendingContactConnection) From 2475a2163aca4659d15d19c508f45c1655321106 Mon Sep 17 00:00:00 2001 From: BarbossHack Date: Mon, 9 Mar 2026 10:19:54 +0100 Subject: [PATCH 024/112] scripts: pin ubuntu manifest digest hash for reproducibility (#6651) --- .github/workflows/build.yml | 6 ++++++ Dockerfile.build | 3 ++- scripts/simplex-chat-reproduce-builds.sh | 7 ++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c882b351e7..c3ef9fa088 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,6 +111,7 @@ jobs: arch: x86_64 runner: "ubuntu-22.04" ghc: "8.10.7" + hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} - os: 22.04 os_underscore: 22_04 @@ -118,24 +119,28 @@ jobs: runner: "ubuntu-22.04" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' - os: 24.04 os_underscore: 24_04 arch: x86_64 runner: "ubuntu-24.04" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - os: 22.04 os_underscore: 22_04 arch: aarch64 runner: "ubuntu-22.04-arm" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - os: 24.04 os_underscore: 24_04 arch: aarch64 runner: "ubuntu-24.04-arm" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' steps: - name: Checkout Code if: matrix.should_run == true @@ -182,6 +187,7 @@ jobs: tags: build/${{ matrix.os }}:latest build-args: | TAG=${{ matrix.os }} + HASH=${{ matrix.hash }} GHC=${{ matrix.ghc }} USER_UID=${{ steps.ids.outputs.uid }} USER_GID=${{ steps.ids.outputs.gid }} diff --git a/Dockerfile.build b/Dockerfile.build index 3ddff59d12..89f8c25101 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1.7.0-labs ARG TAG=24.04 -FROM ubuntu:${TAG} AS build +ARG HASH=sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237 +FROM ubuntu:${TAG}@${HASH} AS build ### Build stage diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index 0ca2522fa0..b05d41efbc 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -38,7 +38,11 @@ git -C "${tempdir}" clone "${repo}.git" &&\ cd "${tempdir}/${repo_name}" &&\ git checkout "${TAG}" -for os in '22.04' '24.04'; do +oses="22.04@sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5 24.04@sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237" + +for os_pair in ${oses}; do + os="${os_pair%@*}" + hash="${os_pair#*@}" os_url="$(printf '%s' "${os}" | tr '.' '_')" cli_name="simplex-chat-ubuntu-${os_url}-x86_64" @@ -49,6 +53,7 @@ for os in '22.04' '24.04'; do docker build \ --no-cache \ --build-arg TAG="${os}" \ + --build-arg HASH="${hash}" \ --build-arg GHC="${ghc}" \ --build-arg=USER_UID="$(id -u)" \ --build-arg=USER_GID="$(id -g)" \ From a4f3e21490d725f0fc99357b46e0b0e4f851b4f9 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:48:22 +0000 Subject: [PATCH 025/112] ios: relay failure indication (#6665) --- apps/ios/Shared/Model/ChatModel.swift | 11 +- .../Chat/ComposeMessage/ComposeView.swift | 103 +++++++++++++----- .../Views/Chat/Group/ChannelRelaysView.swift | 67 +++++++----- .../Chat/Group/GroupMemberInfoView.swift | 9 +- .../Shared/Views/NewChat/AddChannelView.swift | 77 ++++++++++--- apps/ios/SimpleXChat/ChatTypes.swift | 8 +- src/Simplex/Chat/Store/Groups.hs | 9 +- 7 files changed, 202 insertions(+), 82 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 023dc1926c..5e85417326 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -379,6 +379,7 @@ final class ChatModel: ObservableObject { @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? + @Published var creatingChannelId: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @Published var membersLoaded = false @@ -1241,13 +1242,19 @@ final class ChatModel: ObservableObject { updateGroup(groupInfo) return false } - // update current chat - if chatId == groupInfo.id { + // update current chat or channel being created + if chatId == groupInfo.id || creatingChannelId == groupInfo.id { if let i = groupMembersIndexes[member.groupMemberId] { + let connStatusChanged = self.groupMembers[i].wrapped.activeConn?.connStatus != member.activeConn?.connStatus withAnimation(.default) { self.groupMembers[i].wrapped = member self.groupMembers[i].created = Date.now } + // Updating wrapped on a reference-type GMember doesn't mutate the groupMembers array, + // so ChatModel.objectWillChange doesn't fire automatically — notify views explicitly. + if connStatusChanged { + objectWillChange.send() + } return false } else { withAnimation { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index af9b4673c0..271e50c8d7 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -386,9 +386,10 @@ struct ComposeView: View { if gInfo.membership.memberRole == .owner { let relays = channelRelaysModel.groupId == gInfo.groupId ? channelRelaysModel.groupRelays : [] - let activeCount = relays.filter { $0.relayStatus == .rsActive }.count + let failedCount = relays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = relays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count if !relays.isEmpty && activeCount < relays.count { - ownerChannelRelayBar(relays: relays, activeCount: activeCount) + ownerChannelRelayBar(relays: relays, activeCount: activeCount, failedCount: failedCount) } } else { let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted() @@ -398,6 +399,8 @@ struct ComposeView: View { let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count + let failedCount = relayMembers.filter { $0.wrapped.activeConn?.connFailedErr != nil }.count + let errorCount = deletedCount + failedCount let resolvedCount = connectedCount + deletedCount let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count if total > 0, !showProgress || resolvedCount < total { @@ -405,7 +408,7 @@ struct ComposeView: View { hostnames: hostnames, relayMembers: relayMembers, connectedCount: connectedCount, - deletedCount: deletedCount, + errorCount: errorCount, total: total, showProgress: showProgress ) @@ -719,22 +722,35 @@ struct ComposeView: View { } } - private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int) -> some View { + private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int) -> some View { let total = relays.count let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } return VStack(spacing: 0) { relayBarHeader { - if activeCount < total { + if activeCount + failedCount < total { RelayProgressIndicator(active: activeCount, total: total) } - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + } } if relayListExpanded { ForEach(sorted) { relay in - relayBarDetailRow { - Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) - Spacer() - relayStatusIndicator(relay.relayStatus) + let failedErr = relayMemberConnFailed(relay) + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + ownerRelayDetailRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + ownerRelayDetailRow(relay, connFailed: false) } } } @@ -743,25 +759,32 @@ struct ComposeView: View { .animation(nil, value: relayListExpanded) } + private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + private func subscriberChannelRelayBar( hostnames: [String], relayMembers: [GMember], connectedCount: Int, - deletedCount: Int, + errorCount: Int, total: Int, showProgress: Bool ) -> some View { VStack(spacing: 0) { relayBarHeader { - let activeTotal = total - deletedCount - if showProgress && connectedCount < activeTotal { - RelayProgressIndicator(active: connectedCount, total: activeTotal) + if showProgress && connectedCount + errorCount < total { + RelayProgressIndicator(active: connectedCount, total: total) } if showProgress { - if deletedCount > 0 { - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d deleted", comment: "channel subscriber relay bar progress with deleted"), connectedCount, activeTotal, deletedCount)) + if errorCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar progress with errors"), connectedCount, total, errorCount)) } else { - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, activeTotal)) + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total)) } } else { Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total)) @@ -780,16 +803,19 @@ struct ComposeView: View { ForEach(relayMembers) { member in let m = member.wrapped let host = m.relayLink.map { hostFromRelayLink($0) } - relayBarDetailRow { - Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) - .foregroundColor(theme.colors.secondary) - Spacer() - let status = relayConnStatus(m) - Circle() - .fill(status.color) - .frame(width: 8, height: 8) - Text(status.text) - .foregroundColor(theme.colors.secondary) + let failedErr = m.activeConn?.connFailedErr + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + subscriberRelayDetailRow(m, host: host, connFailed: true) + } + .buttonStyle(.plain) + } else { + subscriberRelayDetailRow(m, host: host, connFailed: false) } } } @@ -799,6 +825,24 @@ struct ComposeView: View { .animation(nil, value: relayListExpanded) } + private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) + .foregroundColor(theme.colors.secondary) + Spacer() + let status = relayConnStatus(m) + Circle() + .fill(status.color) + .frame(width: 8, height: 8) + Text(status.text) + .foregroundColor(theme.colors.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + } + } + } + private func relayBarHeader(@ViewBuilder content: () -> Content) -> some View { Button { withAnimation(nil) { relayListExpanded.toggle() } @@ -830,6 +874,11 @@ struct ComposeView: View { .padding(.vertical, 2) } + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { + chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View { Button(action: connect) { ZStack(alignment: .trailing) { diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index 2ed55d1f28..1a4e384e24 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -17,21 +17,20 @@ struct ChannelRelaysView: View { @State private var groupRelays: [GroupRelay] = [] var body: some View { - let isOwner = groupInfo.isOwner List { - relaysList(showRelayStatus: isOwner) + relaysList() } .onAppear { Task { await chatModel.loadGroupMembers(groupInfo) - if isOwner { + if groupInfo.isOwner { groupRelays = await apiGetGroupRelays(groupInfo.groupId) } } } } - @ViewBuilder private func relaysList(showRelayStatus: Bool) -> some View { + @ViewBuilder private func relaysList() -> some View { let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay } if relayMembers.isEmpty { Section { @@ -51,7 +50,10 @@ struct ChannelRelaysView: View { ) .navigationBarHidden(false) } label: { - relayMemberRow(member.wrapped, relayStatus: showRelayStatus ? relayStatusForMember(member.wrapped) : nil) + let statusText = groupInfo.isOwner + ? ownerRelayStatusText(member.wrapped) + : subscriberRelayStatusText(member.wrapped) + relayMemberRow(member.wrapped, statusText: statusText) } } } footer: { @@ -60,28 +62,7 @@ struct ChannelRelaysView: View { } } - private func relayStatusForMember(_ member: GroupMember) -> RelayStatus? { - groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus - } - - private func relayMemberRow(_ member: GroupMember, relayStatus: RelayStatus?) -> some View { - HStack { - MemberProfileImage(member, size: 38) - .padding(.trailing, 2) - VStack(alignment: .leading) { - Text(member.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) - Text(relayStatus?.text ?? relayConnStatusText(member)) - .lineLimit(1) - .font(.caption) - .foregroundColor(theme.colors.secondary) - } - Spacer() - } - } - - private func relayConnStatusText(_ member: GroupMember) -> LocalizedStringKey { + private func subscriberRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { if member.activeConn?.connDisabled ?? false { "disabled" } else if member.activeConn?.connInactive ?? false { @@ -90,13 +71,43 @@ struct ChannelRelaysView: View { relayConnStatus(member).text } } -} + private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if case .failed = member.activeConn?.connStatus { + "failed" + } else if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text + ?? relayConnStatus(member).text + } + } + + private func relayMemberRow(_ member: GroupMember, statusText: LocalizedStringKey) -> some View { + HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + Text(member.chatViewName) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + Text(statusText) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + } + } +} func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) { switch member.activeConn?.connStatus { case .ready: ("connected", .green) case .deleted: ("deleted", .red) + case .failed: ("failed", .red) default: ("connecting", .yellow) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ce9779b578..442f547b9c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -236,7 +236,14 @@ struct GroupMemberInfoView: View { if let connFailedErr = member.activeConn?.connFailedErr { Section { - infoRow("Connection failed", connFailedErr) + Text(connFailedErr) + .foregroundColor(theme.colors.secondary) + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Connection failed") + } } } diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 15be6aa969..4402d0a5ed 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -181,6 +181,7 @@ struct AddChannelView: View { } await MainActor.run { m.updateGroup(gInfo) + m.creatingChannelId = gInfo.id groupInfo = gInfo groupLink = gLink groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } @@ -218,7 +219,8 @@ struct AddChannelView: View { // MARK: - Step 2: Progress private func progressStepView(_ gInfo: GroupInfo) -> some View { - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive }.count + let failedCount = groupRelays.filter { relayConnFailed($0) != nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayConnFailed($0) == nil }.count let total = groupRelays.count return List { Group { @@ -238,10 +240,14 @@ struct AddChannelView: View { withAnimation { relayListExpanded.toggle() } } label: { HStack(spacing: 8) { - if activeCount < total { + if activeCount + failedCount < total { RelayProgressIndicator(active: activeCount, total: total) } - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + } Spacer() Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down") .foregroundColor(theme.colors.secondary) @@ -251,10 +257,19 @@ struct AddChannelView: View { if relayListExpanded { ForEach(groupRelays) { relay in - HStack { - Text(relayDisplayName(relay)) - Spacer() - relayStatusIndicator(relay.relayStatus) + let failed = relayConnFailed(relay) + if let err = failed { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + relayRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + relayRow(relay, connFailed: false) } } } @@ -266,13 +281,21 @@ struct AddChannelView: View { if activeCount >= total { showLinkStep = true } else if activeCount > 0 { + let actions: [UIAlertAction] = if activeCount + failedCount < total { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } + ] + } else { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + cancelAlertAction + ] + } showAlert( NSLocalizedString("Not all relays connected", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total), - actions: {[ - UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, - UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } - ]} + actions: { actions } ) } } @@ -289,13 +312,26 @@ struct AddChannelView: View { .onChange(of: channelRelaysModel.groupRelays) { relays in guard channelRelaysModel.groupId == gInfo.groupId else { return } groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } - if relays.allSatisfy({ $0.relayStatus == .rsActive }) { + if relays.allSatisfy({ $0.relayStatus == .rsActive && relayConnFailed($0) == nil }) { showLinkStep = true channelRelaysModel.reset() } } } + private func relayConnFailed(_ relay: GroupRelay) -> String? { + m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + + private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + HStack { + Text(relayDisplayName(relay)) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + // MARK: - Step 3: Link private func linkStepView(_ gInfo: GroupInfo) -> some View { @@ -307,6 +343,7 @@ struct AddChannelView: View { creatingGroup: true, isChannel: true ) { + m.creatingChannelId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { dismissAllSheets(animated: true) { ItemsModel.shared.loadOpenChat(gInfo.id) @@ -317,6 +354,7 @@ struct AddChannelView: View { } private func cancelChannelCreation(_ gInfo: GroupInfo) { + m.creatingChannelId = nil channelRelaysModel.reset() dismissAllSheets(animated: true) Task { @@ -358,14 +396,21 @@ func relayDisplayName(_ relay: GroupRelay) -> String { return "relay \(relay.groupRelayId)" } -func relayStatusIndicator(_ status: RelayStatus) -> some View { - HStack(spacing: 4) { +func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View { + let color: Color = connFailed ? .red : (status == .rsActive ? .green : .orange) + let text: LocalizedStringKey = connFailed ? "failed" : status.text + return HStack(spacing: 4) { Circle() - .fill(status == .rsActive ? .green : status == .rsNew ? .red : .orange) + .fill(color) .frame(width: 8, height: 8) - Text(status.text) + Text(text) .font(.caption) .foregroundStyle(.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + .font(.caption) + } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b9cd242ef0..86c7694902 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2571,10 +2571,10 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { extension RelayStatus { public var text: LocalizedStringKey { switch self { - case .rsNew: "New" - case .rsInvited: "Invited" - case .rsAccepted: "Accepted" - case .rsActive: "Active" + case .rsNew: "new" + case .rsInvited: "invited" + case .rsAccepted: "accepted" + case .rsActive: "active" } } } diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 451ef5d825..d13003dce8 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -728,7 +728,7 @@ updatePreparedUserAndHostMembers' db vr user - gInfo@GroupInfo {groupId, groupProfile = gp, businessChat} + gInfo@GroupInfo {groupId, membership, groupProfile = gp, businessChat} hostMember fromMember fromMemberProfile @@ -737,7 +737,9 @@ updatePreparedUserAndHostMembers' business membershipStatus = do currentTs <- liftIO getCurrentTime - liftIO $ updateUserMember currentTs + -- For channels, don't regress membership status if already connected via another relay + unless (memberStatus membership == GSMemConnected) $ + liftIO $ updateUserMember currentTs hostMember' <- updateHostMember currentTs when (gp /= groupProfile) $ void $ updateGroupProfile db user gInfo groupProfile @@ -747,8 +749,7 @@ updatePreparedUserAndHostMembers' pure (gInfo', hostMember') where updateUserMember currentTs = do - let GroupInfo {membership} = gInfo - MemberIdRole memberId memberRole = invitedMember + let MemberIdRole memberId memberRole = invitedMember DB.execute db [sql| From ac62ba489277e412e3fd4ce92b95066c8def3b72 Mon Sep 17 00:00:00 2001 From: sh <37271604+shumvgolove@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:22:39 +0000 Subject: [PATCH 026/112] website: file transfer page (#6644) * add implementation plan * website: remove unnecessary libsodium direct dependency from file page plan * website: update file page plan for async encryption, tailwind, no worker * add product plan * update product plan based on the feedback * remove implementation details from product plan * update product plan * add updated implementation plan * website: add build infrastructure for /file route * website: fix card click and overlay hash handling for /file page * website: add /file page with XFTP file transfer and protocol overlay * website: redesign /file page layout and styling * fix(website): scope hero h1/h2 font overrides to .hero-section-1 * fix(website): fix /file overlay diagram scaling on short viewports * style(website): match /file page top padding with /directory * website: remove file page in navbar * website: switch xftp-web to official one * website: fix web.sh * update texts --------- Co-authored-by: Evgeny Poberezkin --- .gitignore | 1 + plans/website-file-page-implementation.md | 472 ++++++++++++++++++ plans/website-file-page-product.md | 309 ++++++++++++ website/.eleventy.js | 5 +- website/langs/en.json | 45 +- website/package.json | 1 + website/src/_data/file_overlays.json | 16 + website/src/_includes/navbar.html | 10 +- .../overlay_content/file/protocol.html | 17 + website/src/css/style.css | 4 +- website/src/file.html | 316 ++++++++++++ website/src/img/new/xftp-protocol-dark.svg | 115 +++++ website/src/img/new/xftp-protocol.svg | 130 +++++ website/src/js/script.js | 4 +- website/tailwind.config.js | 2 +- website/web.sh | 7 + 16 files changed, 1445 insertions(+), 9 deletions(-) create mode 100644 plans/website-file-page-implementation.md create mode 100644 plans/website-file-page-product.md create mode 100644 website/src/_data/file_overlays.json create mode 100644 website/src/_includes/overlay_content/file/protocol.html create mode 100644 website/src/file.html create mode 100644 website/src/img/new/xftp-protocol-dark.svg create mode 100644 website/src/img/new/xftp-protocol.svg diff --git a/.gitignore b/.gitignore index 929bda7250..2f4af38cca 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ website/src/img/images/ website/src/images/ website/src/js/lottie.min.js website/src/js/ethers* +website/src/file-assets/ website/src/privacy.md # Generated files website/package/generated* diff --git a/plans/website-file-page-implementation.md b/plans/website-file-page-implementation.md new file mode 100644 index 0000000000..bca77e77a9 --- /dev/null +++ b/plans/website-file-page-implementation.md @@ -0,0 +1,472 @@ +# File Transfer Page — Implementation Plan + +## Table of Contents +1. [Context](#1-context) +2. [Executive Summary](#2-executive-summary) +3. [High-Level Design](#3-high-level-design) +4. [Detailed Implementation Plan](#4-detailed-implementation-plan) +5. [Known Divergences from Product Plan](#5-known-divergences-from-product-plan) +6. [Verification](#6-verification) + +--- + +## 1. Context + +**Problem**: The website needs a `/file` page that lets users upload/download files via XFTP servers directly in the browser — a live demo that funnels users toward downloading the SimpleX app. + +**Product plan**: `plans/website-file-page-product.md` + +**Approach**: Use the pre-built `dist-web/` bundle from `@shhhum/xftp-web@0.8.0`. Copy three files (`index.js` + `index.css` + `crypto.worker.js`) to website static assets. Wrap with an 11ty page providing the protocol overlay, app download CTA, and i18n bridge. **No Vite/TS build step.** The bundle handles all XFTP protocol, crypto, Web Worker, upload/download UI. + +**Library features used** (v0.8.0): +- `data-xftp-app` — configurable target element +- `data-no-hashchange` — prevents conflict with overlay system +- `window.__XFTP_I18N__` — string externalization for i18n +- `xftp:upload-complete` / `xftp:download-complete` — CustomEvents for CTA injection +- Scoped CSS (`#app` / `.dark #app`) — no global resets +- Relative worker URL — both files co-located in same directory + +**Routing**: `/file/` (no hash) = upload mode; `/file/#` = download mode. + +--- + +## 2. Executive Summary + +| Action | Files | +|--------|-------| +| **Create** | `website/src/file.html`, `website/src/_data/file_overlays.json`, `website/src/_includes/overlay_content/file/protocol.html` | +| **Copy from npm** | `dist-web/assets/index.js` + `dist-web/assets/index.css` + `dist-web/assets/crypto.worker.js` → `src/file-assets/` | +| **Modify** | `website/package.json`, `website/.eleventy.js`, `website/src/_includes/navbar.html`, `website/langs/en.json` (~30 keys), `website/web.sh`, `website/src/js/script.js`, `.gitignore` | + +--- + +## 3. High-Level Design + +### Architecture + +``` +website/src/ +├── file.html # 11ty page +├── _data/file_overlays.json # overlay config (showImage: false for v1) +├── _includes/overlay_content/file/ +│ └── protocol.html # protocol popup content +└── file-assets/ # COPIED from npm dist-web/assets/ (gitignored) + ├── index.js # main bundle (~1.1 MB) + ├── index.css # scoped CSS (~2.3 KB) + └── crypto.worker.js # worker (~1.0 MB) +``` + +### Data flow + +**Upload**: `#app` div → bundle renders drop zone → file input → Worker encrypts (OPFS) → `uploadFile()` → share link → `xftp:upload-complete` event → website shows inline CTA + +**Download**: hash parsed by bundle on init → `decodeDescriptionURI()` → download button → Worker decrypts → browser save → `xftp:download-complete` event → website shows inline CTA + +### Overlay conflict resolution + +Bundle's `hashchange` listener is disabled via `data-no-hashchange` attribute. Protocol overlay opens via **direct DOM manipulation** (inline JS `classList.remove('hidden')`) — not hash-based. script.js's global `.close-overlay-btn` handler still closes it. No hash events fired when opening. + +Note: `closeOverlay()` in script.js calls `history.replaceState(null, null, ' ')` which clears the URL hash. In download mode (`/file/#simplex:...`), this means the hash disappears from the URL bar after closing the overlay. This is cosmetic only — the bundle parses the hash once on init and doesn't re-read it. Download continues unaffected. + +A null guard is added to `openOverlay()` in script.js (Step 9) to prevent crashes when the hash is an XFTP URI fragment rather than a DOM element ID. + +### i18n bridge + +The 11ty template renders `window.__XFTP_I18N__` from en.json keys. The bundle reads via `t(key, fallback)`. All JS-rendered strings are overridable. The bundle renders strings via template literals into innerHTML, so HTML in i18n values (e.g. links in `maxSizeHint`) is rendered correctly. + +--- + +## 4. Detailed Implementation Plan + +### Step 1: Add npm dependency + +**Modify**: `website/package.json` + +```diff + "dependencies": { ++ "@shhhum/xftp-web": "^0.8.0", + } +``` + +### Step 2: Copy dist-web files in web.sh + +**Modify**: `website/web.sh` + +After the existing `cp node_modules/...` lines (after line 30): + +```bash +mkdir -p src/file-assets +cp node_modules/@shhhum/xftp-web/dist-web/assets/index.js src/file-assets/ +cp node_modules/@shhhum/xftp-web/dist-web/assets/index.css src/file-assets/ +cp node_modules/@shhhum/xftp-web/dist-web/assets/crypto.worker.js src/file-assets/ +``` + +Add `file.html` to language copy loop (after line 42, `cp src/fdroid.html src/$lang`): +```bash + cp src/file.html src/$lang +``` + +### Step 3: Create 11ty page — `website/src/file.html` + +``` +--- +layout: layouts/main.html +title: "SimpleX File Transfer" +description: "Send files securely with end-to-end encryption" +templateEngineOverride: njk +active_file: true +--- +{% set lang = page.url | getlang %} +{% from "components/macro.njk" import overlay %} +``` + +**Structure** (top to bottom): + +1. **Noscript fallback**: + ```html + + ``` + +2. **Page section** with centered container: + - `

    ` with i18n title + - `
    ` — bundle renders here, hashchange disabled + - Static "E2E encrypted" note below `#app`: + ```html +

    + {{ "file-e2e-note" | i18n({}, lang) | safe }} +

    + ``` + - "Learn more" link (opens overlay via inline JS, not hash): + ```html +

    + + {{ "file-learn-more" | i18n({}, lang) | safe }} + +

    + ``` + +3. **Inline CTA container** (hidden, shown by JS after upload/download): + ```html + + ``` + +4. **Protocol overlay** via existing macro: + ```html + {% for section in file_overlays.sections %} + {{ overlay(section, lang) }} + {% endfor %} + ``` + +5. **Bottom CTA section** (same pattern as `join_simplex.html`): + - Heading: "Get SimpleX — the most private messenger" + - Subheading about the app using the same protocol + - 5 buttons: Apple Store, Google Play, F-Droid, TestFlight, APK (same markup as inline CTA) + +6. **i18n bridge script** (BEFORE bundle load, so `window.__XFTP_I18N__` is set when bundle initializes): + ```html + + ``` + +7. **Overlay open + CTA injection script**: + ```html + + ``` + +8. **Bundle + CSS** (bundle AFTER i18n bridge): + ```html + + + ``` + +### Step 4: Create protocol overlay data + content + +**New file**: `website/src/_data/file_overlays.json` +```json +{ + "sections": [{ + "id": 1, + "imgLight": "", + "imgDark": "", + "overlayContent": { + "overlayId": "xftp-protocol", + "overlayScrollTo": "", + "title": "file-protocol-title", + "showImage": false, + "contentBody": "overlay_content/file/protocol.html" + } + }] +} +``` + +Note: `showImage: false` — protocol diagram SVGs are deferred to a future iteration. The overlay works without images (same as existing overlays when `showImage` is false — the content section spans full width). + +**New file**: `website/src/_includes/overlay_content/file/protocol.html` + +5 blocks with heading + paragraph structure (existing hero overlay cards use plain `

    ` tags; this overlay uses `

    ` + `

    ` inside `

    ` wrappers since it has titled sections): + +```html +
    +

    {{ "file-proto-h-1" | i18n({}, lang) | safe }}

    +

    {{ "file-proto-p-1" | i18n({}, lang) | safe }}

    +
    +
    +

    {{ "file-proto-h-2" | i18n({}, lang) | safe }}

    +

    {{ "file-proto-p-2" | i18n({}, lang) | safe }}

    +
    +
    +

    {{ "file-proto-h-3" | i18n({}, lang) | safe }}

    +

    {{ "file-proto-p-3" | i18n({}, lang) | safe }}

    +
    +
    +

    {{ "file-proto-h-4" | i18n({}, lang) | safe }}

    +

    {{ "file-proto-p-4" | i18n({}, lang) | safe }}

    +
    +
    +

    {{ "file-proto-h-5" | i18n({}, lang) | safe }}

    +

    {{ "file-proto-p-5" | i18n({}, lang) | safe }}

    +
    +

    + + {{ "file-proto-spec" | i18n({}, lang) | safe }} + +

    +``` + +### Step 5: Add navbar link + +**Modify**: `website/src/_includes/navbar.html` + +After Directory `
  • ` block (after line 27, before the `
    ` at line 29): +```html +
    +
  • +``` + +Add `and ('file' not in page.url)` to language-selector exclusion condition (line 137): +``` +{% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('vouchers' not in page.url) and ('file' not in page.url) %} +``` + +### Step 6: Add translation keys + +**Modify**: `website/langs/en.json` — add these keys: + +``` +Navbar: + "file": "File" + +Noscript + static page content: + "file-noscript": "JavaScript is required for file transfer." + "file-e2e-note": "End-to-end encrypted — the server never sees your file." + "file-learn-more": "Learn more about XFTP protocol" + "file-cta-heading": "Get SimpleX — the most private messenger" + "file-cta-subheading": "The file transfer you just used is built on the same protocol as SimpleX Chat — end-to-end encrypted messaging, voice and video calls, groups, and file sharing. No user IDs. No phone numbers." + +i18n bridge (fed to bundle via window.__XFTP_I18N__): + "file-title": "SimpleX File Transfer" + "file-drop-text": "Drag & drop a file here" + "file-drop-hint": "or" + "file-choose": "Choose file" + "file-max-size": "Max 100 MB — the SimpleX app supports up to 1 GB" + "file-encrypting": "Encrypting\u2026" + "file-uploading": "Uploading\u2026" + "file-cancel": "Cancel" + "file-uploaded": "File uploaded" + "file-copy": "Copy" + "file-copied": "Copied!" + "file-share": "Share" + "file-expiry": "Files are typically available for 48 hours." + "file-sec-1": "Your file was encrypted in the browser before upload — the server never sees file contents." + "file-sec-2": "The link contains the decryption key in the hash fragment, which the browser never sends to any server." + "file-sec-3": "For maximum security, use the SimpleX app." + "file-retry": "Retry" + "file-downloading": "Downloading\u2026" + "file-decrypting": "Decrypting\u2026" + "file-download-complete": "Download complete" + "file-download-btn": "Download" + "file-too-large": "File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB." + "file-empty": "File is empty." + "file-invalid-link": "Invalid or corrupted link." + "file-init-error": "Failed to initialize: %error%" + "file-available": "File available (~%size%)" + "file-dl-sec-1": "This file is encrypted \u2014 the server never sees file contents." + "file-dl-sec-2": "The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server." + "file-dl-sec-3": "For maximum security, use the SimpleX app." + "file-workers-required": "Web Workers required \u2014 update your browser" + +Protocol overlay content: + "file-protocol-title": "Why XFTP is the most private file transfer" + "file-proto-h-1": "No accounts, no identifiers" + "file-proto-p-1": "Each file chunk uses a fresh, random credential that is used once and discarded. The server has no concept of \"users\" — it only sees isolated, anonymous chunk operations." + "file-proto-h-2": "Encrypted in your browser" + "file-proto-p-2": "The entire file is encrypted with a random key before upload. The server stores ciphertext it cannot decrypt. The key travels only in the URL fragment, which browsers never send to any server." + "file-proto-h-3": "Triple encryption" + "file-proto-p-3": "Every transfer has three layers: TLS transport encryption, per-recipient transit encryption (unique ephemeral key exchange per download), and file-level end-to-end encryption." + "file-proto-h-4": "Distributed across independent servers" + "file-proto-p-4": "File chunks are split across servers operated by independent parties. No single operator sees all chunks. Even if one operator is compromised, they only see encrypted fragments." + "file-proto-h-5": "Files expire automatically" + "file-proto-p-5": "Files are deleted after approximately 48 hours. There is no persistent storage, no file management, no way to extend expiration. Ephemeral by design." + "file-proto-spec": "Read the XFTP protocol specification →" +``` + +### Step 7: Update .eleventy.js + +**Modify**: `website/.eleventy.js` + +1. Add `"file"` to `supportedRoutes` array (line 56): +```js +const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "file", ""] +``` + +2. Add passthrough copy (after line 306, with the other `addPassthroughCopy` calls): +```js +ty.addPassthroughCopy("src/file-assets") +``` + +### Step 8: Gitignore + +**Modify**: `.gitignore` (project root) — add: +``` +website/src/file-assets/ +``` + +### Step 9: Fix script.js null guard + +**Modify**: `website/src/js/script.js` + +The `openOverlay()` function (line 180) crashes when the URL hash is an XFTP URI fragment (e.g. `#simplex:...`) because `document.getElementById('simplex:...')` returns null, and `el.classList.contains('overlay')` throws a TypeError on null. + +**Change** (line 184-185): +```js +// Before: +const el = document.getElementById(id) +if (el.classList.contains('overlay')) { + +// After: +const el = document.getElementById(id) +if (el && el.classList.contains('overlay')) { +``` + +This is a one-character change (`if (el.classList` → `if (el && el.classList`). It makes `openOverlay()` safely ignore hash fragments that don't correspond to overlay elements — which is correct behavior regardless of the file page (any non-overlay hash should be silently ignored). + +--- + +## 5. Known Divergences from Product Plan + +These are intentional deviations from the product plan, caused by browser constraints or library limitations: + +1. **Download requires a click**: Product plan says "No intermediate 'click to download' step." The bundle shows a "Download" button instead of auto-starting. This is a browser security constraint — triggering a file download requires a user gesture. The button also lets the user see file metadata before downloading. + +2. **No cancel during download**: Product plan specifies a Cancel button during download. The bundle does not implement this. The download is relatively fast (direct HTTPS) and cancellation can be done by closing the tab. + +3. **Protocol diagram deferred**: Product plan describes a protocol flow diagram in the overlay. SVG diagrams are deferred to a future iteration. The overlay ships with text-only content (`showImage: false`). + +4. **Overlay close clears download hash**: When the protocol overlay is opened and closed during download mode, `closeOverlay()` clears the URL hash. This is cosmetic — the bundle already parsed the hash on init and the download is unaffected. The URL bar loses the fragment, but the user received the link from elsewhere and doesn't need to re-copy it. + +--- + +## 6. Verification + +### Build +```bash +cd website +npm install --ignore-scripts +mkdir -p src/file-assets +cp node_modules/@shhhum/xftp-web/dist-web/assets/{index.js,index.css,crypto.worker.js} src/file-assets/ +npm run build +ls _site/file/index.html _site/file-assets/index.js _site/file-assets/index.css _site/file-assets/crypto.worker.js +``` + +### Manual test checklist +``` +Visit /file/ + 1. Navbar "File" link is active + 2.