feat: compose URL preview, truncated URLs in post body, sync link preview fetch
This commit is contained in:
parent
aa0e75d35f
commit
46bf51a6c4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}...';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue