diff --git a/assets/fonts/RobotoMono-Bold.ttf b/assets/fonts/RobotoMono-Bold.ttf new file mode 100644 index 00000000..900fce68 Binary files /dev/null and b/assets/fonts/RobotoMono-Bold.ttf differ diff --git a/assets/fonts/RobotoMono-Regular.ttf b/assets/fonts/RobotoMono-Regular.ttf new file mode 100644 index 00000000..7c4ce36a Binary files /dev/null and b/assets/fonts/RobotoMono-Regular.ttf differ diff --git a/lib/cwtch/ffi.dart b/lib/cwtch/ffi.dart index 22de1638..2edb3faa 100644 --- a/lib/cwtch/ffi.dart +++ b/lib/cwtch/ffi.dart @@ -61,7 +61,7 @@ typedef VoidFromStringIntFn = void Function(Pointer, int, int); typedef get_json_blob_string_function = Pointer Function(Pointer str, Int32 length); typedef GetJsonBlobStringFn = Pointer Function(Pointer str, int len); -typedef get_json_blob_from_string_int_string_function = Pointer Function(Pointer, Int32 , Int32, Pointer, Int32); +typedef get_json_blob_from_string_int_string_function = Pointer Function(Pointer, Int32, Int32, Pointer, Int32); typedef GetJsonBlobFromStrIntStrFn = Pointer Function(Pointer, int, int, Pointer, int); //func GetMessage(profile_ptr *C.char, profile_len C.int, handle_ptr *C.char, handle_len C.int, message_index C.int) *C.char { @@ -385,7 +385,7 @@ class CwtchFfi implements Cwtch { final SendMessage = sendMessage.asFunction(); final u1 = profileOnion.toNativeUtf8(); final u3 = message.toNativeUtf8(); - Pointer jsonMessageBytes = SendMessage(u1, u1.length, contactHandle, u3, u3.length); + Pointer jsonMessageBytes = SendMessage(u1, u1.length, contactHandle, u3, u3.length); String jsonMessage = jsonMessageBytes.toDartString(); _UnsafeFreePointerAnyUseOfThisFunctionMustBeDoubleApproved(jsonMessageBytes); malloc.free(u1); diff --git a/lib/l10n/intl_cy.arb b/lib/l10n/intl_cy.arb index f20a1f70..8607bd1e 100644 --- a/lib/l10n/intl_cy.arb +++ b/lib/l10n/intl_cy.arb @@ -1,6 +1,8 @@ { "@@locale": "cy", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_da.arb b/lib/l10n/intl_da.arb index ec8aabf3..37254a40 100644 --- a/lib/l10n/intl_da.arb +++ b/lib/l10n/intl_da.arb @@ -1,6 +1,8 @@ { "@@locale": "da", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_de.arb b/lib/l10n/intl_de.arb index 10ca9df0..f9db6b7e 100644 --- a/lib/l10n/intl_de.arb +++ b/lib/l10n/intl_de.arb @@ -1,6 +1,8 @@ { "@@locale": "de", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_el.arb b/lib/l10n/intl_el.arb index 51ee2c2c..9abb7ca3 100644 --- a/lib/l10n/intl_el.arb +++ b/lib/l10n/intl_el.arb @@ -1,6 +1,8 @@ { "@@locale": "el", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index ab9ba40f..48afba83 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -1,6 +1,8 @@ { "@@locale": "en", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksWarning": "Opening this URL will launch an application outside of Cwtch and may reveal metadata or otherwise compromise the security of Cwtch. Only open URLs from people you trust. Are you sure you want to continue?", "clickableLinksCopy": "Copy URL", diff --git a/lib/l10n/intl_es.arb b/lib/l10n/intl_es.arb index d152034e..7afe99f1 100644 --- a/lib/l10n/intl_es.arb +++ b/lib/l10n/intl_es.arb @@ -1,6 +1,8 @@ { "@@locale": "es", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_fr.arb b/lib/l10n/intl_fr.arb index ff335af2..cce76738 100644 --- a/lib/l10n/intl_fr.arb +++ b/lib/l10n/intl_fr.arb @@ -1,10 +1,12 @@ { "@@locale": "fr", - "@@last_modified": "2022-03-21T17:12:38+01:00", - "clickableLinkError": "Error encountered while attempting to open URL", - "clickableLinksCopy": "Copy URL", - "clickableLinkOpen": "Open URL", - "clickableLinksWarning": "Opening this URL will launch an application outside of Cwtch and may reveal metadata or otherwise compromise the security of Cwtch. Only open URLs from people you trust. Are you sure you want to continue?", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", + "clickableLinksWarning": "L'ouverture de cette URL lancera une application en dehors de Cwtch et peut révéler des métadonnées ou compromettre la sécurité de Cwtch. N'ouvrez que les URLs de personnes en qui vous avez confiance. Êtes-vous sûr de vouloir continuer ?", + "clickableLinksCopy": "Copier l'URL", + "clickableLinkOpen": "Ouvrir l'URL", + "clickableLinkError": "Erreur rencontrée lors de la tentative d'ouverture de l'URL", "acceptGroupBtn": "Accepter", "successfullyImportedProfile": "Profil importé avec succès : %profile", "shuttingDownApp": "Fermeture...", diff --git a/lib/l10n/intl_it.arb b/lib/l10n/intl_it.arb index eba5a9ed..a244db96 100644 --- a/lib/l10n/intl_it.arb +++ b/lib/l10n/intl_it.arb @@ -1,6 +1,8 @@ { "@@locale": "it", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_lb.arb b/lib/l10n/intl_lb.arb index 24775d1c..8124b189 100644 --- a/lib/l10n/intl_lb.arb +++ b/lib/l10n/intl_lb.arb @@ -1,6 +1,8 @@ { "@@locale": "lb", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_no.arb b/lib/l10n/intl_no.arb index 757ef74c..16e70ade 100644 --- a/lib/l10n/intl_no.arb +++ b/lib/l10n/intl_no.arb @@ -1,6 +1,8 @@ { "@@locale": "no", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_pl.arb b/lib/l10n/intl_pl.arb index 3112a927..5654c4c0 100644 --- a/lib/l10n/intl_pl.arb +++ b/lib/l10n/intl_pl.arb @@ -1,6 +1,8 @@ { "@@locale": "pl", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_pt.arb b/lib/l10n/intl_pt.arb index 3c06aa20..9a3fd2a9 100644 --- a/lib/l10n/intl_pt.arb +++ b/lib/l10n/intl_pt.arb @@ -1,6 +1,8 @@ { "@@locale": "pt", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_ro.arb b/lib/l10n/intl_ro.arb index bba77430..ac447d71 100644 --- a/lib/l10n/intl_ro.arb +++ b/lib/l10n/intl_ro.arb @@ -1,6 +1,8 @@ { "@@locale": "ro", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f85e4c26..cc1025cc 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -1,6 +1,8 @@ { "@@locale": "ru", - "@@last_modified": "2022-03-21T17:12:38+01:00", + "@@last_modified": "2022-04-06T22:31:33+02:00", + "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*", + "formattingExperiment": "Message Formatting", "clickableLinkError": "Error encountered while attempting to open URL", "clickableLinksCopy": "Copy URL", "clickableLinkOpen": "Open URL", diff --git a/lib/models/message.dart b/lib/models/message.dart index 2f5507c3..f8dcc232 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -76,7 +76,7 @@ class ByIndex implements CacheHandler { return msg; } - Future get( Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async { + Future get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async { // if in cache, get if (index < cache.cacheByIndex.length) { return cache.getByIndex(index); @@ -93,22 +93,22 @@ class ByIndex implements CacheHandler { } } - cache.lockIndexes(index, index+chunk); + cache.lockIndexes(index, index + chunk); var msgs = await cwtch.GetMessages(profileOnion, conversationIdentifier, index, chunk); int i = 0; // i used to loop through returned messages. if doesn't reach the requested count, we will use it in the finally stanza to error out the remaining asked for messages in the cache try { List messagesWrapper = jsonDecode(msgs); - for(; i < messagesWrapper.length; i++) { + for (; i < messagesWrapper.length; i++) { var messageInfo = messageWrapperToInfo(profileOnion, conversationIdentifier, messagesWrapper[i]); cache.addIndexed(messageInfo, index + i); } //messageWrapperToInfo } catch (e, stacktrace) { - EnvironmentConfig.debugLog("Error: Getting indexed messages $index to ${index+chunk} failed parsing: " + e.toString() + " " + stacktrace.toString()); + EnvironmentConfig.debugLog("Error: Getting indexed messages $index to ${index + chunk} failed parsing: " + e.toString() + " " + stacktrace.toString()); } finally { if (i != chunk) { - cache.malformIndexes(index+i, index+chunk); + cache.malformIndexes(index + i, index + chunk); } } return cache.getByIndex(index); @@ -145,7 +145,6 @@ class ById implements CacheHandler { } return fetch(cwtch, profileOnion, conversationIdentifier, cache); } - } class ByContentHash implements CacheHandler { @@ -167,7 +166,7 @@ class ByContentHash implements CacheHandler { return Future.value(messageInfo); } - Future get( Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async { + Future get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async { var messageInfo = await lookup(cache); if (messageInfo != null) { return Future.value(messageInfo); @@ -182,11 +181,7 @@ Future messageHandler(BuildContext context, String profileOnion, int co MessageCache? cache; try { - cache = Provider - .of(context, listen: false) - .contactList - .getContact(conversationIdentifier) - ?.messageCache; + cache = Provider.of(context, listen: false).contactList.getContact(conversationIdentifier)?.messageCache; if (cache == null) { EnvironmentConfig.debugLog("error: cannot get message cache for profile: $profileOnion conversation: $conversationIdentifier"); return MalformedMessage(malformedMetadata); @@ -271,6 +266,6 @@ class MessageMetadata extends ChangeNotifier { notifyListeners(); } - MessageMetadata( - this.profileOnion, this.conversationIdentifier, this.messageID, this.timestamp, this.senderHandle, this.senderImage, this.signature, this._attributes, this._ackd, this._error, this.isAuto, this.contenthash); + MessageMetadata(this.profileOnion, this.conversationIdentifier, this.messageID, this.timestamp, this.senderHandle, this.senderImage, this.signature, this._attributes, this._ackd, this._error, + this.isAuto, this.contenthash); } diff --git a/lib/models/messagecache.dart b/lib/models/messagecache.dart index af418259..76c7aada 100644 --- a/lib/models/messagecache.dart +++ b/lib/models/messagecache.dart @@ -19,7 +19,6 @@ class LocalIndexMessage { late int? messageId; - LocalIndexMessage(int? messageId, {cacheOnly = false, isLoading = false}) { this.messageId = messageId; this.cacheOnly = cacheOnly; @@ -117,20 +116,20 @@ class MessageCache extends ChangeNotifier { // or .failLoad() is called on them to mark them malformed // this prevents successive ui message build requests from triggering multiple GetMesssage requests to the backend, as the first one locks a block of messages and the rest wait on that void lockIndexes(int start, int end) { - for(var i = start; i < end; i++) { + for (var i = start; i < end; i++) { this.cacheByIndex.insert(i, LocalIndexMessage(null, isLoading: true)); } } void malformIndexes(int start, int end) { - for(var i = start; i < end; i++) { + for (var i = start; i < end; i++) { this.cacheByIndex[i].failLoad(); } } void addIndexed(MessageInfo messageInfo, int index) { this.cache[messageInfo.metadata.messageID] = messageInfo; - if (index < this.cacheByIndex.length ) { + if (index < this.cacheByIndex.length) { this.cacheByIndex[index].finishLoad(messageInfo.metadata.messageID); } else { this.cacheByIndex.insert(index, LocalIndexMessage(messageInfo.metadata.messageID)); diff --git a/lib/settings.dart b/lib/settings.dart index 3b9a80cd..749a9649 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -14,6 +14,7 @@ const ServerManagementExperiment = "servers-experiment"; const FileSharingExperiment = "filesharing"; const ImagePreviewsExperiment = "filesharing-images"; const ClickableLinksExperiment = "clickable-links"; +const FormattingExperiment = "message-formatting"; enum DualpaneMode { Single, diff --git a/lib/third_party/linkify/flutter_linkify.dart b/lib/third_party/linkify/flutter_linkify.dart index acf82588..9f5d8e0e 100644 --- a/lib/third_party/linkify/flutter_linkify.dart +++ b/lib/third_party/linkify/flutter_linkify.dart @@ -24,6 +24,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import 'dart:ui'; + import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; @@ -361,6 +363,7 @@ TextSpan buildTextSpan( text: element.text, style: linkStyle, recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null, + semanticsLabel: element.text ), )); } else { @@ -372,6 +375,57 @@ TextSpan buildTextSpan( recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null, )); } + } else if (element is BoldElement) { + return TextSpan( + text: element.text.replaceAll("*", ""), + style: style?.copyWith(fontWeight: FontWeight.bold), + semanticsLabel: element.text + + ); + } else if (element is ItalicElement) { + return TextSpan( + text: element.text.replaceAll("*", ""), + style: style?.copyWith(fontStyle: FontStyle.italic), + semanticsLabel: element.text + ); + } else if (element is SuperElement) { + return WidgetSpan( + child: Transform.translate( + offset: const Offset(2, -6), + child: Text( + element.text.replaceAll("^", ""), + //superscript is usually smaller in size + textScaleFactor: 0.7, + style: style, + semanticsLabel: element.text + ), + )); + } else if (element is SubElement) { + return WidgetSpan( + child: Transform.translate( + offset: const Offset(2, 4), + child: Text( + element.text.replaceAll("_", ""), + //superscript is usually smaller in size + textScaleFactor: 0.7, + style: style, + semanticsLabel: element.text + ), + )); + } else if (element is StrikeElement) { + return TextSpan( + text: element.text.replaceAll("~~", ""), + style: style?.copyWith(decoration: TextDecoration.lineThrough, decorationColor: style.color, decorationStyle: TextDecorationStyle.solid), + semanticsLabel: element.text + ); + } else if (element is CodeElement) { + return TextSpan( + text: element.text.replaceAll("\`", ""), + // monospace fonts at the same size as regular text makes them appear + // slightly larger, so we compensate by making them slightly smaller... + style: style?.copyWith(fontFamily: "RobotoMono", fontSize: style.fontSize!-1.0), + semanticsLabel: element.text + ); } else { return TextSpan( text: element.text, diff --git a/lib/third_party/linkify/linkify.dart b/lib/third_party/linkify/linkify.dart index 7a7a0248..eaa5ddef 100644 --- a/lib/third_party/linkify/linkify.dart +++ b/lib/third_party/linkify/linkify.dart @@ -37,6 +37,30 @@ abstract class LinkifyElement { bool equals(other) => other is LinkifyElement && other.text == text; } +class BoldElement extends LinkifyElement { + BoldElement(String text) : super(text); +} + +class ItalicElement extends LinkifyElement { + ItalicElement(String text) : super(text); +} + +class SuperElement extends LinkifyElement { + SuperElement(String text) : super(text); +} + +class SubElement extends LinkifyElement { + SubElement(String text) : super(text); +} + +class StrikeElement extends LinkifyElement { + StrikeElement(String text) : super(text); +} + +class CodeElement extends LinkifyElement { + CodeElement(String text) : super(text); +} + class LinkableElement extends LinkifyElement { final String url; @@ -81,11 +105,10 @@ class LinkifyOptions { /// Excludes `.` at end of URLs. final bool excludeLastPeriod; - const LinkifyOptions({ - this.looseUrl = false, - this.defaultToHttps = false, - this.excludeLastPeriod = true, - }); + final bool messageFormatting; + final bool parseLinks; + + const LinkifyOptions({this.looseUrl = false, this.defaultToHttps = false, this.excludeLastPeriod = true, this.messageFormatting = false, this.parseLinks = false}); } const _urlLinkifier = UrlLinkifier(); diff --git a/lib/third_party/linkify/uri.dart b/lib/third_party/linkify/uri.dart index 9df90bdd..57b4bbc0 100644 --- a/lib/third_party/linkify/uri.dart +++ b/lib/third_party/linkify/uri.dart @@ -44,56 +44,173 @@ final _protocolIdentifierRegex = RegExp( caseSensitive: false, ); +class Formatter { + final RegExp expression; + final LinkifyElement Function(String) element; + + Formatter(this.expression, this.element); +} + +// regex to match **bold** +final _boldRegex = RegExp( + r'^(.*?)(\*\*([^*]*)\*\*)', + caseSensitive: false, + dotAll: true, +); + +// regex to match *italic* +final _italicRegex = RegExp( + r'^(.*?)(\*([^*]*)\*)', + caseSensitive: false, + dotAll: true, +); + +// regex to match ^superscript^ +final _superRegex = RegExp( + r'^(.*?)(\^([^\^]*)\^)', + caseSensitive: false, + dotAll: true, +); + +// regex to match ^subscript^ +final _subRegex = RegExp( + r'^(.*?)(\_([^\_]*)\_)', + caseSensitive: false, + dotAll: true, +); + +// regex to match ~~strikethrough~~ +final _strikeRegex = RegExp( + r'^(.*?)(\~\~([^\~]*)\~\~)', + caseSensitive: false, + dotAll: true, +); + +// regex to match `code` +final _codeRegex = RegExp( + r'^(.*?)(\`([^\`]*)\`)', + caseSensitive: false, + dotAll: true, +); + class UrlLinkifier extends Linkifier { const UrlLinkifier(); + List replaceAndParse(tle, TextElement element, RegExpMatch match, List list, options) { + final text = element.text.replaceFirst(match.group(0)!, ''); + + if (match.group(1)?.isNotEmpty == true) { + list.addAll(parse([TextElement(match.group(1)!)], options)); + } + + if (match.group(2)?.isNotEmpty == true) { + list.add(tle(match.group(2)!)); + } + + if (text.isNotEmpty) { + list.addAll(parse([TextElement(text)], options)); + } + return list; + } + + List parseFormatting(element, options) { + var list = []; + + // code -> bold -> italic -> super -> sub -> strike + // not we don't currently allow combinations of these elements the first + // one to match a given set will be the only style applied - this will be fixed + final formattingPrecedence = [ + Formatter(_codeRegex, CodeElement.new), + Formatter(_boldRegex, BoldElement.new), + Formatter(_italicRegex, ItalicElement.new), + Formatter(_superRegex, SuperElement.new), + Formatter(_subRegex, SubElement.new), + Formatter(_strikeRegex, StrikeElement.new) + ]; + + // Loop through the formatters in with precedence and break when something is found... + for (var formatter in formattingPrecedence) { + var formattingMatch = formatter.expression.firstMatch(element.text); + if (formattingMatch != null) { + list = replaceAndParse(formatter.element, element, formattingMatch, list, options); + break; + } + } + + // catch all case where we didn't match anything and so need to return back + // the unformatted text + // conceptually this is Formatter((.*), TextElement.new) + if (list.isEmpty) { + list.add(element); + } + + return list; + } + @override List parse(elements, options) { - final list = []; + var list = []; elements.forEach((element) { if (element is TextElement) { - var match = options.looseUrl ? _looseUrlRegex.firstMatch(element.text) : _urlRegex.firstMatch(element.text); - - if (match == null) { + if (options.parseLinks == false && options.messageFormatting == false) { list.add(element); + } else if (options.parseLinks == true) { + // check if there is a link... + var match = options.looseUrl ? _looseUrlRegex.firstMatch(element.text) : _urlRegex.firstMatch(element.text); + + // if not then we only have to consider formatting... + if (match == null) { + // only do formatting if message formatting is enabled + if (options.messageFormatting == false) { + list.add(element); + } else { + // add all the formatting elements contained in this text + list.addAll(parseFormatting(element, options)); + } + } else { + final text = element.text.replaceFirst(match.group(0)!, ''); + + if (match.group(1)?.isNotEmpty == true) { + // we match links first and the feed everything before the link + // back through the parser + list.addAll(parse([TextElement(match.group(1)!)], options)); + } + + if (match.group(2)?.isNotEmpty == true) { + var originalUrl = match.group(2)!; + String? end; + + if ((options.excludeLastPeriod) && originalUrl[originalUrl.length - 1] == ".") { + end = "."; + originalUrl = originalUrl.substring(0, originalUrl.length - 1); + } + + var url = originalUrl; + + // If protocol has not been specified then append a protocol + // to the start of the URL so that it can be opened... + if (!url.startsWith("https://") && !url.startsWith("http://")) { + url = "https://" + url; + } + + list.add(UrlElement(url, originalUrl)); + + if (end != null) { + list.add(TextElement(end)); + } + } + + if (text.isNotEmpty) { + list.addAll(parse([TextElement(text)], options)); + } + } + } else if (options.messageFormatting == true) { + // we can jump straight to message formatting... + list.addAll(parseFormatting(element, options)); } else { - final text = element.text.replaceFirst(match.group(0)!, ''); - - if (match.group(1)?.isNotEmpty == true) { - list.add(TextElement(match.group(1)!)); - } - - if (match.group(2)?.isNotEmpty == true) { - var originalUrl = match.group(2)!; - String? end; - - if ((options.excludeLastPeriod) && originalUrl[originalUrl.length - 1] == ".") { - end = "."; - originalUrl = originalUrl.substring(0, originalUrl.length - 1); - } - - var url = originalUrl; - - // If protocol has not been specified then append a protocol - // to the start of the URL so that it can be opened... - if (!url.startsWith("https://") && !url.startsWith("http://")) { - url = "https://" + url; - } - - list.add(UrlElement(url, originalUrl)); - - if (end != null) { - list.add(TextElement(end)); - } - } - - if (text.isNotEmpty) { - list.addAll(parse([TextElement(text)], options)); - } + // unreachable } - } else { - list.add(element); } }); diff --git a/lib/views/globalsettingsview.dart b/lib/views/globalsettingsview.dart index 99995cd2..5a097d6f 100644 --- a/lib/views/globalsettingsview.dart +++ b/lib/views/globalsettingsview.dart @@ -368,6 +368,24 @@ class _GlobalSettingsViewState extends State { inactiveTrackColor: settings.theme.defaultButtonDisabledColor, secondary: Icon(Icons.link, color: settings.current().mainTextColor), )), + Visibility( + visible: settings.experimentsEnabled, + child: SwitchListTile( + title: Text(AppLocalizations.of(context)!.formattingExperiment, style: TextStyle(color: settings.current().mainTextColor)), + subtitle: Text(AppLocalizations.of(context)!.messageFormattingDescription), + value: settings.isExperimentEnabled(FormattingExperiment), + onChanged: (bool value) { + if (value) { + settings.enableExperiment(FormattingExperiment); + } else { + settings.disableExperiment(FormattingExperiment); + } + saveSettings(context); + }, + activeTrackColor: settings.theme.defaultButtonActiveColor, + inactiveTrackColor: settings.theme.defaultButtonDisabledColor, + secondary: Icon(Icons.link, color: settings.current().mainTextColor), + )), AboutListTile( icon: appIcon, applicationIcon: Padding(padding: EdgeInsets.all(5), child: Icon(CwtchIcons.cwtch_knott)), diff --git a/lib/views/messageview.dart b/lib/views/messageview.dart index efb50ae3..42c504d1 100644 --- a/lib/views/messageview.dart +++ b/lib/views/messageview.dart @@ -230,7 +230,8 @@ class _MessageViewState extends State { ChatMessage cm = new ChatMessage(o: QuotedMessageOverlay, d: quotedMessage); Provider.of(context, listen: false) .cwtch - .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)).then(_sendMessageHandler); + .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)) + .then(_sendMessageHandler); } catch (e) {} Provider.of(context, listen: false).selectedIndex = null; }); @@ -238,7 +239,8 @@ class _MessageViewState extends State { ChatMessage cm = new ChatMessage(o: TextMessageOverlay, d: ctrlrCompose.value.text); Provider.of(context, listen: false) .cwtch - .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)).then(_sendMessageHandler); + .SendMessage(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, jsonEncode(cm)) + .then(_sendMessageHandler); } } } @@ -246,14 +248,15 @@ class _MessageViewState extends State { void _sendInvitation([String? ignoredParam]) { Provider.of(context, listen: false) .cwtch - .SendInvitation(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, this.selectedContact).then(_sendMessageHandler); + .SendInvitation(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, this.selectedContact) + .then(_sendMessageHandler); } void _sendFile(String filePath) { - Provider.of(context, listen: false) .cwtch - .ShareFile(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, filePath).then(_sendMessageHandler); + .ShareFile(Provider.of(context, listen: false).profileOnion, Provider.of(context, listen: false).identifier, filePath) + .then(_sendMessageHandler); } void _sendMessageHandler(dynamic messageJson) { diff --git a/lib/widgets/messagebubble.dart b/lib/widgets/messagebubble.dart index 08312af6..69a57551 100644 --- a/lib/widgets/messagebubble.dart +++ b/lib/widgets/messagebubble.dart @@ -32,7 +32,7 @@ class MessageBubbleState extends State { var fromMe = Provider.of(context).senderHandle == Provider.of(context).onion; var borderRadiousEh = 15.0; var showClickableLinks = Provider.of(context).isExperimentEnabled(ClickableLinksExperiment); - + var formatMessages = Provider.of(context).isExperimentEnabled(FormattingExperiment); DateTime messageDate = Provider.of(context).timestamp; // If the sender is not us, then we want to give them a nickname... @@ -48,40 +48,27 @@ class MessageBubbleState 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; - - if (!showClickableLinks) { - wdgMessage = SelectableText( - widget.content + '\u202F', - //key: Key(myKey), - focusNode: _focus, - style: TextStyle( - color: fromMe ? Provider.of(context).theme.messageFromMeTextColor : Provider.of(context).theme.messageFromOtherTextColor, - ), - textAlign: TextAlign.left, - textWidthBasis: TextWidthBasis.longestLine, - ); - } else { - wdgMessage = SelectableLinkify( - text: widget.content + '\u202F', - // TODO: onOpen breaks the "selectable" functionality. Maybe something to do with gesture handler? - options: LinkifyOptions(looseUrl: true, defaultToHttps: true), - linkifiers: [UrlLinkifier()], - onOpen: (link) { - _modalOpenLink(context, link); - }, - //key: Key(myKey), - focusNode: _focus, - style: TextStyle( - color: fromMe ? Provider.of(context).theme.messageFromMeTextColor : Provider.of(context).theme.messageFromOtherTextColor, - ), - linkStyle: TextStyle( - color: Provider.of(context).current().mainTextColor, - ), - textAlign: TextAlign.left, - textWidthBasis: TextWidthBasis.longestLine, - ); - } + var wdgMessage = SelectableLinkify( + text: widget.content + '\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: Provider.of(context).current().mainTextColor, + ), + textAlign: TextAlign.left, + textWidthBasis: TextWidthBasis.longestLine, + ); var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of(context).ackd, errored: Provider.of(context).error, fromMe: fromMe, messageDate: messageDate); diff --git a/pubspec.yaml b/pubspec.yaml index 9298931a..ac8ccde0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,6 +95,11 @@ flutter: - family: CwtchIcons fonts: - asset: assets/fonts/CwtchIcons.ttf + - family: RobotoMono + fonts: + - asset: assets/fonts/RobotoMono-Regular.ttf + - asset: assets/fonts/RobotoMono-Bold.ttf + weight: 700 # To add custom fonts to your application, add a fonts section here, # in this "flutter" section. Each entry in this list should have a