diff --git a/lib/business_logic/timeline/timeline.dart b/lib/business_logic/timeline/timeline.dart index 13153a7..0bdbe64 100644 --- a/lib/business_logic/timeline/timeline.dart +++ b/lib/business_logic/timeline/timeline.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; +import 'package:localization/localization.dart'; import 'package:loris/business_logic/account/account.dart'; import 'package:loris/business_logic/timeline/media.dart'; import '../../global.dart' as global; @@ -25,6 +27,32 @@ extension VisibilityExtension on Visibility { return true; } } + + String get name { + switch (this) { + case Visibility.direct: + return "direct-visibility".i18n(); + case Visibility.private: + return "private-visibility".i18n(); + case Visibility.public: + return "public-visibility".i18n(); + case Visibility.unlisted: + return "unlisted-visibility".i18n(); + } + } + + IconData get icon { + switch (this) { + case Visibility.direct: + return Icons.message; + case Visibility.private: + return Icons.lock; + case Visibility.public: + return Icons.public; + case Visibility.unlisted: + return Icons.lock_open; + } + } } class PostModel implements Comparable { diff --git a/lib/i18n/en.json b/lib/i18n/en.json index deb05b3..0675867 100644 --- a/lib/i18n/en.json +++ b/lib/i18n/en.json @@ -40,6 +40,13 @@ "interacted-with-you": "interacted with you", "on-remote-instance": "on remote instance", "reblog": "reblog", - "like": "like" + "like": "like", + "load-older-notifications": "load older notifications", + "copied-post-by": "copied link to post by:", + "copy-url-to-clipboard": "copy url to clipboard", + "unlisted-visibility": "unlisted", + "public-visibility": "public", + "private-visibility": "private", + "direct-visibility": "dm" } \ No newline at end of file diff --git a/lib/pages/notifications/notifications.dart b/lib/pages/notifications/notifications.dart index da4008b..8fab23a 100644 --- a/lib/pages/notifications/notifications.dart +++ b/lib/pages/notifications/notifications.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:localization/localization.dart'; import 'package:loris/business_logic/notifications/notifs.dart'; import 'package:loris/pages/notifications/single_notif.dart'; +import 'package:loris/partials/loadingbox.dart'; import '../../business_logic/websocket.dart' as websocket; final notifStream = StreamController.broadcast(); @@ -41,6 +42,21 @@ class _NotificationsState extends State { } } + void reload() { + if (mounted) { + setState(() { + _controller.animateTo( + 0, + duration: const Duration(seconds: 1), + curve: Curves.easeInOut, + ); + maxIdData = {}; + notifs = [const LoadingBox()]; + }); + loadMore(); + } + } + @override void initState() { super.initState(); @@ -96,7 +112,19 @@ class _NotificationsState extends State { Widget build(BuildContext context) { return Column( children: [ - Container(), + Container( + color: Theme.of(context).colorScheme.surface, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + IconButton( + onPressed: () { + reload(); + }, + icon: const Icon(Icons.refresh)) + ], + ), + ), Expanded( child: ListView.separated( controller: _controller, diff --git a/lib/partials/interaction_button.dart b/lib/partials/interaction_button.dart index 7743a7f..94ca219 100644 --- a/lib/partials/interaction_button.dart +++ b/lib/partials/interaction_button.dart @@ -231,9 +231,11 @@ class _InteractionButtonState extends State { } }, ); - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: c, + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: c, + ), ); })); } diff --git a/lib/partials/post.dart b/lib/partials/post.dart index 988a672..ceb1e17 100644 --- a/lib/partials/post.dart +++ b/lib/partials/post.dart @@ -14,9 +14,11 @@ class Post extends StatefulWidget { required this.model, Key? key, this.reblogVisible = true, + this.hideSensitive = true, }) : super(key: key); final tl.PostModel model; final bool reblogVisible; + final bool hideSensitive; @override State createState() => _PostState(); @@ -35,6 +37,7 @@ class _PostState extends State { content: widget.model.content, spoilerText: widget.model.spoilerText, media: widget.model.attachments, + forceShow: !widget.hideSensitive, ), PostActionBar(model: widget.model), RebloggedBy( @@ -163,11 +166,13 @@ class PostBody extends StatefulWidget { required this.spoilerText, required this.content, required this.media, + required this.forceShow, Key? key, }) : super(key: key); final String content; final String spoilerText; final bool sensitive; + final bool forceShow; final List media; @override @@ -178,11 +183,15 @@ class _PostBodyState extends State { bool visible = false; String cwButtonText = "show".i18n(); Icon cwButtonIcon = const Icon(Icons.visibility); + + @override + void initState() { + visible = !widget.sensitive; + super.initState(); + } + @override Widget build(BuildContext context) { - if (!widget.sensitive) { - visible = true; - } return Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(0, 6, 0, 6), @@ -190,44 +199,50 @@ class _PostBodyState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Visibility( - visible: widget.sensitive, - child: SelectableText.rich( - TextSpan( - text: "", - children: [ - WidgetSpan( - child: OutlinedButton.icon( - onPressed: () { - setState(() { - visible = !visible; - if (visible) { - cwButtonIcon = const Icon(Icons.visibility_off); - cwButtonText = "hide".i18n(); - } else { - cwButtonText = "show".i18n(); - cwButtonIcon = const Icon(Icons.visibility); - } - }); - }, - icon: cwButtonIcon, - label: Text(cwButtonText), - ), + visible: widget.forceShow ? false : widget.sensitive, + child: Column( + children: [ + SelectableText.rich( + TextSpan( + children: [ + WidgetSpan( + child: OutlinedButton.icon( + onPressed: () { + setState(() { + visible = !visible; + if (visible) { + cwButtonIcon = const Icon(Icons.visibility_off); + cwButtonText = "hide".i18n(); + } else { + cwButtonText = "show".i18n(); + cwButtonIcon = const Icon(Icons.visibility); + } + }); + }, + icon: cwButtonIcon, + label: Text(cwButtonText), + ), + ), + const WidgetSpan( + child: SizedBox( + width: 8, + )), + TextSpan(text: widget.spoilerText), + ], ), - const WidgetSpan( - child: SizedBox( - width: 8, - )), - TextSpan(text: widget.spoilerText), - ], - ), + ), + const SizedBox( + height: 8, + ) + ], ), ), Visibility( - visible: visible, + visible: visible || widget.forceShow, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - PostTextRenderer(htmlInput: widget.content), + PostTextRenderer(input: widget.content), MediaAttachments(models: widget.media), ], ), diff --git a/lib/partials/post_options.dart b/lib/partials/post_options.dart index cac79f2..376dcac 100644 --- a/lib/partials/post_options.dart +++ b/lib/partials/post_options.dart @@ -4,43 +4,106 @@ import 'package:loris/business_logic/interactions/interactions.dart'; import 'package:loris/business_logic/timeline/timeline.dart'; import 'package:loris/partials/interaction_button.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:clipboard/clipboard.dart'; void popupPostOptions(context, PostModel model) { showModalBottomSheet( context: context, - builder: (context) { - List c = [ - SelectableText("post-options".i18n(), - style: Theme.of(context).textTheme.displayMedium), - SelectableText(model.createdAt), - TextButton.icon( - onPressed: () { - launchUrl( - Uri.parse(model.uri), - ); - }, - icon: const Icon(Icons.open_in_browser), - label: Text("show-in-browser".i18n()), - ), - model.visibility.boostable - ? InteractionButton( - model: model, - type: InteractionType.reblog, - extended: true, - ) - : null, - InteractionButton( - model: model, - type: InteractionType.favorite, - extended: true, - ), - ]; - return Scrollable( - viewportBuilder: ((context, position) => - Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - for (var i in c) - if (i != null) i - ]))); - }, + builder: (context) => PostOptions(model: model), ); } + +class PostOptions extends StatefulWidget { + const PostOptions({Key? key, required this.model}) : super(key: key); + final PostModel model; + + @override + State createState() => _PostOptionsState(); +} + +class _PostOptionsState extends State { + bool justCopied = false; + + @override + Widget build(BuildContext context) { + List c = [ + const SizedBox( + height: 24, + ), + SelectableText("post-options".i18n(), + style: Theme.of(context).textTheme.displayMedium), + SelectableText( + widget.model.createdAt + .replaceAll("T", " ") + .replaceAll("-", ".") + .substring(0, 19), + style: Theme.of(context).textTheme.bodyMedium, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.model.visibility.icon), + SelectableText(widget.model.visibility.name), + ], + ), + const SizedBox( + height: 24, + ), + TextButton.icon( + onPressed: () async { + FlutterClipboard.copy(widget.model.uri); + setState(() { + justCopied = true; + }); + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + setState(() { + justCopied = false; + }); + } + }, + icon: const Icon(Icons.copy), + label: Text( + justCopied + ? "${"copied-post-by".i18n()} ${widget.model.account.acct}" + : "copy-url-to-clipboard".i18n(), + ), + ), + TextButton.icon( + onPressed: () { + launchUrl( + Uri.parse(widget.model.uri), + ); + }, + icon: const Icon(Icons.open_in_browser), + label: Text( + "show-in-browser".i18n(), + ), + ), + widget.model.visibility.boostable + ? InteractionButton( + model: widget.model, + type: InteractionType.reblog, + extended: true, + ) + : null, + InteractionButton( + model: widget.model, + type: InteractionType.favorite, + extended: true, + ), + const SizedBox( + height: 24, + ), + ]; + return SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (var i in c) + if (i != null) i + ], + ), + ); + } +} diff --git a/lib/partials/post_text_renderer.dart b/lib/partials/post_text_renderer.dart index bf460d9..40f7368 100644 --- a/lib/partials/post_text_renderer.dart +++ b/lib/partials/post_text_renderer.dart @@ -1,59 +1,29 @@ import 'package:flutter/material.dart'; -import 'package:html/dom.dart' as dom; -import 'package:html/parser.dart' as parser; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:html2md/html2md.dart' as html2md; +import 'package:url_launcher/url_launcher.dart'; class PostTextRenderer extends StatelessWidget { const PostTextRenderer({ - required this.htmlInput, + required this.input, Key? key, }) : super(key: key); - final String htmlInput; + final String input; @override Widget build(BuildContext context) { - dom.Document document = parser.parse(htmlInput); - final List children = - createSpansFromDoc(document.body!.children); - return SelectableText.rich( - TextSpan( - style: Theme.of(context).textTheme.bodyMedium, - text: "", - children: children, - ), + MarkdownStyleSheet mdStyle = MarkdownStyleSheet( + a: TextStyle(color: Theme.of(context).colorScheme.secondary)); + String s = html2md.convert(input); + return MarkdownBody( + onTapLink: ((text, href, title) { + if (href != null) { + launchUrl(Uri.parse(href)); + } + }), + styleSheet: mdStyle, + data: s, + selectable: true, ); } } - -List createSpansFromDoc(List elements) { - List result = []; - for (int i = 0; i < elements.length; i += 1) { - final e = elements[i]; - result.add( - getSpanForElement( - e, - elements.length, - i, - ), - ); - } - return result; -} - -InlineSpan getSpanForElement(dom.Element e, int bodyLength, int pos) { - if (e.toString() == "") { - return handleParagraph(e, bodyLength, pos); - } - return TextSpan(text: e.text); -} - -InlineSpan handleParagraph(dom.Element e, int bodyLength, int pos) { - String text = e.text; - return WidgetSpan( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 4), - child: SelectableText( - text, - ), - ), - ); -} diff --git a/lib/partials/thread.dart b/lib/partials/thread.dart index a0191c3..ef2822c 100644 --- a/lib/partials/thread.dart +++ b/lib/partials/thread.dart @@ -1,17 +1,53 @@ import 'package:flutter/material.dart'; +import 'package:localization/localization.dart'; import 'package:loris/partials/post.dart'; import '../business_logic/timeline/timeline.dart' as logic; import '../global.dart' as global; -class Thread extends StatelessWidget { +class Thread extends StatefulWidget { const Thread({required this.model, Key? key}) : super(key: key); final logic.ThreadModel model; + @override + State createState() => _ThreadState(); +} + +class _ThreadState extends State { + bool anySensitivePosts = false; + bool showSensitive = false; + @override Widget build(BuildContext context) { - List posts = []; - for (int i = 0; i < model.posts.length; i++) { - posts.add(Post(model: model.posts[i])); + List c = []; + for (var element in widget.model.posts) { + c.add(Post( + model: element, + hideSensitive: !showSensitive, + )); + if (element.sensitive) { + anySensitivePosts = true; + } + } + + if (anySensitivePosts && c.length > 1) { + c.insert( + 0, + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton.icon( + onPressed: () { + setState(() { + showSensitive = !showSensitive; + }); + }, + icon: + Icon(showSensitive ? Icons.visibility_off : Icons.visibility), + label: Text(showSensitive ? "hide".i18n() : "show".i18n()), + ), + ], + ), + ); } return Row( @@ -35,7 +71,7 @@ class Thread extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Column( - children: posts, + children: c, ), ), ), diff --git a/lib/themes/themes.dart b/lib/themes/themes.dart index 9da59b1..64b5a53 100644 --- a/lib/themes/themes.dart +++ b/lib/themes/themes.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'dracula.dart' as color_dracula; // color schemes to pick from can be added here diff --git a/pubspec.lock b/pubspec.lock index 09cdee0..ee32982 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,13 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + url: "https://pub.dartlang.org" + source: hosted + version: "2.3.1" async: dependency: transitive description: @@ -29,6 +36,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.3.1" + clipboard: + dependency: "direct main" + description: + name: clipboard + url: "https://pub.dartlang.org" + source: hosted + version: "0.1.3" clock: dependency: transitive description: @@ -84,7 +98,7 @@ packages: name: file url: "https://pub.dartlang.org" source: hosted - version: "6.1.2" + version: "6.1.4" flutter: dependency: "direct main" description: flutter @@ -102,6 +116,13 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + url: "https://pub.dartlang.org" + source: hosted + version: "0.6.10+5" flutter_test: dependency: "direct dev" description: flutter @@ -119,6 +140,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.15.0" + html2md: + dependency: "direct main" + description: + name: html2md + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.6" http: dependency: "direct main" description: @@ -161,6 +189,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0" + markdown: + dependency: "direct main" + description: + name: markdown + url: "https://pub.dartlang.org" + source: hosted + version: "5.0.0" matcher: dependency: transitive description: @@ -431,7 +466,7 @@ packages: name: xdg_directories url: "https://pub.dartlang.org" source: hosted - version: "0.2.0+1" + version: "0.2.0+2" sdks: dart: ">=2.17.3 <3.0.0" flutter: ">=3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 66a7a0e..0ba9cdb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,10 @@ dependencies: shelf: ^1.3.1 html: ^0.15.0 web_socket_channel: ^2.2.0 + clipboard: ^0.1.3 + flutter_markdown: ^0.6.10+5 + markdown: ^5.0.0 + html2md: ^1.2.6 dev_dependencies: flutter_test: