refactor: Reimplement flutter matrix html locally

This commit is contained in:
Krille 2023-05-17 12:26:59 +02:00
commit 796a6fdae1
9 changed files with 431 additions and 291 deletions

View file

@ -1,31 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_matrix_html/flutter_html.dart';
import 'package:collection/collection.dart';
import 'package:flutter_highlighter/flutter_highlighter.dart';
import 'package:flutter_highlighter/themes/shades-of-purple.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../../config/app_config.dart';
import '../../../config/setting_keys.dart';
import '../../../utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/widgets/avatar.dart';
import '../../../utils/url_launcher.dart';
class HtmlMessage extends StatelessWidget {
final String html;
final int? maxLines;
final Room room;
final TextStyle? defaultTextStyle;
final TextStyle? linkStyle;
final double? emoteSize;
final Color? textColor;
const HtmlMessage({
Key? key,
required this.html,
this.maxLines,
required this.room,
this.defaultTextStyle,
this.linkStyle,
this.emoteSize,
this.textColor,
}) : super(key: key);
@override
@ -46,101 +41,315 @@ class HtmlMessage extends StatelessWidget {
'',
);
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor;
// there is no need to pre-validate the html, as we validate it while rendering
final matrix = Matrix.of(context);
final themeData = Theme.of(context);
return Html(
data: renderHtml,
defaultTextStyle: defaultTextStyle,
emoteSize: emoteSize,
linkStyle: linkStyle ??
themeData.textTheme.bodyMedium!.copyWith(
color: themeData.colorScheme.secondary,
decoration: TextDecoration.underline,
decorationColor: themeData.colorScheme.secondary,
),
shrinkToFit: true,
maxLines: maxLines,
onLinkTap: (url) => UrlLauncher(context, url).launchUrl(),
onPillTap: (url) => UrlLauncher(context, url).launchUrl(),
getMxcUrl: (
String mxc,
double? width,
double? height, {
bool? animated = false,
}) {
final ratio = MediaQuery.of(context).devicePixelRatio;
return Uri.parse(mxc)
.getThumbnail(
matrix.client,
width: (width ?? 800) * ratio,
height: (height ?? 800) * ratio,
method: ThumbnailMethod.scale,
animated: AppConfig.autoplayImages ? animated : false,
)
.toString();
style: {
'*': Style(
color: textColor,
margin: Margins.all(0),
fontSize: FontSize(fontSize),
),
'a': Style(color: textColor?.withAlpha(150)),
'h1': Style(fontSize: FontSize(fontSize * 3)),
'h2': Style(fontSize: FontSize(fontSize * 2.5)),
'h3': Style(fontSize: FontSize(fontSize * 2)),
'h4': Style(fontSize: FontSize(fontSize * 1.75)),
'h5': Style(fontSize: FontSize(fontSize * 1.5)),
'h6': Style(fontSize: FontSize(fontSize * 1.25)),
// Otherwise list tiles are all in the same row$$
'li': Style(display: Display.block),
},
onImageTap: (url) => UrlLauncher(context, url).launchUrl(),
setCodeLanguage: (String key, String value) async {
await matrix.store.setItem('${SettingKeys.codeLanguage}.$key', value);
},
getCodeLanguage: (String key) async {
return await matrix.store.getItem('${SettingKeys.codeLanguage}.$key');
},
getPillInfo: (String url) async {
final identityParts = url.parseIdentifierIntoParts();
final identifier = identityParts?.primaryIdentifier;
if (identifier == null) {
return {};
}
if (identifier.sigil == '@') {
// we have a user pill
final user = room.getState('m.room.member', identifier);
if (user != null) {
return user.content;
}
// there might still be a profile...
final profile = await room.client.getProfileFromUserId(identifier);
return {
'displayname': profile.displayName,
'avatar_url': profile.avatarUrl.toString(),
};
}
if (identifier.sigil == '#') {
// we have an alias pill
for (final r in room.client.rooms) {
final state = r.getState('m.room.canonical_alias');
if (state != null &&
((state.content['alias'] is String &&
state.content['alias'] == identifier) ||
(state.content['alt_aliases'] is List &&
state.content['alt_aliases'].contains(identifier)))) {
// we have a room!
return {
'displayname':
r.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
'avatar_url': r.getState('m.room.avatar')?.content['url'],
};
}
}
return {};
}
if (identifier.sigil == '!') {
// we have a room ID pill
final r = room.client.getRoomById(identifier);
if (r == null) {
return {};
}
return {
'displayname':
r.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
'avatar_url': r.getState('m.room.avatar')?.content['url'],
};
}
return {};
extensions: [
UserPillExtension(context, room),
RoomPillExtension(context, room.client),
CodeExtension(fontSize: fontSize),
MatrixMathExtension(
style: TextStyle(fontSize: fontSize, color: textColor),
),
],
onLinkTap: (url, _, __) => UrlLauncher(context, url).launchUrl(),
onlyRenderTheseTags: const {
...allowedHtmlTags,
// Needed to make it work properly
'body',
'html',
},
shrinkWrap: true,
);
}
/// Keep in sync with: https://spec.matrix.org/v1.6/client-server-api/#mroommessage-msgtypes
static const Set<String> allowedHtmlTags = {
'font',
'del',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'p',
'a',
'ul',
'ol',
'sup',
'sub',
'li',
'b',
'i',
'u',
'strong',
'em',
'strike',
'code',
'hr',
'br',
'div',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'caption',
'pre',
'span',
'img',
'details',
'summary'
};
}
class MatrixMathExtension extends HtmlExtension {
final TextStyle? style;
MatrixMathExtension({this.style});
@override
Set<String> get supportedTags => {'div'};
@override
bool matches(ExtensionContext context) {
if (context.elementName != 'div') return false;
final mathData = context.element?.attributes['data-mx-maths'];
return mathData != null;
}
@override
InlineSpan build(
ExtensionContext context,
Map<StyledElement, InlineSpan> Function() parseChildren,
) {
final data = context.element?.attributes['data-mx-maths'] ?? '';
return WidgetSpan(
child: Math.tex(
data,
textStyle: style,
onErrorFallback: (e) {
Logs().d('Flutter math parse error', e);
return Text(
data,
style: style,
);
},
),
);
}
}
class CodeExtension extends HtmlExtension {
final double fontSize;
CodeExtension({required this.fontSize});
@override
Set<String> get supportedTags => {'code'};
@override
InlineSpan build(
ExtensionContext context,
Map<StyledElement, InlineSpan> Function() parseChildren,
) =>
WidgetSpan(
child: Material(
clipBehavior: Clip.hardEdge,
borderRadius: BorderRadius.circular(4),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: HighlightView(
context.element?.text ?? '',
language: context.element?.className
.split(' ')
.singleWhereOrNull(
(className) => className.startsWith('language-'),
)
?.split('language-')
.last ??
'md',
theme: shadesOfPurpleTheme,
padding: const EdgeInsets.all(6),
textStyle: TextStyle(fontSize: fontSize),
),
),
),
);
}
class UserPillExtension extends HtmlExtension {
final Room room;
final BuildContext context;
UserPillExtension(this.context, this.room);
@override
Set<String> get supportedTags => {'a'};
@override
bool matches(ExtensionContext context) {
if (context.elementName != 'a') return false;
final href = context.element?.attributes['href'];
if (href == null) return false;
final uri = Uri.tryParse(href);
if (uri == null || uri.host != 'matrix.to') return false;
final userId = uri.fragment.split('/').last;
if (userId.sigil != '@') return false;
return true;
}
@override
InlineSpan build(
ExtensionContext context,
Map<StyledElement, InlineSpan> Function() parseChildren,
) {
final href = context.element!.attributes['href']!;
final uri = Uri.parse(href);
final userId = uri.fragment.split('/').last;
final user = room.unsafeGetUserFromMemoryOrFallback(userId);
return WidgetSpan(
child: MatrixPill(
name: user.calcDisplayname(),
avatar: user.avatarUrl,
uri: href,
outerContext: this.context,
),
);
}
}
class RoomPillExtension extends HtmlExtension {
final Client client;
final BuildContext context;
RoomPillExtension(this.context, this.client);
@override
Set<String> get supportedTags => {'a'};
@override
bool matches(ExtensionContext context) {
if (context.elementName != 'a') return false;
final href = context.element?.attributes['href'];
if (href == null) return false;
final uri = Uri.tryParse(href);
if (uri == null || uri.host != 'matrix.to') return false;
final roomId = Uri.decodeComponent(uri.fragment.split('/').last);
if (!{'#', '!'}.contains(roomId.sigil)) return false;
return true;
}
@override
InlineSpan build(
ExtensionContext context,
Map<StyledElement, InlineSpan> Function() parseChildren,
) {
final href = context.element!.attributes['href']!;
final uri = Uri.parse(href);
final roomId = Uri.decodeComponent(uri.fragment.split('/').last);
final room = roomId.sigil == '!'
? client.getRoomById(roomId)
: client.getRoomByAlias(roomId);
if (room != null) {
return WidgetSpan(
child: MatrixPill(
name: room.getLocalizedDisplayname(),
avatar: room.avatar,
uri: href,
outerContext: this.context,
),
);
}
return WidgetSpan(
child: FutureBuilder<QueryPublicRoomsResponse>(
future: client.queryPublicRooms(
server: roomId.domain,
filter: PublicRoomQueryFilter(
genericSearchTerm: roomId,
),
),
builder: (context, snapshot) {
final room = snapshot.data;
return MatrixPill(
name: room?.chunk.singleOrNull?.name ?? roomId,
avatar: room?.chunk.singleOrNull?.avatarUrl,
uri: href,
outerContext: this.context,
);
},
),
);
}
}
class MatrixPill extends StatelessWidget {
final String name;
final BuildContext outerContext;
final Uri? avatar;
final String uri;
const MatrixPill({
super.key,
required this.name,
required this.outerContext,
this.avatar,
required this.uri,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: UrlLauncher(outerContext, uri).launchUrl,
child: Material(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
side: BorderSide(
color: Theme.of(outerContext).colorScheme.onPrimaryContainer,
width: 0.5,
),
),
color: Theme.of(outerContext).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
mxContent: avatar,
name: name,
size: 16,
),
const SizedBox(width: 6),
Text(
name,
style: TextStyle(
color: Theme.of(outerContext).colorScheme.onPrimaryContainer,
),
),
],
),
),
),
);
}
}

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
@ -150,23 +150,10 @@ class MessageContent extends StatelessWidget {
if (event.messageType == MessageTypes.Emote) {
html = '* $html';
}
final bigEmotes = event.onlyEmotes &&
event.numberEmotes > 0 &&
event.numberEmotes <= 10;
return HtmlMessage(
html: html,
defaultTextStyle: TextStyle(
color: textColor,
fontSize: bigEmotes ? fontSize * 3 : fontSize,
),
linkStyle: TextStyle(
color: textColor.withAlpha(150),
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration: TextDecoration.underline,
decorationColor: textColor.withAlpha(150),
),
textColor: textColor,
room: event.room,
emoteSize: bigEmotes ? fontSize * 3 : fontSize * 1.5,
);
}
// else we fall through to the normal message rendering
@ -242,13 +229,13 @@ class MessageContent extends StatelessWidget {
hideReply: true,
),
builder: (context, snapshot) {
return LinkText(
return Linkify(
text: snapshot.data ??
event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
hideReply: true,
),
textStyle: TextStyle(
style: TextStyle(
color: textColor,
fontSize: bigEmotes ? fontSize * 3 : fontSize,
decoration:
@ -260,10 +247,7 @@ class MessageContent extends StatelessWidget {
decoration: TextDecoration.underline,
decorationColor: textColor.withAlpha(150),
),
beforeLaunch: (url) {
UrlLauncher(context, url.toString()).launchUrl();
return false;
},
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
);
},
);

View file

@ -5,7 +5,6 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import '../../../config/app_config.dart';
import 'html_message.dart';
class ReplyContent extends StatelessWidget {
final Event replyEvent;
@ -26,47 +25,23 @@ class ReplyContent extends StatelessWidget {
final displayEvent =
timeline != null ? replyEvent.getDisplayEvent(timeline) : replyEvent;
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor;
if (AppConfig.renderHtml &&
[EventTypes.Message, EventTypes.Encrypted]
.contains(displayEvent.type) &&
[MessageTypes.Text, MessageTypes.Notice, MessageTypes.Emote]
.contains(displayEvent.messageType) &&
!displayEvent.redacted &&
displayEvent.content['format'] == 'org.matrix.custom.html' &&
displayEvent.content['formatted_body'] is String) {
String? html = displayEvent.content['formatted_body'];
if (displayEvent.messageType == MessageTypes.Emote) {
html = '* $html';
}
replyBody = HtmlMessage(
html: html!,
defaultTextStyle: TextStyle(
color: ownMessage
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onBackground,
fontSize: fontSize,
),
maxLines: 1,
room: displayEvent.room,
emoteSize: fontSize * 1.5,
);
} else {
replyBody = Text(
displayEvent.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
withSenderNamePrefix: false,
hideReply: true,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: ownMessage
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onBackground,
fontSize: fontSize,
),
);
}
replyBody = Text(
displayEvent.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
withSenderNamePrefix: false,
hideReply: true,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: ownMessage
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onBackground,
fontSize: fontSize,
),
);
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[

View file

@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
@ -101,7 +101,7 @@ class PinnedEvents extends StatelessWidget {
hideReply: true,
),
builder: (context, snapshot) {
return LinkText(
return Linkify(
text: snapshot.data ??
event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)!),
@ -109,7 +109,7 @@ class PinnedEvents extends StatelessWidget {
hideReply: true,
),
maxLines: 2,
textStyle: TextStyle(
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurfaceVariant,
overflow: TextOverflow.ellipsis,
@ -126,10 +126,8 @@ class PinnedEvents extends StatelessWidget {
decorationColor:
Theme.of(context).colorScheme.onSurfaceVariant,
),
beforeLaunch: (url) {
UrlLauncher(context, url.toString()).launchUrl();
return false;
},
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
);
},
),

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/config/app_config.dart';
@ -125,13 +125,13 @@ class ChatDetailsView extends StatelessWidget {
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: LinkText(
child: Linkify(
text: room.topic.isEmpty
? L10n.of(context)!.addGroupDescription
: room.topic,
linkStyle:
const TextStyle(color: Colors.blueAccent),
textStyle: TextStyle(
style: TextStyle(
fontSize: 14,
color: Theme.of(context)
.textTheme
@ -142,11 +142,8 @@ class ChatDetailsView extends StatelessWidget {
.bodyMedium!
.color,
),
beforeLaunch: (url) {
UrlLauncher(context, url.toString())
.launchUrl();
return false;
},
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
),
const SizedBox(height: 8),

View file

@ -3,8 +3,8 @@ import 'package:flutter/services.dart';
import 'package:flutter_blurhash/flutter_blurhash.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:video_player/video_player.dart';
import 'package:fluffychat/config/app_config.dart';
@ -273,15 +273,13 @@ class StoryView extends StatelessWidget {
controller.storyThemeData.alignmentY.toDouble() / 100,
),
child: SafeArea(
child: LinkText(
child: Linkify(
text: controller.loadingMode
? L10n.of(context)!.loadingPleaseWait
: event.content.tryGet<String>('body') ?? '',
textAlign: TextAlign.center,
beforeLaunch: (url) {
UrlLauncher(context, url.toString()).launchUrl();
return false;
},
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
linkStyle: TextStyle(
fontSize: 24,
color: Colors.blue.shade50,
@ -291,7 +289,7 @@ class StoryView extends StatelessWidget {
? null
: textShadows,
),
textStyle: TextStyle(
style: TextStyle(
fontSize: 24,
color: Colors.white,
shadows: event.messageType == MessageTypes.Text

View file

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix_link_text/link_text.dart';
import 'package:vrouter/vrouter.dart';
import 'package:fluffychat/utils/url_launcher.dart';
@ -153,17 +153,15 @@ class PublicRoomBottomSheet extends StatelessWidget {
color: Theme.of(context).colorScheme.secondary,
),
),
subtitle: LinkText(
subtitle: Linkify(
text: profile!.topic!,
linkStyle: const TextStyle(color: Colors.blueAccent),
textStyle: TextStyle(
style: TextStyle(
fontSize: 14,
color: Theme.of(context).textTheme.bodyMedium!.color,
),
beforeLaunch: (url) {
UrlLauncher(context, url.toString()).launchUrl();
return false;
},
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
),
],