_ReplyPreview nutzt den Sprung-Mechanismus der Suche (pendingJumpEventProvider). Der Sprung scrollt jetzt schrittweise Richtung aeltere Nachrichten, wenn das Ziel zwar geladen, aber in der lazy ListView noch nicht gezeichnet ist (vorher gab er dann nach 40 Versuchen auf); dasselbe Zitat darf erneut angetippt werden. UI-Durchlauf prueft den Sprung per Screenshot. Co-Authored-By: Claude Opus 5.5 <[email protected]>
252 lines
9.0 KiB
Dart
252 lines
9.0 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);
|
|
|
|
// Antippen springt zur zitierten Nachricht und lässt sie kurz aufleuchten
|
|
// (gleicher Mechanismus wie der Sprung aus der Suche).
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => ProviderScope.containerOf(context, listen: false)
|
|
.read(pendingJumpEventProvider.notifier)
|
|
.state = replyEvent.eventId,
|
|
child: MouseRegion(
|
|
cursor: SystemMouseCursors.click,
|
|
child: SpringPress(
|
|
pressedScale: 0.97,
|
|
child: 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);
|
|
}
|
|
}
|