Initial commit
Some checks failed
Main Deploy Workflow / deploy_web (push) Has been cancelled
Main Deploy Workflow / deploy_playstore_internal (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled

This commit is contained in:
2026-09-11 15:59:56 -03:00
commit 0037b0b9cf
651 changed files with 199705 additions and 0 deletions

View File

@@ -0,0 +1,538 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:async';
import 'dart:io';
import 'package:async/async.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/utils/error_reporter.dart';
import 'package:tatchat/utils/file_description.dart';
import 'package:tatchat/utils/localized_exception_extension.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:just_audio/just_audio.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import 'package:ogg_caf_converter/ogg_caf_converter.dart';
import 'package:path_provider/path_provider.dart';
import '../../../utils/matrix_sdk_extensions/event_extension.dart';
import '../../../widgets/tatu_chat_app.dart';
import '../../../widgets/matrix.dart';
class AudioPlayerWidget extends StatefulWidget {
final Color color;
final Color linkColor;
final double fontSize;
final Event event;
static const int wavesCount = 40;
const AudioPlayerWidget(
this.event, {
required this.color,
required this.linkColor,
required this.fontSize,
super.key,
});
@override
AudioPlayerState createState() => AudioPlayerState();
}
enum AudioPlayerStatus { notDownloaded, downloading, downloaded }
class AudioPlayerState extends State<AudioPlayerWidget> {
static const double buttonSize = 36;
AudioPlayerStatus status = AudioPlayerStatus.notDownloaded;
double? _downloadProgress;
late final MatrixState matrix;
List<int>? _waveform;
String? _durationString;
@override
void dispose() {
super.dispose();
final audioPlayer = matrix.voiceMessageEventId.value != widget.event.eventId
? null
: matrix.audioPlayer;
if (audioPlayer != null) {
if (audioPlayer.playing && !audioPlayer.isAtEndPosition) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(matrix.context).showMaterialBanner(
MaterialBanner(
padding: EdgeInsets.zero,
leading: StreamBuilder(
stream: audioPlayer.playerStateStream.asBroadcastStream(),
builder: (context, _) => IconButton(
onPressed: () {
if (audioPlayer.isAtEndPosition) {
audioPlayer.seek(Duration.zero);
} else if (audioPlayer.playing) {
audioPlayer.pause();
} else {
audioPlayer.play();
}
},
icon: audioPlayer.playing && !audioPlayer.isAtEndPosition
? const Icon(Icons.pause_outlined)
: const Icon(Icons.play_arrow_outlined),
),
),
content: StreamBuilder(
stream: audioPlayer.positionStream.asBroadcastStream(),
builder: (context, _) => GestureDetector(
onTap: () => TatuChatApp.router.go(
'/rooms/${widget.event.room.id}?event=${widget.event.eventId}',
),
child: Text(
'🎙️ ${audioPlayer.position.minuteSecondString} / ${audioPlayer.duration?.minuteSecondString} - ${widget.event.senderFromMemoryOrFallback.calcDisplayname()}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
actions: [
IconButton(
onPressed: () {
audioPlayer.pause();
audioPlayer.dispose();
matrix.voiceMessageEventId.value = matrix.audioPlayer =
null;
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(
matrix.context,
).clearMaterialBanners();
});
},
icon: const Icon(Icons.close_outlined),
),
],
),
);
});
return;
}
audioPlayer.pause();
audioPlayer.dispose();
matrix.voiceMessageEventId.value = matrix.audioPlayer = null;
}
}
Future<void> _onButtonTap() async {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(matrix.context).clearMaterialBanners();
});
final currentPlayer =
matrix.voiceMessageEventId.value != widget.event.eventId
? null
: matrix.audioPlayer;
if (currentPlayer != null && !currentPlayer.isAtEndPosition) {
if (currentPlayer.playing) {
currentPlayer.pause();
} else {
currentPlayer.play();
}
return;
}
matrix.voiceMessageEventId.value = widget.event.eventId;
matrix.audioPlayer
?..stop()
..dispose();
File? file;
MatrixFile? matrixFile;
setState(() => status = AudioPlayerStatus.downloading);
try {
final fileSize = widget.event.content
.tryGetMap<String, Object?>('info')
?.tryGet<int>('size');
matrixFile = await widget.event.downloadAndDecryptAttachment(
onDownloadProgress: fileSize != null && fileSize > 0
? (progress) {
final progressPercentage = progress / fileSize;
setState(() {
_downloadProgress = progressPercentage < 1
? progressPercentage
: null;
});
}
: null,
);
final attachmentUrl = widget.event.attachmentOrThumbnailMxcUrl();
if (!kIsWeb && attachmentUrl != null) {
final tempDir = await getTemporaryDirectory();
final fileName = Uri.encodeComponent(attachmentUrl.pathSegments.last);
file = File('${tempDir.path}/${fileName}_${matrixFile.name}');
await file.writeAsBytes(matrixFile.bytes);
if (Platform.isIOS &&
matrixFile.mimeType.toLowerCase() == 'audio/ogg') {
Logs().v('Convert ogg audio file for iOS...');
final convertedFile = File('${file.path}.caf');
if (await convertedFile.exists() == false) {
await OggCafConverter().convertOggToCaf(
input: file.path,
output: convertedFile.path,
);
}
file = convertedFile;
}
}
setState(() {
status = AudioPlayerStatus.downloaded;
});
} catch (e, s) {
Logs().v('Could not download audio file', e, s);
if (!mounted) rethrow;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(e.toLocalizedString(context))));
rethrow;
}
if (!context.mounted) return;
if (matrix.voiceMessageEventId.value != widget.event.eventId) return;
final audioPlayer = matrix.audioPlayer = AudioPlayer();
if (file != null) {
audioPlayer.setFilePath(file.path);
} else {
await audioPlayer.setAudioSource(
AudioSource.uri(
Uri.dataFromBytes(matrixFile.bytes, mimeType: matrixFile.mimeType),
),
);
}
if (!mounted) return;
audioPlayer.play().onError(
ErrorReporter(context, 'Unable to play audio message').onErrorCallback,
);
}
Future<void> _toggleSpeed() async {
final audioPlayer = matrix.audioPlayer;
if (audioPlayer == null) return;
switch (audioPlayer.speed) {
case 1.0:
await audioPlayer.setSpeed(1.25);
break;
case 1.25:
await audioPlayer.setSpeed(1.5);
break;
case 1.5:
await audioPlayer.setSpeed(2.0);
break;
case 2.0:
await audioPlayer.setSpeed(0.5);
break;
case 0.5:
default:
await audioPlayer.setSpeed(1.0);
break;
}
setState(() {});
}
List<int>? _getWaveform() {
final eventWaveForm = widget.event.content
.tryGetMap<String, Object?>('org.matrix.msc1767.audio')
?.tryGetList<int>('waveform');
if (eventWaveForm == null || eventWaveForm.isEmpty) {
return null;
}
while (eventWaveForm.length < AudioPlayerWidget.wavesCount) {
for (var i = 0; i < eventWaveForm.length; i = i + 2) {
eventWaveForm.insert(i, eventWaveForm[i]);
}
}
var i = 0;
final step = (eventWaveForm.length / AudioPlayerWidget.wavesCount).round();
while (eventWaveForm.length > AudioPlayerWidget.wavesCount) {
eventWaveForm.removeAt(i);
i = (i + step) % AudioPlayerWidget.wavesCount;
}
return eventWaveForm.map((i) => i > 1024 ? 1024 : i).toList();
}
@override
void initState() {
super.initState();
matrix = Matrix.of(context);
_waveform = _getWaveform();
if (matrix.voiceMessageEventId.value == widget.event.eventId &&
matrix.audioPlayer != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(matrix.context).clearMaterialBanners();
});
}
final durationInt = widget.event.content
.tryGetMap<String, Object?>('info')
?.tryGet<int>('duration');
if (durationInt != null) {
final duration = Duration(milliseconds: durationInt);
_durationString = duration.minuteSecondString;
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final waveform = _waveform;
return ValueListenableBuilder(
valueListenable: matrix.voiceMessageEventId,
builder: (context, eventId, _) {
final audioPlayer = eventId != widget.event.eventId
? null
: matrix.audioPlayer;
final fileDescription = widget.event.fileDescription;
return StreamBuilder<Object>(
stream: audioPlayer == null
? null
: StreamGroup.merge([
audioPlayer.positionStream.asBroadcastStream(),
audioPlayer.playerStateStream.asBroadcastStream(),
]),
builder: (context, _) {
final maxPosition =
audioPlayer?.duration?.inMilliseconds.toDouble() ?? 1.0;
var currentPosition =
audioPlayer?.position.inMilliseconds.toDouble() ?? 0.0;
if (currentPosition > maxPosition) currentPosition = maxPosition;
final wavePosition =
(currentPosition / maxPosition) * AudioPlayerWidget.wavesCount;
final statusText = audioPlayer == null
? _durationString ?? '00:00'
: audioPlayer.position.minuteSecondString;
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: FluffyThemes.columnWidth,
),
child: Row(
mainAxisSize: .min,
children: <Widget>[
SizedBox(
width: buttonSize,
height: buttonSize,
child: status == AudioPlayerStatus.downloading
? CircularProgressIndicator(
strokeWidth: 2,
color: widget.color,
value: _downloadProgress,
)
: InkWell(
borderRadius: BorderRadius.circular(64),
onLongPress: () =>
widget.event.saveFile(context),
onTap: _onButtonTap,
child: Material(
color: widget.color.withAlpha(64),
borderRadius: BorderRadius.circular(64),
child: Icon(
audioPlayer?.playing == true &&
audioPlayer?.isAtEndPosition ==
false
? Icons.pause_outlined
: Icons.play_arrow_outlined,
color: widget.color,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Stack(
children: [
if (waveform != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Row(
children: [
for (
var i = 0;
i < AudioPlayerWidget.wavesCount;
i++
)
Expanded(
child: Container(
height: 32,
alignment: Alignment.center,
child: Container(
margin:
const EdgeInsets.symmetric(
horizontal: 1,
),
decoration: BoxDecoration(
color: i < wavePosition
? widget.color
: widget.color.withAlpha(
128,
),
borderRadius:
BorderRadius.circular(64),
),
height: 32 * (waveform[i] / 1024),
),
),
),
],
),
),
SizedBox(
height: 32,
child: Slider(
thumbColor:
widget.event.senderId ==
widget.event.room.client.userID
? theme.colorScheme.onPrimary
: theme.colorScheme.primary,
activeColor: waveform == null
? widget.color
: Colors.transparent,
inactiveColor: waveform == null
? widget.color.withAlpha(128)
: Colors.transparent,
max: maxPosition,
value: currentPosition,
onChanged: (position) => audioPlayer == null
? _onButtonTap()
: audioPlayer.seek(
Duration(
milliseconds: position.round(),
),
),
),
),
],
),
),
const SizedBox(width: 8),
SizedBox(
width: 36,
child: Text(
statusText,
style: TextStyle(color: widget.color, fontSize: 12),
),
),
const SizedBox(width: 8),
AnimatedCrossFade(
firstChild: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Icon(
Icons.mic_none_outlined,
color: widget.color,
),
),
secondChild: Material(
color: widget.color.withAlpha(64),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
child: InkWell(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
onTap: _toggleSpeed,
child: SizedBox(
width: 32,
height: 20,
child: Center(
child: Text(
'${audioPlayer?.speed}x',
style: TextStyle(
color: widget.color,
fontSize: 9,
),
),
),
),
),
),
alignment: Alignment.center,
crossFadeState: audioPlayer == null
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: FluffyThemes.animationDuration,
),
],
),
),
if (fileDescription != null) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Linkify(
text: fileDescription,
textScaleFactor: MediaQuery.textScalerOf(
context,
).scale(1),
style: TextStyle(
color: widget.color,
fontSize: widget.fontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: widget.linkColor,
fontSize: widget.fontSize,
decoration: TextDecoration.underline,
decorationColor: widget.linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
),
],
],
),
);
},
);
},
);
}
}
extension on AudioPlayer {
bool get isAtEndPosition {
final duration = this.duration;
if (duration == null) return true;
return position >= duration;
}
}
extension on Duration {
String get minuteSecondString =>
'${inMinutes.toString().padLeft(2, '0')}:${(inSeconds % 60).toString().padLeft(2, '0')}';
}

View File

@@ -0,0 +1,195 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:math';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class CuteContent extends StatefulWidget {
final Event event;
const CuteContent(this.event, {super.key});
@override
State<CuteContent> createState() => _CuteContentState();
}
class _CuteContentState extends State<CuteContent> {
static bool _isOverlayShown = false;
@override
void initState() {
if (AppSettings.autoplayImages.value && !_isOverlayShown) {
addOverlay();
}
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<User?>(
future: widget.event.fetchSenderUser(),
builder: (context, snapshot) {
final label = generateLabel(snapshot.data);
return GestureDetector(
onTap: addOverlay,
child: Column(
mainAxisSize: .min,
mainAxisAlignment: .center,
children: [
Text(widget.event.text, style: const TextStyle(fontSize: 150)),
if (label != null) Text(label),
],
),
);
},
);
}
Future<void> addOverlay() async {
_isOverlayShown = true;
await Future.delayed(const Duration(milliseconds: 50));
if (!mounted) return;
OverlayEntry? overlay;
overlay = OverlayEntry(
builder: (context) => CuteEventOverlay(
emoji: widget.event.text,
onAnimationEnd: () {
_isOverlayShown = false;
overlay?.remove();
},
),
);
Overlay.of(context).insert(overlay);
}
String? generateLabel(User? user) {
switch (widget.event.content['cute_type']) {
case 'googly_eyes':
return L10n.of(context).googlyEyesContent(
user?.displayName ??
widget.event.senderFromMemoryOrFallback.displayName ??
'',
);
case 'cuddle':
return L10n.of(context).cuddleContent(
user?.displayName ??
widget.event.senderFromMemoryOrFallback.displayName ??
'',
);
case 'hug':
return L10n.of(context).hugContent(
user?.displayName ??
widget.event.senderFromMemoryOrFallback.displayName ??
'',
);
}
return null;
}
}
class CuteEventOverlay extends StatefulWidget {
final String emoji;
final VoidCallback onAnimationEnd;
const CuteEventOverlay({
super.key,
required this.emoji,
required this.onAnimationEnd,
});
@override
State<CuteEventOverlay> createState() => _CuteEventOverlayState();
}
class _CuteEventOverlayState extends State<CuteEventOverlay>
with TickerProviderStateMixin {
final List<Size> items = List.generate(
50,
(index) => Size(Random().nextDouble(), 4 + (Random().nextDouble() * 4)),
);
AnimationController? controller;
@override
void initState() {
controller = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
controller?.forward();
controller?.addStatusListener(_hideOverlay);
super.initState();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: controller!,
builder: (context, _) => LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth - _CuteOverlayContent.size;
final height = constraints.maxHeight + _CuteOverlayContent.size;
return SizedBox(
height: constraints.maxHeight,
width: constraints.maxWidth,
child: OverflowBox(
child: Stack(
alignment: Alignment.bottomLeft,
fit: StackFit.expand,
children: items
.map(
(position) => Positioned(
left: position.width * width,
bottom:
(height *
.25 *
position.height *
(controller?.value ?? 0)) -
_CuteOverlayContent.size,
child: _CuteOverlayContent(emoji: widget.emoji),
),
)
.toList(),
),
),
);
},
),
);
}
void _hideOverlay(AnimationStatus status) {
if (status == AnimationStatus.completed) {
widget.onAnimationEnd.call();
}
}
@override
void dispose() {
controller?.dispose();
super.dispose();
}
}
class _CuteOverlayContent extends StatelessWidget {
static const double size = 64.0;
final String emoji;
const _CuteOverlayContent({required this.emoji});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: size,
child: Text(emoji, style: const TextStyle(fontSize: 48)),
);
}
}

View File

@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class FileSendStatusIndicator extends StatelessWidget {
final FileSendingStatus fileSendingStatus;
const FileSendStatusIndicator({super.key, required this.fileSendingStatus});
@override
Widget build(BuildContext context) {
return CircleAvatar(
child: Stack(
alignment: Alignment.center,
children: [
switch (fileSendingStatus) {
FileSendingStatus.generatingThumbnail => Icon(
Icons.compress_outlined,
size: 18,
),
FileSendingStatus.encrypting => Icon(Icons.lock_outlined, size: 18),
FileSendingStatus.uploading => Icon(
Icons.upload_outlined,
size: 18,
),
},
const CircularProgressIndicator(strokeWidth: 3),
],
),
);
}
}

View File

@@ -0,0 +1,743 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:collection/collection.dart';
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/code_highlight_theme.dart';
import 'package:tatchat/utils/event_checkbox_extension.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:highlight/highlight.dart' show highlight;
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../../utils/url_launcher.dart';
class HtmlMessage extends StatelessWidget {
final String html;
final Room room;
final Color textColor;
final double fontSize;
final TextStyle linkStyle;
final void Function(LinkableElement) onOpen;
final String? eventId;
final Set<Event>? checkboxCheckedEvents;
const HtmlMessage({
super.key,
required this.html,
required this.room,
required this.fontSize,
required this.linkStyle,
this.textColor = Colors.black,
required this.onOpen,
this.eventId,
this.checkboxCheckedEvents,
});
/// Keep in sync with: https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes
static const Set<String> allowedHtmlTags = {
'font',
'del',
's',
'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',
// Not in the allowlist of the matrix spec yet but should be harmless:
'ruby',
'rp',
'rt',
'html',
'body',
};
static const Set<String> ignoredHtmlTags = {'mx-reply'};
/// We add line breaks before these tags:
static const Set<String> blockHtmlTags = {
'p',
'ul',
'ol',
'pre',
'div',
'table',
'details',
'blockquote',
};
/// We add line breaks before these tags:
static const Set<String> fullLineHtmlTag = {
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'li',
};
/// Adding line breaks before block elements.
List<InlineSpan> _renderWithLineBreaks(
dom.NodeList nodes,
BuildContext context, {
int depth = 1,
}) {
final onlyElements = nodes.whereType<dom.Element>().toList();
return [
for (var i = 0; i < nodes.length; i++) ...[
// Actually render the node child:
_renderHtml(nodes[i], context, depth: depth + 1),
// Add linebreaks between blocks:
if (nodes[i] is dom.Element &&
onlyElements.indexOf(nodes[i] as dom.Element) <
onlyElements.length - 1) ...[
if (blockHtmlTags.contains((nodes[i] as dom.Element).localName))
const TextSpan(text: '\n\n'),
if (fullLineHtmlTag.contains((nodes[i] as dom.Element).localName))
const TextSpan(text: '\n'),
],
],
];
}
InlineSpan _renderCodeBlockNode(dom.Node node) {
if (node is! dom.Element) {
return TextSpan(text: node.text);
}
final style =
atomOneDarkTheme[node.className.split('-').last] ??
atomOneDarkTheme['root'];
return TextSpan(
children: node.nodes.map(_renderCodeBlockNode).toList(),
style: style,
);
}
/// Transforms a Node to an InlineSpan.
InlineSpan _renderHtml(dom.Node node, BuildContext context, {int depth = 1}) {
// We must not render elements nested more than 100 elements deep:
if (depth >= 100) return const TextSpan();
if (node is dom.Element &&
ignoredHtmlTags.contains(node.localName?.toLowerCase())) {
return const TextSpan();
}
// This is a text node or not permitted node, so we render it as text:
if (node is! dom.Element || !allowedHtmlTags.contains(node.localName)) {
var text = node.text ?? '';
// Single linebreak nodes between Elements are ignored:
if (text == '\n') text = '';
return LinkifySpan(
text: text,
options: const LinkifyOptions(humanize: false),
linkStyle: linkStyle,
onOpen: onOpen,
);
}
switch (node.localName) {
case 'br':
return const TextSpan(text: '\n');
case 'a':
final href = node.attributes['href'];
if (href == null) continue block;
final matrixId = node.attributes['href']
?.parseIdentifierIntoParts()
?.primaryIdentifier;
if (matrixId != null) {
if (matrixId.sigil == '@') {
final user = room.unsafeGetUserFromMemoryOrFallback(matrixId);
return WidgetSpan(
child: MatrixPill(
key: Key('user_pill_$matrixId'),
name: user.calcDisplayname(),
avatar: user.avatarUrl,
uri: href,
outerContext: context,
fontSize: fontSize,
color: linkStyle.color,
),
);
}
if (matrixId.sigil == '#' || matrixId.sigil == '!') {
final room = matrixId.sigil == '!'
? this.room.client.getRoomById(matrixId)
: this.room.client.getRoomByAlias(matrixId);
return WidgetSpan(
child: MatrixPill(
name: room?.getLocalizedDisplayname() ?? matrixId,
avatar: room?.avatar,
uri: href,
outerContext: context,
fontSize: fontSize,
color: linkStyle.color,
),
);
}
}
return WidgetSpan(
child: Tooltip(
message: href,
child: InkWell(
splashColor: Colors.transparent,
onTap: () => UrlLauncher(context, href, node.text).launchUrl(),
child: Text.rich(
TextSpan(
children: _renderWithLineBreaks(
node.nodes,
context,
depth: depth,
),
style: linkStyle,
),
style: const TextStyle(height: 1.25),
),
),
),
);
case 'li':
if (!{'ol', 'ul'}.contains(node.parent?.localName)) {
continue block;
}
final eventId = this.eventId;
final isCheckbox = node.className == 'task-list-item';
final checkboxIndex = isCheckbox
? node.rootElement
.getElementsByClassName('task-list-item')
.indexOf(node) +
1
: null;
final checkedByReaction = !isCheckbox
? null
: checkboxCheckedEvents?.firstWhereOrNull(
(event) => event.checkedCheckboxId == checkboxIndex,
);
final staticallyChecked =
isCheckbox && node.children.first.attributes['checked'] == 'true';
return WidgetSpan(
child: Padding(
padding: EdgeInsets.only(left: fontSize),
child: Text.rich(
TextSpan(
children: [
if (!isCheckbox) ...[
if (node.parent?.localName == 'ul')
const TextSpan(text: '• '),
if (node.parent?.localName == 'ol')
TextSpan(
text:
'${(node.parent?.nodes.whereType<dom.Element>().toList().indexOf(node) ?? 0) + (int.tryParse(node.parent?.attributes['start'] ?? '1') ?? 1)}. ',
),
],
if (node.className == 'task-list-item')
WidgetSpan(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: SizedBox.square(
dimension: fontSize + 2,
child: CupertinoCheckbox(
checkColor: textColor,
side: BorderSide(color: textColor),
activeColor: textColor.withAlpha(64),
value:
staticallyChecked || checkedByReaction != null,
onChanged:
eventId == null ||
checkboxIndex == null ||
staticallyChecked ||
!room.canSendDefaultMessages ||
(checkedByReaction != null &&
checkedByReaction.senderId !=
room.client.userID)
? null
: (_) => showFutureLoadingDialog(
context: context,
future: () => checkedByReaction != null
? room.redactEvent(
checkedByReaction.eventId,
)
: room.checkCheckbox(
eventId,
checkboxIndex,
),
),
),
),
),
),
..._renderWithLineBreaks(node.nodes, context, depth: depth),
],
style: TextStyle(fontSize: fontSize, color: textColor),
),
),
),
);
case 'blockquote':
return WidgetSpan(
child: Container(
padding: const EdgeInsets.only(left: 8.0),
decoration: BoxDecoration(
border: Border(left: BorderSide(color: textColor, width: 5)),
),
child: Text.rich(
TextSpan(
children: _renderWithLineBreaks(
node.nodes,
context,
depth: depth,
),
),
style: TextStyle(
fontStyle: FontStyle.italic,
fontSize: fontSize,
color: textColor,
),
),
),
);
case 'code':
final isInline = node.parent?.localName != 'pre';
final lang =
node.className
.split(' ')
.singleWhereOrNull(
(className) => className.startsWith('language-'),
)
?.split('language-')
.last ??
'md';
final highlightedHtml = highlight
.parse(node.text, language: lang)
.toHtml();
final element = parser.parse(highlightedHtml).body;
if (element == null) {
return const TextSpan(text: 'Unable to render code block!');
}
return WidgetSpan(
child: Material(
color: atomOneBackgroundColor,
shape: RoundedRectangleBorder(
side: const BorderSide(color: hightlightTextColor),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: isInline
? const EdgeInsets.symmetric(horizontal: 4.0)
: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Text.rich(
TextSpan(children: [_renderCodeBlockNode(element)]),
selectionColor: hightlightTextColor.withAlpha(128),
),
),
),
);
case 'img':
final mxcUrl = Uri.tryParse(node.attributes['src'] ?? '');
if (mxcUrl == null || mxcUrl.scheme != 'mxc') {
return TextSpan(text: node.attributes['alt']);
}
final width = double.tryParse(node.attributes['width'] ?? '');
final height = double.tryParse(node.attributes['height'] ?? '');
const defaultDimension = 64.0;
final actualWidth = width ?? height ?? defaultDimension;
final actualHeight = height ?? width ?? defaultDimension;
return WidgetSpan(
child: SizedBox(
width: actualWidth,
height: actualHeight,
child: MxcImage(
uri: mxcUrl,
width: actualWidth,
height: actualHeight,
isThumbnail: (actualWidth * actualHeight) > (256 * 256),
),
),
);
case 'table':
return WidgetSpan(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Table(
defaultColumnWidth: const IntrinsicColumnWidth(),
border: TableBorder.all(color: textColor.withAlpha(100)),
children: node.nodes
.whereType<dom.Element>()
.expand(
(e) =>
e.localName == 'thead' ||
e.localName == 'tbody' ||
e.localName == 'tfoot'
? e.nodes.whereType<dom.Element>()
: [e],
)
.where((e) => e.localName == 'tr')
.map(
(tr) => TableRow(
children: tr.nodes
.whereType<dom.Element>()
.where(
(e) => e.localName == 'td' || e.localName == 'th',
)
.map(
(cell) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
child: Text.rich(
TextSpan(
children: _renderWithLineBreaks(
cell.nodes,
context,
depth: depth,
),
style: cell.localName == 'th'
? const TextStyle(
fontWeight: FontWeight.bold,
)
: null,
),
style: TextStyle(
fontSize: fontSize,
color: textColor,
),
),
),
)
.toList(),
),
)
.toList(),
),
),
);
case 'thead':
case 'tbody':
case 'tfoot':
case 'tr':
case 'th':
case 'td':
case 'caption':
// These are handled by the 'table' case above; if encountered
// outside a table, render children inline as fallback.
return TextSpan(
children: _renderWithLineBreaks(node.nodes, context, depth: depth),
);
case 'hr':
return const WidgetSpan(child: Divider());
case 'details':
var obscure = true;
return WidgetSpan(
child: StatefulBuilder(
builder: (context, setState) => InkWell(
splashColor: Colors.transparent,
onTap: () => setState(() {
obscure = !obscure;
}),
child: Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Icon(
obscure ? Icons.arrow_right : Icons.arrow_drop_down,
size: fontSize * 1.2,
color: textColor,
),
),
if (obscure)
...node.nodes
.where(
(node) =>
node is dom.Element &&
node.localName == 'summary',
)
.map(
(node) => _renderHtml(node, context, depth: depth),
)
else
..._renderWithLineBreaks(
node.nodes,
context,
depth: depth,
),
],
),
style: TextStyle(fontSize: fontSize, color: textColor),
),
),
),
);
case 'span':
if (!node.attributes.containsKey('data-mx-spoiler')) {
continue block;
}
var obscure = true;
return WidgetSpan(
child: StatefulBuilder(
builder: (context, setState) => InkWell(
splashColor: Colors.transparent,
onTap: () => setState(() {
obscure = !obscure;
}),
child: Text.rich(
TextSpan(
children: _renderWithLineBreaks(
node.nodes,
context,
depth: depth,
),
),
style: TextStyle(
fontSize: fontSize,
color: textColor,
backgroundColor: obscure ? textColor : null,
),
),
),
),
);
block:
default:
return TextSpan(
style: switch (node.localName) {
'body' => TextStyle(fontSize: fontSize, color: textColor),
'a' => linkStyle,
'strong' => const TextStyle(fontWeight: FontWeight.bold),
'em' || 'i' => const TextStyle(fontStyle: FontStyle.italic),
'del' || 's' || 'strikethrough' => const TextStyle(
decoration: TextDecoration.lineThrough,
),
'u' => const TextStyle(decoration: TextDecoration.underline),
'h1' => TextStyle(fontSize: fontSize * 1.6, height: 2),
'h2' => TextStyle(fontSize: fontSize * 1.5, height: 2),
'h3' => TextStyle(fontSize: fontSize * 1.4, height: 2),
'h4' => TextStyle(fontSize: fontSize * 1.3, height: 1.75),
'h5' => TextStyle(fontSize: fontSize * 1.2, height: 1.75),
'h6' => TextStyle(fontSize: fontSize * 1.1, height: 1.5),
'span' => TextStyle(
color:
node.attributes['color']?.hexToColor ??
node.attributes['data-mx-color']?.hexToColor ??
textColor,
backgroundColor: node.attributes['data-mx-bg-color']?.hexToColor,
),
'sup' => const TextStyle(
fontFeatures: [FontFeature.superscripts()],
),
'sub' => const TextStyle(fontFeatures: [FontFeature.subscripts()]),
_ => null,
},
children: _renderWithLineBreaks(node.nodes, context, depth: depth),
);
}
}
@override
Widget build(BuildContext context) {
final element = parser.parse(html).body ?? dom.Element.html('');
final configuredMaxLines = AppSettings.messagePreviewMaxLines.value;
final maxLines = configuredMaxLines <= 0 ? null : configuredMaxLines;
final span = _renderHtml(element, context);
final style = TextStyle(fontSize: fontSize, color: textColor);
if (maxLines == null) {
return Text.rich(
span,
style: style,
selectionColor: textColor.withAlpha(128),
);
}
// Heuristic: if plain text has enough newlines or length, it will overflow.
final plainText = element.text;
final lineCount = '\n'.allMatches(plainText).length + 1;
final likelyOverflows =
lineCount > maxLines || plainText.length > maxLines * 50;
if (!likelyOverflows) {
return Text.rich(
span,
style: style,
selectionColor: textColor.withAlpha(128),
);
}
return _CollapsibleText(
span: span,
style: style,
maxLines: maxLines,
textColor: textColor,
linkColor: linkStyle.color ?? textColor,
fontSize: fontSize,
);
}
}
class _CollapsibleText extends StatefulWidget {
final InlineSpan span;
final TextStyle style;
final int maxLines;
final Color textColor;
final Color linkColor;
final double fontSize;
const _CollapsibleText({
required this.span,
required this.style,
required this.maxLines,
required this.textColor,
required this.linkColor,
required this.fontSize,
});
@override
State<_CollapsibleText> createState() => _CollapsibleTextState();
}
class _CollapsibleTextState extends State<_CollapsibleText> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
alignment: Alignment.topLeft,
child: Text.rich(
widget.span,
style: widget.style,
maxLines: _expanded ? null : widget.maxLines,
overflow: _expanded ? TextOverflow.visible : TextOverflow.ellipsis,
selectionColor: widget.textColor.withAlpha(128),
),
),
Center(
child: TextButton.icon(
onPressed: () => setState(() => _expanded = !_expanded),
style: TextButton.styleFrom(foregroundColor: widget.linkColor),
icon: Icon(_expanded ? Icons.arrow_drop_up : Icons.arrow_drop_down),
label: Text(_expanded ? l10n.showLess : l10n.showMore),
),
),
],
);
}
}
class MatrixPill extends StatelessWidget {
final String name;
final BuildContext outerContext;
final Uri? avatar;
final String uri;
final double? fontSize;
final Color? color;
const MatrixPill({
super.key,
required this.name,
required this.outerContext,
this.avatar,
required this.uri,
required this.fontSize,
required this.color,
});
@override
Widget build(BuildContext context) {
return InkWell(
splashColor: Colors.transparent,
onTap: UrlLauncher(outerContext, uri).launchUrl,
child: Text.rich(
TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Avatar(mxContent: avatar, name: name, size: 16),
),
),
TextSpan(
text: name,
style: TextStyle(
color: color,
decorationColor: color,
decoration: TextDecoration.underline,
fontSize: fontSize,
height: 1.25,
),
),
],
),
),
);
}
}
extension on String {
Color? get hexToColor {
var hexCode = this;
if (hexCode.startsWith('#')) hexCode = hexCode.substring(1);
if (hexCode.length == 6) hexCode = 'FF$hexCode';
final colorValue = int.tryParse(hexCode, radix: 16);
return colorValue == null ? null : Color(colorValue);
}
}
extension on dom.Element {
dom.Element get rootElement => parent?.rootElement ?? this;
}

View File

@@ -0,0 +1,175 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/utils/file_description.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../../widgets/blur_hash.dart';
class ImageBubble extends StatelessWidget {
final Event event;
final bool tapToView;
final BoxFit fit;
final Color? backgroundColor;
final Color? textColor;
final Color? linkColor;
final bool thumbnailOnly;
final bool animated;
final double width;
final double height;
final void Function()? onTap;
final BorderRadius? borderRadius;
final Timeline? timeline;
const ImageBubble(
this.event, {
this.tapToView = true,
this.backgroundColor,
this.fit = BoxFit.contain,
this.thumbnailOnly = true,
this.width = 400,
this.height = 300,
this.animated = false,
this.onTap,
this.borderRadius,
this.timeline,
this.textColor,
this.linkColor,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
var borderRadius =
this.borderRadius ?? BorderRadius.circular(AppConfig.borderRadius);
final fileDescription = event.fileDescription;
final textColor = this.textColor;
if (fileDescription != null) {
borderRadius = borderRadius.copyWith(
bottomLeft: Radius.zero,
bottomRight: Radius.zero,
);
}
return Column(
mainAxisSize: .min,
spacing: 8,
children: [
Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
side: BorderSide(
color: event.messageType == MessageTypes.Sticker
? Colors.transparent
: theme.dividerColor,
),
),
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: Stack(
alignment: Alignment.center,
children: [
Hero(
tag: event.eventId,
child: AppSettings.showThumbnailsInTimeline.value
? MxcImage(
cacheKey: event.transactionId ?? event.eventId,
cacheName: event.room.id,
event: event,
width: width,
height: height,
fit: fit,
animated: animated,
isThumbnail: thumbnailOnly,
placeholder: event.messageType == MessageTypes.Sticker
? null
: (_) => _ImageBubblePlaceholder(
event: event,
width: width,
height: height,
fit: fit,
),
)
: _ImageBubblePlaceholder(
event: event,
width: width,
height: height,
fit: fit,
),
),
],
),
),
),
if (fileDescription != null && textColor != null)
SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Linkify(
text: fileDescription,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
),
],
);
}
}
class _ImageBubblePlaceholder extends StatelessWidget {
final Event event;
final double width, height;
final BoxFit fit;
const _ImageBubblePlaceholder({
required this.event,
required this.width,
required this.height,
required this.fit,
});
@override
Widget build(BuildContext context) {
final blurHashString =
event.infoMap.tryGet<String>('xyz.amorgan.blurhash') ??
'LEHV6nWB2yk8pyo0adR*.7kCMdnj';
return SizedBox(
width: width,
height: height,
child: BlurHash(
blurhash: blurHashString,
width: width,
height: height,
fit: fit,
),
);
}
}

View File

@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:material_ui/material_ui.dart';
import '../../../config/app_config.dart';
class MapBubble extends StatelessWidget {
final double latitude;
final double longitude;
final double zoom;
final double width;
final double height;
final double radius;
final VoidCallback? onTap;
const MapBubble({
required this.latitude,
required this.longitude,
this.zoom = 14.0,
this.width = 400,
this.height = 400,
this.radius = 10.0,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
constraints: BoxConstraints.loose(Size(width, height)),
child: AspectRatio(
aspectRatio: width / height,
child: Stack(
children: <Widget>[
FlutterMap(
options: MapOptions(
initialCenter: LatLng(latitude, longitude),
initialZoom: zoom,
),
children: [
TileLayer(
maxZoom: 20,
minZoom: 0,
urlTemplate:
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: AppConfig.appId,
subdomains: const ['a', 'b', 'c'],
),
MarkerLayer(
rotate: true,
markers: [
Marker(
point: LatLng(latitude, longitude),
width: 30,
height: 30,
child: Transform.translate(
// No idea why the offset has to be like this, instead of -15
// It has been determined by trying out, though, that this yields
// the tip of the location pin to be static when zooming.
// Might have to do with psychological perception of where the tip exactly is
offset: const Offset(0, -12.5),
child: const Icon(
Icons.location_pin,
color: Colors.red,
size: 30,
),
),
),
],
),
],
),
Container(
alignment: Alignment.bottomRight,
child: Text(
' © OpenStreetMap contributors ',
style: TextStyle(
color: theme.brightness == Brightness.dark
? Colors.white
: Colors.black,
backgroundColor: theme.appBarTheme.backgroundColor,
),
),
),
Material(
color: Colors.transparent,
child: Tooltip(
message: L10n.of(context).openInMaps,
child: InkWell(onTap: onTap, child: SizedBox.expand()),
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,321 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:math';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat/events/poll.dart';
import 'package:tatchat/pages/chat/events/video_player.dart';
import 'package:tatchat/pages/image_viewer/image_viewer.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../../config/app_config.dart';
import '../../../utils/event_checkbox_extension.dart';
import '../../../utils/platform_infos.dart';
import '../../../utils/url_launcher.dart';
import 'audio_player.dart';
import 'cute_events.dart';
import 'html_message.dart';
import 'image_bubble.dart';
import 'map_bubble.dart';
import 'message_download_content.dart';
class MessageContent extends StatelessWidget {
final Event event;
final Color textColor;
final Color linkColor;
final void Function(Event)? onInfoTab;
final BorderRadius borderRadius;
final Timeline timeline;
final bool selected;
final Set<String> bigEmojis;
const MessageContent(
this.event, {
this.onInfoTab,
super.key,
required this.timeline,
required this.textColor,
required this.linkColor,
required this.borderRadius,
required this.selected,
required this.bigEmojis,
});
@override
Widget build(BuildContext context) {
const fontSize = AppConfig.messageFontSize;
final buttonTextColor = textColor;
switch (event.type) {
case EventTypes.Message:
case EventTypes.Encrypted:
case EventTypes.Sticker:
switch (event.messageType) {
case MessageTypes.Image:
case MessageTypes.Sticker:
if (event.redacted) continue textmessage;
final maxSize = event.messageType == MessageTypes.Sticker
? 128.0
: 256.0;
final w = event.content
.tryGetMap<String, Object?>('info')
?.tryGet<int>('w');
final h = event.content
.tryGetMap<String, Object?>('info')
?.tryGet<int>('h');
var width = maxSize;
var height = maxSize;
var fit = event.messageType == MessageTypes.Sticker
? BoxFit.contain
: BoxFit.cover;
if (w != null && h != null) {
fit = BoxFit.contain;
if (w > h) {
width = maxSize;
height = max(32, maxSize * (h / w));
} else {
height = maxSize;
width = max(32, maxSize * (w / h));
}
}
return ImageBubble(
event,
width: width,
height: height,
fit: fit,
borderRadius: borderRadius,
timeline: timeline,
textColor: textColor,
onTap: () => showDialog(
context: context,
builder: (_) => ImageViewer(
event,
timeline: timeline,
outerContext: context,
),
),
);
case CuteEventContent.eventType:
return CuteContent(event);
case MessageTypes.Audio:
if (PlatformInfos.isMobile ||
PlatformInfos.isMacOS ||
PlatformInfos.isWeb ||
PlatformInfos.isLinux) {
return AudioPlayerWidget(
event,
color: textColor,
linkColor: linkColor,
fontSize: fontSize,
);
}
return MessageDownloadContent(
event,
textColor: textColor,
linkColor: linkColor,
);
case MessageTypes.Video:
return EventVideoPlayer(
event,
textColor: textColor,
linkColor: linkColor,
timeline: timeline,
);
case MessageTypes.File:
return MessageDownloadContent(
event,
textColor: textColor,
linkColor: linkColor,
);
case MessageTypes.Location:
final geoUri = Uri.tryParse(
event.content.tryGet<String>('geo_uri')!,
);
if (geoUri != null && geoUri.scheme == 'geo') {
final latlong = geoUri.path
.split(';')
.first
.split(',')
.map(double.tryParse)
.toList();
if (latlong.length == 2 &&
latlong.first != null &&
latlong.last != null) {
return MapBubble(
onTap: () =>
UrlLauncher(context, geoUri.toString()).launchUrl(),
latitude: latlong.first!,
longitude: latlong.last!,
);
}
}
continue textmessage;
case MessageTypes.Text:
case MessageTypes.Notice:
case MessageTypes.Emote:
case MessageTypes.None:
textmessage:
default:
if (event.redacted) {
return RedactionWidget(
event: event,
buttonTextColor: buttonTextColor,
onInfoTab: onInfoTab,
fontSize: fontSize,
);
}
var html = AppSettings.renderHtml.value && event.isRichMessage
? event.formattedText
: event.body.replaceAll('<', '&lt;').replaceAll('>', '&gt;');
if (event.messageType == MessageTypes.Emote) {
html = '* $html';
}
final bigEmotes =
!event.isRichMessage && bigEmojis.contains(event.body);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: HtmlMessage(
html: html,
textColor: textColor,
room: event.room,
fontSize: AppConfig.messageFontSize * (bigEmotes ? 5 : 1),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
eventId: event.eventId,
checkboxCheckedEvents: event.aggregatedEvents(
timeline,
EventCheckboxRoomExtension.relationshipType,
),
),
);
}
case PollEventContent.startType:
if (event.redacted) {
return RedactionWidget(
event: event,
buttonTextColor: buttonTextColor,
onInfoTab: onInfoTab,
fontSize: fontSize,
);
}
return PollWidget(
event: event,
timeline: timeline,
textColor: textColor,
linkColor: linkColor,
);
case EventTypes.CallInvite:
return FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
return _ButtonContent(
label: L10n.of(context).startedACall(
snapshot.data?.calcDisplayname() ??
event.senderFromMemoryOrFallback.calcDisplayname(),
),
icon: '📞',
textColor: buttonTextColor,
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
);
default:
return FutureBuilder<User?>(
future: event.fetchSenderUser(),
builder: (context, snapshot) {
return _ButtonContent(
label: L10n.of(context).userSentUnknownEvent(
snapshot.data?.calcDisplayname() ??
event.senderFromMemoryOrFallback.calcDisplayname(),
event.type,
),
icon: 'ℹ️',
textColor: buttonTextColor,
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
);
}
}
}
class RedactionWidget extends StatelessWidget {
const RedactionWidget({
super.key,
required this.event,
required this.buttonTextColor,
required this.onInfoTab,
required this.fontSize,
});
final Event event;
final Color buttonTextColor;
final void Function(Event p1)? onInfoTab;
final double fontSize;
@override
Widget build(BuildContext context) {
return FutureBuilder<User?>(
future: event.redactedBecause?.fetchSenderUser(),
builder: (context, snapshot) {
final reason = event.redactedBecause?.content.tryGet<String>('reason');
final redactedBy =
snapshot.data?.calcDisplayname() ??
event.redactedBecause?.senderId.localpart ??
L10n.of(context).user;
return _ButtonContent(
label: reason == null
? L10n.of(context).redactedBy(redactedBy)
: L10n.of(context).redactedByBecause(redactedBy, reason),
icon: '🗑️',
textColor: buttonTextColor.withAlpha(128),
onPressed: () => onInfoTab!(event),
fontSize: fontSize,
);
},
);
}
}
class _ButtonContent extends StatelessWidget {
final void Function() onPressed;
final String label;
final String icon;
final Color? textColor;
final double fontSize;
const _ButtonContent({
required this.label,
required this.icon,
required this.textColor,
required this.onPressed,
required this.fontSize,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: InkWell(
onTap: onPressed,
child: Text(
'$icon $label',
style: TextStyle(color: textColor, fontSize: fontSize),
),
),
);
}
}

View File

@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/pages/chat/events/file_send_status_indicator.dart';
import 'package:tatchat/utils/file_description.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class MessageDownloadContent extends StatelessWidget {
final Event event;
final Color textColor;
final Color linkColor;
const MessageDownloadContent(
this.event, {
required this.textColor,
required this.linkColor,
super.key,
});
@override
Widget build(BuildContext context) {
final filename = event.content.tryGet<String>('filename') ?? event.body;
final filetype = (filename.contains('.')
? filename.split('.').last.toUpperCase()
: event.content
.tryGetMap<String, Object?>('info')
?.tryGet<String>('mimetype')
?.toUpperCase() ??
'UNKNOWN');
final sizeString = event.sizeString ?? '?MB';
final fileDescription = event.fileDescription;
final fileSendingStatus = event.fileSendingStatus;
return Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
spacing: 8,
children: [
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
onTap: () => event.saveFile(context),
child: Container(
width: 400,
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisSize: .min,
spacing: 16,
children: [
if (fileSendingStatus != null)
FileSendStatusIndicator(
fileSendingStatus: fileSendingStatus,
)
else
CircleAvatar(
backgroundColor: textColor.withAlpha(32),
child: Icon(
Icons.file_download_outlined,
color: textColor,
),
),
Flexible(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(
filename,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w500,
),
),
Text(
'$sizeString | $filetype',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: textColor, fontSize: 10),
),
],
),
),
],
),
),
),
),
if (fileDescription != null) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
child: Linkify(
text: fileDescription,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,208 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:collection/collection.dart' show IterableExtension;
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:tatchat/widgets/matrix.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
class MessageReactions extends StatelessWidget {
final Event event;
final Timeline timeline;
const MessageReactions(this.event, this.timeline, {super.key});
@override
Widget build(BuildContext context) {
final allReactionEvents = event.aggregatedEvents(
timeline,
RelationshipTypes.reaction,
);
final reactionMap = <String, _ReactionEntry>{};
final client = Matrix.of(context).client;
for (final e in allReactionEvents) {
final key = e.content
.tryGetMap<String, Object?>('m.relates_to')
?.tryGet<String>('key');
if (key != null) {
if (!reactionMap.containsKey(key)) {
reactionMap[key] = _ReactionEntry(
key: key,
count: 0,
reacted: false,
reactors: [],
);
}
reactionMap[key]!.count++;
reactionMap[key]!.reactors!.add(e.senderFromMemoryOrFallback);
reactionMap[key]!.reacted |= e.senderId == e.room.client.userID;
}
}
final reactionList = reactionMap.values.toList();
reactionList.sort((a, b) => b.count - a.count > 0 ? 1 : -1);
final ownMessage = event.senderId == event.room.client.userID;
return Wrap(
spacing: 4.0,
runSpacing: 4.0,
alignment: ownMessage ? WrapAlignment.end : WrapAlignment.start,
children: [
...reactionList.map(
(r) => _Reaction(
reactionKey: r.key,
count: r.count,
reacted: r.reacted,
onTap: () {
if (r.reacted) {
final evt = allReactionEvents.firstWhereOrNull(
(e) =>
e.senderId == e.room.client.userID &&
e.content.tryGetMap('m.relates_to')?['key'] == r.key,
);
if (evt != null) {
showFutureLoadingDialog(
context: context,
future: evt.redactEvent,
);
}
} else {
event.room.sendReaction(event.eventId, r.key);
}
},
onLongPress: () async => await _AdaptableReactorsDialog(
client: client,
reactionEntry: r,
).show(context),
),
),
],
);
}
}
class _Reaction extends StatelessWidget {
final String reactionKey;
final int count;
final bool? reacted;
final void Function()? onTap;
final void Function()? onLongPress;
const _Reaction({
required this.reactionKey,
required this.count,
required this.reacted,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget content;
if (reactionKey.startsWith('mxc://')) {
content = MxcImage(
uri: Uri.parse(reactionKey),
width: 20,
height: 20,
animated: false,
isThumbnail: false,
);
} else {
var renderKey = Characters(reactionKey);
if (renderKey.length > 10) {
renderKey = renderKey.getRange(0, 9) + Characters('…');
}
content = Text(renderKey.toString(), style: TextStyle(fontSize: 14));
}
return Badge(
isLabelVisible: count > 1,
label: Text(count.toString()),
textStyle: TextStyle(fontSize: 10),
textColor: theme.colorScheme.onPrimary,
backgroundColor: theme.colorScheme.primary.withAlpha(200),
child: InkWell(
onTap: () => onTap != null ? onTap!() : null,
onLongPress: () => onLongPress != null ? onLongPress!() : null,
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
border: Border.all(
color: reacted == true
? theme.colorScheme.secondary
: theme.colorScheme.secondaryContainer,
width: 1,
),
borderRadius: BorderRadius.circular(AppConfig.borderRadius / 2),
),
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 3),
child: content,
),
),
);
}
}
class _ReactionEntry {
String key;
int count;
bool reacted;
List<User>? reactors;
_ReactionEntry({
required this.key,
required this.count,
required this.reacted,
this.reactors,
});
}
class _AdaptableReactorsDialog extends StatelessWidget {
final Client? client;
final _ReactionEntry? reactionEntry;
const _AdaptableReactorsDialog({this.client, this.reactionEntry});
Future<bool?> show(BuildContext context) => showDialog(
context: context,
builder: (context) => this,
barrierDismissible: true,
useRootNavigator: false,
);
@override
Widget build(BuildContext context) {
final body = SingleChildScrollView(
child: Wrap(
spacing: 8.0,
runSpacing: 4.0,
alignment: WrapAlignment.center,
children: <Widget>[
for (final reactor in reactionEntry!.reactors!)
Chip(
avatar: Avatar(
mxContent: reactor.avatarUrl,
name: reactor.displayName,
client: client,
presenceUserId: reactor.stateKey,
),
label: Text(reactor.displayName!),
),
],
),
);
final title = Center(child: Text(reactionEntry!.key));
return AlertDialog(title: title, content: body);
}
}

View File

@@ -0,0 +1,224 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:async/async.dart';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:tatchat/widgets/future_loading_dialog.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart' hide Result;
class PollWidget extends StatelessWidget {
final Event event;
final Timeline timeline;
final Color textColor;
final Color linkColor;
const PollWidget({
required this.event,
required this.timeline,
required this.textColor,
required this.linkColor,
super.key,
});
void _endPoll(BuildContext context) =>
showFutureLoadingDialog(context: context, future: event.endPoll);
void _toggleVote(BuildContext context, String answerId, int maxSelection) {
final userId = event.room.client.userID!;
final answerIds = event.getPollResponses(timeline)[userId] ?? {};
if (!answerIds.remove(answerId)) {
answerIds.add(answerId);
if (answerIds.length > maxSelection) {
answerIds.clear();
answerIds.add(answerId);
}
}
showFutureLoadingDialog(
context: context,
future: () => event.answerPoll(answerIds.toList()),
);
}
@override
Widget build(BuildContext context) {
final eventContentResult = Result(() => event.parsedPollEventContent);
final eventContent = eventContentResult.asValue?.value;
if (eventContent == null) {
Logs().w('Invalid poll event', eventContentResult.error);
return const Text('Unable to parse poll event...');
}
final responses = event.getPollResponses(timeline);
final pollHasBeenEnded = event.getPollHasBeenEnded(timeline);
final canVote =
event.room.canSendEvent(PollEventContent.responseType) &&
!pollHasBeenEnded;
final maxPolls = responses.length;
final answersVisible =
eventContent.pollStartContent.kind == PollKind.disclosed ||
pollHasBeenEnded;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Linkify(
text: eventContent.pollStartContent.question.mText,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
Divider(color: linkColor.withAlpha(64)),
...eventContent.pollStartContent.answers.map((answer) {
final votedUserIds = responses.entries
.where((entry) => entry.value.contains(answer.id))
.map((entry) => entry.key)
.toSet();
return Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
child: CheckboxListTile.adaptive(
value:
responses[event.room.client.userID!]?.contains(answer.id) ??
false,
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
checkboxScaleFactor: 1.5,
checkboxShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(32),
),
onChanged: !canVote
? null
: (_) => _toggleVote(
context,
answer.id,
eventContent.pollStartContent.maxSelections,
),
title: Text(
answer.mText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
),
subtitle: answersVisible
? Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
Text(
L10n.of(
context,
).countVotes(votedUserIds.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: linkColor,
fontSize: 12,
),
),
const SizedBox(width: 2),
...votedUserIds.map((userId) {
final user = event.room
.getState(EventTypes.RoomMember, userId)
?.asUser(event.room);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 2.0,
),
child: Avatar(
mxContent: user?.avatarUrl,
name:
user?.calcDisplayname() ??
userId.localpart,
size: 12,
),
);
}),
const SizedBox(width: 2),
],
),
),
LinearProgressIndicator(
color: linkColor,
backgroundColor: linkColor.withAlpha(128),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
value: maxPolls == 0
? 0
: votedUserIds.length / maxPolls,
),
],
)
: null,
),
);
}),
if (!pollHasBeenEnded && event.senderId == event.room.client.userID)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton(
onPressed: () => _endPoll(context),
style: OutlinedButton.styleFrom(
foregroundColor: linkColor,
side: BorderSide(color: linkColor.withAlpha(64)),
),
child: Text(L10n.of(context).endPoll),
),
)
else if (!answersVisible)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
L10n.of(context).answersWillBeVisibleWhenPollHasEnded,
style: TextStyle(
color: linkColor,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
)
else if (pollHasBeenEnded)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
L10n.of(context).pollHasBeenEnded,
style: TextStyle(
color: linkColor,
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,110 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../../config/app_config.dart';
class ReplyContent extends StatelessWidget {
final Event replyEvent;
final bool ownMessage;
final Timeline? timeline;
const ReplyContent(
this.replyEvent, {
this.ownMessage = false,
super.key,
this.timeline,
});
static const BorderRadius borderRadius = BorderRadius.only(
topRight: Radius.circular(AppConfig.borderRadius / 2),
bottomRight: Radius.circular(AppConfig.borderRadius / 2),
);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final timeline = this.timeline;
final displayEvent = timeline != null
? replyEvent.getDisplayEvent(timeline)
: replyEvent;
const fontSize = AppConfig.messageFontSize;
final color = theme.brightness == Brightness.dark
? theme.colorScheme.onTertiaryContainer
: ownMessage
? theme.colorScheme.tertiaryContainer
: theme.colorScheme.tertiary;
return Material(
color: Colors.transparent,
borderRadius: borderRadius,
child: Row(
mainAxisSize: .min,
children: <Widget>[
Container(
width: 5,
height: fontSize * 2 + 16,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
color: color,
),
),
const SizedBox(width: 6),
Flexible(
child: Column(
crossAxisAlignment: .start,
mainAxisAlignment: .center,
children: <Widget>[
FutureBuilder<User?>(
initialData: displayEvent.senderFromMemoryOrFallback,
future: displayEvent.fetchSenderUser(),
builder: (context, snapshot) {
return Text(
'${snapshot.data?.calcDisplayname() ?? displayEvent.senderFromMemoryOrFallback.calcDisplayname()}:',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
fontSize: fontSize,
),
);
},
),
Text(
displayEvent
.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
withSenderNamePrefix: false,
hideReply: true,
plaintextBody: true,
)
.trim()
.replaceAll('\n', ' '),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
color: theme.brightness == Brightness.dark
? theme.colorScheme.onSurface
: ownMessage
? theme.colorScheme.onTertiary
: theme.colorScheme.onSurface,
fontSize: fontSize,
),
),
],
),
),
const SizedBox(width: 6),
],
),
);
}
}

View File

@@ -0,0 +1,123 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'package:tatchat/config/themes.dart';
import 'package:tatchat/l10n/l10n.dart';
import 'package:tatchat/pages/chat/utd_dialog.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:tatchat/widgets/avatar.dart';
import 'package:flutter/gestures.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../../config/app_config.dart';
class StateMessage extends StatelessWidget {
final Event event;
final void Function()? onExpand;
final bool isCollapsed;
const StateMessage(
this.event, {
this.onExpand,
this.isCollapsed = false,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: isCollapsed
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Center(
child: Padding(
padding: const EdgeInsets.all(4),
child: Material(
color: theme.colorScheme.surface.withAlpha(128),
borderRadius: BorderRadius.circular(
AppConfig.borderRadius / 3,
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 4.0,
),
child: Text.rich(
TextSpan(
children: [
if (event.type != EventTypes.Encrypted)
TextSpan(
text: event.calcLocalizedBodyFallback(
MatrixLocals(L10n.of(context)),
),
)
else ...[
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Avatar(
name: event.senderFromMemoryOrFallback
.calcDisplayname(),
size: 14,
mxContent: event
.senderFromMemoryOrFallback
.avatarUrl,
),
),
),
TextSpan(
text: L10n.of(context).messageNotDecryptable,
),
if (!event.redacted)
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: InkWell(
borderRadius: BorderRadius.circular(7),
onTap: () =>
UtdDialog.show(context, event),
child: Icon(
Icons.info_outlined,
size: 14,
),
),
),
),
],
if (onExpand != null) ...[
const TextSpan(text: '\n'),
TextSpan(
style: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = onExpand,
text: L10n.of(context).moreEvents,
),
],
],
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
decoration: event.redacted
? TextDecoration.lineThrough
: null,
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,163 @@
// SPDX-FileCopyrightText: 2019-Present Christian Kußowski
// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat
//
// SPDX-License-Identifier: AGPL-3.0-or-later
import 'dart:math';
import 'package:tatchat/config/app_config.dart';
import 'package:tatchat/config/setting_keys.dart';
import 'package:tatchat/utils/file_description.dart';
import 'package:tatchat/utils/matrix_sdk_extensions/event_extension.dart';
import 'package:tatchat/utils/platform_infos.dart';
import 'package:tatchat/utils/url_launcher.dart';
import 'package:tatchat/widgets/blur_hash.dart';
import 'package:tatchat/widgets/mxc_image.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:material_ui/material_ui.dart';
import 'package:matrix/matrix.dart';
import '../../image_viewer/image_viewer.dart';
class EventVideoPlayer extends StatelessWidget {
final Event event;
final Timeline? timeline;
final Color? textColor;
final Color? linkColor;
const EventVideoPlayer(
this.event, {
this.timeline,
this.textColor,
this.linkColor,
super.key,
});
static const String fallbackBlurHash = 'L5H2EC=PM+yV0g-mq.wG9c010J}I';
@override
Widget build(BuildContext context) {
final supportsVideoPlayer = PlatformInfos.supportsVideoPlayer;
final blurHash =
(event.infoMap as Map<String, dynamic>).tryGet<String>(
'xyz.amorgan.blurhash',
) ??
fallbackBlurHash;
final fileDescription = event.fileDescription;
const maxDimension = 300.0;
final infoMap = event.content.tryGetMap<String, Object?>('info');
final videoWidth = infoMap?.tryGet<int>('w') ?? maxDimension;
final videoHeight = infoMap?.tryGet<int>('h') ?? maxDimension;
final modifier = max(videoWidth, videoHeight) / maxDimension;
final width = videoWidth / modifier;
final height = videoHeight / modifier;
final durationInt = infoMap?.tryGet<int>('duration');
final duration = durationInt == null
? null
: Duration(milliseconds: durationInt);
return Column(
mainAxisSize: .min,
spacing: 8,
children: [
Material(
color: Colors.black,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: InkWell(
onTap: () => supportsVideoPlayer
? showDialog(
context: context,
builder: (_) => ImageViewer(
event,
timeline: timeline,
outerContext: context,
),
)
: event.saveFile(context),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
child: SizedBox(
width: width,
height: height,
child: Hero(
tag: event.eventId,
child: Stack(
children: [
if (event.hasThumbnail &&
AppSettings.showThumbnailsInTimeline.value)
MxcImage(
event: event,
cacheKey: event.transactionId ?? event.eventId,
cacheName: event.room.id,
isThumbnail: true,
width: width,
height: height,
fit: BoxFit.cover,
placeholder: (context) => BlurHash(
blurhash: blurHash,
width: width,
height: height,
fit: BoxFit.cover,
),
)
else
BlurHash(
blurhash: blurHash,
width: width,
height: height,
fit: BoxFit.cover,
),
Center(
child: CircleAvatar(
child: supportsVideoPlayer
? const Icon(Icons.play_arrow_outlined)
: const Icon(Icons.file_download_outlined),
),
),
if (duration != null)
Positioned(
bottom: 8,
left: 16,
child: Text(
'${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}',
style: TextStyle(
color: Colors.white,
backgroundColor: Colors.black.withAlpha(32),
),
),
),
],
),
),
),
),
),
if (fileDescription != null && textColor != null && linkColor != null)
SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Linkify(
text: fileDescription,
textScaleFactor: MediaQuery.textScalerOf(context).scale(1),
style: TextStyle(
color: textColor,
fontSize: AppConfig.messageFontSize,
),
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: linkColor,
fontSize: AppConfig.messageFontSize,
decoration: TextDecoration.underline,
decorationColor: linkColor,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
),
),
],
);
}
}