Merge branch 'master' into master-android

This commit is contained in:
Evgeny Poberezkin committed 2025-07-15 15:39:41 +01:00
commit cb043120e5
32 files changed
+448 -246

No files matched your search

@@ -46,7 +46,7 @@ actual fun SaveOrOpenFileMenu(
}
ItemAction(
stringResource(MR.strings.save_verb),
painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download),
painterResource(MR.images.ic_download),
color = MaterialTheme.colors.primary,
onClick = {
saveFile()
@@ -28,7 +28,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE)
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
when (cItem.content.msgContent) {
is MsgContent.MCImage -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R || writePermissionState.status == PermissionStatus.Granted) {
@@ -1743,9 +1743,9 @@ data class Contact(
val active get() = contactStatus == ContactStatus.Active
override val nextConnect get() = sendMsgToConnect
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
override val nextConnectPrepared get() = preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared)
override val nextConnectPrepared get() = active && preparedContact != null && (activeConn == null || activeConn.connStatus == ConnStatus.Prepared)
override val profileChangeProhibited get() = activeConn != null
val nextAcceptContactRequest get() = contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New)
val nextAcceptContactRequest get() = active && contactRequestId != null && (activeConn == null || activeConn.connStatus == ConnStatus.New)
val sendMsgToConnect get() = nextSendGrpInv || nextConnectPrepared
override val incognito get() = contactConnIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) {
@@ -2872,6 +2872,7 @@ object ChatController {
}
if (
chatModel.chatId.value == r.groupInfo.id
&& !r.groupInfo.membership.memberPending
&& ModalManager.end.hasModalOpen(ModalViewId.SECONDARY_CHAT)
&& chatModel.secondaryChatsContext.value?.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext
) {
@@ -989,7 +989,7 @@ fun ChatLayout(
if (oneHandUI.value) {
StatusBarBackground()
}
Column(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
Column(if (oneHandUI.value && chatBottomBar.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
Box {
if (selectedChatItems.value == null) {
MemberSupportChatAppBar(chatsCtx, chatsCtx.secondaryContextFilter.groupScopeInfo.groupMember_, { ModalManager.end.closeModal() }, onSearchValueChanged)
@@ -1101,86 +1101,87 @@ fun ComposeView(
}
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when (it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> if (it.durationMs > 300) {
onAudioAdded(it.filePath, it.durationMs, true)
} else {
cancelVoice()
}
is RecordingState.NotStarted -> {}
}
}
}
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)) {
sendMessage(null)
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
@Composable
fun SendMsgView_(
disableSendButton: Boolean,
placeholder: String? = null,
sendToConnect: (() -> Unit)? = null
) {
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when (it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> if (it.durationMs > 300) {
onAudioAdded(it.filePath, it.durationMs, true)
} else {
cancelVoice()
}
is RecordingState.NotStarted -> {}
}
}
}
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)) {
sendMessage(null)
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
@@ -56,6 +56,7 @@ fun MemberSupportChatAppBar(
onSearchValueChanged: (String) -> Unit
) {
val oneHandUI = remember { ChatController.appPrefs.oneHandUI.state }
val chatBottomBar = remember { ChatController.appPrefs.chatBottomBar.state }
val showSearch = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
@@ -71,7 +72,7 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
title = { MemberSupportChatToolbarTitle(scopeMember_) },
onTitleClick = null,
onTop = !oneHandUI.value,
onTop = !oneHandUI.value || !chatBottomBar.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
@@ -85,7 +86,7 @@ fun MemberSupportChatAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
fixedTitleText = stringResource(MR.strings.support_chat),
onTitleClick = null,
onTop = !oneHandUI.value,
onTop = !oneHandUI.value || !chatBottomBar.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
@@ -212,61 +212,75 @@ fun ChatPreviewView(
fun chatPreviewText() {
val previewText = chatPreviewInfoText()
val ci = chat.chatItems.lastOrNull()
if (ci?.content?.hasMsgContent != true && previewText != null) {
if (chatModelDraftChatId == chat.id && chatModelDraft != null) {
val sp20 = with(LocalDensity.current) { 20.sp.toDp() }
val (text: CharSequence, inlineTextContent) = remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) }
val formattedText = null
MarkdownText(
text,
formattedText,
toggleSecrets = false,
linkMode = linkMode,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = Inter,
fontSize = 15.sp,
color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight,
lineHeight = 21.sp
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth()
)
} else if (ci?.content?.hasMsgContent != true && previewText != null) {
Text(previewText.first, color = previewText.second)
} else if (ci != null) {
if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) {
val sp20 = with(LocalDensity.current) { 20.sp.toDp() }
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) }
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
val prefix = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName
else -> null
},
mentions = ci.mentions,
userMemberId = when {
cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId
else -> null
},
toggleSecrets = false,
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = Inter,
fontSize = 15.sp,
color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight,
lineHeight = 21.sp
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
prefix = prefix
)
} else if (ci != null && showChatPreviews) {
val (text: CharSequence, inlineTextContent) = when {
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci, chat.chatInfo) to null
}
val formattedText = when {
ci.meta.itemDeleted == null -> ci.formattedText
else -> null
}
val prefix = when (val mc = ci.content.msgContent) {
is MsgContent.MCReport ->
buildAnnotatedString {
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
}
}
else -> null
}
MarkdownText(
text,
formattedText,
sender = when {
cInfo is ChatInfo.Group && !ci.chatDir.sent && !ci.meta.showGroupAsSender -> ci.memberDisplayName
else -> null
},
mentions = ci.mentions,
userMemberId = when {
cInfo is ChatInfo.Group -> cInfo.groupInfo.membership.memberId
else -> null
},
toggleSecrets = false,
linkMode = linkMode,
senderBold = true,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
style = TextStyle(
fontFamily = Inter,
fontSize = 15.sp,
color = if (isInDarkTheme()) MessagePreviewDark else MessagePreviewLight,
lineHeight = 21.sp
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
prefix = prefix
)
}
}
@@ -120,7 +120,7 @@ fun SubscriptionStatusIndicatorView(subs: SMPServerSubs, hasSess: Boolean, leadi
val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg())
val statusColorAndPercentage = subscriptionStatusColorAndPercentage(chatModel.networkInfo.value.online, netCfg.value.socksProxy, subs, hasSess)
val pref = remember { chatModel.controller.appPrefs.networkShowSubscriptionPercentage }
val percentageText = "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%"
val percentageText = if (subs.total > 0 || hasSess) "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%" else "%"
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -32,7 +32,7 @@ actual fun ReactionIcon(text: String, fontSize: TextUnit) {
@Composable
actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserLauncher, showMenu: MutableState<Boolean>) {
ItemAction(stringResource(MR.strings.save_verb), painterResource(if (cItem.file?.fileSource?.cryptoArgs == null) MR.images.ic_download else MR.images.ic_lock_open_right), onClick = {
ItemAction(stringResource(MR.strings.save_verb), painterResource(MR.images.ic_download), onClick = {
val saveIfExists = {
when (cItem.content.msgContent) {
is MsgContent.MCImage, is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> withLongRunningApi { saveFileLauncher.launch(cItem.file?.fileName ?: "") }