ui: opt-in alert for link previews (#6799)

* ios: opt-in alert for link previews

* rename back

* kotlin: opt-in alert for link previews

* reset hints, refactor

* refactor hints

* move functions

* better UX

* ios buttons

* ios: two buttons

* kotlin refactor

* kotlin: two buttons

* show spinner only after preview decision

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny
2026-04-20 12:10:02 +01:00
committed by GitHub
parent 260bd676cc
commit d3a2c9d08d
10 changed files with 180 additions and 65 deletions
@@ -370,6 +370,8 @@ struct ComposeView: View {
@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 = false
@AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT, store: groupDefaults) private var linkPreviewsShowAlert = true
@State private var updatingCompose = false
@State private var relayListExpanded = false
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
@@ -561,7 +563,7 @@ struct ComposeView: View {
} else {
composeState = composeState.copy(parsedMessage: parsedMsg ?? FormattedText.plain(msg))
}
if composeState.linkPreviewAllowed && UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) {
if composeState.linkPreviewAllowed && useLinkPreviews {
if !msg.isEmpty {
showLinkPreview(parsedMsg)
} else {
@@ -1878,21 +1880,55 @@ struct ComposeView: View {
// Spec: spec/client/compose.md#loadLinkPreview
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 == 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) {
composeState = composeState.copy(preview: .noPreview)
if linkPreviewsShowAlert {
showLinkPreviewsConfirmAlert { enable in
if let enable {
linkPreviewsShowAlert = false
useLinkPreviews = enable
UserDefaults.standard.set(enable, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
if enable {
fetchLinkPreview(url, urlStr: urlStr)
} else {
pendingLinkUrl = nil
composeState = composeState.copy(preview: .noPreview)
}
} else {
cancelLinkPreview()
}
}
pendingLinkUrl = nil
return
}
fetchLinkPreview(url, urlStr: urlStr)
}
}
private func fetchLinkPreview(_ url: URL, urlStr: String) {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in
if let linkPreview, pendingLinkUrl == urlStr {
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
} else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
composeState = composeState.copy(preview: .noPreview)
}
}
pendingLinkUrl = nil
}
}
private func showLinkPreviewsConfirmAlert(onChoice: @escaping (Bool?) -> Void) {
showAlert(
NSLocalizedString("Enable link previews?", comment: "alert title"),
message: NSLocalizedString("Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.", comment: "alert message"),
actions: {
[
UIAlertAction(title: NSLocalizedString("Disable", comment: "alert button"), style: .destructive) { _ in onChoice(false) },
UIAlertAction(title: NSLocalizedString("Enable", comment: "alert button"), style: .default) { _ in onChoice(true) }
]
}
)
}
private func resetLinkPreview() {
linkUrl = nil
prevLinkUrl = nil
@@ -76,7 +76,7 @@ extension AppSettings {
c.privacyEncryptLocalFiles = privacyEncryptLocalFilesGroupDefault.get()
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
c.privacyLinkPreviews = privacyLinkPreviewsGroupDefault.get()
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
@@ -91,6 +91,11 @@ struct DeveloperView: View {
UserDefaults.standard.set(val, forKey: def)
}
}
for def in hintGroupDefaults {
if let val = groupAppDefaults[def] as? Bool {
groupDefaults.set(val, forKey: def)
}
}
hintsUnchanged = true
}
}
@@ -98,6 +103,8 @@ struct DeveloperView: View {
private func hintDefaultsUnchanged() -> Bool {
hintDefaults.allSatisfy { def in
appDefaults[def] as? Bool == UserDefaults.standard.bool(forKey: def)
} && hintGroupDefaults.allSatisfy { def in
groupAppDefaults[def] as? Bool == groupDefaults.bool(forKey: def)
}
}
@@ -13,7 +13,7 @@ struct PrivacySettings: View {
@EnvironmentObject var m: ChatModel
@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_LINK_PREVIEWS, store: groupDefaults) private var useLinkPreviews = true
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@@ -74,8 +74,8 @@ struct PrivacySettings: View {
settingsRow("network", color: theme.colors.secondary) {
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
UserDefaults.standard.set(linkPreviews, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
privacyLinkPreviewsShowAlertGroupDefault.set(false)
}
}
settingsRow("link", color: theme.colors.secondary) {
@@ -150,6 +150,10 @@ let hintDefaults = [
DEFAULT_SHOW_DELETE_CONTACT_NOTICE
]
let hintGroupDefaults = [
GROUP_DEFAULT_PRIVACY_LINK_PREVIEWS_SHOW_ALERT
]
// not used anymore
enum ConnectViaLinkTab: String {
case scan
+1 -2
View File
@@ -465,8 +465,7 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result<SharedCo
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) {
privacyLinkPreviewsShowAlertGroupDefault.set(false) // to avoid showing alert to current users, show alert in v6.5
if privacyLinkPreviewsGroupDefault.get() && !privacyLinkPreviewsShowAlertGroupDefault.get(), let linkPreview = await getLinkPreview(for: url) {
content = .url(preview: linkPreview)
} else {
content = .text(string: url.absoluteString)
+42 -40
View File
@@ -26,7 +26,7 @@ public let GROUP_DEFAULT_NTF_ENABLE_PERIODIC = "ntfEnablePeriodic" // no longer
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 = "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
@@ -70,46 +70,48 @@ public let APP_GROUP_NAME = "group.chat.simplex.app"
public let groupDefaults = UserDefaults(suiteName: APP_GROUP_NAME)!
public let groupAppDefaults: [String: Any] = [
GROUP_DEFAULT_NTF_ENABLE_LOCAL: false,
GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false,
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue,
GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpConnectTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb,
GROUP_DEFAULT_NETWORK_RCV_CONCURRENCY: NetCfg.defaults.rcvConcurrency,
GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval,
GROUP_DEFAULT_NETWORK_SMP_PING_COUNT: NetCfg.defaults.smpPingCount,
GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive,
GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle,
GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl,
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt,
GROUP_DEFAULT_INCOGNITO: false,
GROUP_DEFAULT_STORE_DB_PASSPHRASE: true,
GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false,
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: false,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
GROUP_DEFAULT_ONE_HAND_UI: true,
GROUP_DEFAULT_CHAT_BOTTOM_BAR: true
]
public func registerGroupDefaults() {
groupDefaults.register(defaults: [
GROUP_DEFAULT_NTF_ENABLE_LOCAL: false,
GROUP_DEFAULT_NTF_ENABLE_PERIODIC: false,
GROUP_DEFAULT_NETWORK_USE_ONION_HOSTS: OnionHosts.no.rawValue,
GROUP_DEFAULT_NETWORK_SESSION_MODE: TransportSessionMode.session.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_MODE: SMPProxyMode.unknown.rawValue,
GROUP_DEFAULT_NETWORK_SMP_PROXY_FALLBACK: SMPProxyFallback.allowProtected.rawValue,
GROUP_DEFAULT_NETWORK_SMP_WEB_PORT_SERVERS: SMPWebPortServers.preset.rawValue,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpConnectTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_CONNECT_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpConnectTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_BACKGROUND: NetCfg.defaults.tcpTimeout.backgroundTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_INTERACTIVE: NetCfg.defaults.tcpTimeout.interactiveTimeout,
GROUP_DEFAULT_NETWORK_TCP_TIMEOUT_PER_KB: NetCfg.defaults.tcpTimeoutPerKb,
GROUP_DEFAULT_NETWORK_RCV_CONCURRENCY: NetCfg.defaults.rcvConcurrency,
GROUP_DEFAULT_NETWORK_SMP_PING_INTERVAL: NetCfg.defaults.smpPingInterval,
GROUP_DEFAULT_NETWORK_SMP_PING_COUNT: NetCfg.defaults.smpPingCount,
GROUP_DEFAULT_NETWORK_ENABLE_KEEP_ALIVE: NetCfg.defaults.enableKeepAlive,
GROUP_DEFAULT_NETWORK_TCP_KEEP_IDLE: KeepAliveOpts.defaults.keepIdle,
GROUP_DEFAULT_NETWORK_TCP_KEEP_INTVL: KeepAliveOpts.defaults.keepIntvl,
GROUP_DEFAULT_NETWORK_TCP_KEEP_CNT: KeepAliveOpts.defaults.keepCnt,
GROUP_DEFAULT_INCOGNITO: false,
GROUP_DEFAULT_STORE_DB_PASSPHRASE: true,
GROUP_DEFAULT_INITIAL_RANDOM_DB_PASSPHRASE: false,
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: false,
GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES: true,
GROUP_DEFAULT_PRIVACY_TRANSFER_IMAGES_INLINE: false,
GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES: true,
GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS: true,
GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
GROUP_DEFAULT_CONFIRM_DB_UPGRADES: false,
GROUP_DEFAULT_CALL_KIT_ENABLED: true,
GROUP_DEFAULT_PQ_EXPERIMENTAL_ENABLED: false,
GROUP_DEFAULT_ONE_HAND_UI: true,
GROUP_DEFAULT_CHAT_BOTTOM_BAR: true
])
groupDefaults.register(defaults: groupAppDefaults)
}
public enum AppState: String, Codable {
@@ -266,6 +266,7 @@ class AppPreferences {
showReportsInSupportChatAlert to true,
showDeleteConversationNotice to true,
showDeleteContactNotice to true,
privacyLinkPreviewsShowAlert to true,
)
private fun mkIntPreference(prefName: String, default: Int) =
@@ -1,6 +1,7 @@
@file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class)
package chat.simplex.common.views.chat
import SectionItemView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -19,6 +20,8 @@ import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -399,20 +402,46 @@ fun ComposeView(
val recState: MutableState<RecordingState> = remember { mutableStateOf(RecordingState.NotStarted) }
AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } }
suspend fun fetchAndUpdateLinkPreview(url: String) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
val lp = getLinkPreview(url)
if (lp != null && pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(lp))
pendingLinkUrl.value = null
} else if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
pendingLinkUrl.value = null
}
}
fun loadLinkPreview(url: String, wait: Long? = null) {
if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null))
withLongRunningApi(slow = 60_000) {
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) {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
pendingLinkUrl.value = null
if (pendingLinkUrl.value != url) return@withLongRunningApi
if (chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.get()
&& !chatModel.controller.appPrefs.networkUseSocksProxy.get()) {
showLinkPreviewsConfirmAlert { enable ->
if (enable != null) {
chatModel.controller.appPrefs.privacyLinkPreviewsShowAlert.set(false)
chatModel.controller.appPrefs.privacyLinkPreviews.set(enable)
if (enable) {
withLongRunningApi(slow = 60_000) { fetchAndUpdateLinkPreview(url) }
} else if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
pendingLinkUrl.value = null
}
} else {
cancelledLinks.add(url)
if (pendingLinkUrl.value == url) {
composeState.value = composeState.value.copy(preview = ComposePreview.NoPreview)
pendingLinkUrl.value = null
}
}
}
return@withLongRunningApi
}
fetchAndUpdateLinkPreview(url)
}
}
}
@@ -1672,6 +1701,36 @@ fun ComposeView(
}
}
private fun showLinkPreviewsConfirmAlert(onChoice: (Boolean?) -> Unit) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.link_previews_alert_title),
text = AnnotatedString(generalGetString(MR.strings.link_previews_alert_desc)),
onDismissRequest = { onChoice(null) },
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
onChoice(false)
}) {
Text(stringResource(MR.strings.link_previews_alert_disable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
onChoice(true)
}) {
Text(stringResource(MR.strings.link_previews_alert_enable), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
// SectionItemView({
// AlertManager.shared.hideAlert()
// onChoice(null)
// }) {
// Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.onBackground)
// }
}
}
)
}
@Composable
private fun OwnerChannelRelayBar(
chatModel: ChatModel,
@@ -2970,4 +2970,11 @@
<!-- ChatListNavLinkView.kt channel-related -->
<string name="unblock_subscriber_for_all_question">Unblock subscriber for all?</string>
<!-- ComposeView.kt link previews opt-in alert -->
<string name="link_previews_alert_title">Enable link previews?</string>
<string name="link_previews_alert_desc">Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later.</string>
<string name="link_previews_alert_enable">Enable</string>
<string name="link_previews_alert_disable">Disable</string>
<!-- <string name="link_previews_alert_dont_ask_again">Don't ask again</string> -->
</resources>