mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 17:13:10 +00:00
Merge branch 'master' into ep/address-dr
This commit is contained in:
60 files changed
+1350
-260
No files matched your search
-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
|
||||
|
||||
+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("")
|
||||
|
||||
+6
-3
@@ -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() },
|
||||
|
||||
+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()
|
||||
|
||||
+15
-1
@@ -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()) {
|
||||
|
||||
+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,
|
||||
|
||||
+21
-31
@@ -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()
|
||||
|
||||
+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
|
||||
|
||||
-1
@@ -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),
|
||||
|
||||
+21
-10
@@ -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) {
|
||||
|
||||
-1
@@ -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>
|
||||
|
||||
+31
@@ -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
|
||||
* */
|
||||
|
||||
-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) {
|
||||
|
||||
Reference in new issue
Block a user