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 _cache = {}; @override ConsumerState<_LinkPreview> createState() => _LinkPreviewState(); } class _LinkPreviewState extends ConsumerState<_LinkPreview> { _LinkPreviewData? _data; bool _done = false; @override void initState() { super.initState(); _load(); } Future _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; 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); } }