Merge branch 'master' into ep/address-dr

This commit is contained in:
Evgeny Poberezkin committed 2026-07-24 10:56:52 +01:00
commit 8433d70f7c
60 files changed
+1350 -260

No files matched your search

@@ -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)
@@ -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
@@ -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("")
@@ -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 ->
@@ -1960,7 +1963,7 @@ fun BoxScope.ChatItemsList(
}
false
}
val swipeableModifier = if (appPlatform.isDesktop || !chatInfo.sendMsgEnabled) Modifier else SwipeToDismissModifier(
val swipeableModifier = if (appPlatform.isDesktop || !chatInfo.sendMsgEnabled || cItem.meta.itemDeleted != null) Modifier else SwipeToDismissModifier(
state = dismissState,
directions = setOf(DismissDirection.EndToStart),
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
@@ -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()
@@ -25,6 +25,7 @@ import chat.simplex.common.views.chat.chatViewScrollState
import dev.icerock.moko.resources.StringResource
import java.io.File
import java.net.URI
import kotlin.math.roundToInt
@Composable
fun CIVideoView(
@@ -38,12 +39,25 @@ fun CIVideoView(
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
val preview = remember(image) { base64ToBitmap(image) }
Box(
Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID)
.then(
if (!smallView) {
val w = if (preview.width * 0.97 <= preview.height) videoViewFullWidth(LocalWindowWidth()) * 0.75f else DEFAULT_MAX_IMAGE_WIDTH
// Size the media box from the preview aspect ratio (as CIImageView does), else the unprepared player surface
// expands to PriorityLayout's max height and shows as a black strip; height tracks the clamped width (#7223).
Modifier.width(w).layout { measurable, constraints ->
val width = constraints.maxWidth.coerceAtMost(w.roundToPx().coerceAtLeast(0))
val height = (width * (preview.height.toFloat() / preview.width.toFloat()).coerceAtMost(2.33f)).roundToInt().coerceAtMost(constraints.maxHeight)
val placeable = measurable.measure(Constraints.fixed(width, height))
layout(width, height) { placeable.place(0, 0) }
}
} else Modifier
)
.desktopModifyBlurredState(!smallView, blurred, showMenu),
contentAlignment = Alignment.TopEnd
) {
val preview = remember(image) { base64ToBitmap(image) }
val filePath = remember(file, CIFile.cachedRemoteFileRequests.toList()) { mutableStateOf(getLoadedFilePath(file)) }
val sizeMultiplier = if (smallView) 0.38f else 1f
if (chatModel.connectedToRemote()) {
@@ -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,
@@ -367,7 +367,7 @@ private fun SMPSubscriptionsSection(subs: SMPServerSubs, summary: SMPServerSumma
)
SubscriptionStatusIndicatorView(subs, summary.sessionsOrNew.hasSess)
}
Column(Modifier.padding(PaddingValues()).fillMaxWidth()) {
SectionView {
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_subscribed),
numOrDash(subs.ssActive)
@@ -540,20 +540,16 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant,
)
)
}
if (summary.stats != null || summary.sessions != null) {
SectionDividerSpaced()
}
}
if (summary.stats != null) {
XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt)
if (summary.sessions != null) {
SectionDividerSpaced()
}
}
if (summary.stats != null) {
SectionDividerSpaced()
XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt)
}
if (summary.sessions != null) {
ServerSessionsView(summary.sessions)
}
if (summary.sessions != null) {
SectionDividerSpaced()
ServerSessionsView(summary.sessions)
}
SectionBottomSpacer()
@@ -572,27 +568,21 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r
)
)
}
if (summary.stats != null || summary.subs != null || summary.sessions != null) {
SectionDividerSpaced()
}
}
if (summary.stats != null) {
SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt)
if (summary.subs != null || summary.sessions != null) {
SectionDividerSpaced()
}
}
if (summary.stats != null) {
SectionDividerSpaced()
SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt)
}
if (summary.subs != null) {
SMPSubscriptionsSection(subs = summary.subs, summary = summary, rh = rh)
if (summary.sessions != null) {
SectionDividerSpaced()
}
}
if (summary.subs != null) {
SectionDividerSpaced()
SMPSubscriptionsSection(subs = summary.subs, summary = summary, rh = rh)
}
if (summary.sessions != null) {
ServerSessionsView(summary.sessions)
}
if (summary.sessions != null) {
SectionDividerSpaced()
ServerSessionsView(summary.sessions)
}
SectionBottomSpacer()
@@ -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
}
@@ -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
@@ -196,7 +196,6 @@ private fun MutableState<MigrationFromState>.ChatStopInProgressView() {
private fun MutableState<MigrationFromState>.ChatStopFailedView(reason: String) {
SectionView(stringResource(MR.strings.error_stopping_chat)) {
Text(reason)
SectionDividerSpaced()
SettingsActionItemWithContent(
icon = painterResource(MR.images.ic_report_filled),
text = stringResource(MR.strings.auth_stop_chat),
@@ -147,8 +147,11 @@ private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList<Re
private fun ConnectingDesktop(session: RemoteCtrlSession, rc: RemoteCtrlInfo?) {
AppBarTitle(stringResource(MR.strings.connecting_to_desktop))
SectionView(stringResource(MR.strings.connecting_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
CtrlDeviceVersionText(session)
Column {
CtrlDeviceNameText(session, rc)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
}
}
if (session.sessionCode != null) {
@@ -223,10 +226,12 @@ private fun FoundDesktop(
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.found_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
CtrlDeviceVersionText(session)
if (!compatible) {
Text(stringResource(MR.strings.not_compatible), color = MaterialTheme.colors.error)
Column {
CtrlDeviceNameText(session, rc)
CtrlDeviceVersionText(session)
if (!compatible) {
Text(stringResource(MR.strings.not_compatible), color = MaterialTheme.colors.error)
}
}
}
@@ -255,8 +260,11 @@ private fun FoundDesktop(
private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList<RemoteCtrlInfo>) {
AppBarTitle(stringResource(MR.strings.verify_connection))
SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
CtrlDeviceNameText(session, rc)
CtrlDeviceVersionText(session)
Column {
CtrlDeviceNameText(session, rc)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
}
}
SectionDividerSpaced()
@@ -308,8 +316,11 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) {
private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) {
AppBarTitle(stringResource(MR.strings.connected_to_desktop))
SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(rc.deviceViewName)
CtrlDeviceVersionText(session)
Column {
Text(rc.deviceViewName)
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
CtrlDeviceVersionText(session)
}
}
if (session.sessionCode != null) {
@@ -561,7 +561,6 @@ object AppearanceScope {
color = if (chatModel.remoteHostId != null && themeUserDestination.value != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
SectionDividerSpaced()
}
val state: State<DefaultThemeMode?> = remember(appPrefs.currentTheme.get()) {
@@ -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>
@@ -14,10 +14,12 @@ import chat.simplex.res.MR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.awt.FileDialog
import java.awt.event.ActionListener
import java.io.File
import javax.swing.JFileChooser
import javax.swing.filechooser.FileFilter
import javax.swing.filechooser.FileNameExtensionFilter
import javax.swing.plaf.basic.BasicFileChooserUI
@Composable
actual fun DefaultDialog(
@@ -77,6 +79,13 @@ fun FrameWindowScope.FileDialogChooserMultiple(
fileChooser.dialogTitle = title
fileChooser.isMultiSelectionEnabled = allowMultiple && isLoad
fileChooser.isAcceptAllFileFilterUsed = fileFilter == null
// Only install the glob bypass for the real file-save case (filename != null). When filename
// is null the dialog runs in DIRECTORIES_ONLY mode (e.g. "Save QR code as image"), where the
// Save button must approve the selected directory — the literal-filename handler would instead
// traverse into it (or no-op on an empty field), making directory selection impossible.
if (!isLoad && filename != null && desktopPlatform.isLinux()) {
installUnixSaveGlobBypass(fileChooser)
}
if (fileFilter != null && fileFilterDescription != null) {
fileChooser.addChoosableFileFilter(object: FileFilter() {
override fun accept(file: File?): Boolean = fileFilter(file)
@@ -120,6 +129,28 @@ fun FrameWindowScope.FileDialogChooserMultiple(
}
}
// Replace the Save button's action with a literal-filename handler. This bypasses JFileChooser's
// glob-on-save behaviour, which mis-handles '[' as a glob char on Unix (breaking filenames like
// '[1].pdf') and is not a feature of any native OS save dialog — macOS NSSavePanel and native
// Windows / Linux GTK / KDE save dialogs all treat the typed filename as a literal name.
private fun installUnixSaveGlobBypass(fc: JFileChooser) {
val ui = fc.ui as? BasicFileChooserUI ?: return
val original: ActionListener = ui.approveSelectionAction
val btn = ui.getDefaultButton(fc) ?: return
btn.removeActionListener(original)
btn.addActionListener {
val name = ui.fileName?.takeIf { it.isNotEmpty() } ?: return@addActionListener
val typed = File(name)
val target = if (typed.isAbsolute) typed else File(fc.currentDirectory, name)
if (target.isDirectory && fc.isTraversable(target)) {
fc.currentDirectory = target
} else {
fc.selectedFile = target
fc.approveSelection()
}
}
}
/*
* Has graphic glitches on many Linux distributions, so use only on non-Linux systems. Also file filter doesn't work on Windows
* */
@@ -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) {