core, ui: markdown for hyperlinks, warn on unsanitized links, option to sanitize sent links (#6160)

* core: markdown for "hidden" links

* update, test

* api docs

* chatParseUri FFI function

* ios: hyperlinks, offer to open sanitized links, an option to send sanitized links (enabled by default)

* update markdown

* android, desktop: ditto

* ios: export localizations

* core: rename constructor, change Maybe semantics for web links

* rename
This commit is contained in:
Evgeny
2025-08-09 10:52:35 +01:00
committed by GitHub
parent b4293e361b
commit ef60ceea12
55 changed files with 1004 additions and 288 deletions

View File

@@ -64,6 +64,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_parse_uri(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
extern int chat_json_length(const char *str);
@@ -146,6 +147,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, __unused j
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, __unused jclass clazz, jstring str) {
const char *_str = (*env)->GetStringUTFChars(env, str, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_parse_uri(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused jclass clazz, jstring pwd, jstring salt) {
const char *_pwd = (*env)->GetStringUTFChars(env, pwd, JNI_FALSE);

View File

@@ -37,6 +37,7 @@ extern char *chat_recv_msg(chat_ctrl ctrl); // deprecated
extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str);
extern char *chat_parse_uri(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
extern int chat_json_length(const char *str);
@@ -156,6 +157,14 @@ Java_chat_simplex_common_platform_CoreKt_chatParseServer(JNIEnv *env, jclass cla
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatParseUri(JNIEnv *env, jclass clazz, jstring str) {
const char *_str = encode_to_utf8_chars(env, str);
jstring res = decode_to_utf8_string(env, chat_parse_uri(_str));
(*env)->ReleaseStringUTFChars(env, str, _str);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass clazz, jstring pwd, jstring salt) {
const char *_pwd = encode_to_utf8_chars(env, pwd);

View File

@@ -4369,19 +4369,12 @@ sealed class MsgChatLink {
@Serializable
class FormattedText(val text: String, val format: Format? = null) {
fun link(mode: SimplexLinkMode): String? = when (format) {
is Format.Uri -> if (text.startsWith("http://", ignoreCase = true) || text.startsWith("https://", ignoreCase = true)) text else "https://$text"
is Format.SimplexLink -> if (mode == SimplexLinkMode.BROWSER) text else format.simplexUri
is Format.Email -> "mailto:$text"
is Format.Phone -> "tel:$text"
else -> null
}
fun viewText(mode: SimplexLinkMode): String =
if (format is Format.SimplexLink && mode == SimplexLinkMode.DESCRIPTION) simplexLinkText(format.linkType, format.smpHosts) else text
fun simplexLinkText(linkType: SimplexLinkType, smpHosts: List<String>): String =
"${linkType.description} (${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
val linkUri: String? get() =
when (format) {
is Format.Uri -> text
is Format.HyperLink -> format.linkUri
else -> null
}
companion object {
fun plain(text: String): List<FormattedText> = if (text.isEmpty()) emptyList() else listOf(FormattedText(text))
@@ -4397,7 +4390,13 @@ sealed class Format {
@Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format()
@Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format()
@Serializable @SerialName("simplexLink") class SimplexLink(val showText: String?, val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format() {
val simplexLinkText: String get() =
"${linkType.description} $viaHosts"
val viaHosts: String get() =
"(${String.format(generalGetString(MR.strings.simplex_link_connection), smpHosts.firstOrNull() ?: "?")})"
}
@Serializable @SerialName("command") class Command(val commandStr: String): Format()
@Serializable @SerialName("mention") class Mention(val memberName: String): Format()
@Serializable @SerialName("email") class Email: Format()
@@ -4412,6 +4411,7 @@ sealed class Format {
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is HyperLink -> linkStyle
is SimplexLink -> linkStyle
is Command -> SpanStyle(color = MaterialTheme.colors.primary, fontFamily = FontFamily.Monospace)
is Mention -> SpanStyle(fontWeight = FontWeight.Medium)

View File

@@ -104,6 +104,9 @@ class AppPreferences {
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val privacyLinkPreviewsShowAlert = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, true)
val privacySanitizeLinks = mkBoolPreference(SHARED_PREFS_PRIVACY_SANITIZE_LINKS, true)
// TODO remove
val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } }
val simplexLinkMode: SharedPreference<SimplexLinkMode> = mkSafeEnumPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default)
val privacyShowChatPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS, true)
@@ -369,7 +372,9 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "PrivacyLinkPreviewsShowAlert"
private const val SHARED_PREFS_PRIVACY_SANITIZE_LINKS = "PrivacySanitizeLinks"
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks" // TODO remove
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
@@ -4629,6 +4634,19 @@ data class ParsedServerAddress (
var parseError: String
)
fun parseSanitizeUri(s: String): ParsedUri? {
val parsed = chatParseUri(s)
return runCatching { json.decodeFromString(ParsedUri.serializer(), parsed) }
.onFailure { Log.d(TAG, "parseSanitizeUri decode error: $it") }
.getOrNull()
}
@Serializable
data class ParsedUri(val uriInfo: UriInfo?, val parseError: String)
@Serializable
data class UriInfo(val scheme: String, val sanitized: String?)
@Serializable
data class NetCfg(
val socksProxy: String?,

View File

@@ -28,6 +28,7 @@ external fun chatRecvMsg(ctrl: ChatCtrl): String
external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String
external fun chatParseUri(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String
external fun chatValidName(name: String): String
external fun chatJsonLength(str: String): Int

View File

@@ -356,16 +356,21 @@ fun ComposeView(
fun isSimplexLink(link: String): Boolean =
link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true)
fun getSimplexLink(parsedMsg: List<FormattedText>?): Pair<String?, Boolean> {
fun getMessageLinks(parsedMsg: List<FormattedText>?): Pair<String?, Boolean> {
if (parsedMsg == null) return null to false
val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) }
val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink }
return link?.text to simplexLink
for (ft in parsedMsg) {
val link = ft.linkUri
if (link != null && !cancelledLinks.contains(link) && !isSimplexLink(link)) {
return link to simplexLink
}
}
return null to simplexLink
}
val linkUrl = rememberSaveable { mutableStateOf<String?>(null) }
// default value parsed because of draft
val hasSimplexLink = rememberSaveable { mutableStateOf(getSimplexLink(parseToMarkdown(composeState.value.message.text)).second) }
val hasSimplexLink = rememberSaveable { mutableStateOf(getMessageLinks(parseToMarkdown(composeState.value.message.text)).second) }
val prevLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val pendingLinkUrl = rememberSaveable { mutableStateOf<String?>(null) }
val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get()
@@ -382,6 +387,7 @@ fun ComposeView(
if (wait != null) delay(wait)
val lp = getLinkPreview(url)
if (lp != null && pendingLinkUrl.value == url) {
chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) // to avoid showing alert to current users, show alert in v6.5
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
pendingLinkUrl.value = null
} else if (pendingLinkUrl.value == url) {
@@ -394,7 +400,7 @@ fun ComposeView(
fun showLinkPreview(parsedMessage: List<FormattedText>?) {
prevLinkUrl.value = linkUrl.value
val linkParsed = getSimplexLink(parsedMessage)
val linkParsed = getMessageLinks(parsedMessage)
linkUrl.value = linkParsed.first
hasSimplexLink.value = linkParsed.second
val url = linkUrl.value
@@ -501,7 +507,7 @@ fun ComposeView(
return when (val composePreview = composeState.value.preview) {
is ComposePreview.CLinkPreview -> {
val parsedMsg = parseToMarkdown(msgText)
val url = getSimplexLink(parsedMsg).first
val url = getMessageLinks(parsedMsg).first
val lp = composePreview.linkPreview
if (lp != null && url == lp.uri) {
MsgContent.MCLink(msgText, preview = lp)
@@ -861,9 +867,53 @@ fun ComposeView(
}
}
fun sanitizeMessage(parsedMsg: List<FormattedText>): Triple<String, List<FormattedText>, Int?> {
var pos = 0
var updatedMsg = ""
var sanitizedPos: Int? = null
val updatedParsedMsg = parsedMsg.map { ft ->
var updated = ft
when(ft.format) {
is Format.Uri -> {
val sanitized = parseSanitizeUri(ft.text)?.uriInfo?.sanitized
if (sanitized != null) {
updated = FormattedText(text = sanitized, format = Format.Uri())
pos += updated.text.count()
sanitizedPos = pos
}
}
is Format.HyperLink -> {
val sanitized = parseSanitizeUri(ft.format.linkUri)?.uriInfo?.sanitized
if (sanitized != null) {
val updatedText = if (ft.format.showText == null) sanitized else "[${ft.format.showText}]($sanitized)"
updated = FormattedText(text = updatedText, format = Format.HyperLink(showText = ft.format.showText, linkUri = sanitized))
pos += updated.text.count()
sanitizedPos = pos
}
}
else ->
pos += ft.text.count()
}
updatedMsg += updated.text
updated
}
return Triple(updatedMsg, updatedParsedMsg, sanitizedPos)
}
fun onMessageChange(s: ComposeMessage) {
val parsedMessage = parseToMarkdown(s.text)
composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text))
var parsedMessage = parseToMarkdown(s.text)
if (chatModel.controller.appPrefs.privacySanitizeLinks.state.value && parsedMessage != null) {
val (updatedMsg, updatedParsedMsg, sanitizedPos) = sanitizeMessage(parsedMessage)
if (sanitizedPos == null) {
composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage)
} else {
val message = if (sanitizedPos < s.selection.start) s.copy(text = updatedMsg) else ComposeMessage(updatedMsg, TextRange(sanitizedPos, sanitizedPos))
composeState.value = composeState.value.copy(message = message, parsedMessage = updatedParsedMsg)
parsedMessage = updatedParsedMsg
}
} else {
composeState.value = composeState.value.copy(message = s, parsedMessage = parsedMessage ?: FormattedText.plain(s.text))
}
if (isShortEmoji(s.text)) {
textStyle.value = if (s.text.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
} else {
@@ -876,7 +926,7 @@ fun ComposeView(
hasSimplexLink.value = false
}
} else if (s.text.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) {
hasSimplexLink.value = getSimplexLink(parsedMessage).second
hasSimplexLink.value = getMessageLinks(parsedMessage).second
} else {
hasSimplexLink.value = false
}

View File

@@ -1,5 +1,8 @@
package chat.simplex.common.views.chat.item
import SectionItemView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.MaterialTheme
@@ -12,15 +15,15 @@ import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.text.AnnotatedString.Range
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.*
import chat.simplex.res.*
import kotlinx.coroutines.*
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
@@ -145,55 +148,102 @@ fun MarkdownText (
if (prefix != null) append(prefix)
for ((i, ft) in formattedText.withIndex()) {
if (ft.format == null) append(ft.text)
else if (toggleSecrets && ft.format is Format.Secret) {
val ftStyle = ft.format.style
hasSecrets = true
val key = i.toString()
withAnnotation(tag = "SECRET", annotation = key) {
if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) }
}
} else if (ft.format is Format.Mention) {
val mention = mentions?.get(ft.format.memberName)
if (mention != null) {
if (mention.memberRef != null) {
val displayName = mention.memberRef.displayName
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) {
displayName
} else {
"${mention.memberRef.localAlias} ($displayName)"
else when(ft.format) {
is Format.Bold -> withStyle(ft.format.style) { append(ft.text) }
is Format.Italic -> withStyle(ft.format.style) { append(ft.text) }
is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) }
is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) }
is Format.Colored -> withStyle(ft.format.style) { append(ft.text) }
is Format.Secret -> {
val ftStyle = ft.format.style
if (toggleSecrets) {
hasSecrets = true
val key = i.toString()
withAnnotation(tag = "SECRET", annotation = key) {
if (showSecrets[key] == true) append(ft.text) else withStyle(ftStyle) { append(ft.text) }
}
val mentionStyle = if (mention.memberId == userMemberId) ft.format.style.copy(color = MaterialTheme.colors.primary) else ft.format.style
withStyle(mentionStyle) { append(mentionText(name)) }
} else {
withStyle( ft.format.style) { append(mentionText(ft.format.memberName)) }
}
} else {
append(ft.text)
}
} else if (ft.format is Format.Command) {
if (sendCommandMsg == null) {
append(ft.text)
} else {
hasCommands = true
val ftStyle = ft.format.style
val cmd = ft.format.commandStr
withAnnotation(tag = "COMMAND", annotation = cmd) {
withStyle(ftStyle) { append("/$cmd") }
withStyle(ftStyle) { append(ft.text) }
}
}
} else {
val link = ft.link(linkMode)
if (link != null) {
is Format.Mention -> {
val mention = mentions?.get(ft.format.memberName)
if (mention != null) {
val ftStyle = ft.format.style
if (mention.memberRef != null) {
val displayName = mention.memberRef.displayName
val name = if (mention.memberRef.localAlias.isNullOrEmpty()) {
displayName
} else {
"${mention.memberRef.localAlias} ($displayName)"
}
val mentionStyle = if (mention.memberId == userMemberId) ftStyle.copy(color = MaterialTheme.colors.primary) else ftStyle
withStyle(mentionStyle) { append(mentionText(name)) }
} else {
withStyle(ftStyle) { append(mentionText(ft.format.memberName)) }
}
} else {
append(ft.text)
}
}
is Format.Command ->
if (sendCommandMsg == null) {
append(ft.text)
} else {
hasCommands = true
val ftStyle = ft.format.style
val cmd = ft.format.commandStr
withAnnotation(tag = "COMMAND", annotation = cmd) {
withStyle(ftStyle) { append("/$cmd") }
}
}
is Format.Uri -> {
hasLinks = true
val ftStyle = ft.format.style
withAnnotation(tag = if (ft.format is Format.SimplexLink) "SIMPLEX_URL" else "URL", annotation = link) {
withStyle(ftStyle) { append(ft.viewText(linkMode)) }
val ftStyle = Format.linkStyle
val s = ft.text
val link = if (s.startsWith("http://") || s.startsWith("https://")) s else "https://$s"
withAnnotation(tag = "WEB_URL", annotation = link) {
withStyle(ftStyle) { append(ft.text) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
is Format.HyperLink -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "WEB_URL", annotation = ft.format.linkUri) {
withStyle(ftStyle) { append(ft.format.showText ?: ft.text) }
}
}
is Format.SimplexLink -> {
hasLinks = true
val ftStyle = Format.linkStyle
val link =
if (linkMode == SimplexLinkMode.BROWSER && ft.format.showText == null && !ft.text.startsWith("[")) ft.text
else ft.format.simplexUri
val t = ft.format.showText ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null
withAnnotation(tag = "SIMPLEX_URL", annotation = link) {
if (t == null) {
withStyle(ftStyle) { append(ft.text) }
} else {
withStyle(ftStyle) { append("$t ") }
withStyle(ftStyle.copy(fontStyle = FontStyle.Italic)) { append(ft.format.viaHosts) }
}
}
}
is Format.Email -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "OTHER_URL", annotation = "mailto:${ft.text}") {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Phone -> {
hasLinks = true
val ftStyle = Format.linkStyle
withAnnotation(tag = "OTHER_URL", annotation = "tel:${ft.text}") {
withStyle(ftStyle) { append(ft.text) }
}
}
is Format.Unknown -> append(ft.text)
}
}
if (meta?.isLive == true) {
@@ -209,10 +259,12 @@ fun MarkdownText (
ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow,
onLongClick = { offset ->
if (hasLinks) {
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
annotatedText.getStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> onLinkLongClick(annotation.item) }
val withAnnotation: (String, (Range<String>) -> Unit) -> Unit = { tag, f ->
annotatedText.getStringAnnotations(tag, start = offset, end = offset).firstOrNull()?.let(f)
}
withAnnotation("WEB_URL") { a -> onLinkLongClick(a.item) }
withAnnotation("SIMPLEX_URL") { a -> onLinkLongClick(a.item) }
withAnnotation("OTHER_URL") { a -> onLinkLongClick(a.item) }
}
},
onClick = { offset ->
@@ -220,37 +272,33 @@ fun MarkdownText (
annotatedText.getStringAnnotations(tag, start = offset, end = offset).firstOrNull()?.let(f)
}
if (hasLinks && uriHandler != null) {
withAnnotation("URL") { a ->
try {
uriHandler.openUri(a.item)
} catch (e: Exception) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
}
}
withAnnotation("WEB_URL") { a -> openBrowserAlert(a.item, uriHandler) }
withAnnotation("OTHER_URL") { a -> safeOpenUri(a.item, uriHandler) }
withAnnotation("SIMPLEX_URL") { a -> uriHandler.openVerifiedSimplexUri(a.item) }
} else if (hasSecrets) {
}
if (hasSecrets) {
withAnnotation("SECRET") { a ->
val key = a.item
showSecrets[key] = !(showSecrets[key] ?: false)
}
} else if (hasCommands && sendCommandMsg != null) {
}
if (hasCommands && sendCommandMsg != null) {
withAnnotation("COMMAND") { a -> sendCommandMsg("/${a.item}") }
}
},
onHover = { offset ->
val hasAnnotation: (String) -> Boolean = { tag -> annotatedText.hasStringAnnotations(tag, start = offset, end = offset) }
icon.value =
if (hasAnnotation("URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
if (hasAnnotation("WEB_URL") || hasAnnotation("SIMPLEX_URL") || hasAnnotation("OTHER_URL") || hasAnnotation("SECRET") || hasAnnotation("COMMAND")) {
PointerIcon.Hand
} else {
PointerIcon.Default
}
},
shouldConsumeEvent = { offset ->
annotatedText.hasStringAnnotations(tag = "URL", start = offset, end = offset)
annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset)
|| annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset)
}
)
} else {
@@ -319,6 +367,74 @@ fun ClickableText(
)
}
fun openBrowserAlert(uri: String, uriHandler: UriHandler) {
val (res, err) = sanitizeUri(uri)
if (res == null) {
showInvalidLinkAlert(uri, err)
} else {
val message = if (uri.count() > 160) uri.substring(0, 159) + "" else uri
val sanitizedUri = res.second
if (sanitizedUri == null) {
AlertManager.shared.showAlertDialog(
generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
message,
confirmText = generalGetString(MR.strings.open_verb),
onConfirm = { safeOpenUri(uri, uriHandler) }
)
} else {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
safeOpenUri(uri, uriHandler)
}) {
Text(generalGetString(MR.strings.privacy_chat_list_open_full_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
SectionItemView({
AlertManager.shared.hideAlert()
safeOpenUri(sanitizedUri, uriHandler)
}) {
Text(generalGetString(MR.strings.privacy_chat_list_open_clean_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
})
}
}
}
fun safeOpenUri(uri: String, uriHandler: UriHandler) {
try {
uriHandler.openUri(uri)
} catch (e: Exception) {
// It can happen, for example, when you click on a text 0.00001 but don't have any app that can catch
// `tel:` scheme in url installed on a device (no phone app or contacts, maybe)
Log.e(TAG, "Open url: ${e.stackTraceToString()}")
showInvalidLinkAlert(uri, error = e.message)
}
}
fun showInvalidLinkAlert(uri: String, error: String? = null) {
val message = if (error.isNullOrEmpty()) { uri } else { error + "\n" + uri }
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_parsing_uri_title), message)
}
fun sanitizeUri(s: String): Pair<Pair<Boolean, String?>?, String?> {
val parsed = parseSanitizeUri(s)
return if (parsed?.uriInfo != null) {
(true to parsed.uriInfo.sanitized) to null
} else {
null to parsed?.parseError
}
}
private fun isRtl(s: CharSequence): Boolean {
for (element in s) {
val d = Character.getDirectionality(element)

View File

@@ -659,7 +659,7 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
val linkText = link.format.simplexLinkText
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true

View File

@@ -298,37 +298,9 @@ fun ChatPreviewView(
val uriHandler = LocalUriHandler.current
when (mc) {
is MsgContent.MCLink -> SmallContentPreview {
val linkClicksEnabled = remember { appPrefs.privacyChatListOpenLinks.state }.value != PrivacyChatListOpenLinksMode.NO
IconButton({
when (appPrefs.privacyChatListOpenLinks.get()) {
PrivacyChatListOpenLinksMode.YES -> uriHandler.openUriCatching(mc.preview.uri)
PrivacyChatListOpenLinksMode.NO -> defaultClickAction()
PrivacyChatListOpenLinksMode.ASK -> AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
text = mc.preview.uri,
buttons = {
Column {
if (chatModel.chatId.value != chat.id) {
SectionItemView({
AlertManager.shared.hideAlert()
defaultClickAction()
}) {
Text(stringResource(MR.strings.open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
uriHandler.openUriCatching(mc.preview.uri)
}
) {
Text(stringResource(MR.strings.privacy_chat_list_open_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
},
if (linkClicksEnabled) Modifier.desktopPointerHoverIconHand() else Modifier,
IconButton(
{ openBrowserAlert(mc.preview.uri, uriHandler) },
Modifier.desktopPointerHoverIconHand(),
) {
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
}

View File

@@ -63,7 +63,7 @@ private suspend fun planAndConnectTask(
val (connectionLink, connectionPlan) = result
val link = strHasSingleSimplexLink(shortOrFullLink.trim())
val linkText = if (link?.format is Format.SimplexLink)
"<br><br><u>${link.simplexLinkText(link.format.linkType, link.format.smpHosts)}</u>"
"<br><br><u>${link.format.simplexLinkText}</u>"
else
""
when (connectionPlan) {

View File

@@ -519,10 +519,8 @@ private fun ContactsSearchBar(
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText =
link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value =
searchText.value.copy(linkText, selection = TextRange.Zero)
val linkText = link.format.simplexLinkText
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null

View File

@@ -2,18 +2,10 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import dev.icerock.moko.resources.compose.painterResource
@@ -67,7 +59,15 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) ->
SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls)
}
}
SectionBottomSpacer()
SectionDividerSpaced(maxTopPadding = true)
SectionView(stringResource(MR.strings.deprecated_options_section).uppercase()) {
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
simplexLinkMode.set(it)
chatModel.simplexLinkMode.value = it
})
SectionBottomSpacer()
}
}
}

View File

@@ -56,16 +56,22 @@ fun PrivacySettingsView(
setPerformLA: (Boolean) -> Unit
) {
ColumnWithScrollBar {
val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode
AppBarTitle(stringResource(MR.strings.your_privacy))
PrivacyDeviceSection(showSettingsModal, setPerformLA)
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_chats)) {
SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
ChatListLinksOptions(appPrefs.privacyChatListOpenLinks.state, onSelected = {
appPrefs.privacyChatListOpenLinks.set(it)
})
SettingsPreferenceItem(
painterResource(MR.images.ic_travel_explore),
stringResource(MR.strings.send_link_previews),
chatModel.controller.appPrefs.privacyLinkPreviews,
onChange = { _ -> chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false) } // to avoid showing alert to current users, show alert in v6.5
)
SettingsPreferenceItem(
painterResource(MR.images.ic_link),
stringResource(MR.strings.sanitize_links_toggle),
chatModel.controller.appPrefs.privacySanitizeLinks
)
SettingsPreferenceItem(
painterResource(MR.images.ic_chat_bubble),
stringResource(MR.strings.privacy_show_last_messages),
@@ -84,10 +90,6 @@ fun PrivacySettingsView(
chatModel.draftChatId.value = null
}
})
SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = {
simplexLinkMode.set(it)
chatModel.simplexLinkMode.value = it
})
}
SectionDividerSpaced()
@@ -218,27 +220,7 @@ fun PrivacySettingsView(
}
@Composable
private fun ChatListLinksOptions(state: State<PrivacyChatListOpenLinksMode>, onSelected: (PrivacyChatListOpenLinksMode) -> Unit) {
val values = remember {
PrivacyChatListOpenLinksMode.entries.map {
when (it) {
PrivacyChatListOpenLinksMode.YES -> it to generalGetString(MR.strings.privacy_chat_list_open_links_yes)
PrivacyChatListOpenLinksMode.NO -> it to generalGetString(MR.strings.privacy_chat_list_open_links_no)
PrivacyChatListOpenLinksMode.ASK -> it to generalGetString(MR.strings.privacy_chat_list_open_links_ask)
}
}
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.privacy_chat_list_open_links),
values,
state,
icon = painterResource(MR.images.ic_open_in_new),
onSelected = onSelected
)
}
@Composable
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL)
val pickerValues = modeValues + if (modeValues.contains(simplexLinkModeState.value)) emptyList() else listOf(simplexLinkModeState.value)
val values = remember {

View File

@@ -1072,6 +1072,7 @@
<string name="debug_logs">Enable logs</string>
<string name="developer_options">Database IDs and Transport isolation option.</string>
<string name="developer_options_section">Developer options</string>
<string name="deprecated_options_section">Deprecated options</string>
<string name="show_internal_errors">Show internal errors</string>
<string name="show_slow_api_calls">Show slow API calls</string>
<string name="shutdown_alert_question">Shutdown?</string>
@@ -1368,6 +1369,7 @@
<string name="app_will_ask_to_confirm_unknown_file_servers">The app will ask to confirm downloads from unknown file servers (except .onion or when SOCKS proxy is enabled).</string>
<string name="without_tor_or_vpn_ip_address_will_be_visible_to_file_servers">Without Tor or VPN, your IP address will be visible to file servers.</string>
<string name="send_link_previews">Send link previews</string>
<string name="sanitize_links_toggle">Remove link tracking</string>
<string name="privacy_show_last_messages">Show last messages</string>
<string name="privacy_message_draft">Message draft</string>
<string name="full_backup">App data backup</string>
@@ -1434,6 +1436,8 @@
<string name="privacy_chat_list_open_links_ask">Ask</string>
<string name="privacy_chat_list_open_web_link_question">Open web link?</string>
<string name="privacy_chat_list_open_web_link">Open link</string>
<string name="privacy_chat_list_open_full_web_link">Open full link</string>
<string name="privacy_chat_list_open_clean_web_link">Open clean link</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>