|
|
|
@ -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 {
|
|
|
|
|
'',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// there is no need to pre-validate the html, as we validate it while rendering
|
|
|
|
|
|
|
|
|
|
final matrix = Matrix.of(context);
|
|
|
|
|
final fontSize = AppConfig.messageFontSize * AppConfig.fontSizeFactor;
|
|
|
|
|
|
|
|
|
|
final themeData = Theme.of(context);
|
|
|
|
|
// there is no need to pre-validate the html, as we validate it while rendering
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
onImageTap: (url) => UrlLauncher(context, url).launchUrl(),
|
|
|
|
|
setCodeLanguage: (String key, String value) async {
|
|
|
|
|
await matrix.store.setItem('${SettingKeys.codeLanguage}.$key', value);
|
|
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|