Message Formatting Experiment Initial Commit
continuous-integration/drone/pr Build is passing Details

This commit is contained in:
Sarah Jamie Lewis 2022-04-06 14:35:10 -07:00
parent 471a729d46
commit a4a2af08b4
27 changed files with 352 additions and 122 deletions

Binary file not shown.

Binary file not shown.

View File

@ -61,7 +61,7 @@ typedef VoidFromStringIntFn = void Function(Pointer<Utf8>, int, int);
typedef get_json_blob_string_function = Pointer<Utf8> Function(Pointer<Utf8> str, Int32 length); typedef get_json_blob_string_function = Pointer<Utf8> Function(Pointer<Utf8> str, Int32 length);
typedef GetJsonBlobStringFn = Pointer<Utf8> Function(Pointer<Utf8> str, int len); typedef GetJsonBlobStringFn = Pointer<Utf8> Function(Pointer<Utf8> str, int len);
typedef get_json_blob_from_string_int_string_function = Pointer<Utf8> Function(Pointer<Utf8>, Int32 , Int32, Pointer<Utf8>, Int32); typedef get_json_blob_from_string_int_string_function = Pointer<Utf8> Function(Pointer<Utf8>, Int32, Int32, Pointer<Utf8>, Int32);
typedef GetJsonBlobFromStrIntStrFn = Pointer<Utf8> Function(Pointer<Utf8>, int, int, Pointer<Utf8>, int); typedef GetJsonBlobFromStrIntStrFn = Pointer<Utf8> Function(Pointer<Utf8>, int, int, Pointer<Utf8>, 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 { //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<GetJsonBlobFromStrIntStrFn>(); final SendMessage = sendMessage.asFunction<GetJsonBlobFromStrIntStrFn>();
final u1 = profileOnion.toNativeUtf8(); final u1 = profileOnion.toNativeUtf8();
final u3 = message.toNativeUtf8(); final u3 = message.toNativeUtf8();
Pointer<Utf8> jsonMessageBytes = SendMessage(u1, u1.length, contactHandle, u3, u3.length); Pointer<Utf8> jsonMessageBytes = SendMessage(u1, u1.length, contactHandle, u3, u3.length);
String jsonMessage = jsonMessageBytes.toDartString(); String jsonMessage = jsonMessageBytes.toDartString();
_UnsafeFreePointerAnyUseOfThisFunctionMustBeDoubleApproved(jsonMessageBytes); _UnsafeFreePointerAnyUseOfThisFunctionMustBeDoubleApproved(jsonMessageBytes);
malloc.free(u1); malloc.free(u1);

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "cy", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "da", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "de", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "el", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "en", "@@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", "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?", "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", "clickableLinksCopy": "Copy URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "es", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,10 +1,12 @@
{ {
"@@locale": "fr", "@@locale": "fr",
"@@last_modified": "2022-03-21T17:12:38+01:00", "@@last_modified": "2022-04-06T22:31:33+02:00",
"clickableLinkError": "Error encountered while attempting to open URL", "messageFormattingDescription": "Enable rich text formatting in displayed messages e.g. **bold** and *italic*",
"clickableLinksCopy": "Copy URL", "formattingExperiment": "Message Formatting",
"clickableLinkOpen": "Open URL", "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 ?",
"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": "Copier l'URL",
"clickableLinkOpen": "Ouvrir l'URL",
"clickableLinkError": "Erreur rencontrée lors de la tentative d'ouverture de l'URL",
"acceptGroupBtn": "Accepter", "acceptGroupBtn": "Accepter",
"successfullyImportedProfile": "Profil importé avec succès : %profile", "successfullyImportedProfile": "Profil importé avec succès : %profile",
"shuttingDownApp": "Fermeture...", "shuttingDownApp": "Fermeture...",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "it", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "lb", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "no", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "pl", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "pt", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "ro", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -1,6 +1,8 @@
{ {
"@@locale": "ru", "@@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", "clickableLinkError": "Error encountered while attempting to open URL",
"clickableLinksCopy": "Copy URL", "clickableLinksCopy": "Copy URL",
"clickableLinkOpen": "Open URL", "clickableLinkOpen": "Open URL",

View File

@ -76,7 +76,7 @@ class ByIndex implements CacheHandler {
return msg; return msg;
} }
Future<MessageInfo?> get( Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async { Future<MessageInfo?> get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
// if in cache, get // if in cache, get
if (index < cache.cacheByIndex.length) { if (index < cache.cacheByIndex.length) {
return cache.getByIndex(index); 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); 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 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 { try {
List<dynamic> messagesWrapper = jsonDecode(msgs); List<dynamic> messagesWrapper = jsonDecode(msgs);
for(; i < messagesWrapper.length; i++) { for (; i < messagesWrapper.length; i++) {
var messageInfo = messageWrapperToInfo(profileOnion, conversationIdentifier, messagesWrapper[i]); var messageInfo = messageWrapperToInfo(profileOnion, conversationIdentifier, messagesWrapper[i]);
cache.addIndexed(messageInfo, index + i); cache.addIndexed(messageInfo, index + i);
} }
//messageWrapperToInfo //messageWrapperToInfo
} catch (e, stacktrace) { } 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 { } finally {
if (i != chunk) { if (i != chunk) {
cache.malformIndexes(index+i, index+chunk); cache.malformIndexes(index + i, index + chunk);
} }
} }
return cache.getByIndex(index); return cache.getByIndex(index);
@ -145,7 +145,6 @@ class ById implements CacheHandler {
} }
return fetch(cwtch, profileOnion, conversationIdentifier, cache); return fetch(cwtch, profileOnion, conversationIdentifier, cache);
} }
} }
class ByContentHash implements CacheHandler { class ByContentHash implements CacheHandler {
@ -167,7 +166,7 @@ class ByContentHash implements CacheHandler {
return Future.value(messageInfo); return Future.value(messageInfo);
} }
Future<MessageInfo?> get( Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async { Future<MessageInfo?> get(Cwtch cwtch, String profileOnion, int conversationIdentifier, MessageCache cache) async {
var messageInfo = await lookup(cache); var messageInfo = await lookup(cache);
if (messageInfo != null) { if (messageInfo != null) {
return Future.value(messageInfo); return Future.value(messageInfo);
@ -182,11 +181,7 @@ Future<Message> messageHandler(BuildContext context, String profileOnion, int co
MessageCache? cache; MessageCache? cache;
try { try {
cache = Provider cache = Provider.of<ProfileInfoState>(context, listen: false).contactList.getContact(conversationIdentifier)?.messageCache;
.of<ProfileInfoState>(context, listen: false)
.contactList
.getContact(conversationIdentifier)
?.messageCache;
if (cache == null) { if (cache == null) {
EnvironmentConfig.debugLog("error: cannot get message cache for profile: $profileOnion conversation: $conversationIdentifier"); EnvironmentConfig.debugLog("error: cannot get message cache for profile: $profileOnion conversation: $conversationIdentifier");
return MalformedMessage(malformedMetadata); return MalformedMessage(malformedMetadata);
@ -271,6 +266,6 @@ class MessageMetadata extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
MessageMetadata( MessageMetadata(this.profileOnion, this.conversationIdentifier, this.messageID, this.timestamp, this.senderHandle, this.senderImage, this.signature, this._attributes, this._ackd, this._error,
this.profileOnion, this.conversationIdentifier, this.messageID, this.timestamp, this.senderHandle, this.senderImage, this.signature, this._attributes, this._ackd, this._error, this.isAuto, this.contenthash); this.isAuto, this.contenthash);
} }

View File

@ -19,7 +19,6 @@ class LocalIndexMessage {
late int? messageId; late int? messageId;
LocalIndexMessage(int? messageId, {cacheOnly = false, isLoading = false}) { LocalIndexMessage(int? messageId, {cacheOnly = false, isLoading = false}) {
this.messageId = messageId; this.messageId = messageId;
this.cacheOnly = cacheOnly; this.cacheOnly = cacheOnly;
@ -117,20 +116,20 @@ class MessageCache extends ChangeNotifier {
// or .failLoad() is called on them to mark them malformed // 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 // 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) { 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)); this.cacheByIndex.insert(i, LocalIndexMessage(null, isLoading: true));
} }
} }
void malformIndexes(int start, int end) { void malformIndexes(int start, int end) {
for(var i = start; i < end; i++) { for (var i = start; i < end; i++) {
this.cacheByIndex[i].failLoad(); this.cacheByIndex[i].failLoad();
} }
} }
void addIndexed(MessageInfo messageInfo, int index) { void addIndexed(MessageInfo messageInfo, int index) {
this.cache[messageInfo.metadata.messageID] = messageInfo; this.cache[messageInfo.metadata.messageID] = messageInfo;
if (index < this.cacheByIndex.length ) { if (index < this.cacheByIndex.length) {
this.cacheByIndex[index].finishLoad(messageInfo.metadata.messageID); this.cacheByIndex[index].finishLoad(messageInfo.metadata.messageID);
} else { } else {
this.cacheByIndex.insert(index, LocalIndexMessage(messageInfo.metadata.messageID)); this.cacheByIndex.insert(index, LocalIndexMessage(messageInfo.metadata.messageID));

View File

@ -14,6 +14,7 @@ const ServerManagementExperiment = "servers-experiment";
const FileSharingExperiment = "filesharing"; const FileSharingExperiment = "filesharing";
const ImagePreviewsExperiment = "filesharing-images"; const ImagePreviewsExperiment = "filesharing-images";
const ClickableLinksExperiment = "clickable-links"; const ClickableLinksExperiment = "clickable-links";
const FormattingExperiment = "message-formatting";
enum DualpaneMode { enum DualpaneMode {
Single, Single,

View File

@ -24,6 +24,8 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE. // SOFTWARE.
import 'dart:ui';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
@ -361,6 +363,7 @@ TextSpan buildTextSpan(
text: element.text, text: element.text,
style: linkStyle, style: linkStyle,
recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null, recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null,
semanticsLabel: element.text
), ),
)); ));
} else { } else {
@ -372,6 +375,57 @@ TextSpan buildTextSpan(
recognizer: onOpen != null ? (TapGestureRecognizer()..onTap = () => onOpen(element)) : null, 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 { } else {
return TextSpan( return TextSpan(
text: element.text, text: element.text,

View File

@ -37,6 +37,30 @@ abstract class LinkifyElement {
bool equals(other) => other is LinkifyElement && other.text == text; 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 { class LinkableElement extends LinkifyElement {
final String url; final String url;
@ -81,11 +105,10 @@ class LinkifyOptions {
/// Excludes `.` at end of URLs. /// Excludes `.` at end of URLs.
final bool excludeLastPeriod; final bool excludeLastPeriod;
const LinkifyOptions({ final bool messageFormatting;
this.looseUrl = false, final bool parseLinks;
this.defaultToHttps = false,
this.excludeLastPeriod = true, const LinkifyOptions({this.looseUrl = false, this.defaultToHttps = false, this.excludeLastPeriod = true, this.messageFormatting = false, this.parseLinks = false});
});
} }
const _urlLinkifier = UrlLinkifier(); const _urlLinkifier = UrlLinkifier();

View File

@ -44,56 +44,173 @@ final _protocolIdentifierRegex = RegExp(
caseSensitive: false, 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 { class UrlLinkifier extends Linkifier {
const UrlLinkifier(); const UrlLinkifier();
List<LinkifyElement> replaceAndParse(tle, TextElement element, RegExpMatch match, List<LinkifyElement> 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<LinkifyElement> parseFormatting(element, options) {
var list = <LinkifyElement>[];
// 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 @override
List<LinkifyElement> parse(elements, options) { List<LinkifyElement> parse(elements, options) {
final list = <LinkifyElement>[]; var list = <LinkifyElement>[];
elements.forEach((element) { elements.forEach((element) {
if (element is TextElement) { if (element is TextElement) {
var match = options.looseUrl ? _looseUrlRegex.firstMatch(element.text) : _urlRegex.firstMatch(element.text); if (options.parseLinks == false && options.messageFormatting == false) {
if (match == null) {
list.add(element); 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 { } else {
final text = element.text.replaceFirst(match.group(0)!, ''); // unreachable
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));
}
} }
} else {
list.add(element);
} }
}); });

View File

@ -368,6 +368,24 @@ class _GlobalSettingsViewState extends State<GlobalSettingsView> {
inactiveTrackColor: settings.theme.defaultButtonDisabledColor, inactiveTrackColor: settings.theme.defaultButtonDisabledColor,
secondary: Icon(Icons.link, color: settings.current().mainTextColor), 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( AboutListTile(
icon: appIcon, icon: appIcon,
applicationIcon: Padding(padding: EdgeInsets.all(5), child: Icon(CwtchIcons.cwtch_knott)), applicationIcon: Padding(padding: EdgeInsets.all(5), child: Icon(CwtchIcons.cwtch_knott)),

View File

@ -230,7 +230,8 @@ class _MessageViewState extends State<MessageView> {
ChatMessage cm = new ChatMessage(o: QuotedMessageOverlay, d: quotedMessage); ChatMessage cm = new ChatMessage(o: QuotedMessageOverlay, d: quotedMessage);
Provider.of<FlwtchState>(context, listen: false) Provider.of<FlwtchState>(context, listen: false)
.cwtch .cwtch
.SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, jsonEncode(cm)).then(_sendMessageHandler); .SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, jsonEncode(cm))
.then(_sendMessageHandler);
} catch (e) {} } catch (e) {}
Provider.of<AppState>(context, listen: false).selectedIndex = null; Provider.of<AppState>(context, listen: false).selectedIndex = null;
}); });
@ -238,7 +239,8 @@ class _MessageViewState extends State<MessageView> {
ChatMessage cm = new ChatMessage(o: TextMessageOverlay, d: ctrlrCompose.value.text); ChatMessage cm = new ChatMessage(o: TextMessageOverlay, d: ctrlrCompose.value.text);
Provider.of<FlwtchState>(context, listen: false) Provider.of<FlwtchState>(context, listen: false)
.cwtch .cwtch
.SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, jsonEncode(cm)).then(_sendMessageHandler); .SendMessage(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, jsonEncode(cm))
.then(_sendMessageHandler);
} }
} }
} }
@ -246,14 +248,15 @@ class _MessageViewState extends State<MessageView> {
void _sendInvitation([String? ignoredParam]) { void _sendInvitation([String? ignoredParam]) {
Provider.of<FlwtchState>(context, listen: false) Provider.of<FlwtchState>(context, listen: false)
.cwtch .cwtch
.SendInvitation(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, this.selectedContact).then(_sendMessageHandler); .SendInvitation(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, this.selectedContact)
.then(_sendMessageHandler);
} }
void _sendFile(String filePath) { void _sendFile(String filePath) {
Provider.of<FlwtchState>(context, listen: false) Provider.of<FlwtchState>(context, listen: false)
.cwtch .cwtch
.ShareFile(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, filePath).then(_sendMessageHandler); .ShareFile(Provider.of<ContactInfoState>(context, listen: false).profileOnion, Provider.of<ContactInfoState>(context, listen: false).identifier, filePath)
.then(_sendMessageHandler);
} }
void _sendMessageHandler(dynamic messageJson) { void _sendMessageHandler(dynamic messageJson) {

View File

@ -32,7 +32,7 @@ class MessageBubbleState extends State<MessageBubble> {
var fromMe = Provider.of<MessageMetadata>(context).senderHandle == Provider.of<ProfileInfoState>(context).onion; var fromMe = Provider.of<MessageMetadata>(context).senderHandle == Provider.of<ProfileInfoState>(context).onion;
var borderRadiousEh = 15.0; var borderRadiousEh = 15.0;
var showClickableLinks = Provider.of<Settings>(context).isExperimentEnabled(ClickableLinksExperiment); var showClickableLinks = Provider.of<Settings>(context).isExperimentEnabled(ClickableLinksExperiment);
var formatMessages = Provider.of<Settings>(context).isExperimentEnabled(FormattingExperiment);
DateTime messageDate = Provider.of<MessageMetadata>(context).timestamp; DateTime messageDate = Provider.of<MessageMetadata>(context).timestamp;
// If the sender is not us, then we want to give them a nickname... // If the sender is not us, then we want to give them a nickname...
@ -48,40 +48,27 @@ class MessageBubbleState extends State<MessageBubble> {
var wdgSender = SelectableText(senderDisplayStr, var wdgSender = SelectableText(senderDisplayStr,
style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor : Provider.of<Settings>(context).theme.messageFromOtherTextColor)); style: TextStyle(fontSize: 9.0, color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor : Provider.of<Settings>(context).theme.messageFromOtherTextColor));
var wdgMessage; var wdgMessage = SelectableLinkify(
text: widget.content + '\u202F',
if (!showClickableLinks) { // TODO: onOpen breaks the "selectable" functionality. Maybe something to do with gesture handler?
wdgMessage = SelectableText( options: LinkifyOptions(messageFormatting: formatMessages, parseLinks: showClickableLinks, looseUrl: true, defaultToHttps: true),
widget.content + '\u202F', linkifiers: [UrlLinkifier()],
//key: Key(myKey), onOpen: showClickableLinks
focusNode: _focus, ? (link) {
style: TextStyle( _modalOpenLink(context, link);
color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor : Provider.of<Settings>(context).theme.messageFromOtherTextColor, }
), : null,
textAlign: TextAlign.left, //key: Key(myKey),
textWidthBasis: TextWidthBasis.longestLine, focusNode: _focus,
); style: TextStyle(
} else { color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor : Provider.of<Settings>(context).theme.messageFromOtherTextColor,
wdgMessage = SelectableLinkify( ),
text: widget.content + '\u202F', linkStyle: TextStyle(
// TODO: onOpen breaks the "selectable" functionality. Maybe something to do with gesture handler? color: Provider.of<Settings>(context).current().mainTextColor,
options: LinkifyOptions(looseUrl: true, defaultToHttps: true), ),
linkifiers: [UrlLinkifier()], textAlign: TextAlign.left,
onOpen: (link) { textWidthBasis: TextWidthBasis.longestLine,
_modalOpenLink(context, link); );
},
//key: Key(myKey),
focusNode: _focus,
style: TextStyle(
color: fromMe ? Provider.of<Settings>(context).theme.messageFromMeTextColor : Provider.of<Settings>(context).theme.messageFromOtherTextColor,
),
linkStyle: TextStyle(
color: Provider.of<Settings>(context).current().mainTextColor,
),
textAlign: TextAlign.left,
textWidthBasis: TextWidthBasis.longestLine,
);
}
var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of<MessageMetadata>(context).ackd, errored: Provider.of<MessageMetadata>(context).error, fromMe: fromMe, messageDate: messageDate); var wdgDecorations = MessageBubbleDecoration(ackd: Provider.of<MessageMetadata>(context).ackd, errored: Provider.of<MessageMetadata>(context).error, fromMe: fromMe, messageDate: messageDate);

View File

@ -95,6 +95,11 @@ flutter:
- family: CwtchIcons - family: CwtchIcons
fonts: fonts:
- asset: assets/fonts/CwtchIcons.ttf - 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, # To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a # in this "flutter" section. Each entry in this list should have a