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