feat: compose URL preview, truncated URLs in post body, sync link preview fetch

This commit is contained in:
Patrick Britton 2026-02-08 14:20:38 -06:00
parent aa0e75d35f
commit 46bf51a6c4
3 changed files with 169 additions and 13 deletions

View file

@ -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)
_ = 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

View file

@ -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<ComposeScreen> {
bool _isItalic = false;
int? _ttlHoursOverride;
// Link preview state
String? _detectedUrl;
Map<String, dynamic>? _linkPreview;
bool _isFetchingPreview = false;
Timer? _urlDebounce;
bool _isTyping = false;
File? _selectedImageFile;
Uint8List? _selectedImageBytes;
String? _selectedImageName;
@ -71,6 +79,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
_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<ComposeScreen> {
});
}
/// 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<void> _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<ComposeScreen> {
!isKeyboardOpen
? _buildImagePreview()
: null,
linkPreviewWidget: !_isTyping && _linkPreview != null && !isKeyboardOpen
? _buildComposeLinkPreview()
: null,
),
),
],
@ -694,6 +754,78 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
),
);
}
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<String> suggestions;
final ValueChanged<String> 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!,
],
);

View file

@ -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)}...';
}
}
}