Initial commit
This commit is contained in:
538
lib/pages/chat/events/audio_player.dart
Normal file
538
lib/pages/chat/events/audio_player.dart
Normal 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')}';
|
||||
}
|
||||
195
lib/pages/chat/events/cute_events.dart
Normal file
195
lib/pages/chat/events/cute_events.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/pages/chat/events/file_send_status_indicator.dart
Normal file
35
lib/pages/chat/events/file_send_status_indicator.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
743
lib/pages/chat/events/html_message.dart
Normal file
743
lib/pages/chat/events/html_message.dart
Normal 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;
|
||||
}
|
||||
175
lib/pages/chat/events/image_bubble.dart
Normal file
175
lib/pages/chat/events/image_bubble.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/pages/chat/events/map_bubble.dart
Normal file
105
lib/pages/chat/events/map_bubble.dart
Normal 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()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1037
lib/pages/chat/events/message.dart
Normal file
1037
lib/pages/chat/events/message.dart
Normal file
File diff suppressed because it is too large
Load Diff
321
lib/pages/chat/events/message_content.dart
Normal file
321
lib/pages/chat/events/message_content.dart
Normal 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('<', '<').replaceAll('>', '>');
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
124
lib/pages/chat/events/message_download_content.dart
Normal file
124
lib/pages/chat/events/message_download_content.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
208
lib/pages/chat/events/message_reactions.dart
Normal file
208
lib/pages/chat/events/message_reactions.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
224
lib/pages/chat/events/poll.dart
Normal file
224
lib/pages/chat/events/poll.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
lib/pages/chat/events/reply_content.dart
Normal file
110
lib/pages/chat/events/reply_content.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
123
lib/pages/chat/events/state_message.dart
Normal file
123
lib/pages/chat/events/state_message.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
lib/pages/chat/events/video_player.dart
Normal file
163
lib/pages/chat/events/video_player.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user