Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin
2026-08-12 09:48:04 +01:00
45 changed files with 1123 additions and 443 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
<img src="images/github-banner.jpg" alt="SimpleX logo" width="100%">
Invest in SimpleX Chat. [Register now](https://simplexchat.typeform.com/crowdfunding).
Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplexchat).
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
@@ -42,10 +42,9 @@ actual fun SavePassphraseSetting(
}
Text(
stringResource(MR.strings.save_passphrase_in_keychain),
Modifier.padding(end = 24.dp),
Modifier.weight(1f).padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
@@ -497,8 +497,8 @@ fun ComposeView(
}
}
fun clearCurrentDraft() {
if (chatModel.draftChatId.value == draftChatId(chat.id, chatScope)) {
fun clearCurrentDraft(forChat: Chat = chat) {
if (chatModel.draftChatId.value == draftChatId(forChat.id, chatScope)) {
chatModel.draft.value = null
chatModel.draftChatId.value = null
}
@@ -575,9 +575,10 @@ fun ComposeView(
}
// TODO [short links] connectCheckLinkPreview
fun checkLinkPreview(): MsgContent {
val msgText = composeState.value.message.text
return when (val composePreview = composeState.value.preview) {
// the state is passed in by a send that must not read the current one - see sendMessageAsync
fun checkLinkPreview(cs: ComposeState = composeState.value): MsgContent {
val msgText = cs.message.text
return when (val composePreview = cs.preview) {
is ComposePreview.CLinkPreview -> {
val parsedMsg = parseToMarkdown(msgText)
val url = getMessageLinks(parsedMsg).first
@@ -597,6 +598,10 @@ fun ComposeView(
composeState.value = composeState.value.copy(inProgress = true)
}
// composeState and its inProgress flag are shared between the chats opened in this view, and sending is not cancelled
// when the chat is switched - a send may only clear or reset the state while it still holds the message that was sent
fun composeHasSentMessage(): Boolean = chatModel.chatId.value == chat.id && composeState.value.inProgress
suspend fun sendMemberContactInvitation() {
val mc = checkLinkPreview()
sending()
@@ -604,10 +609,10 @@ fun ComposeView(
if (contact != null) {
withContext(Dispatchers.Main) {
chatsCtx.updateContact(chat.remoteHostId, contact)
clearState()
if (composeHasSentMessage()) clearState()
}
} else {
composeState.value = composeState.value.copy(inProgress = false)
} else withContext(Dispatchers.Main) {
if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false)
}
}
@@ -624,10 +629,10 @@ fun ComposeView(
if (contact != null) {
withContext(Dispatchers.Main) {
chatsCtx.updateContact(chat.remoteHostId, contact)
clearState()
if (composeHasSentMessage()) clearState()
}
} else {
composeState.value = composeState.value.copy(inProgress = false)
} else withContext(Dispatchers.Main) {
if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false)
}
}
@@ -669,15 +674,19 @@ fun ComposeView(
chatModel.channelRelayHostnames.remove(groupInfo.groupId)
chatModel.groupMembers.value = relayResults.map { it.relayMember }
chatModel.populateGroupMembersIndexes()
clearState()
if (composeHasSentMessage()) clearState()
}
} else {
composeState.value = composeState.value.copy(inProgress = false)
} else withContext(Dispatchers.Main) {
if (composeHasSentMessage()) composeState.value = composeState.value.copy(inProgress = false)
}
}
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false): List<ChatItem>? {
val cs = composeState.value
// toChat is the chat the message was composed in - it differs from the one this view shows only for the live message
// committed by a chat switch, which has no context item, so the forwarding, editing and reporting branches below
// cannot run with a different chat. cs is that send's state, captured before the switch replaced it.
suspend fun sendMessageAsync(text: String?, live: Boolean, ttl: Int?, sign: Boolean = false, toChat: Chat = chat, cs: ComposeState = composeState.value): List<ChatItem>? {
// a send for another chat may not write to composeState, even after that chat is opened again - it was handed over
fun composeIsForSend(): Boolean = toChat.id == chat.id
var sent: List<ChatItem>?
var lastMessageFailedToSend: ComposeState? = null
val msgText = text ?: cs.message.text
@@ -727,8 +736,8 @@ fun ComposeView(
fun updateMsgContent(msgContent: MsgContent): MsgContent {
return when (msgContent) {
is MsgContent.MCText -> checkLinkPreview()
is MsgContent.MCLink -> checkLinkPreview()
is MsgContent.MCText -> checkLinkPreview(cs)
is MsgContent.MCLink -> checkLinkPreview(cs)
is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image)
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
@@ -785,12 +794,12 @@ fun ComposeView(
}
val liveMessage = cs.liveMessage
if (!live) {
if (!live && composeIsForSend()) {
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null)
sending()
}
if (!cs.forwarding || chatModel.draft.value?.forwarding == true) {
clearCurrentDraft()
clearCurrentDraft(toChat)
}
if (cs.contextItem is ComposeContextItem.ForwardingItems) {
@@ -801,6 +810,8 @@ fun ComposeView(
if (cs.message.text.isNotEmpty()) {
sent?.mapIndexed { index, message ->
if (index == sent!!.lastIndex) {
// the current state, not cs: forwarding is never reached from the chat switch, and keeps what was typed
// while it was in flight
send(chat, checkLinkPreview(), quoted = message.id, live = false, ttl = ttl, mentions = cs.memberMentions, sign = sign)
} else {
message
@@ -814,7 +825,7 @@ fun ComposeView(
sent = if (updatedMessage != null) listOf(updatedMessage) else null
lastMessageFailedToSend = if (updatedMessage == null) constructFailedMessage(cs) else null
} else if (liveMessage != null && liveMessage.sent) {
val updatedMessage = updateMessage(liveMessage.chatItem, chat, live)
val updatedMessage = updateMessage(liveMessage.chatItem, toChat, live)
sent = if (updatedMessage != null) listOf(updatedMessage) else null
} else if (cs.contextItem is ComposeContextItem.ReportedItem) {
sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id)
@@ -824,7 +835,7 @@ fun ComposeView(
val remoteHost = chatModel.currentRemoteHost.value
when (val preview = cs.preview) {
ComposePreview.NoPreview -> msgs.add(MsgContent.MCText(msgText))
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview())
is ComposePreview.CLinkPreview -> msgs.add(checkLinkPreview(cs))
is ComposePreview.ChatLinkPreview -> {
val linkStr = preview.chatLink.connLinkStr
val text = if (msgText.isEmpty()) linkStr else "$msgText\n$linkStr"
@@ -915,7 +926,7 @@ fun ComposeView(
localPath = file.filePath
)
}
val sendResult = send(chat, content, if (index == 0) quotedItemId else null, file,
val sendResult = send(toChat, content, if (index == 0) quotedItemId else null, file,
live = if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false,
ttl = ttl,
mentions = cs.memberMentions,
@@ -932,23 +943,47 @@ fun ComposeView(
val wasForwarding = cs.forwarding
val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItems)?.fromChatInfo?.id
val lastFailed = lastMessageFailedToSend
if (lastFailed == null) {
clearState(live)
} else {
composeState.value = lastFailed
}
val draft = chatModel.draft.value
if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) {
composeState.value = draft
} else {
clearCurrentDraft()
// composeState is shared between the chats opened in this view, and this runs after the send API call, so the user
// could have switched chats or typed another message in the meantime - only the message that was sent may be
// cleared or restored. On Main, so that these checks and changes are not interleaved with the user switching
// chats or typing.
withContext(Dispatchers.Main) {
val chatIsOpen = composeIsForSend() && chatModel.chatId.value == chat.id
// a live message is held in the compose state of the chat it is sent to, but only while that chat is the one open
val liveSend = live || cs.liveMessage != null
val sentMessageInCompose = chatIsOpen && (liveSend || composeState.value.inProgress)
if (sentMessageInCompose) {
if (lastFailed == null) {
clearState(live)
} else {
composeState.value = lastFailed
}
}
val draft = chatModel.draft.value
if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) {
if (sentMessageInCompose) composeState.value = draft
} else {
clearCurrentDraft(toChat)
// liveSend excluded: a failing keystroke send would otherwise write a draft on every attempt
if (!sentMessageInCompose && !liveSend && lastFailed != null) {
// the message was not sent, so it is restored in the chat it was composed in, or kept as its draft if another chat is open
if (chatIsOpen && composeState.value.empty) {
composeState.value = lastFailed
} else if (saveLastDraft) {
chatModel.draft.value = lastFailed
chatModel.draftChatId.value = draftChatId(chat.id, chatScope)
}
}
}
}
return sent
}
fun sendMessage(ttl: Int?, sign: Boolean = false) {
// toChat and composed are for the chat switch, which hands the compose state over to the chat it opened; passing
// toChat without doing that leaves the sent message in the input
fun sendMessage(ttl: Int?, sign: Boolean = false, toChat: Chat = chat, composed: ComposeState? = null) {
withLongRunningApi(slow = 120_000) {
sendMessageAsync(null, false, ttl, sign)
sendMessageAsync(null, false, ttl, sign, toChat, composed ?: composeState.value)
}
}
@@ -1313,13 +1348,29 @@ fun ComposeView(
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
// the chat is already switched, so the live message goes to the chat with the id it had before the switch
val liveMessageChat = if (prevChatId == null || prevChatId == chat.id) chat else chatsCtx.getChat(prevChatId)
// if that chat is gone there is nowhere to send it, and it must not be sent to the chat opened instead
// cs is captured on this thread, before the compose state is replaced below
if (liveMessageChat != null) sendMessage(null, toChat = liveMessageChat, composed = cs) else clearState()
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
// the sent message belongs to the chat it was composed in; the chat opened next shows its own draft
val draft = chatModel.draft.value
composeState.value = if (draft != null && chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)) draft
else ComposeState(useLinkPreviews = useLinkPreviews)
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
composeState.value = cs.copy(inProgress = false, progressByTimeout = false)
// the message being sent must not be kept in the compose state, it is shared with the chat opened next;
// if it fails to send it is restored in this chat or saved as its draft
clearState()
// clearState() does not load the draft of the chat opened next, and without this it is never shown and is
// dropped when that chat is left
val draft = chatModel.draft.value
if (draft != null && chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)) {
composeState.value = draft
}
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
recState.value = RecordingState.NotStarted
@@ -42,10 +42,9 @@ actual fun SavePassphraseSetting(
}
Text(
stringResource(MR.strings.save_passphrase_in_settings),
Modifier.padding(end = 24.dp),
Modifier.weight(1f).padding(end = 24.dp),
color = Color.Unspecified
)
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(
checked = useKeychain,
onCheckedChange = onCheckedChange,
+4 -16
View File
@@ -5,23 +5,11 @@ module Main where
import Directory.Options
import Directory.Service
import Directory.Store
import Directory.Store.Migrate
import Simplex.Chat.Terminal (terminalChatConfig)
main :: IO ()
main = do
opts@DirectoryOpts {directoryLog, migrateDirectoryLog, runCLI} <- welcomeGetOpts
case migrateDirectoryLog of
Just cmd -> migrate cmd opts terminalChatConfig
Nothing -> do
st <- openDirectoryLog directoryLog
if runCLI
then directoryServiceCLI st opts
else directoryService st opts terminalChatConfig
where
migrate = \case
MLCheck -> checkDirectoryLog
MLImport -> importDirectoryLogToDB
MLExport -> exportDBToDirectoryLog
MLListing -> saveGroupListingFiles
opts@DirectoryOpts {runCLI} <- welcomeGetOpts
if runCLI
then directoryServiceCLI opts
else directoryService opts terminalChatConfig
@@ -14,14 +14,11 @@ module Directory.Options
)
where
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Options.Applicative
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (updateStr, versionNumber, versionString)
import Simplex.Chat.Options (ChatCmdLog (..), ChatOpts (..), CoreChatOpts, CreateBotOpts (..), coreChatOptsP)
import Simplex.Messaging.Parsers (parseAll)
data DirectoryOpts = DirectoryOpts
{ coreOptions :: CoreChatOpts,
@@ -36,8 +33,6 @@ data DirectoryOpts = DirectoryOpts
profileNameLimit :: Int,
captchaGenerator :: Maybe FilePath,
voiceCaptchaGenerator :: Maybe FilePath,
directoryLog :: Maybe FilePath,
migrateDirectoryLog :: Maybe MigrateLog,
serviceName :: T.Text,
clientService :: Bool,
runCLI :: Bool,
@@ -133,21 +128,6 @@ directoryOpts appDir defaultDbName = do
<> metavar "VOICE_CAPTCHA_GENERATOR"
<> help "Executable to generate voice captcha, accepts text as parameter, writes audio file, outputs file_path and duration_seconds to stdout"
)
directoryLog <-
optional $
strOption
( long "directory-file"
<> metavar "DIRECTORY_FILE"
<> help "Append only log for directory state"
)
migrateDirectoryLog <-
optional $
option
parseMigrateLog
( long "migrate-directory-file"
<> metavar "MIGRATE_COMMAND"
<> help "Command to import/export directory log file"
)
serviceName <-
strOption
( long "service-name"
@@ -209,8 +189,6 @@ directoryOpts appDir defaultDbName = do
profileNameLimit,
captchaGenerator,
voiceCaptchaGenerator,
directoryLog,
migrateDirectoryLog,
serviceName = T.pack serviceName,
clientService,
runCLI,
@@ -254,14 +232,3 @@ mkChatOpts DirectoryOpts {coreOptions, serviceName, clientService} =
userDisplayName = Nothing,
userImageFile = Nothing
}
parseMigrateLog :: ReadM MigrateLog
parseMigrateLog = eitherReader $ parseAll mlP . encodeUtf8 . T.pack
where
mlP =
A.takeTill (== ' ') >>= \case
"check" -> pure MLCheck
"import" -> pure MLImport
"export" -> pure MLExport
"listing" -> pure MLListing
_ -> fail "bad MigrateLog"
@@ -154,8 +154,8 @@ welcomeGetOpts = do
knownContact KnownContact {contactId, localDisplayName = n} = knownName contactId n
knownName i n = show i <> ":" <> T.unpack (viewName n)
directoryServiceCLI :: DirectoryLog -> DirectoryOpts -> IO ()
directoryServiceCLI st opts = do
directoryServiceCLI :: DirectoryOpts -> IO ()
directoryServiceCLI opts = do
env@ServiceState {eventQ} <- newServiceState opts
let eventHook _cc resp = atomically $ resp <$ mapM_ (writeTQueue eventQ) (crDirectoryEvent resp)
chatHooks =
@@ -178,7 +178,7 @@ directoryServiceCLI st opts = do
forM_ u_ $ \user ->
forever $ do
event <- atomically $ readTQueue eventQ
directoryServiceEvent st opts env user cc event
directoryServiceEvent opts env user cc event
updateListingDelay :: Int
updateListingDelay = 5 * 60 * 1000000 -- update every 5 minutes
@@ -249,8 +249,8 @@ directoryCommands =
where
idParam = Just "<ID>"
directoryService :: DirectoryLog -> DirectoryOpts -> ChatConfig -> IO ()
directoryService st opts cfg = do
directoryService :: DirectoryOpts -> ChatConfig -> IO ()
directoryService opts cfg = do
env@ServiceState {eventQ} <- newServiceState opts
let chatHooks =
defaultChatHooks
@@ -265,7 +265,7 @@ directoryService st opts cfg = do
mapM_ (atomically . writeTQueue eventQ) $ crDirectoryEvent resp,
forever $ do
event <- atomically $ readTQueue eventQ
directoryServiceEvent st opts env user cc event
directoryServiceEvent opts env user cc event
]
<> maybeToList (updateListingsThread_ opts env)
<> maybeToList (linkCheckThread_ opts env)
@@ -317,8 +317,8 @@ readBlockedWordsConfig DirectoryOpts {blockedFragmentsFile, blockedWordsFile, na
unless testing $ putStrLn $ "Blocked fragments: " <> show (length blockedFragments) <> ", blocked words: " <> show (length blockedWords) <> ", spelling rules: " <> show (M.size spelling)
pure BlockedWordsConfig {blockedFragments, blockedWords, extensionRules, spelling}
directoryServiceEvent :: DirectoryLog -> DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO ()
directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests} user@User {userId} cc = \case
directoryServiceEvent :: DirectoryOpts -> ServiceState -> User -> ChatController -> DirectoryEvent -> IO ()
directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, ownersGroup, searchResults, prohibitedToObserver, alwaysCaptcha} env@ServiceState {searchRequests} user@User {userId} cc = \case
DEContactConnected ct -> deContactConnected ct
DEGroupInvitation {contact = ct, groupInfo = g, fromMemberRole, memberRole} -> deGroupInvitation ct g fromMemberRole memberRole
DEServiceJoinedGroup ctId g owner -> deServiceJoinedGroup ctId g owner
@@ -420,8 +420,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
processInvitation :: Contact -> GroupInfo -> Maybe GroupReg -> IO ()
processInvitation ct g@GroupInfo {groupId, groupProfile = GroupProfile {displayName}} = \case
Nothing -> addGroupReg notifyAdminUsers st cc user ct g GRSProposed joinGroup
Just _gr -> setGroupStatus notifyAdminUsers st env cc groupId GRSProposed joinGroup
Nothing -> addGroupReg notifyAdminUsers cc user ct g GRSProposed joinGroup
Just _gr -> setGroupStatus notifyAdminUsers env cc groupId GRSProposed joinGroup
where
joinGroup _ = do
r <- sendChatCmd cc $ APIJoinGroup groupId MFNone
@@ -452,7 +452,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
Left e -> sendMessage cc ct $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e
where
askConfirmation =
addGroupReg notifyAdminUsers st cc user ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do
addGroupReg notifyAdminUsers cc user ct g GRSPendingConfirmation $ \GroupReg {userGroupRegId} -> do
sendMessage cc ct $ "The group " <> groupNameDescr p <> " is already submitted to the directory.\nTo confirm the registration, please send:"
sendMessage cc ct $ "/confirm " <> tshow userGroupRegId <> ":" <> viewName displayName
@@ -493,11 +493,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
logError msg
notifyOwner gr msg
Right () -> do
logGUpdateOwner st groupId $ groupMemberId' owner
notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…"
sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case
Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} ->
setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do
notifyOwner
gr'
"Created the public link to join the group via this directory service that is always online.\n\n\
@@ -576,19 +575,19 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case
Right ownerMember
| let GroupMember {memberRole = role} = ownerMember, role >= GROwner ->
setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` g')
setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') (`updatedNotification` g')
| otherwise -> do
setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \_ -> pure ()
setGroupStatus notifyAdminUsers env cc groupId GRSSuspendedBadRoles $ \_ -> pure ()
notifyOwner gr $ "The registration owner is no longer an owner. Registration suspended."
Left _ -> logError $ "could not find owner member for " <> groupRef
Nothing -> logError $ "no owner member set for " <> groupRef
_ ->
setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') (`updatedNotification` toGroup)
setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') (`updatedNotification` toGroup)
groupLinkAdded gr byMember =
getDuplicateGroup toGroup >>= \case
Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e
Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup
_ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval gaId) $ \gr' -> do
_ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval gaId) $ \gr' -> do
notifyOwner gr' $
("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember)
<> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours."
@@ -599,16 +598,16 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let userGroupRef = userGroupReference gr toGroup
groupRef = groupReference toGroup
groupProfileUpdate >>= \case
GPNoServiceLink -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do
GPNoServiceLink -> setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do
notifyOwner gr' $
("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n")
<> "The group will remain hidden from the directory until the group link is added and the group is re-approved."
GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers st env cc groupId GRSPendingUpdate $ \gr' -> do
GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do
notifyOwner gr' $
("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember)
<> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved."
notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed."
GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do
GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do
notifyOwner gr' $
("The group link is added to " <> userGroupRef <> byMember)
<> "!\nIt is hidden from the directory until approved."
@@ -620,7 +619,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
("The group " <> userGroupRef <> " is updated" <> byMember)
<> "!\nThe group is listed in directory."
notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory."
| otherwise -> setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n') $ \gr' -> do
| otherwise -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do
notifyOwner gr' $
("The group " <> userGroupRef <> " is updated" <> byMember)
<> "!\nIt is hidden from the directory until approved."
@@ -839,7 +838,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
when groupUpdated $ reapprove pg gr groupRegStatus g''
when (groupUpdated || summary /= groupSummary g'' || groupDomainVerified g'' /= groupDomainVerified gInfo) $ listingsUpdated env
Left (ChatErrorAgent {agentError = SMP _ err}) | linkDeleted err ->
setGroupStatus logError st env cc groupId GRSRemoved $ \gr' ->
setGroupStatus logError env cc groupId GRSRemoved $ \gr' ->
notifyOwner gr' "The channel link is no longer valid.\nThe channel is removed from the directory."
_ -> pure ()
where
@@ -852,7 +851,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
withDB "checkGroupLink" cc (\db -> withExceptT show $ getGroupMember db (storeCxt cc) user groupId ownerGMId) >>= \case
Right GroupMember {memberId, memberPubKey}
| any (\GroupLinkOwner {memberId = mId, memberKey} -> memberId == mId && memberPubKey == Just memberKey) owners -> onValid
_ -> setGroupStatus logError st env cc groupId GRSSuspendedBadRoles $ \gr' ->
_ -> setGroupStatus logError env cc groupId GRSSuspendedBadRoles $ \gr' ->
notifyOwner gr' "The registration owner is no longer a channel owner.\nThe channel is no longer listed in the directory."
Nothing -> onValid
reapprove pg gr groupRegStatus g' = do
@@ -861,7 +860,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
notifyAdminUsers $ "The " <> gt <> " " <> groupRef <> " profile changed."
case groupRegStatus of
GRSActive ->
setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval 1) $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval 1) $ \gr' -> do
notifyOwner gr' $ "The " <> gt <> " profile has changed.\nIt is hidden from the directory until approved."
sendToApprove g' gr' 1
GRSPendingApproval n ->
@@ -877,14 +876,14 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
when (ctId `isOwner` gr) $
case groupRegStatus of
GRSSuspendedBadRoles | rStatus == GRSOk ->
setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSActive $ \gr' -> do
notifyOwner gr' $ uCtRole <> ".\n\nThe group is listed in the directory again."
notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suCtRole
GRSPendingApproval gaId | rStatus == GRSOk -> do
verifyAndSendToApprove g gr gaId
notifyOwner gr $ uCtRole <> ".\n\nThe group is submitted for approval."
GRSActive | rStatus /= GRSOk ->
setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSSuspendedBadRoles $ \gr' -> do
notifyOwner gr' $ uCtRole <> ".\n\nThe group is no longer listed in the directory."
notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suCtRole
_ -> pure ()
@@ -903,7 +902,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
case groupRegStatus of
GRSSuspendedBadRoles | serviceRole == GRAdmin ->
whenContactIsOwner gr $
setGroupStatus notifyAdminUsers st env cc groupId GRSActive $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSActive $ \gr' -> do
notifyOwner gr' $ uSrvRole <> ".\n\nThe group is listed in the directory again."
notifyAdminUsers $ "The group " <> groupRef <> " is listed " <> suSrvRole
GRSPendingApproval gaId | serviceRole == GRAdmin ->
@@ -911,7 +910,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
verifyAndSendToApprove g gr gaId
notifyOwner gr $ uSrvRole <> ".\n\nThe group is submitted for approval."
GRSActive | serviceRole /= GRAdmin ->
setGroupStatus notifyAdminUsers st env cc groupId GRSSuspendedBadRoles $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSSuspendedBadRoles $ \gr' -> do
notifyOwner gr' $ uSrvRole <> ".\n\nThe group is no longer listed in the directory."
notifyAdminUsers $ "The group " <> groupRef <> " is de-listed " <> suSrvRole
_ -> pure ()
@@ -929,7 +928,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g
withGroupReg g "contact removed" $ \gr ->
when (ctId `isOwner` gr) $
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr' -> do
notifyOwner gr' $ "You are removed from the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory."
notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner is removed)."
when (isJust pg_) $ leavePublicGroup g
@@ -940,7 +939,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g
withGroupReg g "contact left" $ \gr ->
when (ctId `isOwner` gr) $
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr' -> do
notifyOwner gr' $ "You left the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory."
notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner left)."
when (isJust pg_) $ leavePublicGroup g
@@ -949,7 +948,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
deServiceRemovedFromGroup g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do
let gt = maybe "group" groupTypeStr' pg_
logInfo $ "service removed from group " <> viewGroupName g
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do
setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr -> do
notifyOwner gr $ serviceName <> " is removed from the " <> gt <> " " <> userGroupReference gr g <> ".\n\nThe " <> gt <> " is no longer listed in the directory."
notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (directory service is removed)."
@@ -957,7 +956,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
deGroupDeleted g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do
let gt = maybe "group" groupTypeStr' pg_
logInfo $ "group removed " <> viewGroupName g
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \gr -> do
setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr -> do
notifyOwner gr $ "The " <> gt <> " " <> userGroupReference gr g <> " is deleted.\n\nThe " <> gt <> " is no longer listed in the directory."
notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " is deleted)."
@@ -1018,7 +1017,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
sendChatCmd cc (APIPrepareGroup userId ccLink False Nothing groupSLinkData) >>= \case
Right (CRNewPreparedChat _ (AChat SCTGroup (Chat (GroupChat gInfo _) _ _))) -> do
let gId = groupId' gInfo
addGroupReg notifyAdminUsers st cc user ct gInfo GRSProposed $ \_ -> pure ()
addGroupReg notifyAdminUsers cc user ct gInfo GRSProposed $ \_ -> pure ()
sendChatCmd cc (APIConnectPreparedGroup gId False (Just ownerContact) Nothing) >>= \case
Right CRStartedConnectionToGroup {groupInfo = gInfo'} ->
withDB "getGroupMember" cc (\db -> withExceptT show $ getGroupMemberByMemberId db (storeCxt cc) user gInfo' mId) >>= \case
@@ -1043,7 +1042,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
| contactId' ct `isOwner` gr -> sameOwnerReregistration gr gt
| otherwise -> sendMessage cc ct $ "This " <> gt <> " is registered by another owner."
Left _ ->
addGroupReg notifyAdminUsers st cc user ct g (GRSPendingApproval 1) $ \gr -> do
addGroupReg notifyAdminUsers cc user ct g (GRSPendingApproval 1) $ \gr -> do
void $ setGroupRegOwner cc groupId ownerMember
verifyAndSendToApprove g gr 1
| role < GROwner -> sendMessage cc ct $ "You must be the " <> gt <> " owner to register it."
@@ -1065,7 +1064,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
GRSRemoved -> pendingApprovalTransition gr gt 1
pendingApprovalTransition gr gt n = do
let userGroupRef = userGroupReference gr g
setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval n) $ \gr' -> do
setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n) $ \gr' -> do
notifyOwner gr' $
"The " <> gt <> " " <> userGroupRef <> " is submitted for approval.\nIt is hidden from the directory until approved."
verifyAndSendToApprove g gr' n
@@ -1079,12 +1078,12 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let GroupMember {memberRole = role} = toMember
gt = maybe "group" groupTypeStr' publicGroup
in if role >= GROwner
then setGroupStatus notifyAdminUsers st env cc groupId (GRSPendingApproval 1) $ \gr' -> do
then setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval 1) $ \gr' -> do
notifyOwner gr' $ "Joined the " <> gt <> " " <> displayName <> ". Registration is pending approval — it may take up to 48 hours."
notifyOwner gr' $ recommendedSettingsNotice (userGroupRegId gr')
verifyAndSendToApprove g gr' 1
else do
setGroupStatus notifyAdminUsers st env cc groupId GRSRemoved $ \_ -> pure ()
setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \_ -> pure ()
sendMessage' cc (dbContactId gr) "The signing key does not belong to a current owner. Registration cancelled."
deUserCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRUser -> IO ()
@@ -1159,7 +1158,6 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let gt = maybe "group" groupTypeStr' pg_
delGroupReg cc dbGroupId >>= \case
Right () -> do
logGDelete st dbGroupId
sendReply $ (if isAdmin then "The " <> gt <> " " else "Your " <> gt <> " ") <> displayName <> " is deleted from the directory"
when (isJust pg_) $ leavePublicGroup g
Left e -> sendReply $ "Error deleting " <> gt <> " " <> displayName <> ": " <> T.pack e
@@ -1333,7 +1331,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let grPromoted'
| promoted || knownCt `elem` superUsers = fromMaybe promoted promote
| otherwise = False
setGroupStatusPromo sendReply st env cc gr GRSActive grPromoted' $ do
setGroupStatusPromo sendReply env cc gr GRSActive grPromoted' $ do
let approved = "The " <> gt <> " " <> userGroupReference' gr n <> " is approved"
let commands
| isPublicGroup_ = ""
@@ -1371,7 +1369,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let groupRef = groupReference' groupId gName
withGroupAndReg sendReply groupId gName $ \_ gr ->
case groupRegStatus gr of
GRSActive -> setGroupStatus sendReply st env cc groupId GRSSuspended $ \gr' -> do
GRSActive -> setGroupStatus sendReply env cc groupId GRSSuspended $ \gr' -> do
let suspended = "The group " <> userGroupReference' gr gName <> " is suspended"
notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators."
sendReply "Group suspended!"
@@ -1381,7 +1379,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
let groupRef = groupReference' groupId gName
withGroupAndReg sendReply groupId gName $ \_ gr ->
case groupRegStatus gr of
GRSSuspended -> setGroupStatus sendReply st env cc groupId GRSActive $ \gr' -> do
GRSSuspended -> setGroupStatus sendReply env cc groupId GRSActive $ \gr' -> do
let groupStr = "The group " <> userGroupReference' gr gName
notifyOwner gr' $ groupStr <> " is listed in the directory again!"
sendReply "Group listing resumed!"
@@ -1452,7 +1450,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
withGroupAndReg sendReply groupId gName $ \_ gr@GroupReg {groupRegStatus, promoted} -> do
let notify = sendReply $ "Group promotion " <> (if promote' then "enabled" <> (if groupRegStatus == GRSActive then "." else ", but the group is not listed.") else "disabled.")
if promote' /= promoted
then setGroupPromoted sendReply st env cc gr promote' notify
then setGroupPromoted sendReply env cc gr promote' notify
else notify
DCExecuteCommand cmdStr ->
sendChatCmdStr cc cmdStr >>= \case
@@ -1510,48 +1508,43 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
msg = maybe (MCText text) (\image -> MCImage {text, image}) image_
in (Nothing, msg)
setGroupStatusPromo :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO ()
setGroupStatusPromo sendReply st env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do
setGroupStatusPromo :: (Text -> IO ()) -> ServiceState -> ChatController -> GroupReg -> GroupRegStatus -> Bool -> IO () -> IO ()
setGroupStatusPromo sendReply env cc GroupReg {dbGroupId = gId} grStatus' grPromoted' continue = do
let status' = grDirectoryStatus grStatus'
setGroupStatusPromoStore cc gId grStatus' grPromoted' >>= \case
Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e
Right (status, grPromoted) -> do
when ((status == DSListed || status' == DSListed) && (status /= status' || grPromoted /= grPromoted')) $
listingsUpdated env
logGUpdateStatus st gId grStatus'
logGUpdatePromotion st gId grPromoted'
continue
addGroupReg :: (Text -> IO ()) -> DirectoryLog -> ChatController -> User -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO ()
addGroupReg sendMsg st cc user ct g@GroupInfo {groupId} grStatus continue =
addGroupReg :: (Text -> IO ()) -> ChatController -> User -> Contact -> GroupInfo -> GroupRegStatus -> (GroupReg -> IO ()) -> IO ()
addGroupReg sendMsg cc user ct g@GroupInfo {groupId} grStatus continue =
addGroupRegStore cc ct g grStatus >>= \case
Left e -> sendMsg $ "Error creating group registation for group " <> tshow groupId <> ": " <> T.pack e
Right gr -> do
logGCreate st gr
let d = toCustomData $ DirectoryGroupData newGroupJoinFilter
withDB' "setGroupCustomData" cc (\db -> setGroupCustomData db user g $ Just d) >>= \case
Right () -> pure ()
Left e -> sendMsg $ "Error setting default captcha for group " <> tshow groupId <> ": " <> T.pack e
continue gr
setGroupStatus :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO ()
setGroupStatus sendMsg st env cc gId grStatus' continue = do
setGroupStatus :: (Text -> IO ()) -> ServiceState -> ChatController -> GroupId -> GroupRegStatus -> (GroupReg -> IO ()) -> IO ()
setGroupStatus sendMsg env cc gId grStatus' continue = do
let status' = grDirectoryStatus grStatus'
setGroupStatusStore cc gId grStatus' >>= \case
Left e -> sendMsg $ "Error updating group " <> tshow gId <> " status: " <> T.pack e
Right (grStatus, gr) -> do
let status = grDirectoryStatus grStatus
when ((status == DSListed || status' == DSListed) && status /= status') $ listingsUpdated env
logGUpdateStatus st gId grStatus'
continue gr
setGroupPromoted :: (Text -> IO ()) -> DirectoryLog -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO ()
setGroupPromoted sendReply st env cc GroupReg {dbGroupId = gId} grPromoted' continue =
setGroupPromoted :: (Text -> IO ()) -> ServiceState -> ChatController -> GroupReg -> Bool -> IO () -> IO ()
setGroupPromoted sendReply env cc GroupReg {dbGroupId = gId} grPromoted' continue =
setGroupPromotedStore cc gId grPromoted' >>= \case
Left e -> sendReply $ "Error updating group " <> tshow gId <> " status: " <> T.pack e
Right (status, grPromoted) -> do
when (status == DSListed && grPromoted' /= grPromoted) $ listingsUpdated env
logGUpdatePromotion st gId grPromoted'
continue
updateGroupListingFiles :: ChatController -> User -> FilePath -> IO ()
@@ -11,8 +11,7 @@
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Directory.Store
( DirectoryLog (..),
GroupReg (..),
( GroupReg (..),
GroupRegStatus (..),
UserGroupRegId,
GroupApprovalId,
@@ -20,9 +19,6 @@ module Directory.Store
DirectoryMemberAcceptance (..),
DirectoryStatus (..),
ProfileCondition (..),
DirectoryLogRecord (..),
openDirectoryLog,
readDirectoryLogData,
addGroupRegStore,
insertGroupReg,
delGroupReg,
@@ -55,16 +51,9 @@ module Directory.Store
strongJoinFilter,
newGroupJoinFilter,
groupDBError,
logGCreate,
logGDelete,
logGUpdateOwner,
logGUpdateStatus,
logGUpdatePromotion,
)
where
import Control.Applicative ((<|>))
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Data.Aeson ((.:), (.=))
@@ -72,18 +61,12 @@ import qualified Data.Aeson.KeyMap as JM
import qualified Data.Aeson.TH as JQ
import qualified Data.Aeson.Types as JT
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.List (sortOn)
import Data.Map (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe, isJust)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.Clock.System (systemEpochDay)
import Directory.Search
import Directory.Util
import Simplex.Chat.Controller
@@ -99,7 +82,6 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON)
import Simplex.Messaging.Util (eitherToMaybe, firstRow, maybeFirstRow', safeDecodeUtf8)
import System.IO (BufferMode (..), Handle, IOMode (..), hSetBuffering, openFile)
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
@@ -109,10 +91,6 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
data DirectoryLog = DirectoryLog
{ directoryLogFile :: Maybe Handle
}
data GroupReg = GroupReg
{ dbGroupId :: GroupId,
userGroupRegId :: UserGroupRegId,
@@ -472,89 +450,6 @@ groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <>
groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at "
groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? "
data DirectoryLogRecord
= GRCreate GroupReg
| GRDelete GroupId
| GRUpdateStatus GroupId GroupRegStatus
| GRUpdatePromotion GroupId Bool
| GRUpdateOwner GroupId GroupMemberId
data DLRTag
= GRCreate_
| GRDelete_
| GRUpdateStatus_
| GRUpdatePromotion_
| GRUpdateOwner_
logDLR :: DirectoryLog -> DirectoryLogRecord -> IO ()
logDLR st r = forM_ (directoryLogFile st) $ \h -> B.hPutStrLn h (strEncode r)
logGCreate :: DirectoryLog -> GroupReg -> IO ()
logGCreate st = logDLR st . GRCreate
logGDelete :: DirectoryLog -> GroupId -> IO ()
logGDelete st = logDLR st . GRDelete
logGUpdateStatus :: DirectoryLog -> GroupId -> GroupRegStatus -> IO ()
logGUpdateStatus st gId = logDLR st . GRUpdateStatus gId
logGUpdatePromotion :: DirectoryLog -> GroupId -> Bool -> IO ()
logGUpdatePromotion st gId = logDLR st . GRUpdatePromotion gId
logGUpdateOwner :: DirectoryLog -> GroupId -> GroupMemberId -> IO ()
logGUpdateOwner st gId = logDLR st . GRUpdateOwner gId
instance StrEncoding DLRTag where
strEncode = \case
GRCreate_ -> "GCREATE"
GRDelete_ -> "GDELETE"
GRUpdateStatus_ -> "GSTATUS"
GRUpdatePromotion_ -> "GPROMOTE"
GRUpdateOwner_ -> "GOWNER"
strP =
A.takeTill (== ' ') >>= \case
"GCREATE" -> pure GRCreate_
"GDELETE" -> pure GRDelete_
"GSTATUS" -> pure GRUpdateStatus_
"GPROMOTE" -> pure GRUpdatePromotion_
"GOWNER" -> pure GRUpdateOwner_
_ -> fail "invalid DLRTag"
instance StrEncoding DirectoryLogRecord where
strEncode = \case
GRCreate gr -> strEncode (GRCreate_, gr)
GRDelete gId -> strEncode (GRDelete_, gId)
GRUpdateStatus gId grStatus -> strEncode (GRUpdateStatus_, gId, grStatus)
GRUpdatePromotion gId promoted -> strEncode (GRUpdatePromotion_, gId, promoted)
GRUpdateOwner gId grOwnerId -> strEncode (GRUpdateOwner_, gId, grOwnerId)
strP =
strP_ >>= \case
GRCreate_ -> GRCreate <$> strP
GRDelete_ -> GRDelete <$> strP
GRUpdateStatus_ -> GRUpdateStatus <$> A.decimal <*> _strP
GRUpdatePromotion_ -> GRUpdatePromotion <$> A.decimal <*> _strP
GRUpdateOwner_ -> GRUpdateOwner <$> A.decimal <* A.space <*> A.decimal
instance StrEncoding GroupReg where
strEncode GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted} =
B.unwords $
[ "group_id=" <> strEncode dbGroupId,
"user_group_id=" <> strEncode userGroupRegId,
"contact_id=" <> strEncode dbContactId,
"owner_member_id=" <> strEncode dbOwnerMemberId,
"status=" <> strEncode groupRegStatus
]
<> ["promoted=" <> strEncode promoted | promoted]
strP = do
dbGroupId <- "group_id=" *> strP_
userGroupRegId <- "user_group_id=" *> strP_
dbContactId <- "contact_id=" *> strP_
dbOwnerMemberId <- "owner_member_id=" *> strP_
groupRegStatus <- "status=" *> strP
promoted <- (" promoted=" *> strP) <|> pure False
let createdAt = UTCTime systemEpochDay 0
pure GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt}
instance StrEncoding GroupRegStatus where
strEncode = \case
GRSPendingConfirmation -> "pending_confirmation"
@@ -580,40 +475,3 @@ instance StrEncoding GroupRegStatus where
instance ToField GroupRegStatus where toField = toField . safeDecodeUtf8 . strEncode
instance FromField GroupRegStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
openDirectoryLog :: Maybe FilePath -> IO DirectoryLog
openDirectoryLog = \case
Just f -> DirectoryLog . Just <$> openLogFile f
Nothing -> pure $ DirectoryLog Nothing
where
openLogFile f = do
h <- openFile f AppendMode
hSetBuffering h LineBuffering
pure h
readDirectoryLogData :: FilePath -> IO [GroupReg]
readDirectoryLogData f =
sortOn dbGroupId . M.elems
<$> (foldM processDLR M.empty . B.lines =<< B.readFile f)
where
processDLR :: Map GroupId GroupReg -> ByteString -> IO (Map GroupId GroupReg)
processDLR m l = case strDecode l of
Left e -> m <$ putStrLn ("Error parsing log record: " <> e <> ", " <> B.unpack (B.take 80 l))
Right r -> case r of
GRCreate gr@GroupReg {dbGroupId = gId} -> do
when (isJust $ M.lookup gId m) $
putStrLn $
"Warning: duplicate group with ID " <> show gId <> ", group replaced."
pure $ M.insert gId gr m
GRDelete gId -> case M.lookup gId m of
Just _ -> pure $ M.delete gId m
Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", deletion ignored.")
GRUpdateStatus gId groupRegStatus -> case M.lookup gId m of
Just gr -> pure $ M.insert gId gr {groupRegStatus} m
Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", status update ignored.")
GRUpdatePromotion gId promoted -> case M.lookup gId m of
Just gr -> pure $ M.insert gId gr {promoted} m
Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", promotion update ignored.")
GRUpdateOwner gId grOwnerId -> case M.lookup gId m of
Just gr -> pure $ M.insert gId gr {dbOwnerMemberId = Just grOwnerId} m
Nothing -> m <$ putStrLn ("Warning: no group with ID " <> show gId <> ", owner update ignored.")
@@ -7,18 +7,15 @@
module Directory.Store.Migrate where
import Control.Concurrent.STM
import Control.Monad
import Control.Monad.Except
import qualified Data.ByteString.Char8 as B
import Data.List (find)
import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Directory.Listing
import Directory.Options
import Directory.Store
import Simplex.Chat (createChatDatabase)
import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..), mkStoreCxt)
import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..))
import Simplex.Chat.Options (CoreChatOpts (..))
import Simplex.Chat.Options.DB
import Simplex.Chat.Store.Groups (getHostMember)
@@ -30,11 +27,7 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Agent.Store.Interface (closeDBStore, migrateDBSchema)
import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConfirmation (..))
import Simplex.Messaging.Encoding.String
import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Util (whenM)
import System.Directory (doesFileExist, renamePath)
import System.Exit (exitFailure)
import System.IO (IOMode (..), withFile)
#if defined(dbPostgres)
import Directory.Store.Postgres.Migrations
@@ -55,66 +48,9 @@ runDirectoryMigrations opts ChatConfig {confirmMigrations} chatStore =
DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations}} = opts
confirm = if confirmMigrations == MCConsole && yesToUpMigrations then MCYesUp else confirmMigrations
checkDirectoryLog :: DirectoryOpts -> ChatConfig -> IO ()
checkDirectoryLog opts cfg =
withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do
runDirectoryMigrations opts cfg st
gs <- readDirectoryLogData logFile
withActiveUser st $ \user -> withTransaction st $ \db -> do
mapM_ (verifyGroupRegistration (mkStoreCxt cfg) db user) gs
putStrLn $ show (length gs) <> " group registrations OK"
importDirectoryLogToDB :: DirectoryOpts -> ChatConfig -> IO ()
importDirectoryLogToDB opts cfg = do
withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do
runDirectoryMigrations opts cfg st
gs <- readDirectoryLogData logFile
ctRegs <- TM.emptyIO
withActiveUser st $ \user -> withTransaction st $ \db -> do
forM_ gs $ \gr ->
whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $ do
putStrLn $ "importing group " <> show (dbGroupId gr)
insertGroupReg db =<< fixUserGroupRegId ctRegs gr
renamePath logFile (logFile ++ ".bak")
putStrLn $ show (length gs) <> " group registrations imported"
where
fixUserGroupRegId ctRegs gr@GroupReg {dbGroupId, dbContactId} = do
ugIds <- fromMaybe [] <$> TM.lookupIO dbContactId ctRegs
gr' <-
if userGroupRegId gr `elem` ugIds
then do
let ugId = maximum ugIds + 1
putStrLn $ "Warning: updating userGroupRegId for group " <> show dbGroupId <> ", contact " <> show dbContactId
pure gr {userGroupRegId = ugId}
else pure gr
atomically $ TM.insert dbContactId (userGroupRegId gr' : ugIds) ctRegs
pure gr'
exit :: String -> IO a
exit err = putStrLn ("Error: " <> err) >> exitFailure
exportDBToDirectoryLog :: DirectoryOpts -> ChatConfig -> IO ()
exportDBToDirectoryLog opts cfg =
withDirectoryLog opts $ \logFile -> withChatStore opts $ \st -> do
whenM (doesFileExist logFile) $ exit $ "directory log file " ++ logFile ++ " already exists"
runDirectoryMigrations opts cfg st
withActiveUser st $ \user -> do
gs <- withFile logFile WriteMode $ \h -> withTransaction st $ \db -> do
gs <- getAllGroupRegs_ db (mkStoreCxt cfg) user
forM_ gs $ \(_, gr) ->
whenM (verifyGroupRegistration (mkStoreCxt cfg) db user gr) $
B.hPutStrLn h $ strEncode $ GRCreate gr
pure gs
putStrLn $ show (length gs) <> " group registrations exported"
saveGroupListingFiles :: DirectoryOpts -> ChatConfig -> IO ()
saveGroupListingFiles opts cfg = case webFolder opts of
Nothing -> exit "use --web-folder to generate listings"
Just dir ->
withChatStore opts $ \st -> withActiveUser st $ \user ->
withTransaction st $ \db ->
getAllListedGroups_ db (mkStoreCxt cfg) user >>= generateListing dir
verifyGroupRegistration :: StoreCxt -> DB.Connection -> User -> GroupReg -> IO Bool
verifyGroupRegistration cxt db user GroupReg {dbGroupId = gId, dbContactId = ctId, dbOwnerMemberId, groupRegStatus} =
runExceptT (getGroupInfo db cxt user gId) >>= \case
@@ -129,10 +65,6 @@ verifyGroupRegistration cxt db user GroupReg {dbGroupId = gId, dbContactId = ctI
| mId /= mId' -> False <$ putStrLn ("Error: different host member ID of " <> groupRef <> " (skipping): " <> show mId')
| otherwise -> True <$ unless (Just ctId == ctId') (putStrLn $ "Warning: bad group " <> groupRef <> " contact ID: " <> show ctId')
withDirectoryLog :: DirectoryOpts -> (FilePath -> IO ()) -> IO ()
withDirectoryLog DirectoryOpts {directoryLog} action =
maybe (exit "directory log file not specified") action directoryLog
withChatStore :: DirectoryOpts -> (DBStore -> IO ()) -> IO ()
withChatStore DirectoryOpts {coreOptions = CoreChatOpts {dbOptions, yesToUpMigrations, migrationBackupPath}} action =
createChatDatabase dbOptions migrationConfig >>= \case
+4 -1
View File
@@ -2,7 +2,10 @@ import {isWeekend} from "./util.js"
export const welcomeMessage = `Hello! This is a *SimpleX team* support bot - not an AI.
*Join public groups* at https://simplex.chat/directory or [via directory bot](https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok)
Please ask any questions about SimpleX Chat.`
We just launched [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat)!
Please ask any questions about SimpleX Chat and about our crowdfunding.`
export function queueMessage(timezone: string, grokEnabled: boolean): string {
const hours = isWeekend(timezone) ? "48" : "24"
+104
View File
@@ -15,6 +15,8 @@ This file is generated automatically.
- [APIDeleteChatItem](#apideletechatitem)
- [APIDeleteMemberChatItem](#apideletememberchatitem)
- [APIChatItemReaction](#apichatitemreaction)
- [APIShareMyAddress](#apisharemyaddress)
- [APIShareChatMsgContent](#apisharechatmsgcontent)
[File commands](#file-commands)
- [ReceiveFile](#receivefile)
@@ -35,6 +37,7 @@ This file is generated automatically.
- [APIAddGroupRelays](#apiaddgrouprelays)
- [APIAllowRelayGroup](#apiallowrelaygroup)
- [APIUpdateGroupProfile](#apiupdategroupprofile)
- [APIVerifyGroupDomain](#apiverifygroupdomain)
[Group link commands](#group-link-commands)
- [APICreateGroupLink](#apicreategrouplink)
@@ -489,6 +492,73 @@ ChatCmdError: Command error (only used in WebSockets API).
---
### APIShareMyAddress
Share user address card
*Network usage*: no.
**Parameters**:
- toSendRef: [ChatRef](./TYPES.md#chatref)
**Syntax**:
```
/_share address<str(toSendRef)>
```
```javascript
'/_share address' + ChatRef.cmdString(toSendRef) // JavaScript
```
```python
'/_share address' + ChatRef_cmd_string(toSendRef) # Python
```
**Response**:
ChatMsgContent: Chat card content that can be sent.
- type: "chatMsgContent"
- user: [User](./TYPES.md#user)
- msgContent: [MsgContent](./TYPES.md#msgcontent)
---
### APIShareChatMsgContent
Share channel address
*Network usage*: no.
**Parameters**:
- shareChatRef: [ChatRef](./TYPES.md#chatref)
- toSendRef: [ChatRef](./TYPES.md#chatref)
**Syntax**:
```
/_share chat content <str(shareChatRef)> <str(toSendRef)>
```
```javascript
'/_share chat content ' + ChatRef.cmdString(shareChatRef) + ' ' + ChatRef.cmdString(toSendRef) // JavaScript
```
```python
'/_share chat content ' + ChatRef_cmd_string(shareChatRef) + ' ' + ChatRef_cmd_string(toSendRef) # Python
```
**Response**:
ChatMsgContent: Chat card content that can be sent.
- type: "chatMsgContent"
- user: [User](./TYPES.md#user)
- msgContent: [MsgContent](./TYPES.md#msgcontent)
---
## File commands
Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files.
@@ -1165,6 +1235,40 @@ ChatCmdError: Command error (only used in WebSockets API).
---
### APIVerifyGroupDomain
Verify group domain
*Network usage*: interactive.
**Parameters**:
- groupId: int64
**Syntax**:
```
/_verify domain #<groupId>
```
```javascript
'/_verify domain #' + groupId // JavaScript
```
```python
'/_verify domain #' + str(groupId) # Python
```
**Response**:
GroupDomainVerified: Group domain verified.
- type: "groupDomainVerified"
- user: [User](./TYPES.md#user)
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
- verificationFailure: string?
---
## Group link commands
These commands can be used by bots that manage multiple public groups
+1
View File
@@ -1448,6 +1448,7 @@ Search:
**Enum type**:
- "human"
- "bot"
- "business"
---
+5 -5
View File
@@ -97,7 +97,9 @@ chatCommandsDocsData =
),
("APIDeleteChatItem", [], "Delete message.", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete item " <> Param "chatRef" <> " " <> Join ',' "chatItemIds" <> " " <> Param "deleteMode"),
("APIDeleteMemberChatItem", [], "Moderate message. Requires Moderator role (and higher than message author's).", ["CRChatItemsDeleted", "CRChatCmdError"], [], Just UNBackground, "/_delete member item #" <> Param "groupId" <> " " <> Join ',' "chatItemIds"),
("APIChatItemReaction", [], "Add/remove message reaction.", ["CRChatItemReaction", "CRChatCmdError"], [], Just UNBackground, "/_reaction " <> Param "chatRef" <> " " <> Param "chatItemId" <> " " <> OnOff "add" <> " " <> Json "reaction")
("APIChatItemReaction", [], "Add/remove message reaction.", ["CRChatItemReaction", "CRChatCmdError"], [], Just UNBackground, "/_reaction " <> Param "chatRef" <> " " <> Param "chatItemId" <> " " <> OnOff "add" <> " " <> Json "reaction"),
("APIShareMyAddress", [], "Share user address card", ["CRChatMsgContent"], [], Nothing, "/_share address" <> Param "toSendRef"),
("APIShareChatMsgContent", [], "Share channel address", ["CRChatMsgContent"], [], Nothing, "/_share chat content " <> Param "shareChatRef" <> " " <> Param "toSendRef")
]
),
( "File commands",
@@ -121,7 +123,8 @@ chatCommandsDocsData =
("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"),
("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"),
("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"),
("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile")
("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile"),
("APIVerifyGroupDomain", [], "Verify group domain", ["CRGroupDomainVerified"], [], Just UNInteractive, "/_verify domain #" <> Param "groupId")
]
),
( "Group link commands",
@@ -426,8 +429,6 @@ undocumentedCommands =
"APISetUserDomain",
"APISetUserServers",
"APISetUserUIThemes",
"APIShareChatMsgContent",
"APIShareMyAddress",
"APIStandaloneFileInfo",
"APIStorageEncryption",
"APISuspendChat",
@@ -446,7 +447,6 @@ undocumentedCommands =
"APIVerifyContact",
"APIVerifyContactDomain",
"APIVerifyGroupMember",
"APIVerifyGroupDomain",
"APIVerifyToken",
"CheckChatRunning",
"ConfirmRemoteCtrl",
+2 -2
View File
@@ -51,6 +51,7 @@ chatResponsesDocsData =
("CRChatItemReaction", "Message reaction"),
("CRChatItemUpdated", "Message updated"),
("CRChatItemsDeleted", "Messages deleted"),
("CRChatMsgContent", "Chat card content that can be sent"),
("CRChatRunning", ""),
("CRChatStarted", ""),
("CRChatStopped", ""),
@@ -77,6 +78,7 @@ chatResponsesDocsData =
("CRGroupMembers", ""),
("CRGroupUpdated", ""),
("CRGroupsList", "Groups"),
("CRGroupDomainVerified", ""),
("CRInvitation", "One-time invitation"),
("CRLeftMemberUser", "User left group"),
("CRMemberAccepted", "Member accepted to group"),
@@ -136,7 +138,6 @@ undocumentedResponses =
"CRChatItemInfo",
"CRChatItems",
"CRChatItemTTL",
"CRChatMsgContent",
"CRChatRelayTestResult",
"CRChats",
"CRConnectionsDiff",
@@ -169,7 +170,6 @@ undocumentedResponses =
"CRGroupMemberRatchetSyncStarted",
"CRGroupMemberSwitchAborted",
"CRGroupMemberSwitchStarted",
"CRGroupDomainVerified",
"CRGroupProfile",
"CRGroupUserChanged",
"CRItemsReadForChat",
+1 -1
View File
@@ -226,7 +226,7 @@ chatTypesDocsData =
(sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""),
(sti @ChatFeature, STEnum, "CF", [], "", ""),
(sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."),
(sti @ChatPeerType, STEnum, "CPT", [], "", ""),
(sti @ChatPeerType, STEnum, "CPT", ["CPTUnknown"], "", ""),
(sti @ChatRef, STRecord, "", [], Param "chatType" <> Param "chatId" <> Optional "" (Param "$0") "chatScope", "Used in API commands. Chat scope can only be passed with groups."),
(sti @ChatSettings, STRecord, "", [], "", ""),
(sti @ChatStats, STRecord, "", [], "", ""),
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: ee4dd0d8ded0f66f70a8890ce09d69e3610aa276
tag: 21568ab27711ff5e1f2c27f4db6546049fb83e29
source-repository-package
type: git
+2
View File
@@ -16,3 +16,5 @@ SimpleX: "Ask SimpleX team" contact in the app or [this address](https://simplex
Email: [chat@simplex.chat](mailto:chat@simplex.chat). You can use PGP to encrypt email messages using our key from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and making your key available for a secure reply.
You can follow our updates on social media: [X/Twitter](https://x.com/simplexchat), [Reddit](https://www.reddit.com/r/SimpleXChat/), [Mastodon](https://mastodon.social/@simplex) and [Nostr](https://primal.net/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828).
Subscribe to our email updates via [this page](https://use.simplex.chat/email-updates) - they are sent via MailChimp as plaintext emails without any tracking.
+22 -38
View File
@@ -1,5 +1,21 @@
# Links to Community Publications
## SimpleX Chat: Private Monero Communities — and Now a Chance to Invest
Monerica
Article
SimpleX Chat calls itself “the messaging network that can’t identify you,” and that is not marketing hyperbole — it is the actual design. For a privacy community built around Monero, that combination of unlinkable messaging and unlinkable money is a natural fit. This post looks at why SimpleX matters, the growing set of private Monero communities already running on it, and the news that users can now buy a stake in the project itself through an equity crowdfunding round on Wefunder.
Image: simplex-monero-investing.jpg
Language: English
Date: Aug 10, 2026
https://blog.monerica.com/articles/simplex-chat-private-monero-communities
## SimpleX Chat: Product Showcase - Removing User Identifiers From Messaging
Help Net Security
@@ -200,19 +216,19 @@ https://opennet.ru/65337/
## Vitalik Buterin Donates $765K in Ethereum to Privacy Messaging Apps
Yahoo Finance
Decrypt
News
Yahoo Finance reports that Vitalik Buterin donated approximately $765,000 in Ethereum to privacy messaging apps Session and SimpleX. Buterin praised both apps for advancing permissionless account creation and metadata privacy, while acknowledging neither is perfect and both need improvements in user experience and security.
Decrypt reports that Vitalik Buterin donated approximately $765,000 in Ethereum to privacy messaging apps Session and SimpleX. Buterin praised both apps for advancing permissionless account creation and metadata privacy, while acknowledging neither is perfect and both need improvements in user experience and security.
Image: yahoo-finance-buterin.jpg
Image: decrypt-buterin.jpg
Language: English
Date: Nov 2025
https://finance.yahoo.com/news/vitalik-buterin-donates-765k-ethereum-190102367.html
https://decrypt.co/350253/vitalik-buterin-donates-765k-in-ethereum-to-privacy-messaging-apps
## Vitalik Buterin Supports Privacy-Focused Messaging Platforms With Significant Ethereum Donation
@@ -2543,24 +2559,6 @@ Date: May 22, 2022
https://www.youtube.com/watch?v=N0prtSOyeUU
## Kostiantyn Korsun: Zaluzhnyi and Messengers
(Kostyantyn Korsun: Zaluzhnyy i mesendzhery)
Tverezo.info
Article
This Ukrainian article, written by Kostyantyn Korsun, discusses General Zaluzhny's essay on technology in modern warfare and the Ukrainian military's widespread reliance on Signal for encrypted communications despite formal prohibitions. While focused on Signal's role in military contexts and the US Defense Secretary's controversy over using Signal for classified data, the article addresses the broader topic of encrypted messengers in sensitive operational environments.
Image: tverezo-korsun-zaluzhnyi.jpg
Language: Ukrainian
Date: 2025
https://tverezo.info/post/205151
## Top 10 Most Secure Messaging Apps in 2024
(Top 10 mest sikre besked-apps i 2024)
@@ -3780,6 +3778,8 @@ Review
This Monerica directory page lists several Monero-focused SimpleX Chat communities spanning multiple languages and regions, including groups for Monero discussion in Slovenian, German, Italian, and Hebrew. It includes an automated bot that sends hourly Monero price updates via SimpleX.
Monerica is established in 2022.
Image: monerica-simplex-communities.jpg
Language: English
@@ -3820,22 +3820,6 @@ Date: 2024 (estimated)
https://www.anarsec.guide/posts/e2ee/
## Join Beginner Privacy on SimpleX
Beginner Privacy
Community
The Beginner Privacy community selected SimpleX Chat as their primary communication platform for its strong privacy features. The page provides setup instructions for beginners across Linux, Mac, Windows, iOS, and Android, emphasizing accessibility through both graphical and command-line interfaces.
Image: beginner-privacy-simplex-group.jpg
Language: English
Date: 2025 (estimated)
https://beginnerprivacy.com/about/join-simplex-group/
## Sofwul.cz: E-Commerce with SimpleX Contact
Sofwul

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 112 KiB

@@ -168,6 +168,35 @@ export namespace APIChatItemReaction {
}
}
// Share user address card
// Network usage: no.
export interface APIShareMyAddress {
toSendRef: T.ChatRef
}
export namespace APIShareMyAddress {
export type Response = CR.ChatMsgContent
export function cmdString(self: APIShareMyAddress): string {
return '/_share address' + T.ChatRef.cmdString(self.toSendRef)
}
}
// Share channel address
// Network usage: no.
export interface APIShareChatMsgContent {
shareChatRef: T.ChatRef
toSendRef: T.ChatRef
}
export namespace APIShareChatMsgContent {
export type Response = CR.ChatMsgContent
export function cmdString(self: APIShareChatMsgContent): string {
return '/_share chat content ' + T.ChatRef.cmdString(self.shareChatRef) + ' ' + T.ChatRef.cmdString(self.toSendRef)
}
}
// File commands
// Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files.
@@ -419,6 +448,20 @@ export namespace APIUpdateGroupProfile {
}
}
// Verify group domain
// Network usage: interactive.
export interface APIVerifyGroupDomain {
groupId: number // int64
}
export namespace APIVerifyGroupDomain {
export type Response = CR.GroupDomainVerified
export function cmdString(self: APIVerifyGroupDomain): string {
return '/_verify domain #' + self.groupId
}
}
// Group link commands
// These commands can be used by bots that manage multiple public groups
@@ -10,6 +10,7 @@ export type ChatResponse =
| CR.ChatItemReaction
| CR.ChatItemUpdated
| CR.ChatItemsDeleted
| CR.ChatMsgContent
| CR.ChatRunning
| CR.ChatStarted
| CR.ChatStopped
@@ -36,6 +37,7 @@ export type ChatResponse =
| CR.GroupMembers
| CR.GroupUpdated
| CR.GroupsList
| CR.GroupDomainVerified
| CR.Invitation
| CR.LeftMemberUser
| CR.MemberAccepted
@@ -69,6 +71,7 @@ export namespace CR {
| "chatItemReaction"
| "chatItemUpdated"
| "chatItemsDeleted"
| "chatMsgContent"
| "chatRunning"
| "chatStarted"
| "chatStopped"
@@ -95,6 +98,7 @@ export namespace CR {
| "groupMembers"
| "groupUpdated"
| "groupsList"
| "groupDomainVerified"
| "invitation"
| "leftMemberUser"
| "memberAccepted"
@@ -162,6 +166,12 @@ export namespace CR {
timed: boolean
}
export interface ChatMsgContent extends Interface {
type: "chatMsgContent"
user: T.User
msgContent: T.MsgContent
}
export interface ChatRunning extends Interface {
type: "chatRunning"
}
@@ -326,6 +336,13 @@ export namespace CR {
groups: T.GroupInfo[]
}
export interface GroupDomainVerified extends Interface {
type: "groupDomainVerified"
user: T.User
groupInfo: T.GroupInfo
verificationFailure?: string
}
export interface Invitation extends Interface {
type: "invitation"
user: T.User
@@ -1680,6 +1680,7 @@ export namespace ChatListQuery {
export enum ChatPeerType {
Human = "human",
Bot = "bot",
Business = "business",
}
// Used in API commands. Chat scope can only be passed with groups.
@@ -149,6 +149,31 @@ def APIChatItemReaction_cmd_string(self: APIChatItemReaction) -> str:
APIChatItemReaction_Response = CR.ChatItemReaction | CR.ChatCmdError
# Share user address card
# Network usage: no.
class APIShareMyAddress(TypedDict):
toSendRef: "T.ChatRef"
def APIShareMyAddress_cmd_string(self: APIShareMyAddress) -> str:
return '/_share address' + T.ChatRef_cmd_string(self['toSendRef'])
APIShareMyAddress_Response = CR.ChatMsgContent
# Share channel address
# Network usage: no.
class APIShareChatMsgContent(TypedDict):
shareChatRef: "T.ChatRef"
toSendRef: "T.ChatRef"
def APIShareChatMsgContent_cmd_string(self: APIShareChatMsgContent) -> str:
return '/_share chat content ' + T.ChatRef_cmd_string(self['shareChatRef']) + ' ' + T.ChatRef_cmd_string(self['toSendRef'])
APIShareChatMsgContent_Response = CR.ChatMsgContent
# File commands
# Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files.
@@ -368,6 +393,18 @@ def APIUpdateGroupProfile_cmd_string(self: APIUpdateGroupProfile) -> str:
APIUpdateGroupProfile_Response = CR.GroupUpdated | CR.ChatCmdError
# Verify group domain
# Network usage: interactive.
class APIVerifyGroupDomain(TypedDict):
groupId: int # int64
def APIVerifyGroupDomain_cmd_string(self: APIVerifyGroupDomain) -> str:
return '/_verify domain #' + str(self['groupId'])
APIVerifyGroupDomain_Response = CR.GroupDomainVerified
# Group link commands
# These commands can be used by bots that manage multiple public groups
@@ -36,6 +36,11 @@ class ChatItemsDeleted(TypedDict):
byUser: bool
timed: bool
class ChatMsgContent(TypedDict):
type: Literal["chatMsgContent"]
user: "T.User"
msgContent: "T.MsgContent"
class ChatRunning(TypedDict):
type: Literal["chatRunning"]
@@ -174,6 +179,12 @@ class GroupsList(TypedDict):
user: "T.User"
groups: list["T.GroupInfo"]
class GroupDomainVerified(TypedDict):
type: Literal["groupDomainVerified"]
user: "T.User"
groupInfo: "T.GroupInfo"
verificationFailure: NotRequired[str]
class Invitation(TypedDict):
type: Literal["invitation"]
user: "T.User"
@@ -319,6 +330,7 @@ ChatResponse = (
| ChatItemReaction
| ChatItemUpdated
| ChatItemsDeleted
| ChatMsgContent
| ChatRunning
| ChatStarted
| ChatStopped
@@ -345,6 +357,7 @@ ChatResponse = (
| GroupMembers
| GroupUpdated
| GroupsList
| GroupDomainVerified
| Invitation
| LeftMemberUser
| MemberAccepted
@@ -371,4 +384,4 @@ ChatResponse = (
| ApiChats
)
ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "serviceReplyAccepted", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"]
ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatMsgContent", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "groupDomainVerified", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "serviceReplyAccepted", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"]
@@ -1175,7 +1175,7 @@ ChatListQuery = ChatListQuery_filters | ChatListQuery_search
ChatListQuery_Tag = Literal["filters", "search"]
ChatPeerType = Literal["human", "bot"]
ChatPeerType = Literal["human", "bot", "business"]
# Used in API commands. Chat scope can only be passed with groups.
@@ -0,0 +1,244 @@
# Fix: message being sent leaks into another chat's compose/draft, and erases what is typed there
Branch: `nd/fix-inflight-send-writes-other-chat-compose` (off `origin/stable`)
Date: 2026-07-25
PR: #7308
Line references are against `origin/stable` at `8dc387cb5`, with this fix
applied. Android and desktop only
(`multiplatform/.../views/chat/ComposeView.kt`); iOS has the same defect
but is not addressed here.
## Problem
Two reported symptoms, one cause. Both need a send that is still in
flight when the chat is switched (slow network, large file, or the send
just hanging with the progress circle showing):
1. **The message ends up in another chat's draft.** Reply to a message
(or just type), press send, switch to another chat while it is
sending: the text *and the reply context* appear in that chat's input,
and leaving it saves them as that chat's draft. No forwarding
involved.
2. **A late send erases what you typed.** Press send, the progress circle
keeps spinning, switch to another chat and back, type a new message —
when the original send finally succeeds, the newly typed message is
erased.
## Cause
The compose state is shared, and the send outlives the chat:
- `ChatView.kt:134` — one `MutableState<ComposeState>` per `ChatView`
instance, `rememberSaveable` with no keys, reused for every chat that
the view displays.
- `Utils.kt:43-46` — `withLongRunningApi` launches on
`CoroutineScope(Dispatchers.Default)`, a standalone scope with no tie
to the composition or to the chat, and `sendMessage`
(`ComposeView.kt:972-976`) uses it. Leaving the chat never cancels an
in-flight send.
Two writes then act on the wrong chat:
- **On the chat switch** — `ComposeView.kt:1343-1347`: the `cs.inProgress`
branch used to keep the message in the shared compose state
(`composeState.value = cs.copy(inProgress = false, progressByTimeout = false)`)
and only cleared the *previous* chat's saved draft. The text and the
quote were therefore sitting in the input of the chat opened next, and
`ComposeView.kt:1348-1358` (`!cs.empty`) then saved them as *that*
chat's draft on the next switch. Symptom 1.
- **When the send completes** — `ComposeView.kt:943-968`, running in the
detached coroutine after the switch: `clearState(live)` on success, or
`composeState.value = lastFailed` on failure, where `lastFailed =
cs.copy(inProgress = false, preview = preview)`
(`ComposeView.kt:729`) **keeps `contextItem`, i.e. the reply**. On
success this wipes whatever is in the input now — including a message
typed after coming back (symptom 2); on failure it dumps the old
message into whichever chat is open (symptom 1 again).
The same function was already inconsistent about which chat it acts on:
its draft bookkeeping (`clearCurrentDraft()`, and the forwarding
condition) uses the **captured** `chat` — the chat the message was
composed in — while its `composeState` writes hit whatever chat is
displayed at that moment.
## Fix
Two changes, both in `ComposeView.kt`.
**1. Do not keep the message being sent in the shared compose state**
(`ComposeView.kt:1343-1347`). On switching away with a send in flight the
compose state is cleared, so nothing leaks into the chat opened next:
```kotlin
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
// the message being sent must not be kept in the compose state, it is shared with the chat opened next;
// if it fails to send it is restored in this chat or saved as its draft
clearState()
}
```
`clearState()` is used rather than assigning an empty `ComposeState` so that
the link preview state is reset too (`pendingLinkUrl` still points at the
sent message's link, and its fetch would otherwise set a preview on the
input of the chat opened next), and so that the attachment size limit is
carried over the same way as everywhere else.
In-flight content is deliberately **not** saved as a draft here: the
message has been submitted and will most likely be sent, and a draft is
for messages that are not sent yet.
`clearState()` alone would leave the chat opened next with an empty input
even when it has a draft: this branch, like the live message one above it,
returns before the branch that loads a draft
(`chatModel.draftChatId.value == draftChatId(chatModel.chatId.value, chatScope)`),
so that draft was never shown - and, being still in the slot but not in
any compose state, it was then dropped by `clearPrevDraft` on the next
chat switch. It is loaded here instead. This is not the one-slot
limitation below: nothing else is competing for the slot, the draft is
simply lost.
**2. Only touch the compose state if it still holds the message that was
sent** (`ComposeView.kt:936-968`):
```kotlin
withContext(Dispatchers.Main) {
val chatIsOpen = chatModel.chatId.value == chat.id
val liveSend = live || cs.liveMessage != null
val sentMessageInCompose = chatIsOpen && (liveSend || composeState.value.inProgress)
if (sentMessageInCompose) {
if (lastFailed == null) {
clearState(live)
} else {
composeState.value = lastFailed
}
}
val draft = chatModel.draft.value
if (wasForwarding && chatModel.draftChatId.value == draftChatId(chat.chatInfo.id, chatScope) && forwardingFromChatId != chat.chatInfo.id && draft != null) {
if (sentMessageInCompose) composeState.value = draft
} else {
clearCurrentDraft()
if (!sentMessageInCompose && lastFailed != null) {
// the message was not sent, so it is restored in the chat it was composed in, or kept as its draft if another chat is open
if (chatIsOpen && composeState.value.empty) {
composeState.value = lastFailed
} else if (saveLastDraft) {
chatModel.draft.value = lastFailed
chatModel.draftChatId.value = draftChatId(chat.id, chatScope)
}
}
}
}
```
Both the checks and the changes run on `Dispatchers.Main` (the block has
no suspension points), so they cannot be interleaved with the user
switching chats or typing - `KeyChangeEffect`, which does change 1, runs
there too.
`inProgress` is the marker that the compose state is still the submitted
message: it is set by `sending()` (`ComposeView.kt:596-598`), preserved by
`copy` while sending (the only other write during a send is
`progressByTimeout` at `ComposeView.kt:1610-1617`), reset when switching
away (change 1), and never set by typing a new message. So a chat switch
*or* newly typed text both make the guard false.
A **failed** send is different from an in-flight one - the message was not
sent, so it is an unsent message. It is put back into the input if that
chat is open and nothing else is being composed there, and kept as that
chat's draft otherwise, so it never appears in another chat (see the
limitations below for when it is still dropped). Staying in the chat is
unaffected: the guard is true there
and the failed message is restored into the input as before, keeping
"preserving long message when failed to send" (`e61babdc8`) working.
Deliberately unchanged:
- The condition of the forwarding branch. Gating the whole branch would
send a forward that completed after the user left to `clearCurrentDraft()`
instead, **deleting** the destination chat's draft that the branch
exists to preserve - only the compose write inside it is gated.
- Live message sends (`live`, or `cs.liveMessage != null` for the send
that finalises a live message when leaving the chat, `ComposeView.kt:1338-1342`),
as long as their chat is the one open. They never call `sending()`, so a
guard based on `inProgress` would change their behaviour: failed live
sends would stop restoring and would write a draft on every failing
keystroke send. That is why `liveSend` is an alternative to `inProgress`
inside the guard, and why it is excluded from the restore/draft branch -
not gating it there would produce exactly that draft-per-keystroke.
What they are **not** exempt from is `chatIsOpen`. An earlier revision
had `live || cs.liveMessage != null` outside it, which holds only while
a live message is always sent to the chat that is open. #7323 removes
that: the live message committed by a chat switch is sent to the chat it
was composed in, while this view already shows another one, so an
unguarded clause here would clear *that* chat's compose state - the leak
this fix exists to prevent. Standalone this changes nothing except a
live send that completes after its chat was left, which now leaves the
opened chat alone. `sendMessageAsync` reads `composeState` inside the
coroutine (`ComposeView.kt:684`), so that branch cannot clear the state
itself without racing the send; #7323 adds the `composed` parameter that
makes the captured state explicit.
**3. The same check where the flag is shared** (`ComposeView.kt:600-602`
and the three senders that connect a prepared chat). They call the same
`sending()`, so an unguarded `clearState()` or `inProgress` reset from
one of them corrupts the state of a send started in the chat opened next.
## Behaviour after the fix
| situation | before | after |
| --- | --- | --- |
| send, stay in chat, succeeds | input cleared | input cleared (unchanged) |
| send, stay in chat, fails | message restored in input | message restored in input (unchanged) |
| send, switch chats, succeeds | message left in the other chat's input, saved as its draft | other chat untouched |
| send, switch chats, fails | message dumped into the other chat's input | message restored in the chat it was composed in, or kept as its draft |
| send hangs, switch away and back, type, then it succeeds | typed message erased | typed message kept |
| forward send, still in destination chat | destination chat's draft restored | unchanged |
| live message sent on leaving the chat | compose state cleared by the send | unchanged |
## Limitations
Kept deliberately, to not grow the change:
- A message that failed to send is dropped, rather than kept, when the
"Message draft" privacy setting is off, when the destination chat of a
failed forward already has a draft (its own draft is preserved
instead), and when the single draft slot is later taken by another
chat - drafts are one global slot, so the last write wins.
- The three senders that connect a prepared chat share the same
`sending()` flag, so they use the same check (`ComposeView.kt:604-616`,
`618-640`, `659-685`). Without it a connect completing after the chat
was switched would clear `inProgress` for a send started in the chat
opened next, and that sent message would then stay in the input. They
have no failed-message restore, so their typed message is dropped when
the chat is switched instead of being carried into the next chat.
- Typing in the same chat while its own send is in flight is still
cleared when the send completes: `inProgress` is preserved by `copy`,
so the guard stays true. Unchanged from before, and different from the
reported symptom, which needs the chat to be switched.
## Verification
- `./gradlew :common:compileKotlinDesktop` — passes.
- Manual (needs a slow or failing send — e.g. airplane mode, or a large
file). On desktop any chat switch exercises it; on Android only an
in-place switch does (member info → open chat), because leaving to the
chat list destroys the view:
1. Reply + type in A, send, switch to B while sending. B's input must
stay empty; leaving B must not create a draft in B. If the send
failed, A must hold the message (with the reply) as its draft.
2. Send in A with the network off so the circle keeps spinning, switch
to B and back to A, type a new message, restore the network. The
typed message must survive the old send completing.
3. Regression: ordinary send in A (input clears), failed send while
staying in A (message comes back in the input), forward into a chat
that has a draft (draft restored after sending).
Rebased onto the scope-aware draft ids introduced by #7309: the draft
written here for a message that failed to send uses
`draftChatId(chat.id, chatScope)`, like every other draft write.
Related: `plans/2026-07-25-fix-forward-moves-draft-to-target-chat.md`
(PR #7307) — different cause (stale `chat` captured by the desktop
`onDispose`), same shared-compose-state design.
@@ -0,0 +1,248 @@
# Fix: live message is sent to the chat opened after switching chats
Branch: `nd/fix-live-message-sent-to-wrong-chat` (off `origin/stable`)
Date: 2026-07-29
Line references are against `origin/stable` at `970ef8932`, with this fix
applied. Android and desktop (`commonMain/ComposeView.kt`).
## Problem
Typing a live message and switching to another chat sends that message to
the chat that was opened, without the user sending anything. Reported on
desktop, where every chat switch reuses the same view.
## Cause
A live message is committed when the chat is switched
(`ComposeView.kt:1353-1369`), which before this change was:
```
if (cs.liveMessage != null && (cs.message.text.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
```
`KeyChangeEffect` is `LaunchedEffect(key1) { block(prev) }`
(`Utils.kt:683-698`), so when the key changes, `remember(key1)` rebuilds
it from the lambda of the composition that is running *now* - and by then
`chatModel.chatId` is already the new chat, `ChatView` has recomposed
`ComposeView` with the new `chat`, and the block that runs captured that
one.
`sendMessage(null)` → `sendMessageAsync` → `send(chat, ...)` uses the
captured `chat`, while the message content comes from `composeState`,
which is shared between the chats opened in this view. So the content of
the chat that was left is sent to the chat that was opened:
- `liveMessage.sent == false` - a new message is created in the wrong
chat, which is what is seen;
- `liveMessage.sent == true` - `apiUpdateChatItem` is called with the new
chat's type and id and the item id from the previous chat, which the
backend cannot resolve.
The same mismatch made the post-send `clearCurrentDraft()` clear the
draft of the chat opened after the switch, deleting a draft that was
never sent.
## Fix
`sendMessageAsync` and `sendMessage` take the chat the message was
composed in, defaulting to the chat this view shows
(`ComposeView.kt:685-694`, `988-993`). Only what a live message can reach
uses it: the message send, the update of an already sent live message,
and the two places that clear the draft after sending. Live messages have
no context item (`SendMsgView.kt:156-165` only offers the button when the
compose is empty and has none), so the forwarding, editing and reporting
branches cannot run with a chat other than the view's and keep using
`chat` - the parameter is not threaded through them.
The chat switch resolves the chat by the id it had before the switch:
```kotlin
val liveMessageChat = if (prevChatId == null || prevChatId == chat.id) chat else chatsCtx.getChat(prevChatId)
// if that chat is gone there is nowhere to send it, and it must not be sent to the chat opened instead
if (liveMessageChat != null) sendMessage(null, toChat = liveMessageChat, composed = cs) else clearState()
```
`prevChatId == chat.id` keeps the view's own chat, which is what secondary
(member support) chat views need - they share the group's chat id, and
only their `chat` carries the scope.
If the previous chat can no longer be found the message is not sent at
all, and the compose state is cleared so it does not leak into the chat
that was opened. Sending it to the chat that is open now is the defect
being fixed, so it is not used as a fallback.
### Handing the compose state over to the opened chat
Sending to the right chat is not enough on its own: `composeState` is
shared between the chats opened in this view, and this is the only branch
of `KeyChangeEffect` that neither resets it nor loads the opened chat's
draft - the branch that loads a draft (`else if (chatModel.draftChatId
.value == draftChatId(chatModel.chatId.value, chatScope) ...)`) is later
in the same `if` chain and cannot be reached. So the live message stayed
in the compose state of a view that now shows another chat, and that
chat's draft was never read.
`sendMessageAsync` then made it visible. It runs on `Dispatchers.Default`,
so its writes land after the switch:
```kotlin
val liveMessage = cs.liveMessage
if (!live) {
if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) // the whole composed state
sending() // and its spinner
}
```
The opened chat's input showed the text composed in the previous one until
the send completed and `clearState()` emptied it; the draft it should have
shown was still in the model, and the next switch away dropped it. This
predates this fix - without it the same writes happen, and there
`clearCurrentDraft()` resolves to the opened chat and deletes its draft
outright.
Four changes, all following from "this send no longer owns the compose
state":
- `sendMessageAsync` takes its `cs` as a parameter defaulting to
`composeState.value`, and `sendMessage` takes `composed: ComposeState? =
null`, so only the chat switch passes a state and every other sender
still reads it inside the coroutine, exactly where the send read it
before. The chat switch
captures it on the main thread before replacing it - without that the
send would read the compose state of the chat that was opened and send
*its draft* to the previous chat.
- `checkLinkPreview` takes that state too. It re-read `composeState`
rather than what was passed in, and it is reached by every text live
message through `updateMsgContent`, so with the compose state handed
over it would have rebuilt the message from the opened chat's draft, or
from nothing - overwriting the live message instead of committing it.
Only the calls a live message can reach pass the state. The forwarding
call site keeps reading the current one - it is unreachable from the
chat switch, and `forwardItem` suspends before it, so passing the
captured state there would drop what was typed while the forward was in
flight. The three senders that connect a prepared chat keep reading the
current one too.
- Every `composeState` write in `sendMessageAsync` is guarded by
`composeIsForSend()` (`toChat.id == chat.id`): directly for the two at
the start, and through `chatIsOpen` for the clear/restore at the end,
which #7308 already routes through `sentMessageInCompose`. It compares
the two chats rather than checking which one is open, so the send made
by a chat switch never takes the compose state back, not even if that
chat is opened again before the send completes.
`clearCurrentDraft(toChat)` is already keyed on the chat and needs no
guard. Whether the *view's own* send may still write when its chat has
been switched away is #7308's question, not this one's.
- The chat-switch branch then resets `composeState` to the opened chat's
draft, or to an empty state, like the branches below it do.
## Blast radius
`toChat` defaults to the chat this view shows, so every other send passes
no chat: the send button (`SendMsgView.kt`), the live updates while typing
(`sendMessageAsync(live = true)`), forwarding, editing and reporting. For
all of them `composeIsForSend()` is true, so every guard added here is a
no-op and they behave exactly as before. Only the send started by the chat
switch passes a different chat, and only the branches it can reach were
changed.
The one change not behind that guard is `checkLinkPreview` reading the
state passed in. It matters only where the two can differ, which is after
a suspension: the forwarding branch waits on `forwardItem`, so that call
site deliberately keeps reading the current state (it is unreachable from
the chat switch anyway). The other call sites are reached with nothing
suspending since the state was captured.
The live message update loop is not affected: it is started once
(`SendMsgView.kt:523-559`) with the `::updateLiveMessage` reference of the
composition in which live mode started, so its updates already go to the
chat the message belongs to. It exits because the chat switch replaces the
compose state with the opened chat's, which has no `liveMessage` - on the
main thread, as the chat is switched, rather than when the send completes
as before. Only that send was created fresh on every composition, which is
why it was the one going to the wrong chat.
Not covered, and unchanged: a live message in a member support chat that
is closed without changing the chat id is never committed - the effect
that commits it is keyed on the chat id, which does not change when that
view is closed.
`chatsCtx.getChat` searches the context's own list, and a secondary
context (member support, reports) is built with an empty one, so there it
can only return null. That branch is not reached from a support chat in
practice - it shares the group's chat id, so `prevChatId == chat.id` holds
and the view's own `chat` is used - and if it ever were, the message is
discarded rather than sent to the chat that was opened, which is the
behaviour intended for "the chat is gone" anyway.
## Verification
- `./gradlew :common:compileKotlinDesktop` — passes.
- Manual:
1. Start a live message in **A**, type, and switch to **B** while
typing. The message must appear in **A**; nothing is sent in **B**,
and B's input and draft are untouched.
2. Repeat with a draft already saved in **B** - it must still be there
after the switch. This is the case that was found failing: B showed
the text composed in A, then emptied when the send completed, and B's
draft was dropped on the next switch. Watch B's input from the moment
of the switch, not only after the send finishes.
3. Slow or failing send (network off) while doing 1 and 2, so the window
between the switch and the send completing is long enough to type in
**B** - what is typed there must survive the send completing.
4. The live message must carry a **link preview**: type a URL in **A**,
let the preview load, then switch. The message committed to A must be
the text that was composed - not the opened chat's draft, and not
empty. Every text live message is rebuilt through
`updateMsgContent` -> `checkLinkPreview`, so this is what breaks if
that one stops reading the state it was given.
5. Switch **back**: live message in A, switch to B, return to A and type
something new before the send completes. What is typed in A must
survive - the send handed the compose state over at the switch and
must not take it back.
6. Regressions: an ordinary send goes to the chat it was typed in;
forwarding still targets the chat it was forwarded to, and text typed
while a forward is in flight is still appended to it; reporting a
message still reports it in the chat it belongs to; sending in a
member support chat still goes to that scope.
## Merged with #7308
#7308 (a send that is still in flight when the chat is switched) landed in
`stable` first, so this branch was merged with it. Both changed the end of
`sendMessageAsync`, and the two guards are **not** the same rule - the
merge keeps both:
- here, `composeIsForSend()` = `toChat.id == chat.id` - is this send for
the chat this view shows, or for another one;
- in #7308, `chatIsOpen` = `chatModel.chatId.value == chat.id` - is the
chat this view shows still the one open.
`chatIsOpen` becomes the conjunction,
`composeIsForSend() && chatModel.chatId.value == chat.id`. Where `toChat`
is `chat` - every send but the one made by a chat switch - that reduces to
#7308's own check, so its behaviour is unchanged.
Nothing else in that block had to move. #7308 already routes both compose
writes through `sentMessageInCompose`, which derives from `chatIsOpen`, so
guarding `chatIsOpen` guards them; the rest of the change there is one
call site taking `toChat`, `clearCurrentDraft`. The draft id a failed
message is saved under keeps using `chat`: that branch is behind
`!liveSend`, which the send made by a chat switch never satisfies, so
`toChat` is always `chat` where it is read.
An earlier revision of this note said that #7308's `cs.liveMessage != null`
clause "already covers the send made by the chat switch". **It did not.**
At the time that clause sat outside the `chatIsOpen` check:
```kotlin
val sentMessageInCompose = live || cs.liveMessage != null || (chatIsOpen && composeState.value.inProgress)
```
which is correct only while a live message is always sent to the chat that
is open - the assumption this fix removes. Read as written, the clause
*exempts* the chat-switch send from the very guard that protects the
opened chat, and a merge that followed it reintroduced the leak described
above. #7308 shipped with the live clauses moved inside `chatIsOpen`,
which was a no-op on its own branch and is what makes this merge work.
@@ -0,0 +1,124 @@
# Fix "Save passphrase in settings" toggle unreachable on desktop
## Symptom
On 7.0 desktop (reproduced on the Linux AppImage and on Windows), in
Chat data → Database passphrase & export → Database passphrase, the
"Save passphrase in settings" toggle cannot be switched off. The reporter sees
the switch sitting slightly past the right edge of the section card. Because the
setting stays on, the passphrase remains stored in `settings.properties` and the
app never prompts for it on start — on desktop that file holds the passphrase in
clear text, since `Cryptor.desktop.kt` is an identity implementation.
This is distinct from the case where the switch is *rendered disabled*
(`DatabaseEncryptionView.kt:127`, `enabled = (!initialRandomDBPassphrase && !progressIndicator) || migration`),
which is intended behaviour for a database still using the initial random
passphrase. The reports here are from users who set their own passphrase, so
`initialRandomDBPassphrase == false` and the switch is enabled — just not
reachable.
## Root cause
1. `SavePassphraseSetting` is hand-rolled in both platform actuals
(`DatabaseEncryptionView.desktop.kt:43-53`, `.android.kt:43-53`) and is the
only toggle row in the app whose label carries no `weight`:
```kotlin
Text(stringResource(MR.strings.save_passphrase_in_settings), Modifier.padding(end = 24.dp))
Spacer(Modifier.fillMaxWidth().weight(1f))
DefaultSwitch(checked = useKeychain, onCheckedChange = onCheckedChange, enabled = enabled)
```
`Row` measures unweighted children first against the full available width, then
divides what is left among weighted ones. A label that does not comfortably fit
consumes the remainder, the weighted `Spacer` collapses to zero, and
`DefaultSwitch` is placed past the row's right edge.
2. Every other toggle row goes through `SettingsActionItemWithContent`
(`SettingsView.kt:380`), which gives the label `Modifier.weight(1f)`. There the
label truncates and the trailing control keeps its size and position, so the
same string length is harmless.
3. Before #6777 this row had enough slack and no clipping. `SectionView` was a
plain `Column` with no horizontal inset, and `SectionItemView` used
`DEFAULT_PADDING` (20.dp) per side — 348.dp of content width on the desktop
start pane (`DEFAULT_START_MODAL_WIDTH` = 388.dp). Any overflow still drew and
still received pointer events.
4. #6777 introduced `LocalCardScreen` / `CardColumnLayout` (`Section.kt`), which
wraps section content in `Modifier.padding(horizontal = CARD_PADDING /* 18.dp */)`
… `.clip(SectionCardShape)`, and switches `itemHPadding` from `DEFAULT_PADDING`
to `CARD_PADDING`. `DatabaseEncryptionView` is opened with `cardScreen = true`
(`DatabaseView.kt:235`), so its row content width drops 348.dp → **316.dp**.
5. `Modifier.clip` clips pointer input as well as drawing. The displaced switch is
therefore both cut off visually and unhittable — the toggle stops working rather
than merely looking wrong.
Budget arithmetic on the desktop start pane: fixed cost in the row is ~96.dp
(24 icon + 8 spacer + 24 label end-padding + ~40 switch), leaving ~220.dp for a
27-character label at 16.sp, which needs ~215.dp in English. Borderline at 100%
font scale and over budget as soon as the label is longer — a longer localization,
or a larger font size, since the label scales with `fontSizeSqrtMultiplier` while
`CARD_PADDING` does not.
The widths above are derived from the layout constants, not measured against a
running client; the reporter's observation that the switch sits slightly past the
card edge is what confirms the row overflows in practice.
## Fix
Move the weight onto the label and drop the weighted spacer, in both actuals:
```kotlin
Text(stringResource(MR.strings.save_passphrase_in_settings), Modifier.weight(1f).padding(end = 24.dp))
DefaultSwitch(checked = useKeychain, onCheckedChange = onCheckedChange, enabled = enabled)
```
The label now truncates instead of displacing the switch, matching what
`SettingsActionItemWithContent` does for every other toggle row.
The spacer has to go: leaving both the label and the spacer weighted would split
the remaining space between them and starve the label instead, which trades one
layout bug for another.
## Why this fix and not alternatives
- **Widening the card or shrinking `CARD_PADDING`** would buy back the ~32.dp lost
in #6777, but only until the next longer localization or font-size step. The row
would stay the one place in the app where a long label can push a control out of
reach.
- **Removing `clip` from `CardColumnLayout`** would restore clickability of
overflowing content, but the clip is what gives section cards their rounded
corners; dropping it would regress the design and leave the switch drawn outside
its card.
- **Shortening the string** is a translation-wide problem, not a fix, and does not
help at larger font sizes.
## Impact
- Desktop and Android only. Both actuals carry the identical defect; Android's row
is in fact narrower still (~288.dp on a 360.dp-wide screen), so it is affected at
least as much — it simply has not been reported.
- iOS is unaffected: `DatabaseEncryptionView.swift` uses a SwiftUI `Toggle` inside
`settingsRow`, where the label truncates and the toggle cannot be displaced. The
`initialRandomDBPassphrase` disabled-state logic is the same on iOS
(`DatabaseEncryptionView.swift:80`) and is unchanged by this fix.
- Users already stuck in the bad state have `StoreDBPassphrase=true` in
`settings.properties` with the passphrase stored alongside it. After this fix
they can turn the setting off in the UI, which removes the stored passphrase via
`removePassphraseFromKeyChain` and restores the prompt on start.
- No behaviour change beyond the row layout: no logic, preference, or string was
touched.
## Verification
- `bash ~/build/linux.sh` on this branch: cold `dist-newstyle`, `libsimplex.so`
rebuilt from master's sources, `:common:compileKotlinDesktop` executed,
`BUILD SUCCESSFUL`, AppImage produced.
- `bash ~/build/android.sh` on this branch: `BUILD SUCCESSFUL`, arm64-v8a debug APK
produced (native libs are the prebuilt ones, so this exercises the Kotlin change
only).
- Not done: the rendered row has not been checked in a running client. Worth
confirming at a raised font size and in a locale with a longer label, which is
the case that made the overflow visible in the first place.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."ee4dd0d8ded0f66f70a8890ce09d69e3610aa276" = "0zahz4011adavfz680wkcqvcrl2f9zjdx6dly6yqkcin4bpr6v3k";
"https://github.com/simplex-chat/simplexmq.git"."21568ab27711ff5e1f2c27f4db6546049fb83e29" = "1sfba1gpjy772qcdppf42javz98zc8hx71sakhiaqakrhy6vip7x";
"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";
+1 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 7.1.0.0
version: 7.1.0.1
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
+2 -1
View File
@@ -70,6 +70,7 @@ import Simplex.Chat.Types.Shared
import Simplex.Chat.Types.UITheme
import Simplex.Chat.Util (liftIOEither)
import Simplex.FileTransfer.Description (FileDescriptionURI)
import Simplex.Messaging.Server.Information (ServerPublicInfo)
import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo)
import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig, ServerCfg, Worker)
@@ -781,7 +782,7 @@ data ChatResponse
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId)
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure}
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure, serverInfo :: Maybe (Either String ServerPublicInfo)}
| CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, relayTestFailure :: Maybe RelayTestFailure}
| CRServerOperatorConditions {conditions :: ServerOperatorConditions}
| CRUserServers {user :: User, userServers :: [UserOperatorServers]}
+8 -2
View File
@@ -5,14 +5,20 @@ module Simplex.Chat.Files where
import Simplex.Chat.Controller
import Simplex.Messaging.Util (ifM)
import System.FilePath (combine, splitExtensions)
import System.FilePath (combine, makeValid, splitExtensions, takeFileName)
import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getHomeDirectory, getTemporaryDirectory)
safeFileNameStr :: String -> String
safeFileNameStr = notDots . makeValid . takeFileName
where
notDots n = if n == "." || n == ".." then "_" else n
-- | The file name is sanitized, so the combined path cannot escape the folder.
uniqueCombine :: FilePath -> String -> IO FilePath
uniqueCombine fPath fName = tryCombine (0 :: Int)
where
tryCombine n =
let (name, ext) = splitExtensions fName
let (name, ext) = splitExtensions $ safeFileNameStr fName
suffix = if n == 0 then "" else "_" <> show n
f = fPath `combine` (name <> suffix <> ext)
in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f)
+7 -4
View File
@@ -1675,8 +1675,9 @@ processChatCommand cxt nm = \case
aUserServer (AProtoServerWithAuth p' srv) = case testEquality p p' of
Just Refl -> pure $ AUS SDBNew $ newUserServer srv
Nothing -> throwCmdError $ "incorrect server protocol: " <> B.unpack (strEncode srv)
APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user ->
lift $ CRServerTestResult user srv <$> withAgent' (\a -> testProtocolServer a nm (aUserId user) server)
APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> do
r <- lift $ withAgent' $ \a -> testProtocolServer a nm (aUserId user) server
pure $ uncurry (CRServerTestResult user srv) $ either ((,Nothing) . Just) (Nothing,) r
TestProtoServer srv -> withUser $ \User {userId} ->
processChatCommand cxt nm $ APITestProtoServer userId srv
APITestChatRelay userId address -> withUserId userId $ \user -> do
@@ -4054,11 +4055,12 @@ processChatCommand cxt nm = \case
lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct'
pure $ CRContactPrefsUpdated user ct ct'
runUpdateGroupProfile :: User -> GroupInfo -> GroupProfile -> Bool -> CM ChatResponse
runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img'} domainVerified = do
runUpdateGroupProfile user gInfo@GroupInfo {businessChat, groupProfile = p@GroupProfile {displayName = n}} p'@GroupProfile {displayName = n', image = img', memberAdmission = ma'} domainVerified = do
assertUserGroupRole gInfo GROwner
when (n /= n') $ checkValidName n'
checkProfileImageSize img'
checkGroupProfileSize p'
when (useRelays' gInfo && isJust (ma' >>= review)) $ throwCmdError "Admission review is not supported in channels"
-- updateGroupProfile clears domain verification; re-set it when the caller already re-resolved the name
gInfo' <- withStore $ \db -> do
g <- updateGroupProfile db user gInfo p'
@@ -4210,10 +4212,11 @@ processChatCommand cxt nm = \case
groupMemberId <- getGroupMemberIdByName db user groupId groupMemberName
pure (groupId, groupMemberId)
newGroup :: User -> IncognitoEnabled -> GroupProfile -> Bool -> MemberId -> Maybe GroupKeys -> Maybe Int64 -> CM GroupInfo
newGroup user incognito gProfile@GroupProfile {displayName, image} useRelays memberId groupKeys_ publicMemberCount_ = do
newGroup user incognito gProfile@GroupProfile {displayName, image, memberAdmission} useRelays memberId groupKeys_ publicMemberCount_ = do
checkValidName displayName
checkProfileImageSize image
checkGroupProfileSize gProfile
when (useRelays && isJust (memberAdmission >>= review)) $ throwCmdError "Admission review is not supported in channels"
-- [incognito] generate incognito profile for group membership
incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing
withFastStore $ \db -> createNewGroup db cxt user gProfile incognitoProfile useRelays memberId groupKeys_ publicMemberCount_
+7 -5
View File
@@ -48,7 +48,7 @@ import Data.Word (Word32)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery
import Simplex.Chat.Files (getChatTempDirectory)
import Simplex.Chat.Files (getChatTempDirectory, safeFileNameStr)
import Simplex.Chat.Library.Internal
import Simplex.Chat.Web (channelContentChanged, channelProfileUpdated, channelRemoved)
import Simplex.Chat.Messages
@@ -101,7 +101,6 @@ import qualified Simplex.Messaging.TMap as TM
import Simplex.Messaging.Transport (TransportError (..))
import Simplex.Messaging.Util
import Simplex.Messaging.Version
import qualified System.FilePath as FP
import System.Mem.Weak (Weak)
import Text.Read (readMaybe)
import UnliftIO.Concurrent (ThreadId, forkIO, mkWeakThreadId)
@@ -916,7 +915,10 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
if useRelays' gInfo''
then do
introduceInChannel cxt user gInfo'' m'
when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m'
case mStatus of
GSMemPendingApproval -> pure ()
GSMemPendingReview -> pure ()
_ -> when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m'
else case mStatus of
GSMemPendingApproval -> pure ()
GSMemPendingReview -> introduceToModerators cxt user gInfo'' m'
@@ -1963,7 +1965,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
pure (ft', CIFile {fileId, fileName, fileSize, fileSource, fileStatus, fileProtocol})
mkValidFileInvitation :: FileInvitation -> FileInvitation
mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = FP.makeValid $ FP.takeFileName fileName}
mkValidFileInvitation fInv@FileInvitation {fileName} = fInv {fileName = safeFileNameStr fileName}
validateFileInvitation :: FileInvitation -> CM FileInvitation
validateFileInvitation fInv@FileInvitation {fileName, fileSize}
@@ -3885,7 +3887,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
Just author -> action author
Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author"
withVerifiedMsg :: MsgEncodingI e => GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a)
withVerifiedMsg :: GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> ParsedMsg e -> UTCTime -> (VerifiedMsg e -> CM a) -> CM (Maybe a)
withVerifiedMsg gInfo@GroupInfo {membership} scopeInfo member (ParsedMsg _ signedMsg_ chatMsg@ChatMessage {chatMsgEvent}) ts action =
case verified of
Just verifiedMsg -> Just <$> action verifiedMsg
+11 -2
View File
@@ -65,10 +65,10 @@ import Simplex.Messaging.Util
import Simplex.RemoteControl.Client
import Simplex.RemoteControl.Invitation (RCInvitation (..), RCSignedInvitation (..), RCVerifiedInvitation (..), verifySignedInvitation)
import Simplex.RemoteControl.Types
import System.FilePath (takeFileName, (</>))
import System.FilePath (takeDirectory, takeFileName, (</>))
import UnliftIO
import UnliftIO.Concurrent (forkIO)
import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile)
import UnliftIO.Directory (canonicalizePath, copyFile, createDirectoryIfMissing, doesDirectoryExist, removeDirectoryRecursive, renameFile)
remoteFilesFolder :: String
remoteFilesFolder = "simplex_v1_files"
@@ -574,10 +574,19 @@ handleStoreFile rfKN fileName fileSize fileDigest getChunk =
Nothing -> storeFileTo =<< getDefaultFilesFolder
storeFileTo :: FilePath -> CM' (Either RemoteProtocolError FilePath)
storeFileTo dir = liftIO . tryAllErrors' $ do
unless (validRemoteFileName fileName) $ throwError $ RPEInvalidBody "invalid file name"
filePath <- liftIO $ dir `uniqueCombine` fileName
-- resolves symlinks, so it also catches a final component linking outside the folder
canonPath <- liftIO $ canonicalizePath filePath
inDir <- liftIO $ (takeDirectory canonPath ==) <$> canonicalizePath dir
unless inDir $ throwError $ RPEInvalidBody "file path outside of files folder"
receiveEncryptedFile rfKN getChunk fileSize fileDigest filePath
pure filePath
-- The controller only ever sends a bare file name (see storeRemoteFile), so a path is a protocol violation.
validRemoteFileName :: FilePath -> Bool
validRemoteFileName fName = fName == takeFileName fName && fName `notElem` (["", ".", ".."] :: [FilePath])
handleGetFile :: User -> RemoteFile -> Respond -> CM ()
handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do
logDebug $ "GetFile: " <> tshow filePath
+5 -1
View File
@@ -126,7 +126,11 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRChatContentTypes cts -> [plain $ "Chat content types: " <> T.intercalate ", " (map (safeDecodeUtf8 . strEncode) cts)]
CRChatTags u tags -> ttyUser u [viewJSON tags]
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
CRServerTestResult u srv testFailure info -> ttyUser u $ viewServerTestResult srv testFailure <> maybe [] viewServerInfo info
where
viewServerInfo = \case
Left e -> [plain $ "Server Info Error: " <> T.pack e]
Right i -> [plain $ "Server Info: " <> tshow i]
CRChatRelayTestResult u relayProfile_ relayTestFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ relayTestFailure_
CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca
CRUserServers u uss -> ttyUser u $ concatMap viewUserServers uss <> (if testView then [] else serversUserHelp)
+3 -8
View File
@@ -19,9 +19,7 @@ import Directory.Captcha
import Directory.Listing
import Directory.Options
import Directory.Service
import Directory.Store
import System.Directory (emptyPermissions, setOwnerExecutable, setOwnerReadable, setOwnerWritable, setPermissions)
import System.IO (hClose)
import Simplex.Chat.Bot.KnownContacts
import Simplex.Chat.Controller (ChatConfig (..))
import qualified Simplex.Chat.Markdown as MD
@@ -136,8 +134,6 @@ mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder =
profileNameLimit = maxBound,
captchaGenerator = Nothing,
voiceCaptchaGenerator = Nothing,
directoryLog = Just $ ps </> "directory_service.log",
migrateDirectoryLog = Nothing,
serviceName = "SimpleX Directory",
clientService = True,
runCLI = False,
@@ -1801,11 +1797,10 @@ withDirectoryOwnersGroup ps cfg dsLink createOwnersGroup webFolder test = do
test superUser dsLink
runDirectory :: ChatConfig -> DirectoryOpts -> IO () -> IO ()
runDirectory cfg opts@DirectoryOpts {directoryLog} action = do
st <- openDirectoryLog directoryLog
t <- forkIO $ directoryService st opts cfg
runDirectory cfg opts action = do
t <- forkIO $ directoryService opts cfg
threadDelay 500000
action `finally` (mapM_ hClose (directoryLogFile st) >> killThread t)
action `finally` killThread t
registerGroup :: TestCC -> TestCC -> String -> String -> IO ()
registerGroup su u n fn = registerGroupId su u n fn 1 1
+35 -4
View File
@@ -11,22 +11,25 @@ import ChatTests.DBUtils
import ChatTests.Utils
import Control.Logger.Simple
import Control.Monad
import Control.Monad.Except (runExceptT)
import qualified Data.Aeson as J
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.List (find, isPrefixOf)
import qualified Data.Map.Strict as M
import Simplex.Chat.Controller (ChatCommand (..), ChatConfig (..), versionNumber)
import Simplex.Chat.Files (safeFileNameStr)
import Simplex.Chat.Library.Commands (parseChatCommand)
import qualified Simplex.Chat.Controller as Controller
import Simplex.Chat.Mobile.File
import Simplex.Chat.Remote (remoteFilesFolder)
import Simplex.Chat.Remote (remoteFilesFolder, validRemoteFileName)
import Simplex.Chat.Remote.Protocol (remoteStoreFile)
import Simplex.Chat.Remote.Types
import Simplex.Messaging.Crypto.File (CryptoFileArgs (..))
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Util
import Simplex.RemoteControl.Types (RCCtrlAddress (..))
import System.FilePath ((</>))
import System.FilePath (takeFileName, (</>))
import Test.Hspec hiding (it)
import UnliftIO
import UnliftIO.Concurrent
@@ -40,10 +43,26 @@ remoteTests = describe "Remote" $ do
`shouldSatisfy` \case
Right (StartRemoteHost Nothing (Just (RCCtrlAddress _ "Ethernet 2")) (Just 12345)) -> True
_ -> False
describe "stored file name" $ do
it "rejects names with directory components" $ \_ ->
filter validRemoteFileName ["../x", "../../etc/passwd", "/etc/cron.d/x", "a/b", "x/", "", ".", ".."]
`shouldBe` []
it "accepts bare file names" $ \_ ->
filter (not . validRemoteFileName) ["test.pdf", "test_1.pdf", ".hidden", "a b.tar.gz"]
`shouldBe` []
it "sanitizes any name to a real file name" $ \_ ->
filter (not . sanitized) fileNames `shouldBe` []
it "sanitizes to a name with no directory components" $ \_ ->
filter (not . bareName) fileNames `shouldBe` []
xdescribe "No compression" $ aroundWith (. ((False, False),)) runRemoteTests
xdescribe "Mobile offers compression" $ aroundWith (. ((True, False),)) runRemoteTests
xdescribe "Desktop offers compression" $ aroundWith (. ((False, True),)) runRemoteTests
describe "With compression" $ aroundWith (. ((True, True),)) runRemoteTests
where
fileNames :: [FilePath]
fileNames = ["", ".", "..", "...", "../x", "../../etc/passwd", "/etc/cron.d/x", "a/b", "x/", "test.pdf", ".hidden", "a b.tar.gz"]
sanitized n = let n' = safeFileNameStr n in n' /= "" && n' /= "." && n' /= ".."
bareName n = let n' = safeFileNameStr n in n' == takeFileName n'
runRemoteTests :: SpecWith ((Bool, Bool), TestParams)
runRemoteTests = do
@@ -243,8 +262,9 @@ remoteStoreFileTest =
contactBob desktop bob
rhs <- readTVarIO (Controller.remoteHostSessions $ chatController desktop)
desktopHostStore <- case M.lookup (RHId 1) rhs of
Just (_, RHSessionConnected {storePath}) -> pure $ desktopHostFiles </> storePath </> remoteFilesFolder
(rhClient, desktopHostStore) <- case M.lookup (RHId 1) rhs of
Just (_, RHSessionConnected {rhClient, storePath}) ->
pure (rhClient, desktopHostFiles </> storePath </> remoteFilesFolder)
_ -> fail "Host session 1 should be started"
desktop ##> "/store remote file 1 tests/fixtures/test.pdf"
desktop <## "file test.pdf stored on remote host 1"
@@ -261,6 +281,17 @@ remoteStoreFileTest =
chatReadFile (mobileFiles </> "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src)
chatReadFile (desktopHostStore </> "test_2.pdf") (strEncode key) (strEncode nonce) `shouldReturn` Right (LB.fromStrict src)
-- the host rejects a traversal name before draining the attachment; only calling the protocol
-- directly can put such a name on the wire, as /store remote file sanitizes it controller-side
runExceptT (remoteStoreFile rhClient "tests/fixtures/test.pdf" "../x") >>= \case
Left (RPEInvalidBody _) -> pure ()
r -> fail $ "expected RPEInvalidBody, got " <> show r
doesFileExist "./tests/tmp/x" `shouldReturn` False
-- the undrained attachment did not break the session
desktop ##> "/store remote file 1 tests/fixtures/test.pdf"
desktop <## "file test_3.pdf stored on remote host 1"
B.readFile (mobileFiles </> "test_3.pdf") `shouldReturn` src
removeFile (desktopHostStore </> "test_1.pdf")
removeFile (desktopHostStore </> "test_2.pdf")
+3 -1
View File
@@ -264,6 +264,8 @@
"index-hero-h1": "Be<br>Free",
"index-hero-h2": "In Your Network",
"index-hero-p1": "The first network without user IDs.<br>You own your contacts, groups and channels.",
"index-hero-invest": "Invest in SimpleX Chat.",
"index-hero-invest-cta": "Learn more on Wefunder.",
"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",
@@ -370,7 +372,7 @@
"file-proto-h-4": "Independent data routers",
"file-proto-p-4": "When file is split to fragments, it is sent via network routers operated by independent parties. No operator can see the actual file size or name. Even if a router is compromised, it can only see encrypted fragments of fixed size. File fragments are cached by network routers for approximately 48 hours.",
"file-proto-spec": "Read the XFTP protocol specification →",
"links": "Links",
"links": "Community",
"links-title": "Community Links",
"links-all-languages": "All languages"
}
+2 -2
View File
@@ -110,11 +110,11 @@
<hr>
<!-- <li class="nav-link {% if active_links %}active{% endif %}">
<li class="nav-link {% if active_links %}active{% endif %}">
<a href="/links">
<span class="nav-link-text">{{ "links" | i18n({}, lang ) | safe }}</span>
</a>
</li> -->
</li>
<li class="nav-link {% if active_blog %}active{% endif %}">
<a href="/blog">
+19 -1
View File
@@ -434,6 +434,16 @@ section.cover div.content p {
max-width: calc(var(--sec-vwu) * 53);
}
section.cover div.content p.invest {
font-weight: 300;
}
section.cover div.content p.invest a {
font-weight: 500;
text-decoration: none;
white-space: nowrap;
}
.publications-btns {
position: absolute;
bottom: 24px;
@@ -960,7 +970,7 @@ main .section-bg {
@media (max-width: 959px) {
section.cover div.content {
gap: calc(var(--sec-vhu) * 2.5);
transform: translateY(calc(var(--sec-vhu) * 5));
transform: translateY(calc(var(--sec-vhu) * 3));
}
.publications-btns,
@@ -994,6 +1004,14 @@ main .section-bg {
max-width: calc(var(--sec-vwu) * 93);
}
section.cover div.content p.invest {
font-weight: 400;
}
section.cover div.content p.invest a {
font-weight: 600;
}
/* --- MAIN SECTIONS --- */
.page .text-container {
justify-content: flex-end;
+1
View File
@@ -100,6 +100,7 @@ active_home: true
<h1>{{ "index-hero-h1" | i18n({}, lang) | safe }}</h1>
<h2 class="gradient-text">{{ "index-hero-h2" | i18n({}, lang) | safe }}</h2>
<p>{{ "index-hero-p1" | i18n({}, lang) | safe }}</p>
<p class="invest">{{ "index-hero-invest" | i18n({}, lang) | safe }} <a class="gradient-text" href="https://wefunder.com/simplexchat" target="_blank">{{ "index-hero-invest-cta" | i18n({}, lang) | safe }}</a></p>
<div class="socials">
<a href="/downloads" class="desktop-app-btn hidden" title="{{ 'index-hero-download-desktop-btn-title' | i18n({}, lang) }}">
+1 -1
View File
@@ -8,7 +8,7 @@ templateEngineOverride: njk
<div data-simplex-channel-preview
data-channel-link="https://smp18.simplex.im/c#grcfG3ulVI4Sh6ow33qBsmSk7uEy3gRSl2KkJ5ER6tA"
data-channel-id="8Ge2ASVRkbDhdJc05ZgSSnZmSjw3UMUaQW_QttI0j_w="
data-relay-domains="relay1.simplex.chat,relay2.simplex.chat"
data-relay-domains="relay1.simplex.chat,relay2.simplex.chat,relay3.simplex.chat"
data-app-download-buttons="off"
data-color-scheme="site"
data-top-offset="54"