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)
|
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 {
|
if h.linkPreviewService != nil {
|
||||||
go func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
linkURL := services.ExtractFirstURL(req.Body)
|
linkURL := services.ExtractFirstURL(req.Body)
|
||||||
if linkURL != "" {
|
if linkURL != "" {
|
||||||
// Check if author is an official account (trusted = skip safety checks)
|
// Check if author is an official account (trusted = skip safety checks)
|
||||||
var isOfficial bool
|
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)
|
lp, lpErr := h.linkPreviewService.FetchPreview(c.Request.Context(), linkURL, isOfficial)
|
||||||
if err == nil && lp != nil {
|
if lpErr == nil && lp != nil {
|
||||||
_ = h.linkPreviewService.SaveLinkPreview(ctx, post.ID.String(), lp)
|
_ = 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
|
// Check for @mentions and notify mentioned users
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../models/sojorn_media_result.dart';
|
||||||
import '../../models/tone_analysis.dart';
|
import '../../models/tone_analysis.dart';
|
||||||
import '../../providers/api_provider.dart';
|
import '../../providers/api_provider.dart';
|
||||||
import '../../providers/feed_refresh_provider.dart';
|
import '../../providers/feed_refresh_provider.dart';
|
||||||
|
import '../../services/api_service.dart';
|
||||||
import '../../services/image_upload_service.dart';
|
import '../../services/image_upload_service.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../../widgets/composer/composer_toolbar.dart';
|
import '../../widgets/composer/composer_toolbar.dart';
|
||||||
|
|
@ -47,6 +48,13 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||||
bool _isItalic = false;
|
bool _isItalic = false;
|
||||||
int? _ttlHoursOverride;
|
int? _ttlHoursOverride;
|
||||||
|
|
||||||
|
// Link preview state
|
||||||
|
String? _detectedUrl;
|
||||||
|
Map<String, dynamic>? _linkPreview;
|
||||||
|
bool _isFetchingPreview = false;
|
||||||
|
Timer? _urlDebounce;
|
||||||
|
bool _isTyping = false;
|
||||||
|
|
||||||
File? _selectedImageFile;
|
File? _selectedImageFile;
|
||||||
Uint8List? _selectedImageBytes;
|
Uint8List? _selectedImageBytes;
|
||||||
String? _selectedImageName;
|
String? _selectedImageName;
|
||||||
|
|
@ -71,6 +79,7 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||||
_bodyController.addListener(() {
|
_bodyController.addListener(() {
|
||||||
_charCountNotifier.value = _bodyController.text.length;
|
_charCountNotifier.value = _bodyController.text.length;
|
||||||
_handleHashtagSuggestions();
|
_handleHashtagSuggestions();
|
||||||
|
_handleUrlDetection();
|
||||||
// Clear blocked banner when user edits their text
|
// Clear blocked banner when user edits their text
|
||||||
if (_blockedMessage != null) {
|
if (_blockedMessage != null) {
|
||||||
setState(() => _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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_urlDebounce?.cancel();
|
||||||
_hashtagDebounce?.cancel();
|
_hashtagDebounce?.cancel();
|
||||||
_charCountNotifier.dispose();
|
_charCountNotifier.dispose();
|
||||||
_bodyFocusNode.dispose();
|
_bodyFocusNode.dispose();
|
||||||
|
|
@ -614,6 +671,9 @@ class _ComposeScreenState extends ConsumerState<ComposeScreen> {
|
||||||
!isKeyboardOpen
|
!isKeyboardOpen
|
||||||
? _buildImagePreview()
|
? _buildImagePreview()
|
||||||
: null,
|
: 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
|
/// Minimal top bar with Cancel + Post pill
|
||||||
|
|
@ -784,6 +916,7 @@ class ComposeBody extends StatelessWidget {
|
||||||
final bool isBold;
|
final bool isBold;
|
||||||
final bool isItalic;
|
final bool isItalic;
|
||||||
final Widget? imageWidget;
|
final Widget? imageWidget;
|
||||||
|
final Widget? linkPreviewWidget;
|
||||||
final List<String> suggestions;
|
final List<String> suggestions;
|
||||||
final ValueChanged<String> onSelectSuggestion;
|
final ValueChanged<String> onSelectSuggestion;
|
||||||
|
|
||||||
|
|
@ -796,6 +929,7 @@ class ComposeBody extends StatelessWidget {
|
||||||
required this.suggestions,
|
required this.suggestions,
|
||||||
required this.onSelectSuggestion,
|
required this.onSelectSuggestion,
|
||||||
this.imageWidget,
|
this.imageWidget,
|
||||||
|
this.linkPreviewWidget,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -867,6 +1001,7 @@ class ComposeBody extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (linkPreviewWidget != null) linkPreviewWidget!,
|
||||||
if (imageWidget != null) imageWidget!,
|
if (imageWidget != null) imageWidget!,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,9 @@ class sojornRichText extends StatelessWidget {
|
||||||
} else {
|
} else {
|
||||||
displayText = 'View Location';
|
displayText = 'View Location';
|
||||||
}
|
}
|
||||||
|
} else if (!isMention && !isHashtag) {
|
||||||
|
// Truncate long URLs for display: "https://apnews.com/article..."
|
||||||
|
displayText = _truncateUrl(matchText);
|
||||||
}
|
}
|
||||||
|
|
||||||
spans.add(
|
spans.add(
|
||||||
|
|
@ -120,4 +123,20 @@ class sojornRichText extends StatelessWidget {
|
||||||
|
|
||||||
return spans;
|
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