mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-20 10:12:24 +00:00
Merge branch 'master' into master-android
This commit is contained in:
@@ -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!
|
||||
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
+88
-37
@@ -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
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1448,6 +1448,7 @@ Search:
|
||||
**Enum type**:
|
||||
- "human"
|
||||
- "bot"
|
||||
- "business"
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }}">
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user