From d3a2c9d08dca16a0c8f9dca2953e06341b6512fa Mon Sep 17 00:00:00 2001 From: Evgeny Date: Mon, 20 Apr 2026 12:10:02 +0100 Subject: [PATCH] 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> --- .../Chat/ComposeMessage/ComposeView.swift | 56 ++++++++++--- .../Views/UserSettings/AppSettings.swift | 2 +- .../Views/UserSettings/DeveloperView.swift | 7 ++ .../Views/UserSettings/PrivacySettings.swift | 6 +- .../Views/UserSettings/SettingsView.swift | 4 + apps/ios/SimpleX SE/ShareModel.swift | 3 +- apps/ios/SimpleXChat/AppGroup.swift | 82 ++++++++++--------- .../chat/simplex/common/model/SimpleXAPI.kt | 1 + .../simplex/common/views/chat/ComposeView.kt | 77 +++++++++++++++-- .../commonMain/resources/MR/base/strings.xml | 7 ++ 10 files changed, 180 insertions(+), 65 deletions(-) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 29daaf37fa..fd47ddfacb 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -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 diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 44e0b20958..8be0798fb1 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -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) diff --git a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift index 6df2d5422e..184b03e679 100644 --- a/apps/ios/Shared/Views/UserSettings/DeveloperView.swift +++ b/apps/ios/Shared/Views/UserSettings/DeveloperView.swift @@ -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) } } diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index eec820833c..3ae9f0eacd 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -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) { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index c091224098..65e34a0ac5 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -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 diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift index fd5c4c990f..18f3e2c344 100644 --- a/apps/ios/SimpleX SE/ShareModel.swift +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -465,8 +465,7 @@ fileprivate func getSharedContent(_ ip: NSItemProvider) async -> Result = 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, diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index abb1d55942..f1ab9b7c30 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2970,4 +2970,11 @@ Unblock subscriber for all? + + + Enable link previews? + Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later. + Enable + Disable + \ No newline at end of file