diff --git a/LICENSE-3RD-PARTY b/LICENSE-3RD-PARTY index 5a9fcd4f5..04f946fcc 100644 --- a/LICENSE-3RD-PARTY +++ b/LICENSE-3RD-PARTY @@ -29,3 +29,9 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ----------------------------------------------------------------------------- +Applies to: The Public Suffix List (assets/other/public_suffix_list.dat) + +This Source Code Form is subject to the terms of the Mozilla Public +License, v. 2.0. You can obtain a copy of this license from +https://mozilla.org/MPL/2.0/. +----------------------------------------------------------------------------- diff --git a/assets/i18n/en/application_settings.arb b/assets/i18n/en/application_settings.arb index ef740a23b..7cb79db7d 100644 --- a/assets/i18n/en/application_settings.arb +++ b/assets/i18n/en/application_settings.arb @@ -78,5 +78,58 @@ "@comment_sort_oldest_first": { "type": "text", "placeholders": {} + }, + "trusted_domains_title": "Manage trusted domains", + "@trusted_domains_title": { + "description": "Title for the Trusted Domains settings page.", + "type": "text", + "placeholders": {} + }, + "ask_for_urls": "Link confirmation", + "@ask_for_urls": { + "description": "Header for the \"ask to open URLs\" and \"trusted domains\" settings.", + "type": "text", + "placeholders": {} + }, + "ask_for_urls_setting": "Ask to open links", + "@ask_for_urls_setting": { + "type": "text", + "placeholders": {} + }, + "trusted_domains_text": "Trusted domains", + "@trusted_domains_text": { + "description": "Text on the button to the Trusted Domains settings page.", + "type": "text", + "placeholders": {} + }, + "trusted_domains_desc": "Manage web domains you have marked as trusted.", + "@trusted_domains_desc": { + "type": "text", + "placeholders": {} + }, + "trusted_domains_resource": "trusted domains", + "@trusted_domains_resource": { + "type": "text", + "placeholders": {} + }, + "delete_domain_failure": "Could not delete the trusted domain", + "@delete_domain_failure": { + "type": "text", + "placeholders": {} + }, + "confirm_url_enabled": "Only untrusted", + "@confirm_url_enabled": { + "type": "text", + "placeholders": {} + }, + "confirm_url_disabled": "Never", + "@confirm_url_disabled": { + "type": "text", + "placeholders": {} + }, + "delete_domain": "Delete", + "@delete_domain": { + "type": "text", + "placeholders": {} } } \ No newline at end of file diff --git a/assets/i18n/en/post.arb b/assets/i18n/en/post.arb index 1ab4b41ae..8b0243652 100644 --- a/assets/i18n/en/post.arb +++ b/assets/i18n/en/post.arb @@ -607,5 +607,30 @@ "description": "Shown when a post was immediately posted, as in time posted is 'now'.Should be as few characters as possible.", "type": "text", "placeholders": {} + }, + "open_url_message": "Do you want to open this link in your browser?", + "@open_url_message": { + "type": "text", + "placeholders": {} + }, + "open_url_continue": "Continue", + "@open_url_continue": { + "type": "text", + "placeholders": {} + }, + "open_url_cancel": "Cancel", + "@open_url_cancel": { + "type": "text", + "placeholders": {} + }, + "open_url_dont_ask_again": "Never ask again", + "@open_url_dont_ask_again": { + "type": "text", + "placeholders": {} + }, + "open_url_dont_ask_again_for": "Trust this domain", + "@open_url_dont_ask_again_for": { + "type": "text", + "placeholders": {} } } \ No newline at end of file diff --git a/assets/i18n/en/user.arb b/assets/i18n/en/user.arb index 7d48ddd25..c8e7c0895 100644 --- a/assets/i18n/en/user.arb +++ b/assets/i18n/en/user.arb @@ -917,7 +917,7 @@ "type": "text", "placeholders": {} }, - "clear_app_preferences_desc": "Clear the application preferences. Currently this is only the preferred order of comments.", + "clear_app_preferences_desc": "Clear the application preferences. Currently only the preferred order of comments and the list of trusted domains.", "@clear_app_preferences_desc": { "type": "text", "placeholders": {} diff --git a/lib/pages/auth/create_account/guidelines_step.dart b/lib/pages/auth/create_account/guidelines_step.dart index f527ef1e3..8f4bb7b16 100644 --- a/lib/pages/auth/create_account/guidelines_step.dart +++ b/lib/pages/auth/create_account/guidelines_step.dart @@ -94,6 +94,7 @@ class OBAuthGuidelinesStepPageState extends State { ) : OBMarkdown( onlyBody: true, + linksRequireConfirmation: false, data: _communityGuidelines, theme: OBTheme( primaryTextColor: '#ffffff', diff --git a/lib/pages/home/bottom_sheets/confirm_open_url.dart b/lib/pages/home/bottom_sheets/confirm_open_url.dart new file mode 100644 index 000000000..dbf6d702d --- /dev/null +++ b/lib/pages/home/bottom_sheets/confirm_open_url.dart @@ -0,0 +1,169 @@ +import 'package:Okuna/models/theme.dart'; +import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/localization.dart'; +import 'package:Okuna/services/theme.dart'; +import 'package:Okuna/services/theme_value_parser.dart'; +import 'package:Okuna/services/user_preferences.dart'; +import 'package:Okuna/widgets/buttons/button.dart'; +import 'package:Okuna/widgets/fields/checkbox_field.dart'; +import 'package:Okuna/widgets/theming/primary_color_container.dart'; +import 'package:Okuna/widgets/theming/smart_text.dart'; +import 'package:flutter/material.dart'; +import 'package:public_suffix/public_suffix.dart'; + +class OBConfirmOpenUrlBottomSheet extends StatefulWidget { + final PublicSuffix _urlInfo; + + OBConfirmOpenUrlBottomSheet({PublicSuffix urlInfo}) : _urlInfo = urlInfo; + + @override + OBConfirmOpenUrlBottomSheetState createState() { + return OBConfirmOpenUrlBottomSheetState(); + } +} + +class OBConfirmOpenUrlBottomSheetState extends State { + UserPreferencesService _preferencesService; + LocalizationService _localizationService; + ThemeService _themeService; + ThemeValueParserService _themeValueParserService; + + bool _needsBootstrap; + bool _ask; + bool _askForHost; + + + @override + void initState() { + super.initState(); + _needsBootstrap = true; + _ask = true; + _askForHost = true; + } + + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var openbookProvider = OpenbookProvider.of(context); + _preferencesService = openbookProvider.userPreferencesService; + _localizationService = openbookProvider.localizationService; + _themeService = openbookProvider.themeService; + _themeValueParserService = openbookProvider.themeValueParserService; + _needsBootstrap = false; + } + + double screenHeight = MediaQuery.of(context).size.height; + double maxUrlBoxHeight = screenHeight * .3; + + OBTheme theme = _themeService.getActiveTheme(); + Color secondaryTextColor = _themeValueParserService.parseColor(theme.secondaryTextColor); + + return OBPrimaryColorContainer( + mainAxisSize: MainAxisSize.min, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + OBText( + _localizationService.post__open_url_message, + textAlign: TextAlign.start, + ), + const SizedBox( + height: 10, + ), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxUrlBoxHeight), + child: SingleChildScrollView( + child: OBSmartText( + text: widget._urlInfo.sourceUri.toString(), + ), + ), + ), + const SizedBox( + height: 10, + ), + OBCheckboxField( + value: !_askForHost, + onTap: _toggleDontAskForHost, + title: _localizationService.post__open_url_dont_ask_again_for, + titleStyle: TextStyle(fontWeight: FontWeight.normal), + subtitle: widget._urlInfo.domain, + subtitleStyle: TextStyle(color: secondaryTextColor), + ), + const SizedBox( + height: 5, + ), + OBCheckboxField( + value: !_ask, + title: _localizationService.post__open_url_dont_ask_again, + onTap: _toggleDontAsk, + titleStyle: TextStyle(fontWeight: FontWeight.normal), + ), + const SizedBox( + height: 10, + ), + Row( + children: [ + Expanded( + child: OBButton( + size: OBButtonSize.medium, + type: OBButtonType.highlight, + child: Text(_localizationService.post__open_url_cancel), + onPressed: _onCancel, + ), + ), + const SizedBox( + width: 20, + ), + Expanded( + child: OBButton( + size: OBButtonSize.medium, + type: OBButtonType.primary, + child: Text(_localizationService.post__open_url_continue), + onPressed: _onConfirmTapped, + ), + ), + ], + ), + ], + ), + ), + ); + } + + void _toggleDontAskForHost() { + setState(() { + _askForHost = !_askForHost; + _ask = true; + }); + } + + void _toggleDontAsk() { + setState(() { + _ask = !_ask; + _askForHost = true; + }); + } + + void _onConfirmTapped() async { + if (!_askForHost) { + await _preferencesService.setAskToConfirmOpenUrl( + _askForHost, host: widget._urlInfo); + } + if (!_ask) { + await _preferencesService.setAskToConfirmOpenUrl(_ask); + } + + _onConfirmOpen(); + } + + void _onConfirmOpen() { + Navigator.pop(context, true); + } + + void _onCancel() { + Navigator.pop(context, false); + } +} diff --git a/lib/pages/home/bottom_sheets/confirm_url_setting_picker.dart b/lib/pages/home/bottom_sheets/confirm_url_setting_picker.dart new file mode 100644 index 000000000..8ecad4c21 --- /dev/null +++ b/lib/pages/home/bottom_sheets/confirm_url_setting_picker.dart @@ -0,0 +1,63 @@ +import 'package:Okuna/provider.dart'; +import 'package:Okuna/widgets/theming/primary_color_container.dart'; +import 'package:Okuna/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBConfirmUrlSettingPickerBottomSheet extends StatefulWidget { + final ValueChanged onTypeChanged; + + final bool initialValue; + + const OBConfirmUrlSettingPickerBottomSheet( + {Key key, @required this.onTypeChanged, this.initialValue}) + : super(key: key); + + @override + OBConfirmUrlSettingPickerBottomSheetState createState() { + return OBConfirmUrlSettingPickerBottomSheetState(); + } +} + +class OBConfirmUrlSettingPickerBottomSheetState + extends State { + FixedExtentScrollController _cupertinoPickerController; + List allValues; + + @override + void initState() { + super.initState(); + allValues = [true, false]; + _cupertinoPickerController = FixedExtentScrollController( + initialItem: widget.initialValue != null + ? allValues.indexOf(widget.initialValue) + : 0, + ); + } + + @override + Widget build(BuildContext context) { + var openbookProvider = OpenbookProvider.of(context); + + Map localizationMap = openbookProvider.userPreferencesService + .getConfirmUrlSettingLocalizationMap(); + + return OBPrimaryColorContainer( + mainAxisSize: MainAxisSize.min, + child: SizedBox( + height: 216, + child: CupertinoPicker( + scrollController: _cupertinoPickerController, + backgroundColor: Colors.transparent, + onSelectedItemChanged: (int index) { + widget.onTypeChanged(allValues[index]); + }, + itemExtent: 32, + children: allValues.map((setting) { + return OBText(localizationMap[setting]); + }).toList(), + ), + ), + ); + } +} diff --git a/lib/pages/home/modals/accept_guidelines/accept_guidelines.dart b/lib/pages/home/modals/accept_guidelines/accept_guidelines.dart index c8498ff77..794d3e86e 100644 --- a/lib/pages/home/modals/accept_guidelines/accept_guidelines.dart +++ b/lib/pages/home/modals/accept_guidelines/accept_guidelines.dart @@ -108,6 +108,7 @@ class OBAcceptGuidelinesModalState extends State { ) : OBMarkdown( onlyBody: true, + linksRequireConfirmation: false, data: _guidelinesText, ) ], diff --git a/lib/pages/home/modals/accept_guidelines/pages/confirm_reject_guidelines.dart b/lib/pages/home/modals/accept_guidelines/pages/confirm_reject_guidelines.dart index 211baf4cc..691ce7674 100644 --- a/lib/pages/home/modals/accept_guidelines/pages/confirm_reject_guidelines.dart +++ b/lib/pages/home/modals/accept_guidelines/pages/confirm_reject_guidelines.dart @@ -88,7 +88,7 @@ class OBConfirmRejectGuidelinesState extends State { subtitle: OBSecondaryText( localizationService.user__confirm_guidelines_reject_join_slack), onTap: () { - openbookProvider.urlLauncherService.launchUrl( + openbookProvider.urlLauncherService.launchUrlWithoutConfirmation( 'https://join.slack.com/t/okuna/shared_invite/enQtNDI2NjI3MDM0MzA2LTYwM2E1Y2NhYWRmNTMzZjFhYWZlYmM2YTQ0MWEwYjYyMzcxMGI0MTFhNTIwYjU2ZDI1YjllYzlhOWZjZDc4ZWY'); }, ), diff --git a/lib/pages/home/pages/menu/pages/community_guidelines.dart b/lib/pages/home/pages/menu/pages/community_guidelines.dart index 9ddfe9221..3cbc6543e 100644 --- a/lib/pages/home/pages/menu/pages/community_guidelines.dart +++ b/lib/pages/home/pages/menu/pages/community_guidelines.dart @@ -58,6 +58,7 @@ class OBCommunityGuidelinesPageState extends State { children: [ Expanded( child: OBMarkdown( + linksRequireConfirmation: false, data: _guidelinesText, )), ], diff --git a/lib/pages/home/pages/menu/pages/settings/pages/application_settings.dart b/lib/pages/home/pages/menu/pages/settings/pages/application_settings/application_settings.dart similarity index 79% rename from lib/pages/home/pages/menu/pages/settings/pages/application_settings.dart rename to lib/pages/home/pages/menu/pages/settings/pages/application_settings/application_settings.dart index 976fb3405..27a6f5631 100644 --- a/lib/pages/home/pages/menu/pages/settings/pages/application_settings.dart +++ b/lib/pages/home/pages/menu/pages/settings/pages/application_settings/application_settings.dart @@ -1,12 +1,11 @@ import 'package:Okuna/provider.dart'; import 'package:Okuna/services/localization.dart'; -import 'package:Okuna/widgets/icon.dart'; import 'package:Okuna/widgets/nav_bars/themed_nav_bar.dart'; import 'package:Okuna/widgets/theming/primary_color_container.dart'; import 'package:Okuna/widgets/tile_group_title.dart'; -import 'package:Okuna/widgets/tiles/actions/clear_application_cache_tile.dart'; -import 'package:Okuna/widgets/tiles/actions/clear_application_preferences_tile.dart'; +import 'package:Okuna/widgets/tiles/actions/ask_open_urls_setting_tile.dart'; import 'package:Okuna/widgets/tiles/actions/link_previews_setting_tile.dart'; +import 'package:Okuna/widgets/tiles/actions/trusted_domains_setting_tile.dart'; import 'package:Okuna/widgets/tiles/actions/videos_autoplay_setting_tile.dart'; import 'package:Okuna/widgets/tiles/actions/videos_sound_setting_tile.dart'; import 'package:flutter/cupertino.dart'; @@ -41,6 +40,14 @@ class OBApplicationSettingsPage extends StatelessWidget { ), ), OBLinkPreviewsSettingTile(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: OBTileGroupTitle( + title: _localizationService.application_settings__ask_for_urls, + ), + ), + OBAskToOpenUrlSettingTile(), + OBTrustedDomainsSettingTile() ], ), ), diff --git a/lib/pages/home/pages/menu/pages/settings/pages/application_settings/pages/trusted_domains.dart b/lib/pages/home/pages/menu/pages/settings/pages/application_settings/pages/trusted_domains.dart new file mode 100644 index 000000000..19db77dd3 --- /dev/null +++ b/lib/pages/home/pages/menu/pages/settings/pages/application_settings/pages/trusted_domains.dart @@ -0,0 +1,219 @@ +import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/localization.dart'; +import 'package:Okuna/services/toast.dart'; +import 'package:Okuna/services/user_preferences.dart'; +import 'package:Okuna/widgets/alerts/button_alert.dart'; +import 'package:Okuna/widgets/icon.dart'; +import 'package:Okuna/widgets/nav_bars/themed_nav_bar.dart'; +import 'package:Okuna/widgets/page_scaffold.dart'; +import 'package:Okuna/widgets/search_bar.dart'; +import 'package:Okuna/widgets/theming/actionable_smart_text.dart'; +import 'package:Okuna/widgets/theming/primary_color_container.dart'; +import 'package:async/async.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; + +class OBTrustedDomainsPage extends StatefulWidget { + @override + State createState() { + return OBTrustedDomainsPageState(); + } +} + +class OBTrustedDomainsPageState extends State { + LocalizationService _localizationService; + UserPreferencesService _preferencesService; + ToastService _toastService; + + GlobalKey _listRefreshIndicatorKey = GlobalKey(); + CancelableOperation _refreshOperation; + + List _trustedDomains; + List _searchResults; + String _searchQuery; + bool _hasSearch; + bool _needsBootstrap; + bool _refreshInProgress; + + @override + void initState() { + super.initState(); + _needsBootstrap = true; + _hasSearch = false; + _searchQuery = ''; + _trustedDomains = []; + _searchResults = []; + _refreshInProgress = true; + } + + void _bootstrap() async { + Future.delayed(Duration(), + () async => await _listRefreshIndicatorKey.currentState.show()); + } + + @override + Widget build(BuildContext context) { + if (_needsBootstrap) { + var provider = OpenbookProvider.of(context); + _localizationService = provider.localizationService; + _preferencesService = provider.userPreferencesService; + _toastService = provider.toastService; + _bootstrap(); + _needsBootstrap = false; + } + + return OBCupertinoPageScaffold( + navigationBar: OBThemedNavigationBar( + title: _localizationService.application_settings__trusted_domains_title, + ), + child: OBPrimaryColorContainer( + child: _buildSettings(), + ), + ); + } + + Widget _buildSettings() { + return Column( + children: [ + OBSearchBar( + onSearch: _onSearch, + hintText: _localizationService.user_search__list_search_text( + _localizationService + .application_settings__trusted_domains_resource), + ), + Expanded( + child: RefreshIndicator( + key: _listRefreshIndicatorKey, + child: SingleChildScrollView( + child: _buildList(), + ), + onRefresh: _refreshTrustedDomains, + ), + ), + ], + ); + } + + Widget _buildList() { + List children = []; + + if (_hasSearch) { + if (_searchResults.isNotEmpty || _refreshInProgress) { + children.addAll(_buildListItems(_searchResults)); + } else { + children.add(_buildNoSearchResults()); + } + } else { + if (_trustedDomains.isNotEmpty || _refreshInProgress) { + children.addAll(_buildListItems(_trustedDomains)); + } else { + children.add(_buildNoList()); + } + } + + return Padding( + padding: EdgeInsets.only(left: 20.0), + child: Column(children: children)); + } + + List _buildListItems(List items) { + List list = []; + + for (var domain in items) { + list.add(Slidable( + delegate: const SlidableDrawerDelegate(), + actionExtentRatio: 0.25, + child: ListTile( + title: OBText(domain), + ), + secondaryActions: [ + new IconSlideAction( + caption: _localizationService.application_settings__delete_domain, + color: Colors.red, + icon: Icons.delete, + onTap: () => _deleteDomain(domain), + ) + ], + )); + } + + return list; + } + + Widget _buildNoList() { + return OBButtonAlert( + text: _localizationService.user_search__list_no_results_found( + _localizationService.application_settings__trusted_domains_resource), + onPressed: _refreshTrustedDomains, + buttonText: _localizationService.user_search__list_refresh_text, + buttonIcon: OBIcons.refresh, + assetImage: 'assets/images/stickers/perplexed-owl.png', + ); + } + + Widget _buildNoSearchResults() { + return ListTile( + leading: const OBIcon(OBIcons.sad), + title: OBText( + _localizationService.user_search__no_results_for(_searchQuery), + ), + ); + } + + void _deleteDomain(String domain) async { + bool wasDeleted = await _preferencesService.setAskToConfirmOpenUrl(true, + hostAsString: domain); + + if (wasDeleted) { + setState(() { + _trustedDomains.remove(domain); + }); + } else { + _toastService.error( + message: + _localizationService.application_settings__delete_domain_failure, + context: context); + } + } + + void _onSearch(String query) { + setState(() { + _searchQuery = query.toLowerCase(); + _searchResults = _trustedDomains + .where((domain) => domain.contains(_searchQuery)) + .toList(); + _hasSearch = query.isNotEmpty; + }); + } + + void _setList(List trustedDomains) { + setState(() { + _trustedDomains = trustedDomains.toList(); + _trustedDomains.sort((a, b) => a.compareTo(b)); + _onSearch(_searchQuery); + }); + } + + void _setRefreshInProgress(bool refreshInProgress) { + setState(() { + _refreshInProgress = refreshInProgress; + }); + } + + Future _refreshTrustedDomains() async { + if (_refreshOperation != null) { + _refreshOperation.cancel(); + } + _setRefreshInProgress(true); + + _refreshOperation = + CancelableOperation.fromFuture(_preferencesService.getTrustedDomains()); + List trustedDomains = await _refreshOperation.value; + + _setList(trustedDomains); + + _setRefreshInProgress(false); + _refreshOperation = null; + } +} diff --git a/lib/pages/home/pages/menu/pages/useful_links.dart b/lib/pages/home/pages/menu/pages/useful_links.dart index 6c003e156..c27181059 100644 --- a/lib/pages/home/pages/menu/pages/useful_links.dart +++ b/lib/pages/home/pages/menu/pages/useful_links.dart @@ -45,7 +45,7 @@ class OBUsefulLinksPage extends StatelessWidget { subtitle: OBSecondaryText( _localizationService.drawer__useful_links_guidelines_github_desc), onTap: () { - urlLauncherService.launchUrl( + urlLauncherService.launchUrlWithoutConfirmation( 'https://github.com/orgs/OkunaOrg/projects/3'); }, ), @@ -55,7 +55,7 @@ class OBUsefulLinksPage extends StatelessWidget { subtitle: OBSecondaryText( _localizationService.drawer__useful_links_guidelines_feature_requests_desc), onTap: () { - urlLauncherService.launchUrl( + urlLauncherService.launchUrlWithoutConfirmation( 'https://okuna.canny.io/feature-requests'); }, ), @@ -65,8 +65,8 @@ class OBUsefulLinksPage extends StatelessWidget { subtitle: OBSecondaryText(_localizationService.drawer__useful_links_guidelines_bug_tracker_desc), onTap: () { - urlLauncherService - .launchUrl('https://okuna.canny.io/bugs'); + urlLauncherService.launchUrlWithoutConfirmation( + 'https://okuna.canny.io/bugs'); }, ), ListTile( @@ -75,7 +75,8 @@ class OBUsefulLinksPage extends StatelessWidget { subtitle: OBSecondaryText( _localizationService.drawer__useful_links_guidelines_handbook_desc), onTap: () { - urlLauncherService.launchUrl('https://okuna.support/'); + urlLauncherService. + launchUrlWithoutConfirmation('https://okuna.support/'); }, ), ListTile( @@ -84,7 +85,7 @@ class OBUsefulLinksPage extends StatelessWidget { subtitle: OBSecondaryText( _localizationService.drawer__useful_links_slack_channel_desc), onTap: () { - urlLauncherService.launchUrl( + urlLauncherService.launchUrlWithoutConfirmation( 'https://join.slack.com/t/okuna/shared_invite/enQtNDI2NjI3MDM0MzA2LTYwM2E1Y2NhYWRmNTMzZjFhYWZlYmM2YTQ0MWEwYjYyMzcxMGI0MTFhNTIwYjU2ZDI1YjllYzlhOWZjZDc4ZWY'); }, ), @@ -94,7 +95,8 @@ class OBUsefulLinksPage extends StatelessWidget { subtitle: OBSecondaryText( _localizationService.drawer__useful_links_support_desc), onTap: () { - urlLauncherService.launchUrl('https://www.okuna.io/en/faq'); + urlLauncherService. + launchUrlWithoutConfirmation('https://www.okuna.io/en/faq'); }, ) ], diff --git a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_details/widgets/profile_url.dart b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_details/widgets/profile_url.dart index 0dc3dc90d..97040386e 100644 --- a/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_details/widgets/profile_url.dart +++ b/lib/pages/home/pages/profile/widgets/profile_card/widgets/profile_details/widgets/profile_url.dart @@ -20,7 +20,7 @@ class OBProfileUrl extends StatelessWidget { return GestureDetector( onTap: () async { OpenbookProviderState openbookProvider = OpenbookProvider.of(context); - openbookProvider.urlLauncherService.launchUrl(url); + openbookProvider.urlLauncherService.launchUrlWithConfirmation(url, context); }, child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/pages/home/pages/report_object/pages/confirm_report_object.dart b/lib/pages/home/pages/report_object/pages/confirm_report_object.dart index cde70f4f1..93a28ef2b 100644 --- a/lib/pages/home/pages/report_object/pages/confirm_report_object.dart +++ b/lib/pages/home/pages/report_object/pages/confirm_report_object.dart @@ -125,6 +125,7 @@ class OBConfirmReportObjectState extends State { height: 20, ), OBMarkdown( + linksRequireConfirmation: false, onlyBody: true, data: _localizationService.moderation__confirm_report_provide_happen_next_desc) ], diff --git a/lib/provider.dart b/lib/provider.dart index de70d8d3b..66704274b 100644 --- a/lib/provider.dart +++ b/lib/provider.dart @@ -37,6 +37,7 @@ import 'package:Okuna/services/theme.dart'; import 'package:Okuna/services/theme_value_parser.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/url_launcher.dart'; +import 'package:Okuna/services/url_parser.dart'; import 'package:Okuna/services/user.dart'; import 'package:Okuna/services/user_invites_api.dart'; import 'package:Okuna/services/user_preferences.dart'; @@ -105,6 +106,7 @@ class OpenbookProviderState extends State { PushNotificationsService pushNotificationsService = PushNotificationsService(); UrlLauncherService urlLauncherService = UrlLauncherService(); + UrlParserService urlParserService = UrlParserService(); IntercomService intercomService = IntercomService(); DialogService dialogService = DialogService(); UtilsService utilsService = UtilsService(); @@ -156,6 +158,7 @@ class OpenbookProviderState extends State { userService.setDevicesApiService(devicesApiService); userService.setCreateAccountBlocService(createAccountBloc); userService.setWaitlistApiService(waitlistApiService); + userService.setUserPreferenceService(userPreferencesService); userService.setDraftService(draftService); waitlistApiService.setHttpService(httpService); userService.setModerationApiService(moderationApiService); @@ -182,6 +185,9 @@ class OpenbookProviderState extends State { documentsService.setHttpService(httpService); moderationApiService.setStringTemplateService(stringTemplateService); moderationApiService.setHttpieService(httpService); + urlLauncherService.setBottomSheetService(bottomSheetService); + urlLauncherService.setUserPreferencesService(userPreferencesService); + urlLauncherService.setUrlParserService(urlParserService); linkPreviewService.setHttpieService(httpService); linkPreviewService.setUtilsService(utilsService); linkPreviewService.setValidationService(validationService); @@ -210,6 +216,7 @@ class OpenbookProviderState extends State { categoriesApiService.setApiURL(environment.apiUrl); notificationsApiService.setApiURL(environment.apiUrl); devicesApiService.setApiURL(environment.apiUrl); + urlParserService.loadSuffixRules(); waitlistApiService .setOpenbookSocialApiURL(environment.openbookSocialApiUrl); intercomService.bootstrap( diff --git a/lib/services/bottom_sheet.dart b/lib/services/bottom_sheet.dart index 17d6d3d9c..0c2ceb666 100644 --- a/lib/services/bottom_sheet.dart +++ b/lib/services/bottom_sheet.dart @@ -9,6 +9,8 @@ import 'package:Okuna/models/post_comment_reaction.dart'; import 'package:Okuna/models/post_reaction.dart'; import 'package:Okuna/pages/home/bottom_sheets/community_actions.dart'; import 'package:Okuna/pages/home/bottom_sheets/community_type_picker.dart'; +import 'package:Okuna/pages/home/bottom_sheets/confirm_open_url.dart'; +import 'package:Okuna/pages/home/bottom_sheets/confirm_url_setting_picker.dart'; import 'package:Okuna/pages/home/bottom_sheets/connection_circles_picker.dart'; import 'package:Okuna/pages/home/bottom_sheets/image_picker.dart'; import 'package:Okuna/pages/home/bottom_sheets/link_previews_setting_picker.dart'; @@ -24,6 +26,7 @@ import 'package:Okuna/services/user_preferences.dart'; import 'package:flutter/material.dart'; import 'dart:async'; import 'package:meta/meta.dart'; +import 'package:public_suffix/public_suffix.dart'; import 'media.dart'; @@ -119,6 +122,18 @@ class BottomSheetService { }); } + Future showConfirmUrlSettingPicker( + {@required BuildContext context, + ValueChanged onChanged, + bool initialValue}) { + return showModalBottomSheetApp( + context: context, + builder: (BuildContext context) { + return OBConfirmUrlSettingPickerBottomSheet( + onTypeChanged: onChanged, initialValue: initialValue); + }); + } + Future> showFollowsListsPicker( {@required BuildContext context, @required String title, @@ -198,6 +213,17 @@ class BottomSheetService { }); } + Future showConfirmOpenUrl( + {@required BuildContext context, @required PublicSuffix link}) { + return showModalBottomSheetApp( + context: context, + builder: (BuildContext context) { + return OBConfirmOpenUrlBottomSheet( + urlInfo: link, + ); + }); + } + Future showImagePicker({@required BuildContext context}) { return showModalBottomSheetApp( context: context, diff --git a/lib/services/link_preview.dart b/lib/services/link_preview.dart index 7ca698b71..7b159c553 100644 --- a/lib/services/link_preview.dart +++ b/lib/services/link_preview.dart @@ -6,8 +6,6 @@ import 'package:Okuna/widgets/theming/smart_text.dart'; import 'package:flutter/material.dart'; import 'package:html/parser.dart' as parser; import 'package:html/dom.dart'; -import 'package:flutter/services.dart' show rootBundle; -import 'package:public_suffix/public_suffix_io.dart'; class LinkPreviewService { ValidationService _validationService; @@ -19,16 +17,6 @@ class LinkPreviewService { static RegExp allowedProtocolsPattern = RegExp('http|https', caseSensitive: false); - LinkPreviewService() { - _initPublicSuffixes(); - } - - void _initPublicSuffixes() async { - String publicSuffixes = - await rootBundle.loadString('assets/other/public_suffix_list.dat'); - SuffixRules.initFromString(publicSuffixes); - } - void setTrustedProxyUrl(String proxyUrl) { _trustedProxyUrl = proxyUrl; } diff --git a/lib/services/localization.dart b/lib/services/localization.dart index d1bb60a5a..09a564693 100644 --- a/lib/services/localization.dart +++ b/lib/services/localization.dart @@ -2,14 +2,13 @@ import 'dart:async'; +import 'package:Okuna/locale/messages_all.dart'; import 'package:Okuna/models/user.dart'; import 'package:Okuna/provider.dart'; import 'package:Okuna/translation/constants.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; -import 'package:Okuna/locale/messages_all.dart'; - import '../main.dart'; class LocalizationService { @@ -2169,6 +2168,31 @@ class LocalizationService { name: 'post__time_short_now_text'); } + String get post__open_url_message { + return Intl.message("Do you want to open this link in your browser?", + name: "post__open_url_message"); + } + + String get post__open_url_continue { + return Intl.message("Continue", + name: "post__open_url_continue"); + } + + String get post__open_url_cancel { + return Intl.message("Cancel", + name: "post__open_url_cancel"); + } + + String get post__open_url_dont_ask_again { + return Intl.message("Never ask again", + name: "post__open_url_dont_ask_again"); + } + + String get post__open_url_dont_ask_again_for { + return Intl.message("Trust this domain", + name: "post__open_url_dont_ask_again_for"); + } + String get user__thousand_postfix { return Intl.message("k", desc: 'For eg. communty has 3k members', @@ -2994,8 +3018,7 @@ class LocalizationService { } String get user__clear_app_preferences_desc { - return Intl.message( - "Clear the application preferences. Currently this is only the preferred order of comments.", + return Intl.message("Clear the application preferences. Currently only the preferred order of comments and the list of trusted domains.", name: 'user__clear_app_preferences_desc'); } @@ -3828,6 +3851,58 @@ class LocalizationService { name: 'application_settings__comment_sort_oldest_first'); } + String get application_settings__trusted_domains_title { + return Intl.message("Manage trusted domains", + name: 'application_settings__trusted_domains_title', + desc: 'Title for the Trusted Domains settings page.'); + } + + String get application_settings__ask_for_urls { + return Intl.message("Link confirmation", + name: 'application_settings__ask_for_urls', + desc: 'Header for the "ask to open URLs" and "trusted domains" settings.'); + } + + String get application_settings__ask_for_urls_setting { + return Intl.message("Ask to open links", + name: 'application_settings__ask_for_urls_setting'); + } + + String get application_settings__trusted_domains_text { + return Intl.message("Trusted domains", + name: 'application_settings__trusted_domains_text', + desc: 'Text on the button to the Trusted Domains settings page.'); + } + + String get application_settings__trusted_domains_desc { + return Intl.message("Manage web domains you have marked as trusted.", + name: 'application_settings__trusted_domains_desc'); + } + + String get application_settings__trusted_domains_resource { + return Intl.message("trusted domains", + name: 'application_settings__trusted_domains_resource'); + } + + String get application_settings__delete_domain_failure { + return Intl.message("Could not delete the trusted domain", + name: 'application_settings__delete_domain_failure'); + } + + String get application_settings__confirm_url_enabled { + return Intl.message("Only untrusted", + name: "application_settings__confirm_url_enabled"); + } + + String get application_settings__confirm_url_disabled { + return Intl.message("Never", + name: "application_settings__confirm_url_disabled"); + } + + String get application_settings__delete_domain { + return Intl.message("Delete", name: 'application_settings__delete_domain'); + } + String get media_service__crop_image { return Intl.message("Crop image", name: 'media_service__crop_image'); diff --git a/lib/services/navigation_service.dart b/lib/services/navigation_service.dart index 27ae69fda..36ff67c48 100644 --- a/lib/services/navigation_service.dart +++ b/lib/services/navigation_service.dart @@ -47,14 +47,15 @@ import 'package:Okuna/pages/home/pages/menu/pages/my_moderation_tasks/my_moderat import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/account_settings/account_settings.dart'; import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/account_settings/pages/blocked_users.dart'; import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/account_settings/pages/user_language_settings/user_language_settings.dart'; -import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/application_settings.dart'; +import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/application_settings/application_settings.dart'; +import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/application_settings/pages/trusted_domains.dart'; import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/developer_settings.dart'; import 'package:Okuna/pages/home/pages/menu/pages/settings/pages/top_posts_excluded_communities.dart'; import 'package:Okuna/pages/home/pages/menu/pages/settings/settings.dart'; +import 'package:Okuna/pages/home/pages/menu/pages/themes/themes.dart'; import 'package:Okuna/pages/home/pages/menu/pages/useful_links.dart'; import 'package:Okuna/pages/home/pages/menu/pages/user_invites/pages/user_invite_detail.dart'; import 'package:Okuna/pages/home/pages/menu/pages/user_invites/user_invites.dart'; -import 'package:Okuna/pages/home/pages/menu/pages/themes/themes.dart'; import 'package:Okuna/pages/home/pages/moderated_objects/moderated_objects.dart'; import 'package:Okuna/pages/home/pages/moderated_objects/pages/moderated_object_community_review.dart'; import 'package:Okuna/pages/home/pages/moderated_objects/pages/moderated_object_global_review.dart'; @@ -728,6 +729,18 @@ class NavigationService { ); } + Future navigateToTrustedDomainsSettings( + {@required BuildContext context}) { + return Navigator.push( + context, + OBSlideRightRoute( + slidableKey: _getKeyRandomisedWithWord('obTrustedDomainsPage'), + builder: (BuildContext context) { + return OBTrustedDomainsPage(); + }), + ); + } + Future navigateToNotificationsSettings({ @required BuildContext context, }) { diff --git a/lib/services/storage.dart b/lib/services/storage.dart index 7e5251d42..34ab37049 100644 --- a/lib/services/storage.dart +++ b/lib/services/storage.dart @@ -32,10 +32,18 @@ class OBStorage { return value; } + Future> getList(String key) { + return this.store.getList(_makeKey(key)); + } + Future set(String key, dynamic value) { return this.store.set(_makeKey(key), value); } + Future setList(String key, dynamic value) { + return this.store.setList(_makeKey(key), value); + } + Future remove(String key) { return this.store.remove(this._makeKey(key)); } @@ -83,6 +91,13 @@ class _SecureStore implements _Store { } } + Future> getList(String key) { + throw UnimplementedError("_SecureStore.getList and .setList haven't been" + "implemented yet as they require custom parsing between strings and lists."); + // Additional note: when implemented, the parsing must be able to handle elements + // that contain whatever delimiter is used to separate elements. + } + Future set(String key, String value) { if (_storedKeys.add(key)) { _saveStoredKeys(); @@ -90,6 +105,13 @@ class _SecureStore implements _Store { return storage.write(key: key, value: value); } + Future setList(String key, List value) { + throw UnimplementedError("_SecureStore.getList and .setList haven't been" + "implemented yet as they require custom parsing between strings and lists."); + // Additional note: when implemented, the conversion from list to string must + // escape element separators that are found within elements themselves. + } + Future remove(String key) { if (_storedKeys.remove(key)) { _saveStoredKeys(); @@ -121,11 +143,21 @@ class _SystemPreferencesStorage implements _Store { return sharedPreferences.get(key); } + Future> getList(String key) async { + SharedPreferences sharedPreferences = await _getSharedPreferences(); + return sharedPreferences.getStringList(key); + } + Future set(String key, String value) async { SharedPreferences sharedPreferences = await _getSharedPreferences(); return sharedPreferences.setString(key, value); } + Future setList(String key, List value) async { + SharedPreferences sharedPreferences = await _getSharedPreferences(); + return sharedPreferences.setStringList(key, value); + } + Future remove(String key) async { SharedPreferences sharedPreferences = await _getSharedPreferences(); return sharedPreferences.remove(key); @@ -143,8 +175,12 @@ class _SystemPreferencesStorage implements _Store { abstract class _Store { Future get(String key); + Future> getList(String key); + Future set(String key, T value); + Future setList(String key, List value); + Future remove(String key); Future clear(); diff --git a/lib/services/url_launcher.dart b/lib/services/url_launcher.dart index c34e0371d..cf1817aae 100644 --- a/lib/services/url_launcher.dart +++ b/lib/services/url_launcher.dart @@ -1,19 +1,62 @@ +import 'package:Okuna/services/bottom_sheet.dart'; +import 'package:Okuna/services/url_parser.dart'; +import 'package:Okuna/services/user_preferences.dart'; +import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; class UrlLauncherService { - Future launchUrl(String url) async { - bool canOpenUrl = await canLaunchUrl(url); + UserPreferencesService _preferencesService; + BottomSheetService _bottomSheetService; + UrlParserService _urlParserService; - if (canOpenUrl) { - await launch(url); + void setUserPreferencesService(UserPreferencesService preferencesService) { + _preferencesService = preferencesService; + } + + void setBottomSheetService(BottomSheetService bottomSheetService) { + _bottomSheetService = bottomSheetService; + } + + void setUrlParserService(UrlParserService urlParserService) { + _urlParserService = urlParserService; + } + + Future launchUrlWithoutConfirmation(String url) async { + return await _launchUrl(url, showConfirmation: false); + } + + Future launchUrlWithConfirmation(String url, BuildContext context) async { + return await _launchUrl(url, context: context); + } + + Future _launchUrl(String url, + {bool showConfirmation = true, BuildContext context}) async { + if (await canLaunchUrl(url)) { + if (!showConfirmation || await requestConfirmation(url, context)) { + await launch(url); + } } else { throw UrlLauncherUnsupportedUrlException(url); } } - Future canLaunchUrl(String url){ + Future canLaunchUrl(String url) { return canLaunch(url); } + + Future requestConfirmation(String url, BuildContext context) async { + var parsedUrl = _urlParserService.parse(url); + var approved; + + if (await _preferencesService.getAskToConfirmOpenUrl(host: parsedUrl)) { + approved = await _bottomSheetService.showConfirmOpenUrl( + link: parsedUrl, context: context); + } else { + approved = true; + } + + return approved; + } } class UrlLauncherUnsupportedUrlException implements Exception { diff --git a/lib/services/url_parser.dart b/lib/services/url_parser.dart new file mode 100644 index 000000000..ce3485304 --- /dev/null +++ b/lib/services/url_parser.dart @@ -0,0 +1,16 @@ +import 'package:flutter/services.dart'; +import 'package:public_suffix/public_suffix.dart'; + +class UrlParserService { + Map parsedUrlCache = {}; + + void loadSuffixRules() async { + String suffixListString = + await rootBundle.loadString('assets/other/public_suffix_list.dat'); + SuffixRules.initFromString(suffixListString); + } + + PublicSuffix parse(String url) { + return parsedUrlCache.putIfAbsent(url, () => PublicSuffix(Uri.parse(url))); + } +} diff --git a/lib/services/user.dart b/lib/services/user.dart index 6e5c4d449..7ed1afdc4 100644 --- a/lib/services/user.dart +++ b/lib/services/user.dart @@ -63,6 +63,7 @@ import 'package:Okuna/services/notifications_api.dart'; import 'package:Okuna/services/posts_api.dart'; import 'package:Okuna/services/storage.dart'; import 'package:Okuna/services/user_invites_api.dart'; +import 'package:Okuna/services/user_preferences.dart'; import 'package:Okuna/services/waitlist_service.dart'; import 'package:crypto/crypto.dart'; import 'package:device_info/device_info.dart'; @@ -98,6 +99,7 @@ class UserService { CreateAccountBloc _createAccountBlocService; WaitlistApiService _waitlistApiService; LocalizationService _localizationService; + UserPreferencesService _preferenceService; DraftService _draftService; // If this is null, means user logged out. @@ -186,6 +188,10 @@ class UserService { _localizationService = localizationService; } + void setUserPreferenceService(UserPreferencesService preferenceService) { + _preferenceService = preferenceService; + } + void setDraftService(DraftService draftService) { _draftService = draftService; } @@ -201,6 +207,7 @@ class UserService { await _removeStoredUserData(); await _removeStoredAuthToken(); _httpieService.removeAuthorizationToken(); + await _preferenceService.clearUrlConfirmationPreferences(); _draftService.clear(); _removeLoggedInUser(); await clearCache(); diff --git a/lib/services/user_preferences.dart b/lib/services/user_preferences.dart index 444c2a883..f8af11b72 100644 --- a/lib/services/user_preferences.dart +++ b/lib/services/user_preferences.dart @@ -4,11 +4,15 @@ import 'package:Okuna/models/post_comment.dart'; import 'package:Okuna/services/connectivity.dart'; import 'package:Okuna/services/storage.dart'; import 'package:connectivity/connectivity.dart'; +import 'package:public_suffix/public_suffix.dart'; import 'package:rxdart/rxdart.dart'; import 'localization.dart'; class UserPreferencesService { + static const keyAskToConfirmOpen = 'askToConfirmOpen'; + static const keyAskToConfirmExceptions = 'askToConfirmExceptions'; + LocalizationService _localizationService; OBStorage _storage; ConnectivityService _connectivityService; @@ -55,10 +59,11 @@ class UserPreferencesService { final _videosAutoPlaySettingChangeSubject = BehaviorSubject(); - void setStorageService(StorageService storageService) { - _storage = storageService.getSystemPreferencesStorage( - namespace: 'userPreferences'); - } + Stream get confirmUrlSettingChange => + _confirmUrlSettingChangeSubject.stream; + + final _confirmUrlSettingChangeSubject = + BehaviorSubject(); // Bootstrapped after connectivity service is given in the provider void bootstrap() async { @@ -82,12 +87,18 @@ class UserPreferencesService { _connectivityService = connectivityService; } + void setStorageService(StorageService storageService) { + _storage = storageService.getSystemPreferencesStorage( + namespace: 'userPreferences'); + } + void dispose() { _connectivityChangeSubscription?.cancel(); _linkPreviewsEnabledChangeSubject.close(); _videosSoundSettingChangeSubject.close(); _videosAutoPlaySettingChangeSubject.close(); _videosAutoPlayEnabledChangeSubject.close(); + _confirmUrlSettingChangeSubject.close(); } bool getLinkPreviewsAreEnabled() { @@ -220,6 +231,71 @@ class UserPreferencesService { _videosAutoPlayEnabledChangeSubject.add(_videosAutoPlayAreEnabled); } + Future setAskToConfirmOpenUrl(bool ask, + {PublicSuffix host, String hostAsString}) async { + var domain = (host != null ? host.domain : hostAsString); + domain = domain?.toLowerCase(); + + Future status = Future.value(true); + if (host == null && hostAsString == null) { + status = _storage?.set(keyAskToConfirmOpen, ask.toString()); + _confirmUrlSettingChangeSubject.add(ask); + } else if (domain != null) { + List exceptions = + await _storage?.getList(keyAskToConfirmExceptions) ?? []; + + var hasException = exceptions.contains(domain); + + if (!hasException && !ask) { + exceptions.add(domain); + status = _storage?.setList(keyAskToConfirmExceptions, exceptions); + } else if (hasException && ask) { + exceptions.remove(domain); + status = _storage?.setList(keyAskToConfirmExceptions, exceptions); + } + } + + return status; + } + + Future getAskToConfirmOpenUrl({PublicSuffix host}) async { + String ask = await _storage?.get(keyAskToConfirmOpen); + bool shouldAsk = true; + + if (ask != null && ask.toLowerCase() == "false") { + shouldAsk = false; + } else if (host != null && host.domain != null) { + List exceptions = + await _storage?.getList(keyAskToConfirmExceptions); + + if (exceptions != null && + exceptions.contains(host.domain.toLowerCase())) { + shouldAsk = false; + } + } + + return shouldAsk; + } + + Map getConfirmUrlSettingLocalizationMap() { + return { + true: + _localizationService.application_settings__confirm_url_enabled, + false: + _localizationService.application_settings__confirm_url_disabled + }; + } + + Future> getTrustedDomains() async { + var domains = await _storage?.getList(keyAskToConfirmExceptions); + return domains ?? []; + } + + Future clearUrlConfirmationPreferences() async { + _storage?.setList(keyAskToConfirmExceptions, null); + _storage?.set(keyAskToConfirmOpen, null); + } + Future clear() { return _storage.clear(); } diff --git a/lib/widgets/fields/checkbox_field.dart b/lib/widgets/fields/checkbox_field.dart index 78b559253..88f1bcba6 100644 --- a/lib/widgets/fields/checkbox_field.dart +++ b/lib/widgets/fields/checkbox_field.dart @@ -11,6 +11,7 @@ class OBCheckboxField extends StatelessWidget { final String subtitle; final bool isDisabled; final TextStyle titleStyle; + final TextStyle subtitleStyle; OBCheckboxField( {@required this.value, @@ -19,7 +20,8 @@ class OBCheckboxField extends StatelessWidget { this.leading, @required this.title, this.isDisabled = false, - this.titleStyle}); + this.titleStyle, + this.subtitleStyle,}); @override Widget build(BuildContext context) { @@ -34,7 +36,7 @@ class OBCheckboxField extends StatelessWidget { title, style: finalTitleStyle, ), - subtitle: subtitle != null ? OBText(subtitle) : null, + subtitle: subtitle != null ? OBText(subtitle, style: subtitleStyle,) : null, trailing: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/icon.dart b/lib/widgets/icon.dart index 6e5f771b3..b93951443 100644 --- a/lib/widgets/icon.dart +++ b/lib/widgets/icon.dart @@ -159,6 +159,7 @@ class OBIcons { static const location = OBIconData(nativeIcon: Icons.location_on); static const link = OBIconData(nativeIcon: Icons.link); static const linkOff = OBIconData(nativeIcon: Icons.link_off); + static const url = OBIconData(nativeIcon: Icons.public); static const email = OBIconData(nativeIcon: Icons.email); static const lock = OBIconData(nativeIcon: Icons.lock); static const bio = OBIconData(nativeIcon: Icons.bookmark); diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index ef46623ac..f803bce73 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -13,10 +13,10 @@ import 'package:Okuna/widgets/progress_indicator.dart'; import 'package:Okuna/widgets/theming/highlighted_box.dart'; import 'package:Okuna/widgets/theming/secondary_text.dart'; import 'package:Okuna/widgets/theming/text.dart'; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:flutter_advanced_networkimage/provider.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:async/async.dart'; class OBLinkPreview extends StatefulWidget { final LinkPreview linkPreview; @@ -204,7 +204,8 @@ class OBLinkPreviewState extends State { ], )), onTap: () { - _urlLauncherService.launchUrl(_linkPreview.url ?? widget.link); + _urlLauncherService.launchUrlWithConfirmation( + _linkPreview.url ?? widget.link, context); }, ); } diff --git a/lib/widgets/markdown.dart b/lib/widgets/markdown.dart index 37f4a48f0..1ab539d01 100644 --- a/lib/widgets/markdown.dart +++ b/lib/widgets/markdown.dart @@ -11,9 +11,14 @@ class OBMarkdown extends StatelessWidget { final String data; final OBTheme theme; final bool onlyBody; + final bool linksRequireConfirmation; const OBMarkdown( - {Key key, @required this.data, this.theme, this.onlyBody = false}) + {Key key, + @required this.data, + this.theme, + this.onlyBody = false, + this.linksRequireConfirmation = true}) : super(key: key); @override @@ -101,7 +106,11 @@ class OBMarkdown extends StatelessWidget { Function onTapLink = (String tappedLink) async { bool canLaunchUrl = await urlLauncherService.canLaunchUrl(tappedLink); if (canLaunchUrl) { - urlLauncherService.launchUrl(tappedLink); + if (linksRequireConfirmation) { + urlLauncherService.launchUrlWithConfirmation(tappedLink, context); + } else { + urlLauncherService.launchUrlWithoutConfirmation(tappedLink); + } } }; diff --git a/lib/widgets/theming/actionable_smart_text.dart b/lib/widgets/theming/actionable_smart_text.dart index 0ae6c779f..9a9950c6c 100644 --- a/lib/widgets/theming/actionable_smart_text.dart +++ b/lib/widgets/theming/actionable_smart_text.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:Okuna/models/community.dart'; import 'package:Okuna/models/user.dart'; import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/bottom_sheet.dart'; import 'package:Okuna/services/navigation_service.dart'; import 'package:Okuna/services/toast.dart'; import 'package:Okuna/services/url_launcher.dart'; import 'package:Okuna/services/user.dart'; +import 'package:Okuna/services/user_preferences.dart'; import 'package:Okuna/widgets/theming/smart_text.dart'; export 'package:Okuna/widgets/theming/smart_text.dart'; import 'package:flutter/material.dart'; @@ -109,9 +111,9 @@ class OBActionableTextState extends State { _navigationService.navigateToUserProfile(user: user, context: context); } - void _onLinkTapped(String link) { + void _onLinkTapped(String link) async { try { - _urlLauncherService.launchUrl(link); + _urlLauncherService.launchUrlWithConfirmation(link, context); } on UrlLauncherUnsupportedUrlException { _toastService.info(message: 'Unsupported link', context: context); } catch (error) { diff --git a/lib/widgets/tiles/actions/ask_open_urls_setting_tile.dart b/lib/widgets/tiles/actions/ask_open_urls_setting_tile.dart new file mode 100644 index 000000000..934d05b43 --- /dev/null +++ b/lib/widgets/tiles/actions/ask_open_urls_setting_tile.dart @@ -0,0 +1,62 @@ +import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/bottom_sheet.dart'; +import 'package:Okuna/services/localization.dart'; +import 'package:Okuna/services/user_preferences.dart'; +import 'package:Okuna/widgets/icon.dart'; +import 'package:Okuna/widgets/theming/primary_accent_text.dart'; +import 'package:Okuna/widgets/theming/secondary_text.dart'; +import 'package:Okuna/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBAskToOpenUrlSettingTile extends StatefulWidget { + @override + State createState() { + return OBAskToOpenUrlSettingTileState(); + } +} + +class OBAskToOpenUrlSettingTileState extends State { + @override + Widget build(BuildContext context) { + var provider = OpenbookProvider.of(context); + LocalizationService localizationService = provider.localizationService; + UserPreferencesService preferencesService = provider.userPreferencesService; + BottomSheetService bottomSheetService = provider.bottomSheetService; + + Map confirmUrlLocalizationMap = + preferencesService.getConfirmUrlSettingLocalizationMap(); + + return FutureBuilder( + future: preferencesService.getAskToConfirmOpenUrl(), + builder: (BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.data == null) return const SizedBox(); + + return StreamBuilder( + stream: preferencesService.confirmUrlSettingChange, + initialData: snapshot.data, + builder: (BuildContext context, AsyncSnapshot snapshot) { + var currentSetting = snapshot.data; + + return ListTile( + leading: OBIcon(OBIcons.url), + title: OBText(localizationService + .application_settings__ask_for_urls_setting), + subtitle: OBSecondaryText( + localizationService.application_settings__tap_to_change, + size: OBTextSize.small, + ), + trailing: OBPrimaryAccentText( + confirmUrlLocalizationMap[currentSetting], + style: TextStyle(fontSize: 16), + ), + onTap: () { + bottomSheetService.showConfirmUrlSettingPicker( + initialValue: currentSetting, + context: context, + onChanged: preferencesService.setAskToConfirmOpenUrl); + }); + }); + }); + } +} diff --git a/lib/widgets/tiles/actions/trusted_domains_setting_tile.dart b/lib/widgets/tiles/actions/trusted_domains_setting_tile.dart new file mode 100644 index 000000000..56f39cbae --- /dev/null +++ b/lib/widgets/tiles/actions/trusted_domains_setting_tile.dart @@ -0,0 +1,36 @@ +import 'package:Okuna/provider.dart'; +import 'package:Okuna/services/localization.dart'; +import 'package:Okuna/services/navigation_service.dart'; +import 'package:Okuna/widgets/icon.dart'; +import 'package:Okuna/widgets/theming/secondary_text.dart'; +import 'package:Okuna/widgets/theming/text.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +class OBTrustedDomainsSettingTile extends StatefulWidget { + @override + State createState() { + return OBTrustedDomainsSettingTileState(); + } +} + +class OBTrustedDomainsSettingTileState + extends State { + @override + Widget build(BuildContext context) { + var provider = OpenbookProvider.of(context); + LocalizationService localizationService = provider.localizationService; + NavigationService navigationService = provider.navigationService; + + return ListTile( + leading: OBIcon(OBIcons.link), + title: OBText( + localizationService.application_settings__trusted_domains_text), + subtitle: OBSecondaryText( + localizationService.application_settings__trusted_domains_desc), + onTap: () { + navigationService.navigateToTrustedDomainsSettings(context: context); + }, + ); + } +}