diff --git a/lib/controllers/open_link_modal.dart b/lib/controllers/open_link_modal.dart new file mode 100644 index 00000000..7b021f6d --- /dev/null +++ b/lib/controllers/open_link_modal.dart @@ -0,0 +1,61 @@ +import 'package:cwtch/third_party/linkify/linkify.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +void modalOpenLink(BuildContext ctx, LinkableElement link) { + showModalBottomSheet( + context: ctx, + builder: (BuildContext bcontext) { + return Container( + height: 200, // bespoke value courtesy of the [TextField] docs + child: Center( + child: Padding( + padding: EdgeInsets.all(30.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text(AppLocalizations.of(bcontext)!.clickableLinksWarning), + Flex(direction: Axis.horizontal, mainAxisAlignment: MainAxisAlignment.center, children: [ + Container( + margin: EdgeInsets.symmetric(vertical: 20, horizontal: 10), + child: ElevatedButton( + child: Text(AppLocalizations.of(bcontext)!.clickableLinksCopy, semanticsLabel: AppLocalizations.of(bcontext)!.clickableLinksCopy), + onPressed: () { + Clipboard.setData(new ClipboardData(text: link.url)); + + final snackBar = SnackBar( + content: Text(AppLocalizations.of(bcontext)!.copiedToClipboardNotification), + ); + + Navigator.pop(bcontext); + ScaffoldMessenger.of(bcontext).showSnackBar(snackBar); + }, + ), + ), + Container( + margin: EdgeInsets.symmetric(vertical: 20, horizontal: 10), + child: ElevatedButton( + child: Text(AppLocalizations.of(bcontext)!.clickableLinkOpen, semanticsLabel: AppLocalizations.of(bcontext)!.clickableLinkOpen), + onPressed: () async { + if (await canLaunch(link.url)) { + await launch(link.url); + Navigator.pop(bcontext); + } else { + final snackBar = SnackBar( + content: Text(AppLocalizations.of(bcontext)!.clickableLinkError), + ); + ScaffoldMessenger.of(bcontext).showSnackBar(snackBar); + } + }, + ), + ), + ]), + ], + )), + )); + }); +} diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 8c7004cf..9006ff40 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -1,6 +1,7 @@ import 'package:cwtch/widgets/messagerow.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'message.dart'; import 'messagecache.dart'; @@ -44,6 +45,7 @@ class ContactInfoState extends ChangeNotifier { late Map> keys; int _newMarkerMsgIndex = -1; late MessageCache messageCache; + ItemScrollController messageScrollController = new ItemScrollController(); // todo: a nicer way to model contacts, groups and other "entities" late bool _isGroup; diff --git a/lib/models/messages/quotedmessage.dart b/lib/models/messages/quotedmessage.dart index 35cd21a9..3fefee0b 100644 --- a/lib/models/messages/quotedmessage.dart +++ b/lib/models/messages/quotedmessage.dart @@ -8,10 +8,6 @@ import 'package:cwtch/widgets/quotedmessage.dart'; import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -import '../../main.dart'; -import '../messagecache.dart'; -import '../profile.dart'; - class QuotedMessageStructure { final String quotedHash; final String body; diff --git a/lib/models/messages/textmessage.dart b/lib/models/messages/textmessage.dart index cb160319..01a51048 100644 --- a/lib/models/messages/textmessage.dart +++ b/lib/models/messages/textmessage.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/messages/malformedmessage.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; @@ -19,7 +21,7 @@ class TextMessage extends Message { return ChangeNotifierProvider.value( value: this.metadata, builder: (bcontext, child) { - return SelectableText(this.content); + return Text(this.content.substring(0, min(this.content.length, 50))); }); } diff --git a/lib/views/contactsview.dart b/lib/views/contactsview.dart index 719336cd..e332e757 100644 --- a/lib/views/contactsview.dart +++ b/lib/views/contactsview.dart @@ -29,20 +29,21 @@ class ContactsView extends StatefulWidget { // selectConversation can be called from anywhere to set the active conversation void selectConversation(BuildContext context, int handle) { // requery instead of using contactinfostate directly because sometimes listview gets confused about data that resorts - var initialIndex = Provider.of(context, listen: false).contactList.getContact(handle)!.unreadMessages; + var unread = Provider.of(context, listen: false).contactList.getContact(handle)!.unreadMessages; var previouslySelected = Provider.of(context, listen: false).selectedConversation; if (previouslySelected != null) { Provider.of(context, listen: false).contactList.getContact(previouslySelected)!.unselected(); } Provider.of(context, listen: false).contactList.getContact(handle)!.selected(); // triggers update in Double/TripleColumnView - Provider.of(context, listen: false).initialScrollIndex = initialIndex; + Provider.of(context, listen: false).initialScrollIndex = unread; Provider.of(context, listen: false).selectedConversation = handle; Provider.of(context, listen: false).selectedIndex = null; Provider.of(context, listen: false).hoveredIndex = -1; // if in singlepane mode, push to the stack var isLandscape = Provider.of(context, listen: false).isLandscape(context); if (Provider.of(context, listen: false).uiColumns(isLandscape).length == 1) _pushMessageView(context, handle); + // Set last message seen time in backend Provider.of(context, listen: false) .cwtch diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index e1281651..b69066f2 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -42,8 +42,8 @@ class _MessageViewState extends State { final focusNode = FocusNode(); int selectedContact = -1; ItemPositionsListener scrollListener = ItemPositionsListener.create(); - ItemScrollController scrollController = ItemScrollController(); File? imagePreview; + bool showDown = false; @override void initState() { @@ -54,6 +54,12 @@ class _MessageViewState extends State { Provider.of(context, listen: false).initialScrollIndex = 0; Provider.of(context, listen: false).unreadMessagesBelow = false; } + + if (scrollListener.itemPositions.value.length != 0 && !scrollListener.itemPositions.value.any((element) => element.index == 0)) { + showDown = true; + } else { + showDown = false; + } }); super.initState(); } @@ -128,13 +134,13 @@ class _MessageViewState extends State { onWillPop: _onWillPop, child: Scaffold( backgroundColor: Provider.of(context).theme.backgroundMainColor, - floatingActionButton: appState.unreadMessagesBelow + floatingActionButton: showDown ? FloatingActionButton( child: Icon(Icons.arrow_downward, color: Provider.of(context).current().defaultButtonTextColor), onPressed: () { Provider.of(context, listen: false).initialScrollIndex = 0; Provider.of(context, listen: false).unreadMessagesBelow = false; - scrollController.scrollTo(index: 0, duration: Duration(milliseconds: 600)); + Provider.of(context).messageScrollController.scrollTo(index: 0, duration: Duration(milliseconds: 600)); }) : null, appBar: AppBar( @@ -169,7 +175,11 @@ class _MessageViewState extends State { ]), actions: appBarButtons, ), - body: Padding(padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 108.0), child: MessageList(scrollController, scrollListener)), + body: Padding( + padding: EdgeInsets.fromLTRB(8.0, 8.0, 8.0, 108.0), + child: MessageList( + scrollListener, + )), bottomSheet: _buildComposeBox(), )); } diff --git a/lib/widgets/messagebubble.dart b/lib/widgets/messagebubble.dart index cd563fab..19e7a103 100644 --- a/lib/widgets/messagebubble.dart +++ b/lib/widgets/messagebubble.dart @@ -1,16 +1,13 @@ import 'dart:io'; +import 'package:cwtch/controllers/open_link_modal.dart'; import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; import 'package:cwtch/third_party/linkify/flutter_linkify.dart'; import 'package:cwtch/models/profile.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../settings.dart'; import 'messagebubbledecorations.dart'; @@ -55,7 +52,7 @@ class MessageBubbleState extends State { linkifiers: [UrlLinkifier()], onOpen: showClickableLinks ? (link) { - _modalOpenLink(context, link); + modalOpenLink(context, link); } : null, //key: Key(myKey), @@ -104,59 +101,4 @@ class MessageBubbleState extends State { children: fromMe ? [wdgMessage, wdgDecorations] : [wdgSender, wdgMessage, wdgDecorations]))))); }); } - - void _modalOpenLink(BuildContext ctx, LinkableElement link) { - showModalBottomSheet( - context: ctx, - builder: (BuildContext bcontext) { - return Container( - height: 200, // bespoke value courtesy of the [TextField] docs - child: Center( - child: Padding( - padding: EdgeInsets.all(30.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text(AppLocalizations.of(bcontext)!.clickableLinksWarning), - Flex(direction: Axis.horizontal, mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - margin: EdgeInsets.symmetric(vertical: 20, horizontal: 10), - child: ElevatedButton( - child: Text(AppLocalizations.of(bcontext)!.clickableLinksCopy, semanticsLabel: AppLocalizations.of(bcontext)!.clickableLinksCopy), - onPressed: () { - Clipboard.setData(new ClipboardData(text: link.url)); - - final snackBar = SnackBar( - content: Text(AppLocalizations.of(bcontext)!.copiedToClipboardNotification), - ); - - Navigator.pop(bcontext); - ScaffoldMessenger.of(bcontext).showSnackBar(snackBar); - }, - ), - ), - Container( - margin: EdgeInsets.symmetric(vertical: 20, horizontal: 10), - child: ElevatedButton( - child: Text(AppLocalizations.of(bcontext)!.clickableLinkOpen, semanticsLabel: AppLocalizations.of(bcontext)!.clickableLinkOpen), - onPressed: () async { - if (await canLaunch(link.url)) { - await launch(link.url); - Navigator.pop(bcontext); - } else { - final snackBar = SnackBar( - content: Text(AppLocalizations.of(bcontext)!.clickableLinkError), - ); - ScaffoldMessenger.of(bcontext).showSnackBar(snackBar); - } - }, - ), - ), - ]), - ], - )), - )); - }); - } } diff --git a/lib/widgets/messagelist.dart b/lib/widgets/messagelist.dart index 6e5583e5..bc50376b 100644 --- a/lib/widgets/messagelist.dart +++ b/lib/widgets/messagelist.dart @@ -13,9 +13,8 @@ import '../main.dart'; import '../settings.dart'; class MessageList extends StatefulWidget { - ItemScrollController scrollController; ItemPositionsListener scrollListener; - MessageList(this.scrollController, this.scrollListener); + MessageList(this.scrollListener); @override _MessageListState createState() => _MessageListState(); @@ -30,7 +29,6 @@ class _MessageListState extends State { MessageCache? cache = Provider.of(outerContext, listen: false).contactList.getContact(conversationId)?.messageCache; ByIndex(0).loadUnsynced(Provider.of(context, listen: false).cwtch, Provider.of(outerContext, listen: false).selectedProfile!, conversationId, cache!); } - var initi = Provider.of(outerContext, listen: false).initialScrollIndex; bool isP2P = !Provider.of(context).isGroup; bool isGroupAndSyncing = Provider.of(context).isGroup == true && Provider.of(context).status == "Authenticated"; @@ -82,7 +80,7 @@ class _MessageListState extends State { child: loadMessages ? ScrollablePositionedList.builder( itemPositionsListener: widget.scrollListener, - itemScrollController: widget.scrollController, + itemScrollController: Provider.of(outerContext).messageScrollController, initialScrollIndex: initi > 4 ? initi - 4 : 0, itemCount: Provider.of(outerContext).totalMessages, reverse: true, // NOTE: There seems to be a bug in flutter that corrects the mouse wheel scroll, but not the drag direction... diff --git a/lib/widgets/quotedmessage.dart b/lib/widgets/quotedmessage.dart index 24010544..0195d3cb 100644 --- a/lib/widgets/quotedmessage.dart +++ b/lib/widgets/quotedmessage.dart @@ -1,6 +1,10 @@ +import 'package:cwtch/controllers/open_link_modal.dart'; +import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/message.dart'; import 'package:cwtch/models/profile.dart'; +import 'package:cwtch/third_party/linkify/flutter_linkify.dart'; +import 'package:cwtch/views/contactsview.dart'; import 'package:cwtch/widgets/malformedbubble.dart'; import 'package:cwtch/widgets/messageloadingbubble.dart'; import 'package:flutter/material.dart'; @@ -43,12 +47,29 @@ class QuotedMessageBubbleState extends State { var wdgSender = SelectableText(senderDisplayStr, style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of(context).theme.messageFromMeTextColor : Provider.of(context).theme.messageFromOtherTextColor)); - var wdgMessage = SelectableText( - widget.body + '\u202F', + var showClickableLinks = Provider.of(context).isExperimentEnabled(ClickableLinksExperiment); + var formatMessages = Provider.of(context).isExperimentEnabled(FormattingExperiment); + + var wdgMessage = SelectableLinkify( + text: widget.body + '\u202F', + // TODO: onOpen breaks the "selectable" functionality. Maybe something to do with gesture handler? + options: LinkifyOptions(messageFormatting: formatMessages, parseLinks: showClickableLinks, looseUrl: true, defaultToHttps: true), + linkifiers: [UrlLinkifier()], + onOpen: showClickableLinks + ? (link) { + modalOpenLink(context, link); + } + : null, + //key: Key(myKey), focusNode: _focus, style: TextStyle( color: fromMe ? Provider.of(context).theme.messageFromMeTextColor : Provider.of(context).theme.messageFromOtherTextColor, ), + linkStyle: TextStyle(color: fromMe ? Provider.of(context).theme.messageFromMeTextColor : Provider.of(context).theme.messageFromOtherTextColor), + codeStyle: TextStyle( + // note: these colors are flipped + color: fromMe ? Provider.of(context).theme.messageFromOtherTextColor : Provider.of(context).theme.messageFromMeTextColor, + backgroundColor: fromMe ? Provider.of(context).theme.messageFromOtherBackgroundColor : Provider.of(context).theme.messageFromMeBackgroundColor), textAlign: TextAlign.left, textWidthBasis: TextWidthBasis.longestLine, ); @@ -61,14 +82,23 @@ class QuotedMessageBubbleState extends State { var qMessage = (snapshot.data! as Message); // Swap the background color for quoted tweets.. var qTextColor = fromMe ? Provider.of(context).theme.messageFromOtherTextColor : Provider.of(context).theme.messageFromMeTextColor; - return Container( - margin: EdgeInsets.all(5), - padding: EdgeInsets.all(5), - color: fromMe ? Provider.of(context).theme.messageFromOtherBackgroundColor : Provider.of(context).theme.messageFromMeBackgroundColor, - child: Wrap(runAlignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly, runSpacing: 1.0, crossAxisAlignment: WrapCrossAlignment.center, children: [ - Center(widthFactor: 1, child: Padding(padding: EdgeInsets.all(10.0), child: Icon(Icons.reply, size: 32, color: qTextColor))), - Center(widthFactor: 1.0, child: DefaultTextStyle(child: qMessage.getPreviewWidget(context), style: TextStyle(color: qTextColor))) - ])); + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + var index = Provider.of(context, listen: false).messageCache.cacheByHash[qMessage.getMetadata().contenthash]; + var totalMessages = Provider.of(context, listen: false).totalMessages; + // we have to reverse here because the list itself is reversed... + Provider.of(context).messageScrollController.scrollTo(index: totalMessages - index!, duration: Duration(milliseconds: 100)); + }, + child: Container( + margin: EdgeInsets.all(5), + padding: EdgeInsets.all(5), + color: fromMe ? Provider.of(context).theme.messageFromOtherBackgroundColor : Provider.of(context).theme.messageFromMeBackgroundColor, + child: Wrap(runAlignment: WrapAlignment.spaceEvenly, alignment: WrapAlignment.spaceEvenly, runSpacing: 1.0, crossAxisAlignment: WrapCrossAlignment.center, children: [ + Center(widthFactor: 1, child: Padding(padding: EdgeInsets.all(10.0), child: Icon(Icons.reply, size: 32, color: qTextColor))), + Center(widthFactor: 1.0, child: DefaultTextStyle(child: qMessage.getPreviewWidget(context), style: TextStyle(color: qTextColor))) + ])))); } catch (e) { print(e); return MalformedBubble();