mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-30 14:15:55 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user