From 46bf51a6c4c15b0c488a59dd293ee5b7e066182e Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sun, 8 Feb 2026 14:20:38 -0600 Subject: [PATCH] feat: compose URL preview, truncated URLs in post body, sync link preview fetch --- go-backend/internal/handlers/post_handler.go | 28 ++-- .../lib/screens/compose/compose_screen.dart | 135 ++++++++++++++++++ sojorn_app/lib/widgets/sojorn_rich_text.dart | 19 +++ 3 files changed, 169 insertions(+), 13 deletions(-) diff --git a/go-backend/internal/handlers/post_handler.go b/go-backend/internal/handlers/post_handler.go index 230b14a..2e2ceba 100644 --- a/go-backend/internal/handlers/post_handler.go +++ b/go-backend/internal/handlers/post_handler.go @@ -575,22 +575,24 @@ func (h *PostHandler) CreatePost(c *gin.Context) { h.moderationService.LogAIDecision(c.Request.Context(), "post", post.ID, userID, req.Body, scores, nil, decision, flagReason, orDecision, nil) } - // Auto-extract link preview from post body (async — don't block response) + // Auto-extract link preview from post body (synchronous so it's in the response) if h.linkPreviewService != nil { - go func() { - ctx := context.Background() - linkURL := services.ExtractFirstURL(req.Body) - if linkURL != "" { - // Check if author is an official account (trusted = skip safety checks) - var isOfficial bool - _ = h.postRepo.Pool().QueryRow(ctx, `SELECT COALESCE(is_official, false) FROM profiles WHERE id = $1`, userID).Scan(&isOfficial) + linkURL := services.ExtractFirstURL(req.Body) + if linkURL != "" { + // Check if author is an official account (trusted = skip safety checks) + var isOfficial bool + _ = h.postRepo.Pool().QueryRow(c.Request.Context(), `SELECT COALESCE(is_official, false) FROM profiles WHERE id = $1`, userID).Scan(&isOfficial) - lp, err := h.linkPreviewService.FetchPreview(ctx, linkURL, isOfficial) - if err == nil && lp != nil { - _ = h.linkPreviewService.SaveLinkPreview(ctx, post.ID.String(), lp) - } + lp, lpErr := h.linkPreviewService.FetchPreview(c.Request.Context(), linkURL, isOfficial) + if lpErr == nil && lp != nil { + _ = h.linkPreviewService.SaveLinkPreview(c.Request.Context(), post.ID.String(), lp) + post.LinkPreviewURL = &lp.URL + post.LinkPreviewTitle = &lp.Title + post.LinkPreviewDescription = &lp.Description + post.LinkPreviewImageURL = &lp.ImageURL + post.LinkPreviewSiteName = &lp.SiteName } - }() + } } // Check for @mentions and notify mentioned users diff --git a/sojorn_app/lib/screens/compose/compose_screen.dart b/sojorn_app/lib/screens/compose/compose_screen.dart index bde0f72..309dee9 100644 --- a/sojorn_app/lib/screens/compose/compose_screen.dart +++ b/sojorn_app/lib/screens/compose/compose_screen.dart @@ -12,6 +12,7 @@ import '../../models/sojorn_media_result.dart'; import '../../models/tone_analysis.dart'; import '../../providers/api_provider.dart'; import '../../providers/feed_refresh_provider.dart'; +import '../../services/api_service.dart'; import '../../services/image_upload_service.dart'; import '../../theme/app_theme.dart'; import '../../widgets/composer/composer_toolbar.dart'; @@ -47,6 +48,13 @@ class _ComposeScreenState extends ConsumerState { bool _isItalic = false; int? _ttlHoursOverride; + // Link preview state + String? _detectedUrl; + Map? _linkPreview; + bool _isFetchingPreview = false; + Timer? _urlDebounce; + bool _isTyping = false; + File? _selectedImageFile; Uint8List? _selectedImageBytes; String? _selectedImageName; @@ -71,6 +79,7 @@ class _ComposeScreenState extends ConsumerState { _bodyController.addListener(() { _charCountNotifier.value = _bodyController.text.length; _handleHashtagSuggestions(); + _handleUrlDetection(); // Clear blocked banner when user edits their text if (_blockedMessage != null) { setState(() => _blockedMessage = null); @@ -82,8 +91,56 @@ class _ComposeScreenState extends ConsumerState { }); } + /// Detect URLs in text and fetch a preview after a pause in typing. + void _handleUrlDetection() { + setState(() => _isTyping = true); + _urlDebounce?.cancel(); + _urlDebounce = Timer(const Duration(milliseconds: 800), () { + if (!mounted) return; + setState(() => _isTyping = false); + final urlMatch = RegExp(r'https?://\S+').firstMatch(_bodyController.text); + final url = urlMatch?.group(0); + if (url != null && url != _detectedUrl && url.length > 10) { + _detectedUrl = url; + _fetchLinkPreview(url); + } else if (url == null) { + setState(() { + _detectedUrl = null; + _linkPreview = null; + }); + } + }); + } + + Future _fetchLinkPreview(String url) async { + if (_isFetchingPreview) return; + setState(() => _isFetchingPreview = true); + try { + final data = await ApiService.instance.callGoApi( + '/safe-domains/check', + method: 'GET', + queryParams: {'url': url}, + ); + if (!mounted) return; + // Now fetch the OG preview from a lightweight endpoint + // For now, just show the URL + safety status as a card + setState(() { + _linkPreview = { + 'url': url, + 'domain': data['domain'] ?? '', + 'safe': data['safe'] ?? false, + 'status': data['status'] ?? 'unknown', + }; + }); + } catch (_) { + // Silently fail - preview is optional + } + if (mounted) setState(() => _isFetchingPreview = false); + } + @override void dispose() { + _urlDebounce?.cancel(); _hashtagDebounce?.cancel(); _charCountNotifier.dispose(); _bodyFocusNode.dispose(); @@ -614,6 +671,9 @@ class _ComposeScreenState extends ConsumerState { !isKeyboardOpen ? _buildImagePreview() : null, + linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen + ? _buildComposeLinkPreview() + : null, ), ), ], @@ -694,6 +754,78 @@ class _ComposeScreenState extends ConsumerState { ), ); } + + Widget _buildComposeLinkPreview() { + final preview = _linkPreview!; + final domain = preview['domain'] as String? ?? ''; + final url = preview['url'] as String? ?? ''; + final isSafe = preview['safe'] as bool? ?? false; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: AppTheme.spacingLg, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSafe + ? AppTheme.navyBlue.withValues(alpha: 0.05) + : Colors.amber.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSafe + ? AppTheme.navyBlue.withValues(alpha: 0.15) + : Colors.amber.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + isSafe ? Icons.link_rounded : Icons.warning_amber_rounded, + size: 20, + color: isSafe ? AppTheme.navyBlue : Colors.amber.shade700, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + domain.toUpperCase(), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.8, + color: AppTheme.textTertiary, + ), + ), + const SizedBox(height: 2), + Text( + url.length > 60 ? '${url.substring(0, 57)}...' : url, + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (_isFetchingPreview) + const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + GestureDetector( + onTap: () => setState(() { + _linkPreview = null; + _detectedUrl = null; + }), + child: Icon(Icons.close, size: 18, color: AppTheme.textTertiary), + ), + ], + ), + ); + } } /// Minimal top bar with Cancel + Post pill @@ -784,6 +916,7 @@ class ComposeBody extends StatelessWidget { final bool isBold; final bool isItalic; final Widget? imageWidget; + final Widget? linkPreviewWidget; final List suggestions; final ValueChanged onSelectSuggestion; @@ -796,6 +929,7 @@ class ComposeBody extends StatelessWidget { required this.suggestions, required this.onSelectSuggestion, this.imageWidget, + this.linkPreviewWidget, }); @override @@ -867,6 +1001,7 @@ class ComposeBody extends StatelessWidget { ), ), ), + if (linkPreviewWidget != null) linkPreviewWidget!, if (imageWidget != null) imageWidget!, ], ); diff --git a/sojorn_app/lib/widgets/sojorn_rich_text.dart b/sojorn_app/lib/widgets/sojorn_rich_text.dart index 3eaac4d..6cf278d 100644 --- a/sojorn_app/lib/widgets/sojorn_rich_text.dart +++ b/sojorn_app/lib/widgets/sojorn_rich_text.dart @@ -75,6 +75,9 @@ class sojornRichText extends StatelessWidget { } else { displayText = 'View Location'; } + } else if (!isMention && !isHashtag) { + // Truncate long URLs for display: "https://apnews.com/article..." + displayText = _truncateUrl(matchText); } spans.add( @@ -120,4 +123,20 @@ class sojornRichText extends StatelessWidget { return spans; } + + /// Truncate long URLs for display: show domain + start of path + "..." + static String _truncateUrl(String url) { + if (url.length <= 45) return url; + try { + final uri = Uri.parse(url); + final domain = uri.host; + final path = uri.path; + if (path.length > 15) { + return '${uri.scheme}://$domain${path.substring(0, 12)}...'; + } + return '${uri.scheme}://$domain$path'; + } catch (_) { + return '${url.substring(0, 42)}...'; + } + } }