5bf45a9366
Gleiche Technik wie bei settings_modal.dart: reine Verschiebung per Dart part/part-of, keine Logikänderung. message_group.dart schrumpft von 2775 auf 30 Zeilen (nur noch Bibliothekskopf). Verifiziert per automatisiertem Blockvergleich gegen das Original und flutter analyze (Baseline unverändert). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
238 lines
8.5 KiB
Dart
238 lines
8.5 KiB
Dart
part of '../message_group.dart';
|
|
|
|
class _LinkPreviewData {
|
|
final String? title;
|
|
final String? description;
|
|
final String? imageMxc;
|
|
const _LinkPreviewData({this.title, this.description, this.imageMxc});
|
|
bool get isEmpty =>
|
|
(title == null || title!.isEmpty) &&
|
|
(description == null || description!.isEmpty) &&
|
|
imageMxc == null;
|
|
}
|
|
|
|
class _LinkPreview extends ConsumerStatefulWidget {
|
|
final String url;
|
|
final PyramidTheme pt;
|
|
const _LinkPreview({required this.url, required this.pt});
|
|
|
|
// url → preview (null = fetched but nothing usable). Avoids refetching on
|
|
// every rebuild/scroll.
|
|
static final Map<String, _LinkPreviewData?> _cache = {};
|
|
|
|
@override
|
|
ConsumerState<_LinkPreview> createState() => _LinkPreviewState();
|
|
}
|
|
|
|
class _LinkPreviewState extends ConsumerState<_LinkPreview> {
|
|
_LinkPreviewData? _data;
|
|
bool _done = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
if (_LinkPreview._cache.containsKey(widget.url)) {
|
|
setState(() { _data = _LinkPreview._cache[widget.url]; _done = true; });
|
|
return;
|
|
}
|
|
try {
|
|
final client = await ref.read(matrixClientProvider.future);
|
|
final hs = client.homeserver.toString();
|
|
final enc = Uri.encodeQueryComponent(widget.url);
|
|
final headers = {'authorization': 'Bearer ${client.accessToken}'};
|
|
|
|
var res = await client.httpClient.get(
|
|
Uri.parse('$hs/_matrix/client/v1/media/preview_url?url=$enc'),
|
|
headers: headers,
|
|
);
|
|
if (res.statusCode == 404) {
|
|
// Legacy fallback for older servers.
|
|
res = await client.httpClient.get(
|
|
Uri.parse('$hs/_matrix/media/v3/preview_url?url=$enc'),
|
|
headers: headers,
|
|
);
|
|
}
|
|
_LinkPreviewData? data;
|
|
if (res.statusCode == 200) {
|
|
final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map<String, dynamic>;
|
|
data = _LinkPreviewData(
|
|
title: (json['og:title'] as String?)?.trim(),
|
|
description: (json['og:description'] as String?)?.trim(),
|
|
imageMxc: json['og:image'] as String?,
|
|
);
|
|
if (data.isEmpty) data = null;
|
|
}
|
|
_LinkPreview._cache[widget.url] = data;
|
|
if (mounted) setState(() { _data = data; _done = true; });
|
|
} catch (_) {
|
|
_LinkPreview._cache[widget.url] = null;
|
|
if (mounted) setState(() { _data = null; _done = true; });
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final data = _data;
|
|
if (!_done || data == null) return const SizedBox.shrink();
|
|
final pt = widget.pt;
|
|
final client = ref.watch(matrixClientProvider).valueOrNull;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 6, bottom: 2),
|
|
child: GestureDetector(
|
|
onTap: () => launchUrl(Uri.parse(widget.url), mode: LaunchMode.externalApplication),
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 380),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(pt.rBase),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: pt.bg2,
|
|
border: Border(left: BorderSide(color: pt.accent, width: 3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (data.imageMxc != null && client != null)
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 180),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: MxcImage(
|
|
mxcUri: data.imageMxc!,
|
|
client: client,
|
|
fit: BoxFit.cover,
|
|
placeholder: const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (data.title != null && data.title!.isNotEmpty)
|
|
Text(
|
|
data.title!,
|
|
style: TextStyle(color: pt.fg, fontSize: 13, fontWeight: FontWeight.w600),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (data.description != null && data.description!.isNotEmpty) ...[
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
data.description!,
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 12, height: 1.35),
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ReplyPreview extends StatelessWidget {
|
|
final Event replyEvent;
|
|
final PyramidTheme pt;
|
|
const _ReplyPreview({required this.replyEvent, required this.pt});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final senderName = replyEvent.senderFromMemoryOrFallback.calcDisplayname();
|
|
|
|
// Media-aware preview: show an icon + label instead of a raw filename,
|
|
// matching WhatsApp/Discord. Falls back to stripped text for m.text.
|
|
final (IconData? icon, String body) = _previewFor(replyEvent);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: IntrinsicHeight(
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(pt.rSm),
|
|
child: Container(
|
|
color: pt.accentSoft,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Accent bar flush with the left edge of the tinted card.
|
|
Container(width: 3, color: pt.accent),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 5, 10, 5),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(senderName, style: TextStyle(color: pt.accent, fontSize: 12, fontWeight: FontWeight.w600)),
|
|
Row(
|
|
children: [
|
|
if (icon != null) ...[
|
|
Icon(icon, size: 13, color: pt.fgMuted),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Expanded(
|
|
child: Text(
|
|
body,
|
|
style: TextStyle(color: pt.fgMuted, fontSize: 12),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
(IconData?, String) _previewFor(Event e) {
|
|
switch (e.messageType) {
|
|
case 'm.image':
|
|
return (Icons.image_outlined, 'Bild');
|
|
case 'm.video':
|
|
return (Icons.videocam_outlined, 'Video');
|
|
case 'm.audio':
|
|
return (Icons.mic_none_rounded, 'Sprachnachricht');
|
|
case 'm.file':
|
|
return (Icons.insert_drive_file_outlined, e.body.isNotEmpty ? e.body : 'Datei');
|
|
}
|
|
if (e.type == EventTypes.Sticker) return (Icons.emoji_emotions_outlined, 'Sticker');
|
|
|
|
// Plain text: strip nested reply fallback, collapse to first line.
|
|
String body = e.body.replaceAll('\r\n', '\n');
|
|
if (body.startsWith('> ')) {
|
|
final lines = body.split('\n');
|
|
var i = 0;
|
|
while (i < lines.length && (lines[i].startsWith('> ') || lines[i].trimRight() == '>')) {
|
|
i++;
|
|
}
|
|
if (i < lines.length && lines[i].trim().isEmpty) i++;
|
|
if (i < lines.length) body = lines.skip(i).join(' ').trim();
|
|
}
|
|
return (null, body.split('\n').first);
|
|
}
|
|
}
|