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
File diff suppressed because one or more lines are too long
@@ -187,23 +187,25 @@ private func handleTextTaps(
}
}
}
if let index, let (url, browser) = attributedStringLink(s, for: index) {
if let index, let (uri, browser) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: url)
} else {
openBrowserAlert(uri: uri)
} else if let url = URL(string: uri) {
UIApplication.shared.open(url)
} else {
showInvalidLinkAlert(uri)
}
}
})
}
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
var linkURL: URL?
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
var linkURL: String?
var browser: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let url = attrs[linkAttrKey] as? NSURL {
linkURL = url.absoluteURL
if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
@@ -356,22 +358,32 @@ func messageText(
case .uri:
attrs = linkAttrs()
if !preview {
let s = t.lowercased()
let link = s.hasPrefix("http://") || s.hasPrefix("https://")
let link = t.hasPrefix("http://") || t.hasPrefix("https://")
? t
: "https://" + t
attrs[linkAttrKey] = NSURL(string: link)
attrs[linkAttrKey] = link
attrs[webLinkAttrKey] = true
handleTaps = true
}
case let .simplexLink(linkType, simplexUri, smpHosts):
case let .hyperLink(text, uri):
attrs = linkAttrs()
if let text { t = text }
if !preview {
attrs[linkAttrKey] = NSURL(string: simplexUri)
attrs[linkAttrKey] = uri
attrs[webLinkAttrKey] = true
handleTaps = true
}
if case .description = privacySimplexLinkModeDefault.get() {
t = simplexLinkText(linkType, smpHosts)
case let .simplexLink(text, linkType, simplexUri, smpHosts):
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = simplexUri
handleTaps = true
}
if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
res.append(NSAttributedString(string: s + " ", attributes: attrs))
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
attrs[.font] = italic
t = viaHost(smpHosts)
}
case let .command(cmdStr):
snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
@@ -403,13 +415,13 @@ func messageText(
case .email:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
attrs[linkAttrKey] = "mailto:" + ft.text
handleTaps = true
}
case .phone:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
attrs[linkAttrKey] = "tel:" + t.replacingOccurrences(of: " ", with: "")
handleTaps = true
}
case .unknown: ()
@@ -439,7 +451,11 @@ private func mentionText(_ name: String) -> String {
}
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
linkType.description + " " + "(via \(smpHosts.first ?? "?"))"
linkType.description + " " + viaHost(smpHosts)
}
func viaHost(_ smpHosts: [String]) -> String {
"(via \(smpHosts.first ?? "?"))"
}
struct MsgContentView_Previews: PreviewProvider {
File diff suppressed because one or more lines are too long
@@ -329,10 +329,10 @@ struct ComposeView: View {
@Binding var selectedRange: NSRange
var disabledText: LocalizedStringKey? = nil
@State var linkUrl: URL? = nil
@State var linkUrl: String? = nil
@State var hasSimplexLink: Bool = false
@State var prevLinkUrl: URL? = nil
@State var pendingLinkUrl: URL? = nil
@State var prevLinkUrl: String? = nil
@State var pendingLinkUrl: String? = nil
@State var cancelledLinks: Set<String> = []
@Environment(\.colorScheme) private var colorScheme
@@ -353,6 +353,8 @@ struct ComposeView: View {
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = true
@State private var updatingCompose = false
var body: some View {
VStack(spacing: 0) {
@@ -454,8 +456,26 @@ struct ComposeView: View {
.ignoresSafeArea(.all, edges: .bottom)
}
.onChange(of: composeState.message) { msg in
let parsedMsg = parseSimpleXMarkdown(msg)
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
if updatingCompose {
updatingCompose = false
return
}
var parsedMsg = parseSimpleXMarkdown(msg)
if privacySanitizeLinks, let parsed = parsedMsg {
let r = sanitizeMessage(parsed)
if let sanitizedPos = r.sanitizedPos {
updatingCompose = true
composeState = composeState.copy(message: r.message, parsedMessage: r.parsedMsg)
if sanitizedPos < selectedRange.location {
selectedRange = NSRange(location: sanitizedPos, length: 0)
}
parsedMsg = r.parsedMsg
} else {
composeState = composeState.copy(parsedMessage: parsedMsg)
}
} else {
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
}
if composeState.linkPreviewAllowed {
if msg.count > 0 {
showLinkPreview(parsedMsg)
@@ -464,7 +484,7 @@ struct ComposeView: View {
hasSimplexLink = false
}
} else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) {
(_, hasSimplexLink) = getSimplexLink(parsedMsg)
(_, hasSimplexLink) = getMessageLinks(parsedMsg)
} else {
hasSimplexLink = false
}
@@ -845,7 +865,7 @@ struct ComposeView: View {
switch (composeState.preview) {
case let .linkPreview(linkPreview: linkPreview):
if let parsedMsg = parseSimpleXMarkdown(msgText),
let url = getSimplexLink(parsedMsg).url,
let url = getMessageLinks(parsedMsg).url,
let linkPreview = linkPreview,
url == linkPreview.uri {
return .link(text: msgText, preview: linkPreview)
@@ -1448,7 +1468,7 @@ struct ComposeView: View {
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
prevLinkUrl = linkUrl
(linkUrl, hasSimplexLink) = getSimplexLink(parsedMsg)
(linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg)
if let url = linkUrl {
if url != composeState.linkPreview?.uri && url != pendingLinkUrl {
pendingLinkUrl = url
@@ -1465,39 +1485,38 @@ struct ComposeView: View {
}
}
private func getSimplexLink(_ parsedMsg: [FormattedText]?) -> (url: URL?, hasSimplexLink: Bool) {
private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) {
guard let parsedMsg else { return (nil, false) }
let url: URL? = if let uri = parsedMsg.first(where: { ft in
ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text)
}) {
URL(string: uri.text)
} else {
nil
}
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
return (url, simplexLink)
for ft in parsedMsg {
if let link = ft.linkUri, !cancelledLinks.contains(link) && !isSimplexLink(link) {
return (link, simplexLink)
}
}
return (nil, simplexLink)
}
private func isSimplexLink(_ link: String) -> Bool {
link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat")
link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") || link.starts(with: "simplex:/")
}
private func cancelLinkPreview() {
if let pendingLink = pendingLinkUrl?.absoluteString {
if let pendingLink = pendingLinkUrl {
cancelledLinks.insert(pendingLink)
}
if let uri = composeState.linkPreview?.uri.absoluteString {
if let uri = composeState.linkPreview?.uri {
cancelledLinks.insert(uri)
}
pendingLinkUrl = nil
composeState = composeState.copy(preview: .noPreview)
}
private func loadLinkPreview(_ url: URL) {
if pendingLinkUrl == url {
private func loadLinkPreview(_ urlStr: String) {
if pendingLinkUrl == urlStr, let url = URL(string: urlStr) {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in
if let linkPreview, pendingLinkUrl == url {
if let linkPreview, pendingLinkUrl == urlStr {
privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
@@ -1516,3 +1535,32 @@ struct ComposeView: View {
cancelledLinks = []
}
}
func sanitizeMessage(_ parsedMsg: [FormattedText]) -> (message: String, parsedMsg: [FormattedText], sanitizedPos: Int?) {
var pos: Int = 0
var updatedMsg = ""
var sanitizedPos: Int? = nil
let updatedParsedMsg = parsedMsg.map { ft in
var updated = ft
switch ft.format {
case .uri:
if let sanitized = parseSanitizeUri(ft.text)?.uriInfo?.sanitized {
updated = FormattedText(text: sanitized, format: .uri)
pos += updated.text.count
sanitizedPos = pos
}
case let .hyperLink(text, uri):
if let sanitized = parseSanitizeUri(uri)?.uriInfo?.sanitized {
let updatedText = if let text { "[\(text)](\(sanitized))" } else { sanitized }
updated = FormattedText(text: updatedText, format: .hyperLink(showText: text, linkUri: sanitized))
pos += updated.text.count
sanitizedPos = pos
}
default:
pos += ft.text.count
}
updatedMsg += updated.text
return updated
}
return (message: updatedMsg, parsedMsg: updatedParsedMsg, sanitizedPos: sanitizedPos)
}
@@ -629,7 +629,7 @@ struct ChatListSearchBar: View {
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
if case let .simplexLink(linkType, _, smpHosts) = link.format {
if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
@@ -381,7 +381,7 @@ struct ContactsListSearchBar: View {
} else {
if let link = strHasSingleSimplexLink(t.trimmingCharacters(in: .whitespaces)) { // if SimpleX link is pasted, show connection dialogue
searchFocussed = false
if case let .simplexLink(linkType, _, smpHosts) = link.format {
if case let .simplexLink(_, linkType, _, smpHosts) = link.format {
ignoreSearchTextChange = true
searchText = simplexLinkText(linkType, smpHosts)
}
@@ -14,6 +14,7 @@ struct DeveloperView: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
@State private var hintsUnchanged = hintDefaultsUnchanged()
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@Environment(\.colorScheme) var colorScheme
@@ -65,6 +66,21 @@ struct DeveloperView: View {
Text("Developer options")
}
}
Section("Deprecated options") {
settingsRow("link", color: theme.colors.secondary) {
Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach(
SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
) { mode in
Text(mode.text)
}
}
}
.frame(height: 36)
.onChange(of: simplexLinkMode) { mode in
privacySimplexLinkModeDefault.set(mode)
}
}
}
}
}
@@ -14,11 +14,11 @@ struct PrivacySettings: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = true
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@@ -75,8 +75,12 @@ struct PrivacySettings: View {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
privacyLinkPreviewsGroupDefault.set(linkPreviews)
privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
}
}
settingsRow("link", color: theme.colors.secondary) {
Toggle("Remove link tracking", isOn: $privacySanitizeLinks)
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
}
@@ -89,19 +93,6 @@ struct PrivacySettings: View {
m.draftChatId = nil
}
}
settingsRow("link", color: theme.colors.secondary) {
Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach(
SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
) { mode in
Text(mode.text)
}
}
}
.frame(height: 36)
.onChange(of: simplexLinkMode) { mode in
privacySimplexLinkModeDefault.set(mode)
}
} header: {
Text("Chats")
.foregroundColor(theme.colors.secondary)
@@ -2563,6 +2563,10 @@ swipe action</note>
<target>Потвърждениe за доставка!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Описание</target>
@@ -4289,7 +4293,7 @@ More improvements are coming soon!</source>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Невалиден линк</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5460,10 +5464,18 @@ Requires compatible VPN.</source>
<target>Отвори конзолата</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Отвори група</target>
@@ -6193,6 +6205,10 @@ swipe action</note>
<source>Remove image</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Острани член</target>
@@ -2455,6 +2455,10 @@ swipe action</note>
<target>Potvrzení o doručení!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Popis</target>
@@ -4128,7 +4132,7 @@ More improvements are coming soon!</source>
</trans-unit>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5267,10 +5271,18 @@ Vyžaduje povolení sítě VPN.</target>
<target>Otevřete konzolu chatu</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<note>new chat action</note>
@@ -5976,6 +5988,10 @@ swipe action</note>
<source>Remove image</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Odstranit člena</target>
@@ -2680,6 +2680,10 @@ swipe action</note>
<target>Empfangsbestätigungen!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Beschreibung</target>
@@ -4517,7 +4521,7 @@ Weitere Verbesserungen sind bald verfügbar!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Ungültiger Link</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5770,11 +5774,19 @@ Dies erfordert die Aktivierung eines VPNs.</target>
<target>Chat-Konsole öffnen</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Nutzungsbedingungen öffnen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Gruppe öffnen</target>
@@ -6564,6 +6576,10 @@ swipe action</note>
<target>Bild entfernen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Mitglied entfernen</target>
@@ -2684,6 +2684,11 @@ swipe action</note>
<target>Delivery receipts!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<target>Deprecated options</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Description</target>
@@ -4522,7 +4527,7 @@ More improvements are coming soon!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Invalid link</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5777,11 +5782,21 @@ Requires compatible VPN.</target>
<target>Open chat console</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<target>Open clean link</target>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Open conditions</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<target>Open full link</target>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Open group</target>
@@ -6572,6 +6587,11 @@ swipe action</note>
<target>Remove image</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<target>Remove link tracking</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Remove member</target>
@@ -2680,6 +2680,10 @@ swipe action</note>
<target>¡Confirmación de entrega!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Descripción</target>
@@ -4517,7 +4521,7 @@ More improvements are coming soon!</source>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Enlace no válido</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5770,11 +5774,19 @@ Requiere activación de la VPN.</target>
<target>Abrir consola de Chat</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Abrir condiciones</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Grupo abierto</target>
@@ -6564,6 +6576,10 @@ swipe action</note>
<target>Eliminar imagen</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Expulsar miembro</target>
@@ -2426,6 +2426,10 @@ swipe action</note>
<target>Toimituskuittaukset!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Kuvaus</target>
@@ -4096,7 +4100,7 @@ More improvements are coming soon!</source>
</trans-unit>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5233,10 +5237,18 @@ Edellyttää VPN:n sallimista.</target>
<target>Avaa keskustelukonsoli</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<note>new chat action</note>
@@ -5942,6 +5954,10 @@ swipe action</note>
<source>Remove image</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Poista jäsen</target>
@@ -2661,6 +2661,10 @@ swipe action</note>
<target>Justificatifs de réception!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Description</target>
@@ -4474,7 +4478,7 @@ D'autres améliorations sont à venir!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Lien invalide</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5694,11 +5698,19 @@ Nécessite l'activation d'un VPN.</target>
<target>Ouvrir la console du chat</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Ouvrir les conditions</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Ouvrir le groupe</target>
@@ -6467,6 +6479,10 @@ swipe action</note>
<target>Enlever l'image</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Retirer le membre</target>
@@ -2680,6 +2680,10 @@ swipe action</note>
<target>Kézbesítési jelentések!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Leírás</target>
@@ -4517,7 +4521,7 @@ További fejlesztések hamarosan!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Érvénytelen hivatkozás</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5770,11 +5774,19 @@ VPN engedélyezése szükséges.</target>
<target>Csevegési konzol megnyitása</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Feltételek megnyitása</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Csoport megnyitása</target>
@@ -6564,6 +6576,10 @@ swipe action</note>
<target>Kép eltávolítása</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Eltávolítás</target>
@@ -2680,6 +2680,10 @@ swipe action</note>
<target>Ricevute di consegna!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Descrizione</target>
@@ -4517,7 +4521,7 @@ Altri miglioramenti sono in arrivo!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Link non valido</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5770,11 +5774,19 @@ Richiede l'attivazione della VPN.</target>
<target>Apri la console della chat</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Apri le condizioni</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Apri gruppo</target>
@@ -6564,6 +6576,10 @@ swipe action</note>
<target>Rimuovi immagine</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Rimuovi membro</target>
@@ -2504,6 +2504,10 @@ swipe action</note>
<target>配信通知!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>説明</target>
@@ -4177,7 +4181,7 @@ More improvements are coming soon!</source>
</trans-unit>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5318,10 +5322,18 @@ VPN を有効にする必要があります。</target>
<target>チャットのコンソールを開く</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<note>new chat action</note>
@@ -6027,6 +6039,10 @@ swipe action</note>
<source>Remove image</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>メンバーを除名する</target>
@@ -2670,6 +2670,10 @@ swipe action</note>
<target>Ontvangstbewijzen!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Beschrijving</target>
@@ -4498,7 +4502,7 @@ Binnenkort meer verbeteringen!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Ongeldige link</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5743,11 +5747,19 @@ Vereist het inschakelen van VPN.</target>
<target>Chat console openen</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Open voorwaarden</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Open groep</target>
@@ -6530,6 +6542,10 @@ swipe action</note>
<target>Verwijder afbeelding</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Lid verwijderen</target>
@@ -2630,6 +2630,10 @@ swipe action</note>
<target>Potwierdzenia dostawy!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Opis</target>
@@ -4406,7 +4410,7 @@ More improvements are coming soon!</source>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Nieprawidłowy link</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5607,10 +5611,18 @@ Wymaga włączenia VPN.</target>
<target>Otwórz konsolę czatu</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Grupa otwarta</target>
@@ -6373,6 +6385,10 @@ swipe action</note>
<target>Usuń obraz</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Usuń członka</target>
@@ -2680,6 +2680,10 @@ swipe action</note>
<target>Отчёты о доставке!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Описание</target>
@@ -4516,7 +4520,7 @@ More improvements are coming soon!</source>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Ошибка ссылки</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5769,11 +5773,19 @@ Requires compatible VPN.</source>
<target>Открыть консоль</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Открыть условия</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Открыть группу</target>
@@ -6563,6 +6575,10 @@ swipe action</note>
<target>Удалить изображение</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Удалить члена группы</target>
@@ -2414,6 +2414,10 @@ swipe action</note>
<target>ใบตอบรับการจัดส่ง!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>คำอธิบาย</target>
@@ -4080,7 +4084,7 @@ More improvements are coming soon!</source>
</trans-unit>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5212,10 +5216,18 @@ Requires compatible VPN.</source>
<target>เปิดคอนโซลการแชท</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<note>new chat action</note>
@@ -5919,6 +5931,10 @@ swipe action</note>
<source>Remove image</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>ลบสมาชิกออก</target>
@@ -2680,6 +2680,10 @@ swipe action</note>
<target>Mesaj gönderildi bilgisi!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Açıklama</target>
@@ -4516,7 +4520,7 @@ Daha fazla iyileştirme yakında geliyor!</target>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Geçersiz bağlantı</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5767,11 +5771,19 @@ VPN'nin etkinleştirilmesi gerekir.</target>
<target>Sohbet konsolunu aç</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Açık koşullar</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Grubu aç</target>
@@ -6561,6 +6573,10 @@ swipe action</note>
<target>Resmi kaldır</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Kişiyi sil</target>
@@ -2679,6 +2679,10 @@ swipe action</note>
<target>Квитанції про доставку!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>Опис</target>
@@ -4515,7 +4519,7 @@ More improvements are coming soon!</source>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>Невірне посилання</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5767,11 +5771,19 @@ Requires compatible VPN.</source>
<target>Відкрийте консоль чату</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>Відкриті умови</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>Відкрита група</target>
@@ -6561,6 +6573,10 @@ swipe action</note>
<target>Видалити зображення</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>Видалити учасника</target>
@@ -2662,6 +2662,10 @@ swipe action</note>
<target>送达回执!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Deprecated options" xml:space="preserve">
<source>Deprecated options</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Description" xml:space="preserve">
<source>Description</source>
<target>描述</target>
@@ -4487,7 +4491,7 @@ More improvements are coming soon!</source>
<trans-unit id="Invalid link" xml:space="preserve">
<source>Invalid link</source>
<target>无效链接</target>
<note>No comment provided by engineer.</note>
<note>alert title</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
@@ -5728,11 +5732,19 @@ Requires compatible VPN.</source>
<target>打开聊天控制台</target>
<note>authentication reason</note>
</trans-unit>
<trans-unit id="Open clean link" xml:space="preserve">
<source>Open clean link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open conditions" xml:space="preserve">
<source>Open conditions</source>
<target>打开条款</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open full link" xml:space="preserve">
<source>Open full link</source>
<note>alert action</note>
</trans-unit>
<trans-unit id="Open group" xml:space="preserve">
<source>Open group</source>
<target>打开群</target>
@@ -6497,6 +6509,10 @@ swipe action</note>
<target>移除图片</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove link tracking" xml:space="preserve">
<source>Remove link tracking</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Remove member" xml:space="preserve">
<source>Remove member</source>
<target>删除成员</target>
+8 -7
View File
@@ -390,7 +390,7 @@ enum SharedContent {
switch self {
case let .image(preview, _): .image(text: comment, image: preview)
case let .movie(preview, duration, _): .video(text: comment, image: preview, duration: duration)
case let .url(preview): .link(text: preview.uri.absoluteString + (comment == "" ? "" : "\n" + comment), preview: preview)
case let .url(preview): .link(text: preview.uri + (comment == "" ? "" : "\n" + comment), preview: preview)
case .text: .text(comment)
case .data: .file(comment)
}
@@ -464,12 +464,13 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedCo
// Prepare Link message
case .url:
if let url = try? await ip.loadItem(forTypeIdentifier: type.identifier) as? URL {
let content: SharedContent =
if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
.url(preview: linkPreview)
} else {
.text(string: url.absoluteString)
}
let content: SharedContent
if privacyLinkPreviewsGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
content = .url(preview: linkPreview)
} else {
content = .text(string: url.absoluteString)
}
return .success(content)
} else { return .failure(ErrorAlert("Error preparing message")) }
+1 -1
View File
@@ -172,7 +172,7 @@ struct ShareView: View {
VStack(alignment: .center, spacing: 4) {
Text(linkPreview.title)
.lineLimit(1)
Text(linkPreview.uri.absoluteString)
Text(linkPreview.uri)
.font(.caption)
.lineLimit(1)
.foregroundColor(.secondary)
+8 -8
View File
@@ -178,8 +178,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -545,8 +545,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -708,8 +708,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -795,8 +795,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-B5zq2t60gbA45EwEeb0rfE.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.4.2.1-K3DJnRKS60C6Ik3NluxepC.a */,
);
path = Libraries;
sourceTree = "<group>";
+24
View File
@@ -186,6 +186,30 @@ struct ParsedServerAddress: Decodable {
var parseError: String
}
public func parseSanitizeUri(_ s: String) -> ParsedUri? {
var c = s.cString(using: .utf8)!
if let cjson = chat_parse_uri(&c) {
if let d = dataFromCString(cjson) {
do {
return try jsonDecoder.decode(ParsedUri.self, from: d)
} catch {
logger.error("parseSanitizeUri jsonDecoder.decode error: \(error.localizedDescription)")
}
}
}
return nil
}
public struct ParsedUri: Decodable {
public var uriInfo: UriInfo?
public var parseError: String
}
public struct UriInfo: Decodable {
public var scheme: String
public var sanitized: String?
}
@inline(__always)
public func fromCString(_ c: UnsafeMutablePointer<CChar>) -> String {
let s = String.init(cString: c)
+6
View File
@@ -27,6 +27,8 @@ let GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED = "appLocalAuthEnabled"
public let GROUP_DEFAULT_ALLOW_SHARE_EXTENSION = "allowShareExtension"
// replaces DEFAULT_PRIVACY_LINK_PREVIEWS
let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews"
public let GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT = "privacyLinkPreviewsShowAlert"
public let GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS = "privacySanitizeLinks"
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages"
public let GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE = "privacyTransferImagesInline" // no longer used
@@ -95,6 +97,8 @@ public func registerGroupDefaults() {
GROUP_DEFAULT_APP_LOCAL_AUTH_ENABLED: true,
GROUP_DEFAULT_ALLOW_SHARE_EXTENSION: false,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS: true,
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT: true,
GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: true,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
@@ -222,6 +226,8 @@ public let allowShareExtensionGroupDefault = BoolDefault(defaults: groupDefaults
public let privacyLinkPreviewsGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS)
public let privacyLinkPreviewsShowAlertGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT)
// This setting is a main one, while having an unused duplicate from the past: DEFAULT_PRIVACY_ACCEPT_IMAGES
public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES)
+17 -3
View File
@@ -4611,6 +4611,11 @@ public struct FormattedText: Decodable, Hashable {
public var text: String
public var format: Format?
public init(text: String, format: Format? = nil) {
self.text = text
self.format = format
}
public static func plain(_ text: String) -> [FormattedText] {
text.isEmpty
? []
@@ -4620,6 +4625,14 @@ public struct FormattedText: Decodable, Hashable {
public var isSecret: Bool {
if case .secret = format { true } else { false }
}
public var linkUri: String? {
switch format {
case .uri: text
case let .hyperLink(_, linkUri): linkUri
default: nil
}
}
}
public enum Format: Decodable, Equatable, Hashable {
@@ -4630,7 +4643,8 @@ public enum Format: Decodable, Equatable, Hashable {
case secret
case colored(color: FormatColor)
case uri
case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
case hyperLink(showText: String?, linkUri: String)
case simplexLink(showText: String?, linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
case command(commandStr: String)
case mention(memberName: String)
case email
@@ -4748,14 +4762,14 @@ extension ReportReason: Decodable {
// Struct to use with simplex API
public struct LinkPreview: Codable, Equatable, Hashable {
public init(uri: URL, title: String, description: String = "", image: String) {
public init(uri: String, title: String, description: String = "", image: String) {
self.uri = uri
self.title = title
self.description = description
self.image = image
}
public var uri: URL
public var uri: String
public var title: String
// TODO remove once optional in haskell
public var description: String = ""
+1 -1
View File
@@ -446,7 +446,7 @@ public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
let resized = resizeImageToStrSizeSync(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
linkPreview = LinkPreview(uri: uri.absoluteString, title: title, image: resized)
}
}
cb(linkPreview)
+1
View File
@@ -24,6 +24,7 @@ extern char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
extern char *chat_parse_markdown(char *str);
extern char *chat_parse_server(char *str);
extern char *chat_parse_uri(char *str);
extern char *chat_password_hash(char *pwd, char *salt);
extern char *chat_valid_name(char *name);
extern int chat_json_length(char *str);
@@ -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>
+6
View File
@@ -1979,8 +1979,14 @@ Colored:
Uri:
- type: "uri"
HyperLink:
- type: "hyperLink"
- showText: string?
- linkUri: string
SimplexLink:
- type: "simplexLink"
- showText: string?
- linkType: [SimplexLinkType](#simplexlinktype)
- simplexUri: string
- smpHosts: [string]
+2
View File
@@ -382,6 +382,7 @@
"chat_migrate_init"
"chat_parse_markdown"
"chat_parse_server"
"chat_parse_uri"
"chat_password_hash"
"chat_read_file"
"chat_recv_msg"
@@ -489,6 +490,7 @@
"chat_migrate_init"
"chat_parse_markdown"
"chat_parse_server"
"chat_parse_uri"
"chat_password_hash"
"chat_read_file"
"chat_recv_msg"
+1
View File
@@ -12,6 +12,7 @@ EXPORTS
chat_recv_msg_wait
chat_parse_markdown
chat_parse_server
chat_parse_uri
chat_password_hash
chat_valid_name
chat_json_length
+1
View File
@@ -298,6 +298,7 @@ library
, tls >=1.9.0 && <1.10
, unliftio ==0.2.*
, unliftio-core ==0.2.*
, uri-bytestring >=0.3.3.1 && <0.4
, uuid ==1.3.*
, zip ==2.0.*
, zstd ==0.1.3.*
+54 -12
View File
@@ -1,6 +1,8 @@
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
@@ -12,11 +14,13 @@
module Simplex.Chat.Markdown where
import Control.Applicative (optional, (<|>))
import Control.Monad
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import qualified Data.ByteString.Char8 as B
import Data.Char (isAlpha, isAscii, isDigit, isPunctuation, isSpace)
import Data.Either (fromRight)
import Data.Functor (($>))
@@ -34,9 +38,10 @@ import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ConnReqUriData (.
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fstToLower, sumTypeJSON)
import Simplex.Messaging.Protocol (ProtocolServer (..))
import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8)
import Simplex.Messaging.Util (decodeJSON, safeDecodeUtf8, tshow)
import System.Console.ANSI.Types
import qualified Text.Email.Validate as Email
import qualified URI.ByteString as U
data Markdown = Markdown (Maybe Format) Text | Markdown :|: Markdown
deriving (Eq, Show)
@@ -49,7 +54,9 @@ data Format
| Secret
| Colored {color :: FormatColor}
| Uri
| SimplexLink {linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text}
-- showText is Nothing for the usual Uri without text
| HyperLink {showText :: Maybe Text, linkUri :: Text}
| SimplexLink {showText :: Maybe Text, linkType :: SimplexLinkType, simplexUri :: AConnectionLink, smpHosts :: NonEmpty Text}
| Command {commandStr :: Text}
| Mention {memberName :: Text}
| Email
@@ -187,6 +194,7 @@ markdownP = mconcat <$> A.many' fragmentP
'!' -> coloredP <|> wordP
'@' -> mentionP <|> wordP
'/' -> commandP <|> wordP
'[' -> sowLinkP <|> wordP
_
| isDigit c -> phoneP <|> wordP
| otherwise -> wordP
@@ -224,6 +232,20 @@ markdownP = mconcat <$> A.many' fragmentP
let origStr = if c == '\'' then '\'' `T.cons` str `T.snoc` '\'' else str
res = markdown (format str) (pfx `T.cons` origStr)
pure $ if T.null punct then res else res :|: unmarked punct
sowLinkP = do
t <- '[' `inParens` ']'
l <- '(' `inParens` ')'
let hasPunct = T.any (\c -> isPunctuation c && c /= '-' && c /= '_') t
when (hasPunct && t /= l && ("https://" <> t) /= l) $ fail "punctuation in hyperlink text"
f <- case strDecode $ encodeUtf8 l of
Right lnk@(ACL _ cLink) -> case cLink of
CLShort _ -> pure $ simplexUriFormat (Just t) lnk
CLFull _ -> fail "full SimpleX link in hyperlink"
Left _ -> case parseUri $ encodeUtf8 l of
Right _ -> pure $ HyperLink (Just t) l
Left e -> fail $ "not uri: " <> T.unpack e
pure $ markdown f $ T.concat ["[", t, "](", l, ")"]
inParens open close = A.char open *> A.takeWhile1 (/= close) <* A.char close
colorP =
A.anyChar >>= \case
'r' -> optional "ed" $> Red
@@ -253,7 +275,11 @@ markdownP = mconcat <$> A.many' fragmentP
wordMD :: Text -> Markdown
wordMD s
| T.null s = unmarked s
| isUri s' = res $ uriMarkdown s'
| isUri s' = case strDecode $ encodeUtf8 s of
Right cLink -> res $ markdown (simplexUriFormat Nothing cLink) s'
Left _ -> case parseUri $ encodeUtf8 s' of
Right _ -> res $ markdown Uri s'
Left _ -> unmarked s
| isDomain s' = res $ markdown Uri s'
| isEmail s' = res $ markdown Email s'
| otherwise = unmarked s
@@ -265,9 +291,6 @@ markdownP = mconcat <$> A.many' fragmentP
'/' -> False
')' -> False
c -> isPunctuation c
uriMarkdown s = case strDecode $ encodeUtf8 s of
Right cLink -> markdown (simplexUriFormat cLink) s
_ -> markdown Uri s
isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"]
-- matches what is likely to be a domain, not all valid domain names
isDomain s = case T.splitOn "." s of
@@ -281,11 +304,11 @@ markdownP = mconcat <$> A.many' fragmentP
&& (let p c = isAscii c && isAlpha c in T.all p name && T.all p tld)
isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s)
noFormat = pure . unmarked
simplexUriFormat :: AConnectionLink -> Format
simplexUriFormat = \case
simplexUriFormat :: Maybe Text -> AConnectionLink -> Format
simplexUriFormat showText = \case
ACL m (CLFull cReq) -> case cReq of
CRContactUri crData -> SimplexLink (linkType' crData) cLink $ uriHosts crData
CRInvitationUri crData _ -> SimplexLink XLInvitation cLink $ uriHosts crData
CRContactUri crData -> SimplexLink showText (linkType' crData) cLink $ uriHosts crData
CRInvitationUri crData _ -> SimplexLink showText XLInvitation cLink $ uriHosts crData
where
cLink = ACL m $ CLFull $ simplexConnReqUri cReq
uriHosts ConnReqUriData {crSmpQueues} = L.map strEncodeText $ sconcat $ L.map (host . qServer) crSmpQueues
@@ -293,8 +316,8 @@ markdownP = mconcat <$> A.many' fragmentP
Just (CRDataGroup _) -> XLGroup
Nothing -> XLContact
ACL m (CLShort sLnk) -> case sLnk of
CSLContact _ ct srv _ -> SimplexLink (linkType' ct) cLink $ uriHosts srv
CSLInvitation _ srv _ _ -> SimplexLink XLInvitation cLink $ uriHosts srv
CSLContact _ ct srv _ -> SimplexLink showText (linkType' ct) cLink $ uriHosts srv
CSLInvitation _ srv _ _ -> SimplexLink showText XLInvitation cLink $ uriHosts srv
where
cLink = ACL m $ CLShort $ simplexShortLink sLnk
uriHosts srv = L.map strEncodeText $ host srv
@@ -305,6 +328,24 @@ markdownP = mconcat <$> A.many' fragmentP
strEncodeText :: StrEncoding a => a -> Text
strEncodeText = safeDecodeUtf8 . strEncode
parseUri :: B.ByteString -> Either Text U.URI
parseUri s = case U.parseURI U.laxURIParserOptions s of
Left e -> Left $ "Invalid URI: " <> tshow e
Right uri@U.URI {uriScheme = U.Scheme sch, uriAuthority}
| sch /= "http" && sch /= "https" -> Left $ "Unsupported URI scheme: " <> safeDecodeUtf8 sch
| otherwise -> case uriAuthority of
Nothing -> Left "No URI host"
Just U.Authority {authorityHost = U.Host h}
| '.' `B.notElem` h -> Left $ "Invalid URI host: " <> safeDecodeUtf8 h
| otherwise -> Right uri
sanitizeUri :: U.URI -> Maybe U.URI
sanitizeUri uri@U.URI {uriQuery = U.Query originalQS} =
let sanitizedQS = filter (\(p, _) -> p == "q" || p == "search") originalQS
in if length sanitizedQS == length originalQS
then Nothing
else Just $ uri {U.uriQuery = U.Query sanitizedQS}
markdownText :: FormattedText -> Text
markdownText (FormattedText f_ t) = case f_ of
Nothing -> t
@@ -316,6 +357,7 @@ markdownText (FormattedText f_ t) = case f_ of
Secret -> around '#'
Colored (FormatColor c) -> color c
Uri -> t
HyperLink {} -> t
SimplexLink {} -> t
Mention _ -> t
Command _ -> t
+33 -1
View File
@@ -1,5 +1,6 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
@@ -25,6 +26,7 @@ import Data.Functor (($>))
import Data.List (find)
import qualified Data.List.NonEmpty as L
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Word (Word8)
import Foreign.C.String
import Foreign.C.Types (CInt (..))
@@ -35,7 +37,7 @@ import GHC.IO.Encoding (setFileSystemEncoding, setForeignEncoding, setLocaleEnco
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Library.Commands
import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList)
import Simplex.Chat.Markdown (ParsedMarkdown (..), parseMaybeMarkdownList, parseUri, sanitizeUri)
import Simplex.Chat.Mobile.File
import Simplex.Chat.Mobile.Shared
import Simplex.Chat.Mobile.WebRTC
@@ -56,6 +58,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..)
import Simplex.Messaging.Util (catchAll, liftEitherWith, safeDecodeUtf8)
import System.IO (utf8)
import System.Timeout (timeout)
import qualified URI.ByteString as U
#if !defined(dbPostgres)
import Data.ByteArray (ScrubbedBytes)
import Database.SQLite.Simple (SQLError (..))
@@ -81,6 +84,20 @@ eitherToResult :: Maybe RemoteHostId -> Either ChatError r -> APIResult r
eitherToResult rhId = either (APIError rhId) (APIResult rhId)
{-# INLINE eitherToResult #-}
data ParsedUri = ParsedUri
{ uriInfo :: Maybe UriInfo,
parseError :: Text
}
data UriInfo = UriInfo
{ scheme :: Text,
sanitized :: Maybe Text
}
$(JQ.deriveJSON defaultJSON ''UriInfo)
$(JQ.deriveJSON defaultJSON ''ParsedUri)
$(pure [])
instance ToJSON r => ToJSON (APIResult r) where
@@ -111,6 +128,8 @@ foreign export ccall "chat_parse_markdown" cChatParseMarkdown :: CString -> IO C
foreign export ccall "chat_parse_server" cChatParseServer :: CString -> IO CJSONString
foreign export ccall "chat_parse_uri" cChatParseUri :: CString -> IO CJSONString
foreign export ccall "chat_password_hash" cChatPasswordHash :: CString -> CString -> IO CString
foreign export ccall "chat_valid_name" cChatValidName :: CString -> IO CString
@@ -200,6 +219,10 @@ cChatParseMarkdown s = newCStringFromLazyBS . chatParseMarkdown =<< B.packCStrin
cChatParseServer :: CString -> IO CJSONString
cChatParseServer s = newCStringFromLazyBS . chatParseServer =<< B.packCString s
-- | parse web URI - returns ParsedUri JSON
cChatParseUri :: CString -> IO CJSONString
cChatParseUri s = newCStringFromLazyBS . chatParseUri =<< B.packCString s
cChatPasswordHash :: CString -> CString -> IO CString
cChatPasswordHash cPwd cSalt = do
pwd <- B.packCString cPwd
@@ -293,6 +316,7 @@ chatMigrateInitKey chatDbOpts keepKey confirm backgroundMode = runExceptT $ do
DB.ErrorNotADatabase -> Left $ DBMErrorNotADatabase errDbStr
_ -> dbError e
#endif
dbError :: Show e => e -> Either DBMigrationResult DBStore
dbError e = Left . DBMErrorSQL errDbStr $ show e
chatCloseStore :: ChatController -> IO String
@@ -342,6 +366,14 @@ chatParseServer = J.encode . toServerAddress . strDecode
enc :: StrEncoding a => a -> String
enc = B.unpack . strEncode
chatParseUri :: ByteString -> JSONByteString
chatParseUri s = J.encode $ case parseUri s of
Left e -> ParsedUri Nothing e
Right uri@U.URI {uriScheme = U.Scheme sch} ->
let sanitized = safeDecodeUtf8 . U.serializeURIRef' <$> sanitizeUri uri
uriInfo = UriInfo {scheme = safeDecodeUtf8 sch, sanitized}
in ParsedUri (Just uriInfo) ""
chatPasswordHash :: ByteString -> ByteString -> ByteString
chatPasswordHash pwd salt = either (const "") passwordHash salt'
where
+28 -5
View File
@@ -20,6 +20,7 @@ markdownTests = do
secretText
textColor
textWithUri
textWithHyperlink
textWithEmail
textWithPhone
textWithMentions
@@ -172,11 +173,11 @@ uri :: Text -> Markdown
uri = Markdown $ Just Uri
simplexLink :: SimplexLinkType -> Text -> NonEmpty Text -> Text -> Markdown
simplexLink linkType uriText smpHosts t = Markdown (simplexLinkFormat linkType uriText smpHosts) t
simplexLink linkType uriText smpHosts t = Markdown (simplexLinkFormat linkType uriText smpHosts Nothing) t
simplexLinkFormat :: SimplexLinkType -> Text -> NonEmpty Text -> Maybe Format
simplexLinkFormat linkType uriText smpHosts = case strDecode $ encodeUtf8 uriText of
Right simplexUri -> Just SimplexLink {linkType, simplexUri, smpHosts}
simplexLinkFormat :: SimplexLinkType -> Text -> NonEmpty Text -> Maybe Text -> Maybe Format
simplexLinkFormat linkType uriText smpHosts showText = case strDecode $ encodeUtf8 uriText of
Right simplexUri -> Just SimplexLink {linkType, simplexUri, smpHosts, showText}
Left e -> error e
textWithUri :: Spec
@@ -210,6 +211,7 @@ textWithUri = describe "text with Uri" do
"www." <==> "www."
".com" <==> ".com"
"example.academytoolong" <==> "example.academytoolong"
"simplex:/example" <==> "simplex:/example"
it "SimpleX links" do
let inv = "/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D"
("https://simplex.chat" <> inv) <==> simplexLink XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] ("https://simplex.chat" <> inv)
@@ -222,6 +224,27 @@ textWithUri = describe "text with Uri" do
("https://simplex.chat" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("https://simplex.chat" <> gr)
("simplex:" <> gr) <==> simplexLink XLGroup ("simplex:" <> gr) ["smp4.simplex.im", "o5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion"] ("simplex:" <> gr)
web :: Text -> Text -> Text -> Markdown
web t u = Markdown $ Just HyperLink {showText = Just t, linkUri = u}
textWithHyperlink :: Spec
textWithHyperlink = describe "text with HyperLink without link text" do
let addr = "https://smp6.simplex.im/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw"
addr' = "simplex:/a#lrdvu2d8A1GumSmoKb2krQmtKhWXq-tyGpHuM7aMwsw?h=smp6.simplex.im"
it "correct markdown" do
"[click here](https://example.com)" <==> web "click here" "https://example.com" "[click here](https://example.com)"
"For details [click here](https://example.com)" <==> "For details " <> web "click here" "https://example.com" "[click here](https://example.com)"
"[example.com](https://example.com)" <==> web "example.com" "https://example.com" "[example.com](https://example.com)"
"[example.com/page](https://example.com/page)" <==> web "example.com/page" "https://example.com/page" "[example.com/page](https://example.com/page)"
("[Connect to me](" <> addr <> ")") <==> Markdown (simplexLinkFormat XLContact addr' ["smp6.simplex.im"] (Just "Connect to me")) ("[Connect to me](" <> addr <> ")")
it "potentially spoofed link" do
"[https://example.com](https://another.com)" <==> "[https://example.com](https://another.com)"
"[example.com/page](https://another.com/page)" <==> "[example.com/page](https://another.com/page)"
("[Connect.to.me](" <> addr <> ")") <==> Markdown Nothing ("[Connect.to.me](" <> addr <> ")")
it "ignored as markdown" do
"[click here](example.com)" <==> "[click here](example.com)"
"[click here](https://example.com )" <==> "[click here](https://example.com )"
email :: Text -> Markdown
email = Markdown $ Just Email
@@ -330,7 +353,7 @@ multilineMarkdownList = describe "multiline markdown" do
it "multiline with simplex link" do
("https://simplex.chat" <> inv <> "\ntext")
<<==>>
[ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"]) ("https://simplex.chat" <> inv),
[ FormattedText (simplexLinkFormat XLInvitation ("simplex:" <> inv) ["smp.simplex.im"] Nothing) ("https://simplex.chat" <> inv),
"\ntext"
]
it "command markdown" do
+11
View File
@@ -75,6 +75,9 @@ mobileTests = do
it "should convert invalid name to a valid name" testValidNameCApi
describe "JSON length" $ do
it "should compute length of JSON encoded string" testChatJsonLengthCApi
describe "Parsers" $ do
it "should parse server address" testChatParseServer
it "should parse and sanitize URI" testChatParseUri
noActiveUser :: LB.ByteString
noActiveUser =
@@ -318,6 +321,14 @@ testChatJsonLengthCApi _ = do
cInt2 <- cChatJsonLength =<< newCString "こんにちは!"
cInt2 `shouldBe` 18
testChatParseServer :: TestParams -> IO ()
testChatParseServer _ = do
pure ()
testChatParseUri :: TestParams -> IO ()
testChatParseUri _ = do
pure ()
jDecode :: FromJSON a => String -> IO (Maybe a)
jDecode = pure . J.decode . LB.pack