From fa473472e4182e5263a5398282f06b16db2261be Mon Sep 17 00:00:00 2001 From: Tlaster Date: Wed, 1 Jul 2026 23:33:56 +0900 Subject: [PATCH 1/2] deeplink handling enhancement --- .../dev/dimension/flare/ui/route/Route.kt | 6 + .../screen/home/DeepLinkAccountPickerModal.kt | 70 +++- .../settings/AppearanceDisplayScreen.kt | 32 +- .../ui/screen/settings/BehaviorScreen.kt | 245 ++++++++++++ .../ui/screen/settings/SettingsScreen.kt | 22 ++ .../settings/SettingsSelectEntryBuilder.kt | 26 ++ app/src/main/res/values/strings.xml | 10 +- .../AppearanceSettingsSections.swift | 102 ++++- .../DeepLinkAccountPickerView.swift | 58 ++- .../Resources/Localizable.xcstrings | 88 ++++- appleApp/ios/UI/Route/Route.swift | 6 + appleApp/ios/UI/Screen/SettingsScreen.swift | 36 ++ .../macos/UI/Screen/MacSettingsScreen.swift | 178 +++++++++ .../main/composeResources/values/strings.xml | 10 +- .../ui/screen/home/DeepLinkAccountPicker.kt | 68 +++- .../ui/screen/settings/SettingsScreen.kt | 126 ++++++ .../deeplink/PlatformDeepLinkMatcher.kt | 25 +- .../flare/data/datastore/model/AppSettings.kt | 26 ++ .../data/datastore/model/LinkOpenDefaults.kt | 50 +++ .../ui/presenter/home/DeepLinkPresenter.kt | 59 ++- .../settings/LinkOpenDefaultsPresenter.kt | 369 ++++++++++++++++++ 21 files changed, 1531 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/dev/dimension/flare/ui/screen/settings/BehaviorScreen.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/LinkOpenDefaults.kt create mode 100644 shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/LinkOpenDefaultsPresenter.kt diff --git a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt index f9a4efb450..fb1ce64b96 100644 --- a/app/src/main/java/dev/dimension/flare/ui/route/Route.kt +++ b/app/src/main/java/dev/dimension/flare/ui/route/Route.kt @@ -112,6 +112,12 @@ internal sealed interface Route : NavKey { @Serializable data object AppearanceMedia : Settings + @Serializable + data object Behavior : Settings + + @Serializable + data object LinkOpenDefaults : Settings + @Serializable data object Storage : Settings diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt b/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt index fc10a5f564..d6e0915e77 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/home/DeepLinkAccountPickerModal.kt @@ -8,13 +8,18 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.Checkbox import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource @@ -28,7 +33,9 @@ import dev.dimension.flare.model.MicroBlogKey import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.listCard import dev.dimension.flare.ui.presenter.home.UserPresenter +import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.LinkOpenDefaultsActionsPresenter import dev.dimension.flare.ui.route.Route import dev.dimension.flare.ui.screen.settings.AccountItem import dev.dimension.flare.ui.theme.screenHorizontalPadding @@ -47,6 +54,12 @@ internal fun DeepLinkAccountPickerModal( val scope = rememberCoroutineScope() val clipboard = LocalClipboard.current val accounts by producePresenter { presenter(data) } + val defaultsActions by producePresenter("link_open_defaults_picker") { + remember(originalUrl) { LinkOpenDefaultsActionsPresenter(originalUrl) }.invoke() + } + var saveAsDefault by remember(defaultsActions.canSaveDefault) { + mutableStateOf(defaultsActions.canSaveDefault) + } LazyColumn( contentPadding = PaddingValues( @@ -60,12 +73,15 @@ internal fun DeepLinkAccountPickerModal( item { Spacer(modifier = Modifier.height(12.dp)) } - itemsIndexed(accounts) { index, (userState, route) -> + itemsIndexed(accounts) { index, item -> AccountItem( - userState.user, + item.userState.user, onClick = { + if (saveAsDefault && defaultsActions.canSaveDefault) { + defaultsActions.setAccountDefault(item.accountKey) + } onDismissRequest() - onNavigate(route) + onNavigate(item.route) }, toLogin = {}, modifier = @@ -80,12 +96,12 @@ internal fun DeepLinkAccountPickerModal( val text = stringResource(R.string.media_menu_copy_link) ListItem( headlineContent = { - Text(stringResource(R.string.media_menu_copy_link)) + Text(text) }, leadingContent = { FAIcon( FontAwesomeIcons.Solid.Globe, - contentDescription = stringResource(R.string.media_menu_copy_link), + contentDescription = text, ) }, modifier = @@ -109,20 +125,56 @@ internal fun DeepLinkAccountPickerModal( }, ) } + if (defaultsActions.canSaveDefault) { + item { + ListItem( + headlineContent = { + Text(stringResource(R.string.deeplink_account_selection_save_default)) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + trailingContent = { + Checkbox( + checked = saveAsDefault, + onCheckedChange = { + saveAsDefault = it + }, + ) + }, + modifier = + Modifier + .clickable { + saveAsDefault = !saveAsDefault + }, + ) + } + } item { Spacer(modifier = Modifier.height(14.dp)) } } } +private data class DeepLinkAccountItem( + val accountKey: MicroBlogKey, + val userState: UserState, + val route: Route, +) + @Composable private fun presenter(data: ImmutableMap) = run { remember(data) { - data.map { - UserPresenter(AccountType.Specific(it.key), null) to it.value + data.map { (accountKey, route) -> + accountKey to (UserPresenter(AccountType.Specific(accountKey), null) to route) } - }.map { - it.first.invoke() to it.second + }.map { (accountKey, presenterAndRoute) -> + DeepLinkAccountItem( + accountKey = accountKey, + userState = presenterAndRoute.first.invoke(), + route = presenterAndRoute.second, + ) }.toImmutableList() } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceDisplayScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceDisplayScreen.kt index 6f273a41b5..e1c4ca504e 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceDisplayScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/AppearanceDisplayScreen.kt @@ -27,11 +27,11 @@ import dev.dimension.flare.data.model.appearance.AppearanceKeys import dev.dimension.flare.ui.component.BackButton import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar import dev.dimension.flare.ui.component.FlareScaffold -import dev.dimension.flare.ui.component.LocalGlobalAppearance import dev.dimension.flare.ui.component.LocalTimelineAppearance import dev.dimension.flare.ui.component.status.StatusItem import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.presenter.invoke import dev.dimension.flare.ui.theme.first import dev.dimension.flare.ui.theme.item import dev.dimension.flare.ui.theme.last @@ -43,7 +43,6 @@ import moe.tlaster.precompose.molecule.producePresenter internal fun AppearanceDisplayScreen(onBack: () -> Unit) { val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val state by producePresenter { appearancePresenter() } - val globalAppearance = LocalGlobalAppearance.current val timelineAppearance = LocalTimelineAppearance.current FlareScaffold( topBar = { @@ -132,7 +131,12 @@ internal fun AppearanceDisplayScreen(onBack: () -> Unit) { onClick = { state.update(AppearanceKeys.ShowLinkPreview, !timelineAppearance.showLinkPreview) }, - shapes = ListItemDefaults.item(), + shapes = + if (timelineAppearance.showLinkPreview) { + ListItemDefaults.item() + } else { + ListItemDefaults.last() + }, content = { Text(text = stringResource(id = R.string.settings_appearance_show_link_previews)) }, @@ -153,7 +157,7 @@ internal fun AppearanceDisplayScreen(onBack: () -> Unit) { onClick = { state.update(AppearanceKeys.CompatLinkPreview, !timelineAppearance.compatLinkPreview) }, - shapes = ListItemDefaults.item(), + shapes = ListItemDefaults.last(), content = { Text(text = stringResource(id = R.string.settings_appearance_compat_link_previews)) }, @@ -170,26 +174,6 @@ internal fun AppearanceDisplayScreen(onBack: () -> Unit) { }, ) } - SegmentedListItem( - onClick = { - state.update(AppearanceKeys.InAppBrowser, !globalAppearance.inAppBrowser) - }, - shapes = ListItemDefaults.last(), - content = { - Text(text = stringResource(id = R.string.settings_appearance_in_app_browser)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.settings_appearance_in_app_browser_description)) - }, - trailingContent = { - Switch( - checked = globalAppearance.inAppBrowser, - onCheckedChange = { - state.update(AppearanceKeys.InAppBrowser, it) - }, - ) - }, - ) } } } diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/BehaviorScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/BehaviorScreen.kt new file mode 100644 index 0000000000..9f22c71b39 --- /dev/null +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/BehaviorScreen.kt @@ -0,0 +1,245 @@ +package dev.dimension.flare.ui.screen.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.dimension.flare.R +import dev.dimension.flare.data.model.appearance.AppearanceKeys +import dev.dimension.flare.ui.component.AvatarComponent +import dev.dimension.flare.ui.component.BackButton +import dev.dimension.flare.ui.component.FlareDropdownMenu +import dev.dimension.flare.ui.component.FlareLargeFlexibleTopAppBar +import dev.dimension.flare.ui.component.FlareScaffold +import dev.dimension.flare.ui.component.LocalGlobalAppearance +import dev.dimension.flare.ui.component.RichText +import dev.dimension.flare.ui.model.onError +import dev.dimension.flare.ui.model.onLoading +import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.model.takeSuccessOr +import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.LinkOpenDefaultsPresenter +import dev.dimension.flare.ui.theme.first +import dev.dimension.flare.ui.theme.item +import dev.dimension.flare.ui.theme.last +import dev.dimension.flare.ui.theme.screenHorizontalPadding +import dev.dimension.flare.ui.theme.single +import kotlinx.collections.immutable.persistentListOf +import moe.tlaster.precompose.molecule.producePresenter + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun BehaviorScreen( + toLinkOpenDefaults: () -> Unit, + onBack: () -> Unit, +) { + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val state by producePresenter { appearancePresenter() } + val globalAppearance = LocalGlobalAppearance.current + FlareScaffold( + topBar = { + FlareLargeFlexibleTopAppBar( + title = { + Text(text = stringResource(id = R.string.settings_behavior_title)) + }, + navigationIcon = { + BackButton(onBack = onBack) + }, + scrollBehavior = topAppBarScrollBehavior, + ) + }, + modifier = + Modifier + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(it) + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + SegmentedListItem( + onClick = { + state.update(AppearanceKeys.InAppBrowser, !globalAppearance.inAppBrowser) + }, + shapes = ListItemDefaults.first(), + content = { + Text(text = stringResource(id = R.string.settings_appearance_in_app_browser)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.settings_appearance_in_app_browser_description)) + }, + trailingContent = { + Switch( + checked = globalAppearance.inAppBrowser, + onCheckedChange = { + state.update(AppearanceKeys.InAppBrowser, it) + }, + ) + }, + ) + SegmentedListItem( + onClick = toLinkOpenDefaults, + shapes = ListItemDefaults.last(), + content = { + Text(text = stringResource(id = R.string.settings_link_open_defaults_title)) + }, + supportingContent = { + Text(text = stringResource(id = R.string.settings_link_open_defaults_description)) + }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun LinkOpenDefaultsScreen(onBack: () -> Unit) { + val topAppBarScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val linkOpenDefaultsState by producePresenter("link_open_defaults_settings") { + remember { LinkOpenDefaultsPresenter() }.invoke() + } + val linkOpenTargets = linkOpenDefaultsState.targets.takeSuccessOr(persistentListOf()) + FlareScaffold( + topBar = { + FlareLargeFlexibleTopAppBar( + title = { + Text(text = stringResource(id = R.string.settings_link_open_defaults_title)) + }, + navigationIcon = { + BackButton(onBack = onBack) + }, + scrollBehavior = topAppBarScrollBehavior, + ) + }, + modifier = + Modifier + .nestedScroll(topAppBarScrollBehavior.nestedScrollConnection), + ) { + Column( + modifier = + Modifier + .verticalScroll(rememberScrollState()) + .padding(it) + .padding(horizontal = screenHorizontalPadding), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + linkOpenTargets.forEachIndexed { index, target -> + LinkOpenDefaultSettingsItem( + target = target, + state = linkOpenDefaultsState, + shapes = + when (index) { + 0 -> if (linkOpenTargets.size == 1) ListItemDefaults.single() else ListItemDefaults.first() + linkOpenTargets.lastIndex -> ListItemDefaults.last() + else -> ListItemDefaults.item() + }, + ) + } + } + } +} + +@Composable +private fun LinkOpenDefaultSettingsItem( + target: LinkOpenDefaultsPresenter.Target, + state: LinkOpenDefaultsPresenter.State, + shapes: ListItemShapes, +) { + val askLabel = stringResource(id = R.string.settings_link_open_default_ask_every_time) + val browserLabel = stringResource(id = R.string.deeplink_account_selection_browser) + var showMenu by remember { mutableStateOf(false) } + SegmentedListItem( + checked = showMenu, + onCheckedChange = { showMenu = it }, + shapes = shapes, + content = { + Text(text = target.title) + }, + supportingContent = { + LinkOpenDefaultOptionContent( + option = target.selectedOption, + askLabel = askLabel, + browserLabel = browserLabel, + ) + }, + trailingContent = { + FlareDropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + target.options.forEach { option -> + DropdownMenuItem( + text = { + LinkOpenDefaultOptionContent( + option = option, + askLabel = askLabel, + browserLabel = browserLabel, + ) + }, + onClick = { + state.select(target, option) + showMenu = false + }, + ) + } + } + }, + ) +} + +@Composable +private fun LinkOpenDefaultOptionContent( + option: LinkOpenDefaultsPresenter.Option, + askLabel: String, + browserLabel: String, +) { + when { + option.isAsk -> Text(text = askLabel) + option.isBrowser -> Text(text = browserLabel) + option.account != null -> LinkOpenDefaultAccountOption(account = option.account!!) + } +} + +@Composable +private fun LinkOpenDefaultAccountOption(account: LinkOpenDefaultsPresenter.Account) { + account.profile + .onSuccess { user -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AvatarComponent(data = user.avatar, size = 24.dp) + Column { + RichText(text = user.name, maxLines = 1) + Text(text = user.handle.canonical, maxLines = 1) + } + } + }.onLoading { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AvatarComponent(data = null, size = 24.dp) + Text(text = account.accountKey.toString()) + } + }.onError { + Text(text = account.accountKey.toString()) + } +} diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index 0a9001d9b5..374a127509 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -35,6 +35,7 @@ import compose.icons.fontawesomeicons.solid.Newspaper import compose.icons.fontawesomeicons.solid.Palette import compose.icons.fontawesomeicons.solid.PhotoFilm import compose.icons.fontawesomeicons.solid.Robot +import compose.icons.fontawesomeicons.solid.Sliders import compose.icons.fontawesomeicons.solid.TableList import dev.dimension.flare.BuildConfig import dev.dimension.flare.R @@ -84,6 +85,7 @@ internal fun SettingsScreen( toAppearanceLayout: () -> Unit, toAppearanceDisplay: () -> Unit, toAppearanceMedia: () -> Unit, + toBehavior: () -> Unit, toStorage: () -> Unit, toAbout: () -> Unit, toColorSpace: () -> Unit, @@ -244,6 +246,26 @@ internal fun SettingsScreen( } } + SegmentedListItem( + onClick = { + toBehavior.invoke() + }, + shapes = ListItemDefaults.single(), + content = { + Text(text = stringResource(id = R.string.settings_behavior_title)) + }, + leadingContent = { + ThemedIcon( + imageVector = FontAwesomeIcons.Solid.Sliders, + contentDescription = stringResource(id = R.string.settings_behavior_title), + color = ThemeIconData.Color.SapphireBlue, + ) + }, + supportingContent = { + Text(text = stringResource(id = R.string.settings_behavior_description)) + }, + ) + Column( verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), ) { diff --git a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt index c206481f24..68ca34a9dd 100644 --- a/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt +++ b/app/src/main/java/dev/dimension/flare/ui/screen/settings/SettingsSelectEntryBuilder.kt @@ -38,6 +38,9 @@ internal fun EntryProviderScope.settingsSelectEntryBuilder( toAppearanceMedia = { navigate(Route.Settings.AppearanceMedia) }, + toBehavior = { + navigate(Route.Settings.Behavior) + }, toStorage = { navigate(Route.Settings.Storage) }, @@ -132,6 +135,29 @@ internal fun EntryProviderScope.settingsSelectEntryBuilder( ) } + entry( + metadata = ListDetailSceneStrategy.detailPane( + sceneKey = "Settings" + ) + ) { + BehaviorScreen( + toLinkOpenDefaults = { + navigate(Route.Settings.LinkOpenDefaults) + }, + onBack = onBack, + ) + } + + entry( + metadata = ListDetailSceneStrategy.detailPane( + sceneKey = "Settings" + ) + ) { + LinkOpenDefaultsScreen( + onBack = onBack, + ) + } + entry( metadata = ListDetailSceneStrategy.detailPane( sceneKey = "Settings" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 531c803f70..6f4c67d0ae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,7 +117,9 @@ Layout Timeline layout, navigation, and post actions Display - Timestamps, source badges, links, and browser behavior + Timestamps, source badges, and link previews + Behavior + Browser and link handling preferences Media Media visibility, playback, and sensitive content Storage @@ -622,6 +624,12 @@ Select account Select an account to open this link Open in browser + Always open this way + Link opening + Choose defaults for supported links by host + Default for %1$s links + Choose how supported links open when Flare can handle them + Ask every time Block user Are you sure you want to block this user? diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift index 265037bb76..77f4a56c3c 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/AppearanceSettingsSections.swift @@ -154,7 +154,6 @@ public struct AppearanceDisplaySettingsSection: View { @StateObject private var presenter = KotlinPresenter(presenter: SettingsPresenter()) #if os(iOS) @StateObject private var statusPresenter = KotlinPresenter(presenter: AppearancePresenter()) - @Environment(\.globalAppearance) private var globalAppearance #endif @Environment(\.timelineAppearance) private var timelineAppearance @@ -201,6 +200,21 @@ public struct AppearanceDisplaySettingsSection: View { Text("appearance_compat_link_preview_description", bundle: FlareAppleUILocalization.bundle) } } + } + } +} + +public struct BehaviorSettingsSection: View { + @StateObject private var presenter = KotlinPresenter(presenter: SettingsPresenter()) + @Environment(\.globalAppearance) private var globalAppearance + private let linkOpenDefaultsLink: () -> LinkOpenDefaultsLink + + public init(@ViewBuilder linkOpenDefaultsLink: @escaping () -> LinkOpenDefaultsLink) { + self.linkOpenDefaultsLink = linkOpenDefaultsLink + } + + public var body: some View { + Section { #if os(iOS) Toggle(isOn: Binding(get: { globalAppearance.inAppBrowser @@ -211,6 +225,92 @@ public struct AppearanceDisplaySettingsSection: View { Text("appearance_in_app_browser_description", bundle: FlareAppleUILocalization.bundle) } #endif + linkOpenDefaultsLink() + } + } +} + +public extension BehaviorSettingsSection where LinkOpenDefaultsLink == EmptyView { + init() { + self.init { + EmptyView() + } + } +} + +public struct LinkOpenDefaultsSettingsSection: View { + @StateObject private var presenter = KotlinPresenter(presenter: LinkOpenDefaultsPresenter()) + + public init() {} + + public var body: some View { + Section { + StateView(state: presenter.state.targets) { data in + let targets = data.cast(LinkOpenDefaultsPresenter.Target.self) + ForEach(targets, id: \.id) { target in + LinkOpenDefaultSettingsPicker( + target: target, + state: presenter.state + ) + } + } loadingContent: { + EmptyView() + } + } + } +} + +private struct LinkOpenDefaultSettingsPicker: View { + let target: LinkOpenDefaultsPresenter.Target + let state: LinkOpenDefaultsPresenterState + + var body: some View { + Picker(selection: Binding(get: { + target.selectedOption.id + }, set: { newValue in + if let option = options.first(where: { $0.id == newValue }) { + state.select(target: target, option: option) + } + })) { + ForEach(options, id: \.id) { option in + LinkOpenDefaultOptionLabel(option: option) + .tag(option.id) + } + } label: { + Text(target.title) + LinkOpenDefaultOptionLabel(option: target.selectedOption) + } + } + + private var options: [any LinkOpenDefaultsPresenterOption] { + target.options + } +} + +private struct LinkOpenDefaultOptionLabel: View { + let option: any LinkOpenDefaultsPresenterOption + + var body: some View { + if option.isAsk { + Text("settings_link_open_default_ask_every_time", bundle: FlareAppleUILocalization.bundle) + } else if option.isBrowser { + Text("deep_link_account_picker_open_in_browser", bundle: FlareAppleUILocalization.bundle) + } else if let account = option.account { + LinkOpenDefaultAccountRow(account: account) + } + } +} + +private struct LinkOpenDefaultAccountRow: View { + let account: LinkOpenDefaultsPresenter.Account + + var body: some View { + StateView(state: account.profile) { user in + UserOnelineView(data: user) + } errorContent: { error in + Text(error.message ?? "Unknown error") + } loadingContent: { + Text("#loading", bundle: FlareAppleUILocalization.bundle) } } } diff --git a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/DeepLinkAccountPickerView.swift b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/DeepLinkAccountPickerView.swift index 4e268f0efa..f1585d7485 100644 --- a/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/DeepLinkAccountPickerView.swift +++ b/appleApp/Shared/FlareAppleCore/Sources/FlareAppleUI/DeepLinkAccountPickerView.swift @@ -5,6 +5,8 @@ import SwiftUI public struct DeepLinkAccountPickerView: View { @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL + @StateObject private var defaultsActions: KotlinPresenter + @State private var saveAsDefault: Bool private let originalUrl: String private let data: [MicroBlogKey: Route] @@ -18,36 +20,52 @@ public struct DeepLinkAccountPickerView: View { self.originalUrl = originalUrl self.data = data self.onNavigate = onNavigate + _defaultsActions = StateObject(wrappedValue: KotlinPresenter(presenter: LinkOpenDefaultsActionsPresenter(originalUrl: originalUrl))) + _saveAsDefault = State(initialValue: true) } public var body: some View { List { - ForEach(data.keys.sorted(by: { $0.id < $1.id }), id: \.self) { userKey in - if let route = data[userKey] { - Button { - onNavigate(route) - dismiss() - } label: { - DeepLinkAccountRow(userKey: userKey) + Section { + ForEach(data.keys.sorted(by: { $0.id < $1.id }), id: \.self) { userKey in + if let route = data[userKey] { + Button { + if saveAsDefault, defaultsActions.state.canSaveDefault { + defaultsActions.state.setAccountDefault(accountKey: userKey) + } + onNavigate(route) + dismiss() + } label: { + DeepLinkAccountRow(userKey: userKey) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } - } - Button { - let routeLink = DeeplinkRoute.OpenLinkDirectly(url: originalUrl).toUri() - if let url = URL(string: routeLink) { - openURL(url) + Button { + if saveAsDefault, defaultsActions.state.canSaveDefault { + defaultsActions.state.setBrowserDefault() + } + let routeLink = DeeplinkRoute.OpenLinkDirectly(url: originalUrl).toUri() + if let url = URL(string: routeLink) { + openURL(url) + } + dismiss() + } label: { + Label { + Text("deep_link_account_picker_open_in_browser", bundle: FlareAppleUILocalization.bundle) + } icon: { + Image(systemName: "globe") + } } - dismiss() - } label: { - Label { - Text("deep_link_account_picker_open_in_browser", bundle: FlareAppleUILocalization.bundle) - } icon: { - Image(systemName: "globe") + .buttonStyle(.plain) + } footer: { + if defaultsActions.state.canSaveDefault { + Toggle(isOn: $saveAsDefault) { + Text("deep_link_account_picker_save_default", bundle: FlareAppleUILocalization.bundle) + } } } - .buttonStyle(.plain) } .navigationTitle(Text("deep_link_account_picker_title", bundle: FlareAppleUILocalization.bundle)) .toolbar { diff --git a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings index 7f6a6dd125..5f013b8cec 100644 --- a/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings +++ b/appleApp/Shared/FlareAppleResource/Resources/Localizable.xcstrings @@ -10953,7 +10953,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Timestamps, source badges, links, and browser behavior" + "value" : "Timestamps, source badges, and link previews" } }, "es" : { @@ -33202,6 +33202,16 @@ } } }, + "deep_link_account_picker_save_default" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Always open this way" + } + } + } + }, "deep_link_account_picker_title" : { "localizations" : { "af" : { @@ -99128,13 +99138,13 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "已保存" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "已儲存" } } @@ -115880,6 +115890,78 @@ } } }, + "settings_behavior_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Browser and link handling preferences" + } + } + } + }, + "settings_behavior_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Behavior" + } + } + } + }, + "settings_link_open_default_ask_every_time" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask every time" + } + } + } + }, + "settings_link_open_default_description" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose how supported links open when Flare can handle them" + } + } + } + }, + "settings_link_open_default_title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Default for %@ links" + } + } + } + }, + "settings_link_open_defaults_description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose defaults for supported links by host" + } + } + } + }, + "settings_link_open_defaults_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link opening" + } + } + } + }, "settings_privacy_policy" : { "localizations" : { "af" : { diff --git a/appleApp/ios/UI/Route/Route.swift b/appleApp/ios/UI/Route/Route.swift index eb1501372c..1627773ba6 100644 --- a/appleApp/ios/UI/Route/Route.swift +++ b/appleApp/ios/UI/Route/Route.swift @@ -114,6 +114,10 @@ enum Route: Hashable, Identifiable { AppearanceDisplayScreen() case .appearanceMedia: AppearanceMediaScreen() + case .behavior: + BehaviorSettingsScreen() + case .linkOpenDefaults: + LinkOpenDefaultsSettingsScreen() case .appIconSettings: AppIconSettingsScreen() case .about: @@ -290,6 +294,8 @@ enum Route: Hashable, Identifiable { case postActionLayout case appearanceDisplay case appearanceMedia + case behavior + case linkOpenDefaults case appIconSettings case settings case about diff --git a/appleApp/ios/UI/Screen/SettingsScreen.swift b/appleApp/ios/UI/Screen/SettingsScreen.swift index 93366fb710..ae409076d7 100644 --- a/appleApp/ios/UI/Screen/SettingsScreen.swift +++ b/appleApp/ios/UI/Screen/SettingsScreen.swift @@ -80,6 +80,17 @@ struct SettingsScreen: View { // } } + Section { + NavigationLink(value: Route.behavior) { + Label { + Text("settings_behavior_title") + Text("settings_behavior_description") + } icon: { + Image(fontAwesome: .sliders) + } + } + } + Section { NavigationLink(value: Route.localFilter) { Label { @@ -133,3 +144,28 @@ struct SettingsScreen: View { .navigationTitle("settings_title") } } + +struct BehaviorSettingsScreen: View { + var body: some View { + List { + BehaviorSettingsSection { + NavigationLink(value: Route.linkOpenDefaults) { + VStack(alignment: .leading) { + Text("settings_link_open_defaults_title") + Text("settings_link_open_defaults_description") + } + } + } + } + .navigationTitle("settings_behavior_title") + } +} + +struct LinkOpenDefaultsSettingsScreen: View { + var body: some View { + List { + LinkOpenDefaultsSettingsSection() + } + .navigationTitle("settings_link_open_defaults_title") + } +} diff --git a/appleApp/macos/UI/Screen/MacSettingsScreen.swift b/appleApp/macos/UI/Screen/MacSettingsScreen.swift index 2a40e6fd5f..df041b9789 100644 --- a/appleApp/macos/UI/Screen/MacSettingsScreen.swift +++ b/appleApp/macos/UI/Screen/MacSettingsScreen.swift @@ -33,6 +33,7 @@ struct MacSettingsScreen: View { private enum MacSettingsPane: String, CaseIterable, Identifiable, Hashable { case accountManagement case appearance + case behavior case localFilter case storage case aiConfig @@ -47,6 +48,8 @@ private enum MacSettingsPane: String, CaseIterable, Identifiable, Hashable { "account_management_title" case .appearance: "appearance_title" + case .behavior: + "settings_behavior_title" case .localFilter: "local_filter_title" case .storage: @@ -66,6 +69,8 @@ private enum MacSettingsPane: String, CaseIterable, Identifiable, Hashable { "account_management_description" case .appearance: "appearance_description" + case .behavior: + "settings_behavior_description" case .localFilter: "local_filter_description" case .storage: @@ -85,6 +90,8 @@ private enum MacSettingsPane: String, CaseIterable, Identifiable, Hashable { .circleUser case .appearance: .palette + case .behavior: + .sliders case .localFilter: .filter case .storage: @@ -105,6 +112,8 @@ private enum MacSettingsPane: String, CaseIterable, Identifiable, Hashable { MacAccountManagementSettingsPane() case .appearance: MacAppearanceSettingsPane() + case .behavior: + MacBehaviorSettingsPane() case .localFilter: MacLocalFilterSettingsPane() case .storage: @@ -535,6 +544,175 @@ private struct MacAppearanceSettingsPane: View { } } +private struct MacBehaviorSettingsPane: View { + @StateObject private var linkOpenDefaultsPresenter = KotlinPresenter(presenter: LinkOpenDefaultsPresenter()) + + var body: some View { + MacSettingsForm( + title: "settings_behavior_title", + subtitle: "settings_behavior_description" + ) { + MacLinkOpenDefaultsInlineSection(presenter: linkOpenDefaultsPresenter) + } + } +} + +private struct MacLinkOpenDefaultsInlineSection: View { + @ObservedObject var presenter: KotlinPresenter + + var body: some View { + Section { + StateView(state: presenter.state.targets) { data in + let targets = data.cast(LinkOpenDefaultsPresenter.Target.self) + if targets.isEmpty { + ContentUnavailableView( + "settings_link_open_defaults_title", + systemImage: "link", + description: Text("settings_link_open_defaults_description") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ForEach(targets, id: \.id) { target in + MacLinkOpenDefaultInlineRow( + target: target, + state: presenter.state + ) + } + } + } loadingContent: { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } header: { + Text("settings_link_open_defaults_title") + } footer: { + Text("settings_link_open_defaults_description") + } + } +} + +private struct MacLinkOpenDefaultInlineRow: View { + @State private var isPopoverPresented = false + + let target: LinkOpenDefaultsPresenter.Target + let state: LinkOpenDefaultsPresenterState + + var body: some View { + Button { + isPopoverPresented = true + } label: { + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 3) { + Text(verbatim: target.title) + MacLinkOpenDefaultOptionLabel(option: target.selectedOption) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .popover(isPresented: $isPopoverPresented, arrowEdge: .trailing) { + MacLinkOpenDefaultOptionsPopover( + target: target, + state: state, + onSelect: { + isPopoverPresented = false + } + ) + } + } +} + +private struct MacLinkOpenDefaultOptionsPopover: View { + let target: LinkOpenDefaultsPresenter.Target + let state: LinkOpenDefaultsPresenterState + let onSelect: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(options, id: \.id) { option in + MacLinkOpenDefaultOptionRow( + isSelected: option.id == target.selectedOption.id, + action: { + state.select(target: target, option: option) + onSelect() + } + ) { + MacLinkOpenDefaultOptionLabel(option: option) + } + } + } + .padding(8) + .frame(width: 320) + } + + private var options: [any LinkOpenDefaultsPresenterOption] { + target.options + } +} + +private struct MacLinkOpenDefaultOptionRow: View { + let isSelected: Bool + let action: () -> Void + @ViewBuilder let content: () -> Content + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .frame(width: 18) + + content() + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background( + isSelected ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } +} + +private struct MacLinkOpenDefaultOptionLabel: View { + let option: any LinkOpenDefaultsPresenterOption + + var body: some View { + if option.isAsk { + Text("settings_link_open_default_ask_every_time") + } else if option.isBrowser { + Text("deep_link_account_picker_open_in_browser") + } else if let account = option.account { + MacLinkOpenDefaultAccountRadioLabel(account: account) + } + } +} + +private struct MacLinkOpenDefaultAccountRadioLabel: View { + let account: LinkOpenDefaultsPresenter.Account + + var body: some View { + StateView(state: account.profile) { user in + UserOnelineView(data: user) + } errorContent: { _ in + Text(verbatim: account.accountKey.description()) + } loadingContent: { + Text(verbatim: account.accountKey.description()) + } + } +} + private struct MacLocalFilterSettingsPane: View { var body: some View { LocalFilterSettingsView() diff --git a/desktopApp/src/main/composeResources/values/strings.xml b/desktopApp/src/main/composeResources/values/strings.xml index bb1ade9ca5..bc35664f68 100644 --- a/desktopApp/src/main/composeResources/values/strings.xml +++ b/desktopApp/src/main/composeResources/values/strings.xml @@ -217,7 +217,9 @@ Layout Timeline layout, navigation, and post actions Display - Timestamps, source badges, links, and browser behavior + Timestamps, source badges, and link previews + Behavior + Browser and link handling preferences Media Media visibility, playback, and sensitive content Storage @@ -557,6 +559,12 @@ Select account Select an account to open this link Open in browser + Always open this way + Link opening + Choose defaults for supported links by host + Default for %1$s links + Choose how supported links open when Flare can handle them + Ask every time Block user Are you sure you want to block this user? diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt index 5160f63b94..8621781400 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/home/DeepLinkAccountPicker.kt @@ -1,11 +1,19 @@ package dev.dimension.flare.ui.screen.home +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp import compose.icons.FontAwesomeIcons import compose.icons.fontawesomeicons.Solid import compose.icons.fontawesomeicons.solid.Globe @@ -13,6 +21,7 @@ import dev.dimension.flare.Res import dev.dimension.flare.cancel import dev.dimension.flare.deeplink_account_selection_browser import dev.dimension.flare.deeplink_account_selection_message +import dev.dimension.flare.deeplink_account_selection_save_default import dev.dimension.flare.deeplink_account_selection_title import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.MicroBlogKey @@ -20,9 +29,12 @@ import dev.dimension.flare.ui.component.AccountItem import dev.dimension.flare.ui.component.FAIcon import dev.dimension.flare.ui.component.Header import dev.dimension.flare.ui.presenter.home.UserPresenter +import dev.dimension.flare.ui.presenter.home.UserState import dev.dimension.flare.ui.presenter.invoke +import dev.dimension.flare.ui.presenter.settings.LinkOpenDefaultsActionsPresenter import dev.dimension.flare.ui.route.Route import io.github.composefluent.component.CardExpanderItem +import io.github.composefluent.component.CheckBox import io.github.composefluent.component.ContentDialog import io.github.composefluent.component.Text import kotlinx.collections.immutable.ImmutableMap @@ -39,6 +51,12 @@ internal fun DeepLinkAccountPicker( ) { val uriHandler = LocalUriHandler.current val accounts by producePresenter { presenter(data) } + val defaultsActions by producePresenter("link_open_defaults_picker") { + remember(originalUrl) { LinkOpenDefaultsActionsPresenter(originalUrl) }.invoke() + } + var saveAsDefault by remember(defaultsActions.canSaveDefault) { + mutableStateOf(defaultsActions.canSaveDefault) + } ContentDialog( visible = true, title = stringResource(Res.string.deeplink_account_selection_title), @@ -51,12 +69,15 @@ internal fun DeepLinkAccountPicker( item { Header(stringResource(Res.string.deeplink_account_selection_message)) } - items(accounts) { (userState, route) -> + items(accounts) { item -> AccountItem( - userState.user, + item.userState.user, onClick = { + if (saveAsDefault && defaultsActions.canSaveDefault) { + defaultsActions.setAccountDefault(item.accountKey) + } onDismissRequest() - onNavigate(route) + onNavigate(item.route) }, toLogin = {}, ) @@ -64,6 +85,9 @@ internal fun DeepLinkAccountPicker( item { CardExpanderItem( onClick = { + if (saveAsDefault && defaultsActions.canSaveDefault) { + defaultsActions.setBrowserDefault() + } uriHandler.openUri(originalUrl) onDismissRequest.invoke() }, @@ -78,19 +102,49 @@ internal fun DeepLinkAccountPicker( }, ) } + if (defaultsActions.canSaveDefault) { + item { + Row( + modifier = + Modifier.clickable { + saveAsDefault = !saveAsDefault + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CheckBox( + checked = saveAsDefault, + onCheckStateChange = { + saveAsDefault = it + }, + ) + Text(stringResource(Res.string.deeplink_account_selection_save_default)) + } + } + } } }, ) } +private data class DeepLinkAccountItem( + val accountKey: MicroBlogKey, + val userState: UserState, + val route: Route, +) + @Composable private fun presenter(data: ImmutableMap) = run { remember(data) { - data.map { - UserPresenter(AccountType.Specific(it.key), null) to it.value + data.map { (accountKey, route) -> + accountKey to (UserPresenter(AccountType.Specific(accountKey), null) to route) } - }.map { - it.first.invoke() to it.second + }.map { (accountKey, presenterAndRoute) -> + DeepLinkAccountItem( + accountKey = accountKey, + userState = presenterAndRoute.first.invoke(), + route = presenterAndRoute.second, + ) }.toImmutableList() } diff --git a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt index a14840acd3..b92238cdc9 100644 --- a/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt +++ b/desktopApp/src/main/kotlin/dev/dimension/flare/ui/screen/settings/SettingsScreen.kt @@ -65,6 +65,7 @@ import dev.dimension.flare.data.model.VideoAutoplay import dev.dimension.flare.data.model.appearance.AppearanceKey import dev.dimension.flare.data.model.appearance.AppearanceKeys import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.deeplink_account_selection_browser import dev.dimension.flare.delete import dev.dimension.flare.edit import dev.dimension.flare.home_login @@ -195,10 +196,14 @@ import dev.dimension.flare.settings_appearance_timeline_display_mode_plain import dev.dimension.flare.settings_appearance_title import dev.dimension.flare.settings_appearance_video_autoplay import dev.dimension.flare.settings_appearance_video_autoplay_description +import dev.dimension.flare.settings_behavior_title import dev.dimension.flare.settings_draft_box_description import dev.dimension.flare.settings_draft_box_title import dev.dimension.flare.settings_language_description import dev.dimension.flare.settings_language_title +import dev.dimension.flare.settings_link_open_default_ask_every_time +import dev.dimension.flare.settings_link_open_defaults_description +import dev.dimension.flare.settings_link_open_defaults_title import dev.dimension.flare.settings_local_history_description import dev.dimension.flare.settings_local_history_title import dev.dimension.flare.settings_nostr_relays_manage @@ -241,6 +246,7 @@ import dev.dimension.flare.ui.model.isSuccess import dev.dimension.flare.ui.model.onError import dev.dimension.flare.ui.model.onLoading import dev.dimension.flare.ui.model.onSuccess +import dev.dimension.flare.ui.model.takeSuccessOr import dev.dimension.flare.ui.presenter.ExportDataPresenter import dev.dimension.flare.ui.presenter.ImportDataPresenter import dev.dimension.flare.ui.presenter.home.ActiveAccountPresenter @@ -251,6 +257,7 @@ import dev.dimension.flare.ui.presenter.settings.AiConfigPresenter import dev.dimension.flare.ui.presenter.settings.AiReasoningEffortOption import dev.dimension.flare.ui.presenter.settings.AiTranslationTestPresenter import dev.dimension.flare.ui.presenter.settings.AiTypeOption +import dev.dimension.flare.ui.presenter.settings.LinkOpenDefaultsPresenter import dev.dimension.flare.ui.presenter.settings.StoragePresenter import dev.dimension.flare.ui.presenter.settings.StorageState import dev.dimension.flare.ui.presenter.settings.TranslateProviderOption @@ -340,6 +347,10 @@ internal fun SettingsScreen( }, ) } + val linkOpenDefaultsState by producePresenter("link_open_defaults_settings") { + remember { LinkOpenDefaultsPresenter() }.invoke() + } + val linkOpenTargets = linkOpenDefaultsState.targets.takeSuccessOr(persistentListOf()) var pendingDeleteAccountKey by remember { mutableStateOf(null) } var pendingDeleteAccountLabel by remember { mutableStateOf(null) } @@ -1130,6 +1141,30 @@ internal fun SettingsScreen( ) } + Header(stringResource(Res.string.settings_behavior_title)) + var behaviorExpanded by remember { mutableStateOf(false) } + Expander( + icon = null, + expanded = behaviorExpanded, + onExpandedChanged = { behaviorExpanded = it }, + heading = { + Text(stringResource(Res.string.settings_link_open_defaults_title)) + }, + caption = { + Text(stringResource(Res.string.settings_link_open_defaults_description)) + }, + ) { + linkOpenTargets.forEachIndexed { index, target -> + if (index > 0) { + ExpanderItemSeparator() + } + LinkOpenDefaultSettingsItem( + target = target, + state = linkOpenDefaultsState, + ) + } + } + Header(stringResource(Res.string.settings_storage_title)) CardExpanderItem( onClick = toRSSManagement, @@ -2581,6 +2616,97 @@ private data class LanguageOption( val label: String, ) +@Composable +private fun LinkOpenDefaultSettingsItem( + target: LinkOpenDefaultsPresenter.Target, + state: LinkOpenDefaultsPresenter.State, +) { + val askLabel = stringResource(Res.string.settings_link_open_default_ask_every_time) + val browserLabel = stringResource(Res.string.deeplink_account_selection_browser) + var isFlyoutVisible by remember { mutableStateOf(false) } + ExpanderItem( + heading = { + Text(target.title) + }, + caption = { + LinkOpenDefaultOptionContent( + option = target.selectedOption, + askLabel = askLabel, + browserLabel = browserLabel, + ) + }, + trailing = { + MenuFlyoutContainer( + flyout = { + target.options.forEach { option -> + MenuFlyoutItem( + onClick = { + state.select(target, option) + isFlyoutVisible = false + }, + text = { + LinkOpenDefaultOptionContent( + option = option, + askLabel = askLabel, + browserLabel = browserLabel, + ) + }, + ) + } + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = !isFlyoutVisible }, + content = { + Text(stringResource(Res.string.edit)) + }, + ) + }, + ) + }, + ) +} + +@Composable +private fun LinkOpenDefaultOptionContent( + option: LinkOpenDefaultsPresenter.Option, + askLabel: String, + browserLabel: String, +) { + when { + option.isAsk -> Text(askLabel) + option.isBrowser -> Text(browserLabel) + option.account != null -> LinkOpenDefaultAccountOption(option.account!!) + } +} + +@Composable +private fun LinkOpenDefaultAccountOption(account: LinkOpenDefaultsPresenter.Account) { + account.profile + .onSuccess { user -> + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AvatarComponent(data = user.avatar, size = 24.dp) + Column { + RichText(text = user.name, maxLines = 1) + Text(text = user.handle.canonical, maxLines = 1) + } + } + }.onLoading { + Row( + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AvatarComponent(data = null, size = 24.dp) + Text(account.accountKey.toString()) + } + }.onError { + Text(account.accountKey.toString()) + } +} + private fun translationLanguageOptions(selectedLanguages: ImmutableList): ImmutableList { val displayLocale = Locale.getDefault() val baseOptions = diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/PlatformDeepLinkMatcher.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/PlatformDeepLinkMatcher.kt index 7776db64ce..b8e5a5178e 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/PlatformDeepLinkMatcher.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/common/deeplink/PlatformDeepLinkMatcher.kt @@ -11,18 +11,18 @@ internal object PlatformDeepLinkMatcher { fun matches( url: String, mapping: Map>>, - ): ImmutableMap { + ): ImmutableMap { val request = DeepLinkRequest(Url(url)) - val resultBuilder = persistentMapOf().builder() + val resultBuilder = persistentMapOf().builder() mapping.forEach { (account, deepLinks) -> - val route = + val match = deepLinks.firstNotNullOfOrNull { deepLink -> match(request, deepLink) } - if (route != null) { - resultBuilder[account] = route + if (match != null) { + resultBuilder[account] = match } } @@ -32,14 +32,23 @@ internal object PlatformDeepLinkMatcher { private fun match( request: DeepLinkRequest, deepLink: PlatformDeepLink, - ): DeeplinkRoute? { + ): Match? { + val uriPattern = Url(deepLink.uriPattern) val pattern = DeepLinkPattern( serializer = deepLink.serializer, - uriPattern = Url(deepLink.uriPattern), + uriPattern = uriPattern, ) val match = DeepLinkMatcher(request, pattern).match() ?: return null val data = KeyDecoder(match.args).decodeSerializableValue(match.serializer) - return deepLink.callback(data) + return Match( + route = deepLink.callback(data), + host = uriPattern.host, + ) } + + data class Match( + val route: DeeplinkRoute, + val host: String, + ) } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt index 79eaab1e1c..46a9758abe 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/AppSettings.kt @@ -1,5 +1,6 @@ package dev.dimension.flare.data.datastore.model +import dev.dimension.flare.model.MicroBlogKey import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -9,7 +10,32 @@ public data class AppSettings( val aiConfig: AiConfig = AiConfig(), val language: String = "", val translateConfig: TranslateConfig = TranslateConfig(), + val linkOpenDefaults: LinkOpenDefaults = LinkOpenDefaults(), ) { + @Serializable + public data class LinkOpenDefaults( + val rules: List = emptyList(), + ) { + @Serializable + public data class Rule( + val host: String, + val method: Method, + ) + + @Serializable + public sealed interface Method { + @Serializable + @SerialName("Browser") + public data object Browser : Method + + @Serializable + @SerialName("Account") + public data class Account( + val accountKey: MicroBlogKey, + ) : Method + } + } + @Serializable public data class TranslateConfig( val preTranslate: Boolean = false, diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/LinkOpenDefaults.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/LinkOpenDefaults.kt new file mode 100644 index 0000000000..e2a0375c2b --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/data/datastore/model/LinkOpenDefaults.kt @@ -0,0 +1,50 @@ +package dev.dimension.flare.data.datastore.model + +import dev.dimension.flare.model.MicroBlogKey + +public sealed interface LinkOpenDefaultMethod { + public data object Browser : LinkOpenDefaultMethod + + public data class Account( + val accountKey: MicroBlogKey, + ) : LinkOpenDefaultMethod +} + +public fun AppSettings.LinkOpenDefaults.methodFor(host: String): LinkOpenDefaultMethod? = + rules + .lastOrNull { it.host == host } + ?.method + ?.toLinkOpenDefaultMethod() + +public fun AppSettings.LinkOpenDefaults.upsert( + host: String, + method: LinkOpenDefaultMethod, +): AppSettings.LinkOpenDefaults = + copy( + rules = + rules + .filterNot { it.host == host } + .plus( + AppSettings.LinkOpenDefaults.Rule( + host = host, + method = method.toAppSettingsMethod(), + ), + ), + ) + +public fun AppSettings.LinkOpenDefaults.remove(host: String): AppSettings.LinkOpenDefaults = + copy( + rules = rules.filterNot { it.host == host }, + ) + +private fun AppSettings.LinkOpenDefaults.Method.toLinkOpenDefaultMethod(): LinkOpenDefaultMethod = + when (this) { + AppSettings.LinkOpenDefaults.Method.Browser -> LinkOpenDefaultMethod.Browser + is AppSettings.LinkOpenDefaults.Method.Account -> LinkOpenDefaultMethod.Account(accountKey) + } + +private fun LinkOpenDefaultMethod.toAppSettingsMethod(): AppSettings.LinkOpenDefaults.Method = + when (this) { + LinkOpenDefaultMethod.Browser -> AppSettings.LinkOpenDefaults.Method.Browser + is LinkOpenDefaultMethod.Account -> AppSettings.LinkOpenDefaults.Method.Account(accountKey) + } diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt index 3af87e0945..6b522cc5de 100644 --- a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/home/DeepLinkPresenter.kt @@ -9,13 +9,17 @@ import androidx.compose.runtime.setValue import dev.dimension.flare.common.deeplink.PlatformDeepLinkMatcher import dev.dimension.flare.data.database.cache.model.TranslationDisplayMode import dev.dimension.flare.data.datasource.microblog.datasource.PostDataSource +import dev.dimension.flare.data.datastore.model.LinkOpenDefaultMethod +import dev.dimension.flare.data.datastore.model.methodFor import dev.dimension.flare.data.repository.AccountRepository +import dev.dimension.flare.data.repository.SettingsRepository import dev.dimension.flare.data.repository.accountServiceFlow import dev.dimension.flare.data.translation.PreTranslationService import dev.dimension.flare.di.koinInject import dev.dimension.flare.model.AccountType import dev.dimension.flare.model.PlatformRegistry import dev.dimension.flare.ui.model.DeeplinkEvent +import dev.dimension.flare.ui.model.UiAccount import dev.dimension.flare.ui.presenter.PresenterBase import dev.dimension.flare.ui.route.DeeplinkRoute import kotlinx.collections.immutable.toImmutableMap @@ -31,6 +35,7 @@ public class DeepLinkPresenter( private val accountRepository: AccountRepository by koinInject() private val preTranslationService: PreTranslationService by koinInject() private val platformRegistry: PlatformRegistry by koinInject() + private val settingsRepository: SettingsRepository by koinInject() @androidx.compose.runtime.Immutable public interface State { @@ -122,20 +127,54 @@ public class DeepLinkPresenter( } pendingUrl = null } else { - deepLinkFlow.collect { deepLinks -> - val matches = PlatformDeepLinkMatcher.matches(url, deepLinks) - if (matches.isEmpty()) { + val deepLinks = deepLinkFlow.firstOrNull().orEmpty() + val matches = PlatformDeepLinkMatcher.matches(url, deepLinks) + if (matches.isEmpty()) { + withContext(Dispatchers.Main) { + onLink.invoke(url) + } + } else { + val defaultHost = matches.defaultHost() + val defaultMethod = + defaultHost?.let { host -> + settingsRepository + .appSettings + .firstOrNull() + ?.linkOpenDefaults + ?.methodFor(host) + } + val defaultRoute = + when (defaultMethod) { + LinkOpenDefaultMethod.Browser -> { + withContext(Dispatchers.Main) { + onLink.invoke(url) + } + null + } + + is LinkOpenDefaultMethod.Account -> { + matches.entries + .firstOrNull { it.key.accountKey == defaultMethod.accountKey } + ?.value + ?.route + } + + null -> { + null + } + } + if (defaultRoute != null) { withContext(Dispatchers.Main) { - onLink.invoke(url) + onRoute.invoke(defaultRoute) } - } else { + } else if (defaultMethod !is LinkOpenDefaultMethod.Browser) { val route = DeeplinkRoute.DeepLinkAccountPicker( originalUrl = url, data = matches .map { - it.key.accountKey to it.value + it.key.accountKey to it.value.route }.toMap() .toImmutableMap(), ) @@ -143,8 +182,8 @@ public class DeepLinkPresenter( onRoute.invoke(route) } } - pendingUrl = null } + pendingUrl = null } } } @@ -156,3 +195,9 @@ public class DeepLinkPresenter( } } } + +private fun Map.defaultHost(): String? { + val hosts = values.map { it.host }.distinct() + if (hosts.size != 1) return null + return hosts.single() +} diff --git a/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/LinkOpenDefaultsPresenter.kt b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/LinkOpenDefaultsPresenter.kt new file mode 100644 index 0000000000..ee0f7abc36 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/dimension/flare/ui/presenter/settings/LinkOpenDefaultsPresenter.kt @@ -0,0 +1,369 @@ +package dev.dimension.flare.ui.presenter.settings + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import dev.dimension.flare.common.combineLatestFlowLists +import dev.dimension.flare.data.datasource.microblog.AuthenticatedMicroblogDataSource +import dev.dimension.flare.data.datasource.microblog.datasource.UserDataSource +import dev.dimension.flare.data.datastore.model.AppSettings +import dev.dimension.flare.data.datastore.model.LinkOpenDefaultMethod +import dev.dimension.flare.data.datastore.model.methodFor +import dev.dimension.flare.data.datastore.model.remove +import dev.dimension.flare.data.datastore.model.upsert +import dev.dimension.flare.data.repository.AccountService +import dev.dimension.flare.data.repository.SettingsRepository +import dev.dimension.flare.di.koinInject +import dev.dimension.flare.model.MicroBlogKey +import dev.dimension.flare.model.PlatformRegistry +import dev.dimension.flare.ui.model.UiProfile +import dev.dimension.flare.ui.model.UiState +import dev.dimension.flare.ui.model.collectAsUiState +import dev.dimension.flare.ui.model.takeSuccess +import dev.dimension.flare.ui.model.toUi +import dev.dimension.flare.ui.presenter.PresenterBase +import dev.dimension.flare.web.shared.WebIgnore +import io.ktor.http.Url +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +public class LinkOpenDefaultsActionsPresenter( + private val originalUrl: String, +) : PresenterBase() { + private val settingsRepository: SettingsRepository by koinInject() + + @Composable + override fun body(): State { + val scope = rememberCoroutineScope() + val host = originalUrl.toLinkOpenDefaultHost() + return object : State { + override val canSaveDefault: Boolean = host != null + + override fun setBrowserDefault() { + host?.let { + scope.setDefault( + settingsRepository = settingsRepository, + host = it, + method = LinkOpenDefaultMethod.Browser, + ) + } + } + + override fun setAccountDefault(accountKey: MicroBlogKey) { + host?.let { + scope.setDefault( + settingsRepository = settingsRepository, + host = it, + method = LinkOpenDefaultMethod.Account(accountKey), + ) + } + } + + override fun clearDefault() { + host?.let { + scope.clearDefault( + settingsRepository = settingsRepository, + host = it, + ) + } + } + } + } + + @Immutable + public interface State { + @WebIgnore + public val canSaveDefault: Boolean + + @WebIgnore + public fun setBrowserDefault() + + @WebIgnore + public fun setAccountDefault(accountKey: MicroBlogKey) + + @WebIgnore + public fun clearDefault() + } +} + +private fun String.toLinkOpenDefaultHost(): String? = + runCatching { + Url(this).host.takeIf { it.isNotBlank() } + }.getOrNull() + +public class LinkOpenDefaultsPresenter : PresenterBase() { + private val accountService: AccountService by koinInject() + private val platformRegistry: PlatformRegistry by koinInject() + private val settingsRepository: SettingsRepository by koinInject() + + private val targetSourcesFlow by lazy { + accountService + .allAccountServicesFlow() + .map { services -> + services.mapNotNull { service -> + val spec = platformRegistry.get(service.platformType) ?: return@mapNotNull null + val hosts = + spec + .deepLinks(service.accountKey) + .mapNotNull { deepLink -> + Url(deepLink.uriPattern).host.takeIf { it.isNotBlank() } + }.distinct() + if (hosts.isEmpty()) { + return@mapNotNull null + } + val dataSource = service.dataSource + val profileFlow = + if (dataSource is UserDataSource && dataSource is AuthenticatedMicroblogDataSource) { + dataSource.userHandler + .userById(dataSource.accountKey.id) + .toUi() + .distinctUntilChangedBy { state -> + state.takeSuccess()?.let { user -> + buildString { + append(user.key) + append("-") + append(user.name.raw) + append("-") + append(user.avatar) + append("-") + append(user.handle.raw) + } + } + } + } else { + flowOf(UiState.Error(IllegalStateException("Account service is not authenticated user data source"))) + } + profileFlow.map { profile -> + hosts.map { host -> + TargetCandidate( + host = host, + platformName = spec.metadata.displayName, + account = + Account( + accountKey = service.accountKey, + profile = profile, + ), + ) + } + } + } + }.combineLatestFlowLists() + .map { candidateGroups -> + candidateGroups + .flatten() + .groupBy { it.host } + .map { (host, candidates) -> + val first = candidates.first() + TargetSource( + id = host, + host = host, + title = host, + platformName = first.platformName, + accounts = + candidates + .map { it.account } + .distinctBy { it.accountKey } + .toImmutableList(), + ) + }.sortedWith( + compareBy( + { it.platformName }, + { it.title }, + ), + ).toImmutableList() + }.distinctUntilChanged() + } + + private val targetsFlow by lazy { + combine( + targetSourcesFlow, + settingsRepository.appSettings, + ) { targets, appSettings -> + targets + .map { target -> target.toTarget(appSettings) } + .toImmutableList() + }.distinctUntilChanged() + } + + private data class TargetCandidate( + val host: String, + val platformName: String, + val account: Account, + ) + + private data class TargetSource( + val id: String, + val host: String, + val title: String, + val platformName: String, + val accounts: ImmutableList, + ) { + fun toTarget(appSettings: AppSettings): Target { + val options = + buildList { + add(Option.Ask) + add(Option.Browser) + accounts.forEach { account -> + add(Option.Account(account)) + } + }.toImmutableList() + val selectedOption = + when (val method = appSettings.linkOpenDefaults.methodFor(host)) { + LinkOpenDefaultMethod.Browser -> { + Option.Browser + } + + is LinkOpenDefaultMethod.Account -> { + accounts + .firstOrNull { it.accountKey == method.accountKey } + ?.let { Option.Account(it) } + ?: Option.Ask + } + + null -> { + Option.Ask + } + } + return Target( + id = id, + title = title, + platformName = platformName, + options = options, + selectedOption = selectedOption, + ) + } + } + + @Composable + override fun body(): State { + val scope = rememberCoroutineScope() + val targets by targetsFlow.collectAsUiState() + return object : State { + override val targets: UiState> = targets + + override fun select( + target: Target, + option: Option, + ) { + when (option) { + Option.Ask -> { + scope.clearDefault( + settingsRepository = settingsRepository, + host = target.id, + ) + } + + Option.Browser -> { + scope.setDefault( + settingsRepository = settingsRepository, + host = target.id, + method = LinkOpenDefaultMethod.Browser, + ) + } + + is Option.Account -> { + scope.setDefault( + settingsRepository = settingsRepository, + host = target.id, + method = LinkOpenDefaultMethod.Account(option.account.accountKey), + ) + } + } + } + } + } + + @Immutable + public interface State { + @WebIgnore + public val targets: UiState> + + @WebIgnore + public fun select( + target: Target, + option: Option, + ) + } + + @Immutable + public data class Target( + val id: String, + val title: String, + val platformName: String, + val options: ImmutableList