feat: link preview system - OG tag fetching, safe URL validation, full-width thumbnail card

This commit is contained in:
Patrick Britton 2026-02-08 13:27:13 -06:00
parent d623320256
commit e9e140df5e
9 changed files with 635 additions and 13 deletions

View file

@ -127,11 +127,14 @@ func main() {
// Initialize content filter (hard blocklist + strike system)
contentFilter := services.NewContentFilter(dbPool)
// Initialize link preview service
linkPreviewService := services.NewLinkPreviewService(dbPool)
hub := realtime.NewHub()
wsHandler := handlers.NewWSHandler(hub, cfg.JWTSecret)
userHandler := handlers.NewUserHandler(userRepo, postRepo, notificationService, assetService)
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService)
postHandler := handlers.NewPostHandler(postRepo, userRepo, feedService, assetService, notificationService, moderationService, contentFilter, openRouterService, linkPreviewService)
chatHandler := handlers.NewChatHandler(chatRepo, notificationService, hub)
authHandler := handlers.NewAuthHandler(userRepo, cfg, emailService, sendPulseService)
categoryHandler := handlers.NewCategoryHandler(categoryRepo)

View file

@ -0,0 +1,10 @@
-- Add link preview columns to posts table
ALTER TABLE public.posts
ADD COLUMN IF NOT EXISTS link_preview_url TEXT,
ADD COLUMN IF NOT EXISTS link_preview_title TEXT,
ADD COLUMN IF NOT EXISTS link_preview_description TEXT,
ADD COLUMN IF NOT EXISTS link_preview_image_url TEXT,
ADD COLUMN IF NOT EXISTS link_preview_site_name TEXT;
-- Index for quick lookups when enriching posts
CREATE INDEX IF NOT EXISTS idx_posts_link_preview ON public.posts (id) WHERE link_preview_url IS NOT NULL;

View file

@ -25,9 +25,10 @@ type PostHandler struct {
moderationService *services.ModerationService
contentFilter *services.ContentFilter
openRouterService *services.OpenRouterService
linkPreviewService *services.LinkPreviewService
}
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService) *PostHandler {
func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.UserRepository, feedService *services.FeedService, assetService *services.AssetService, notificationService *services.NotificationService, moderationService *services.ModerationService, contentFilter *services.ContentFilter, openRouterService *services.OpenRouterService, linkPreviewService *services.LinkPreviewService) *PostHandler {
return &PostHandler{
postRepo: postRepo,
userRepo: userRepo,
@ -37,6 +38,49 @@ func NewPostHandler(postRepo *repository.PostRepository, userRepo *repository.Us
moderationService: moderationService,
contentFilter: contentFilter,
openRouterService: openRouterService,
linkPreviewService: linkPreviewService,
}
}
// enrichLinkPreviews populates link_preview fields on a slice of posts via batch query.
func (h *PostHandler) enrichLinkPreviews(ctx context.Context, posts []models.Post) {
if h.linkPreviewService == nil || len(posts) == 0 {
return
}
ids := make([]string, len(posts))
for i, p := range posts {
ids[i] = p.ID.String()
}
previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, ids)
if err != nil || len(previews) == 0 {
return
}
for i := range posts {
if lp, ok := previews[posts[i].ID.String()]; ok {
posts[i].LinkPreviewURL = &lp.URL
posts[i].LinkPreviewTitle = &lp.Title
posts[i].LinkPreviewDescription = &lp.Description
posts[i].LinkPreviewImageURL = &lp.ImageURL
posts[i].LinkPreviewSiteName = &lp.SiteName
}
}
}
// enrichSinglePostLinkPreview populates link_preview fields on a single post.
func (h *PostHandler) enrichSinglePostLinkPreview(ctx context.Context, post *models.Post) {
if h.linkPreviewService == nil || post == nil {
return
}
previews, err := h.linkPreviewService.EnrichPostsWithLinkPreviews(ctx, []string{post.ID.String()})
if err != nil || len(previews) == 0 {
return
}
if lp, ok := previews[post.ID.String()]; ok {
post.LinkPreviewURL = &lp.URL
post.LinkPreviewTitle = &lp.Title
post.LinkPreviewDescription = &lp.Description
post.LinkPreviewImageURL = &lp.ImageURL
post.LinkPreviewSiteName = &lp.SiteName
}
}
@ -531,6 +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)
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)
lp, err := h.linkPreviewService.FetchPreview(ctx, linkURL, isOfficial)
if err == nil && lp != nil {
_ = h.linkPreviewService.SaveLinkPreview(ctx, post.ID.String(), lp)
}
}
}()
}
// Check for @mentions and notify mentioned users
go func() {
if h.notificationService != nil && strings.Contains(req.Body, "@") {
@ -569,6 +631,7 @@ func (h *PostHandler) GetFeed(c *gin.Context) {
return
}
h.enrichLinkPreviews(c.Request.Context(), posts)
c.JSON(http.StatusOK, gin.H{"posts": posts})
}
@ -602,6 +665,7 @@ func (h *PostHandler) GetProfilePosts(c *gin.Context) {
return
}
h.enrichLinkPreviews(c.Request.Context(), posts)
c.JSON(http.StatusOK, gin.H{"posts": posts})
}
@ -636,6 +700,7 @@ func (h *PostHandler) GetPost(c *gin.Context) {
post.ThumbnailURL = &signed
}
h.enrichSinglePostLinkPreview(c.Request.Context(), post)
c.JSON(http.StatusOK, gin.H{"post": post})
}
@ -863,6 +928,7 @@ func (h *PostHandler) GetSavedPosts(c *gin.Context) {
return
}
h.enrichLinkPreviews(c.Request.Context(), posts)
c.JSON(http.StatusOK, gin.H{"posts": posts})
}
@ -897,6 +963,7 @@ func (h *PostHandler) GetLikedPosts(c *gin.Context) {
return
}
h.enrichLinkPreviews(c.Request.Context(), posts)
c.JSON(http.StatusOK, gin.H{"posts": posts})
}
@ -933,6 +1000,7 @@ func (h *PostHandler) GetPostChain(c *gin.Context) {
}
}
h.enrichLinkPreviews(c.Request.Context(), posts)
c.JSON(http.StatusOK, gin.H{"posts": posts})
}
@ -958,6 +1026,13 @@ func (h *PostHandler) GetPostFocusContext(c *gin.Context) {
h.signPostMedia(&focusContext.Children[i])
}
// Enrich link previews for all posts in focus context
h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.TargetPost)
h.enrichSinglePostLinkPreview(c.Request.Context(), focusContext.ParentPost)
for i := range focusContext.Children {
h.enrichSinglePostLinkPreview(c.Request.Context(), &focusContext.Children[i])
}
c.JSON(http.StatusOK, focusContext)
}

View file

@ -34,6 +34,13 @@ type Post struct {
IsNSFW bool `json:"is_nsfw" db:"is_nsfw"`
NSFWReason string `json:"nsfw_reason" db:"nsfw_reason"`
ExpiresAt *time.Time `json:"expires_at" db:"expires_at"`
// Link preview (populated via enrichment, not in every query)
LinkPreviewURL *string `json:"link_preview_url,omitempty" db:"link_preview_url"`
LinkPreviewTitle *string `json:"link_preview_title,omitempty" db:"link_preview_title"`
LinkPreviewDescription *string `json:"link_preview_description,omitempty" db:"link_preview_description"`
LinkPreviewImageURL *string `json:"link_preview_image_url,omitempty" db:"link_preview_image_url"`
LinkPreviewSiteName *string `json:"link_preview_site_name,omitempty" db:"link_preview_site_name"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
EditedAt *time.Time `json:"edited_at,omitempty" db:"edited_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`

View file

@ -0,0 +1,322 @@
package services
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/rs/zerolog/log"
)
// LinkPreview represents the OG metadata extracted from a URL.
type LinkPreview struct {
URL string `json:"link_preview_url"`
Title string `json:"link_preview_title"`
Description string `json:"link_preview_description"`
ImageURL string `json:"link_preview_image_url"`
SiteName string `json:"link_preview_site_name"`
}
// LinkPreviewService fetches and parses OpenGraph metadata from URLs.
type LinkPreviewService struct {
pool *pgxpool.Pool
httpClient *http.Client
}
func NewLinkPreviewService(pool *pgxpool.Pool) *LinkPreviewService {
return &LinkPreviewService{
pool: pool,
httpClient: &http.Client{
Timeout: 8 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
return nil
},
},
}
}
// blockedIPRanges are private/internal IP ranges that untrusted URLs must not resolve to.
var blockedIPRanges = []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"::1/128",
"fc00::/7",
"fe80::/10",
}
var blockedNets []*net.IPNet
func init() {
for _, cidr := range blockedIPRanges {
_, ipNet, err := net.ParseCIDR(cidr)
if err == nil {
blockedNets = append(blockedNets, ipNet)
}
}
}
func isPrivateIP(ip net.IP) bool {
for _, n := range blockedNets {
if n.Contains(ip) {
return true
}
}
return false
}
// ExtractFirstURL finds the first http/https URL in a text string.
func ExtractFirstURL(text string) string {
re := regexp.MustCompile(`https?://[^\s<>"')\]]+`)
match := re.FindString(text)
// Clean trailing punctuation that's not part of the URL
match = strings.TrimRight(match, ".,;:!?")
return match
}
// FetchPreview fetches OG metadata from a URL.
// If trusted is false, performs safety checks (no internal IPs, domain validation).
func (s *LinkPreviewService) FetchPreview(ctx context.Context, rawURL string, trusted bool) (*LinkPreview, error) {
if rawURL == "" {
return nil, fmt.Errorf("empty URL")
}
parsed, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("unsupported scheme: %s", parsed.Scheme)
}
// Safety checks for untrusted URLs
if !trusted {
if err := s.validateURL(parsed); err != nil {
return nil, fmt.Errorf("unsafe URL: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Sojorn/1.0; +https://sojorn.net)")
req.Header.Set("Accept", "text/html")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "text/html") && !strings.Contains(ct, "application/xhtml") {
return nil, fmt.Errorf("not HTML: %s", ct)
}
// Read max 1MB
limited := io.LimitReader(resp.Body, 1*1024*1024)
body, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
preview := s.parseOGTags(string(body), rawURL)
if preview.Title == "" && preview.Description == "" && preview.ImageURL == "" {
return nil, fmt.Errorf("no OG metadata found")
}
preview.URL = rawURL
if preview.SiteName == "" {
preview.SiteName = parsed.Hostname()
}
return preview, nil
}
// validateURL checks that an untrusted URL doesn't point to internal resources.
func (s *LinkPreviewService) validateURL(u *url.URL) error {
host := u.Hostname()
// Block bare IPs for untrusted requests
if ip := net.ParseIP(host); ip != nil {
if isPrivateIP(ip) {
return fmt.Errorf("private IP not allowed")
}
}
// Resolve DNS and check all IPs
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("DNS lookup failed: %w", err)
}
for _, ip := range ips {
if isPrivateIP(ip) {
return fmt.Errorf("resolves to private IP")
}
}
return nil
}
// parseOGTags extracts OpenGraph meta tags from raw HTML.
func (s *LinkPreviewService) parseOGTags(html string, sourceURL string) *LinkPreview {
preview := &LinkPreview{}
// Use regex to extract meta tags — lightweight, no dependency needed
metaRe := regexp.MustCompile(`(?i)<meta\s+[^>]*>`)
metas := metaRe.FindAllString(html, -1)
for _, tag := range metas {
prop := extractAttr(tag, "property")
if prop == "" {
prop = extractAttr(tag, "name")
}
content := extractAttr(tag, "content")
if content == "" {
continue
}
switch strings.ToLower(prop) {
case "og:title":
if preview.Title == "" {
preview.Title = content
}
case "og:description":
if preview.Description == "" {
preview.Description = content
}
case "og:image":
if preview.ImageURL == "" {
preview.ImageURL = resolveImageURL(content, sourceURL)
}
case "og:site_name":
if preview.SiteName == "" {
preview.SiteName = content
}
case "description":
// Fallback if no og:description
if preview.Description == "" {
preview.Description = content
}
}
}
// Fallback: try <title> tag if no og:title
if preview.Title == "" {
titleRe := regexp.MustCompile(`(?i)<title[^>]*>(.*?)</title>`)
if m := titleRe.FindStringSubmatch(html); len(m) > 1 {
preview.Title = strings.TrimSpace(m[1])
}
}
// Truncate long fields
if len(preview.Title) > 300 {
preview.Title = preview.Title[:300]
}
if len(preview.Description) > 500 {
preview.Description = preview.Description[:500]
}
return preview
}
// extractAttr pulls a named attribute value from a raw HTML tag string.
func extractAttr(tag string, name string) string {
// Match name="value" or name='value'
re := regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*?)["']`)
m := re.FindStringSubmatch(tag)
if len(m) > 1 {
return strings.TrimSpace(m[1])
}
return ""
}
// resolveImageURL makes relative image URLs absolute.
func resolveImageURL(imgURL string, sourceURL string) string {
if strings.HasPrefix(imgURL, "http://") || strings.HasPrefix(imgURL, "https://") {
return imgURL
}
base, err := url.Parse(sourceURL)
if err != nil {
return imgURL
}
ref, err := url.Parse(imgURL)
if err != nil {
return imgURL
}
return base.ResolveReference(ref).String()
}
// EnrichPostsWithLinkPreviews does a batch query to populate link_preview fields
// on a slice of posts. This avoids modifying every existing SELECT query.
func (s *LinkPreviewService) EnrichPostsWithLinkPreviews(ctx context.Context, postIDs []string) (map[string]*LinkPreview, error) {
if len(postIDs) == 0 {
return nil, nil
}
query := `
SELECT id::text, link_preview_url, link_preview_title,
link_preview_description, link_preview_image_url, link_preview_site_name
FROM public.posts
WHERE id = ANY($1::uuid[]) AND link_preview_url IS NOT NULL AND link_preview_url != ''
`
rows, err := s.pool.Query(ctx, query, postIDs)
if err != nil {
log.Warn().Err(err).Msg("Failed to fetch link previews for posts")
return nil, err
}
defer rows.Close()
result := make(map[string]*LinkPreview)
for rows.Next() {
var postID string
var lp LinkPreview
var title, desc, imgURL, siteName *string
if err := rows.Scan(&postID, &lp.URL, &title, &desc, &imgURL, &siteName); err != nil {
continue
}
if title != nil {
lp.Title = *title
}
if desc != nil {
lp.Description = *desc
}
if imgURL != nil {
lp.ImageURL = *imgURL
}
if siteName != nil {
lp.SiteName = *siteName
}
result[postID] = &lp
}
return result, nil
}
// SaveLinkPreview stores the link preview data for a post.
func (s *LinkPreviewService) SaveLinkPreview(ctx context.Context, postID string, lp *LinkPreview) error {
_, err := s.pool.Exec(ctx, `
UPDATE public.posts
SET link_preview_url = $2, link_preview_title = $3, link_preview_description = $4,
link_preview_image_url = $5, link_preview_site_name = $6
WHERE id = $1
`, postID, lp.URL, lp.Title, lp.Description, lp.ImageURL, lp.SiteName)
return err
}

View file

@ -419,14 +419,8 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf
}
}
// Get user_id from profile_id
var authorID string
err = s.pool.QueryRow(ctx, `SELECT user_id FROM public.profiles WHERE id = $1`, cfg.ProfileID).Scan(&authorID)
if err != nil {
return "", fmt.Errorf("failed to get user_id for profile: %w", err)
}
authorUUID, _ := uuid.Parse(authorID)
// profile_id IS the author_id (profiles.id = users.id in this schema)
authorUUID, _ := uuid.Parse(cfg.ProfileID)
postID := uuid.New()
tx, err := s.pool.Begin(ctx)
@ -476,6 +470,23 @@ func (s *OfficialAccountsService) CreatePostForAccount(ctx context.Context, conf
return "", err
}
// Fetch and store link preview for posts with URLs (trusted — official account)
go func() {
bgCtx := context.Background()
linkURL := ExtractFirstURL(body)
if linkURL == "" && article != nil {
linkURL = article.Link
}
if linkURL != "" {
lps := NewLinkPreviewService(s.pool)
lp, err := lps.FetchPreview(bgCtx, linkURL, true)
if err == nil && lp != nil {
_ = lps.SaveLinkPreview(bgCtx, postID.String(), lp)
log.Debug().Str("post_id", postID.String()).Str("url", linkURL).Msg("Saved link preview for official account post")
}
}
}()
return postID.String(), nil
}

View file

@ -91,6 +91,15 @@ class Post {
final bool isNsfw;
final String? nsfwReason;
// Link preview (OG metadata)
final String? linkPreviewUrl;
final String? linkPreviewTitle;
final String? linkPreviewDescription;
final String? linkPreviewImageUrl;
final String? linkPreviewSiteName;
bool get hasLinkPreview => linkPreviewUrl != null && linkPreviewUrl!.isNotEmpty;
Post({
required this.id,
required this.authorId,
@ -140,6 +149,11 @@ class Post {
this.ctaText,
this.isNsfw = false,
this.nsfwReason,
this.linkPreviewUrl,
this.linkPreviewTitle,
this.linkPreviewDescription,
this.linkPreviewImageUrl,
this.linkPreviewSiteName,
});
static int? _parseInt(dynamic value) {
@ -283,6 +297,11 @@ class Post {
ctaText: json['advertiser_cta_text'] as String?,
isNsfw: json['is_nsfw'] as bool? ?? false,
nsfwReason: json['nsfw_reason'] as String?,
linkPreviewUrl: json['link_preview_url'] as String?,
linkPreviewTitle: json['link_preview_title'] as String?,
linkPreviewDescription: json['link_preview_description'] as String?,
linkPreviewImageUrl: json['link_preview_image_url'] as String?,
linkPreviewSiteName: json['link_preview_site_name'] as String?,
);
}
@ -333,6 +352,11 @@ class Post {
'reaction_users': reactionUsers,
'is_nsfw': isNsfw,
'nsfw_reason': nsfwReason,
'link_preview_url': linkPreviewUrl,
'link_preview_title': linkPreviewTitle,
'link_preview_description': linkPreviewDescription,
'link_preview_image_url': linkPreviewImageUrl,
'link_preview_site_name': linkPreviewSiteName,
};
}
}

View file

@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/post.dart';
import '../../theme/app_theme.dart';
import 'post_view_mode.dart';
/// Full-width link preview card shown below the post body.
/// Displays OG image as a full-width thumbnail (same sizing as post images),
/// with title, description, and site name overlaid/below.
class PostLinkPreview extends StatelessWidget {
final Post post;
final PostViewMode mode;
const PostLinkPreview({
super.key,
required this.post,
this.mode = PostViewMode.feed,
});
double get _imageHeight {
switch (mode) {
case PostViewMode.feed:
return 220.0;
case PostViewMode.detail:
return 280.0;
case PostViewMode.compact:
return 160.0;
}
}
@override
Widget build(BuildContext context) {
if (!post.hasLinkPreview) return const SizedBox.shrink();
final hasImage = post.linkPreviewImageUrl != null &&
post.linkPreviewImageUrl!.isNotEmpty;
final title = post.linkPreviewTitle ?? '';
final description = post.linkPreviewDescription ?? '';
final siteName = post.linkPreviewSiteName ?? '';
return Padding(
padding: const EdgeInsets.only(top: 12),
child: GestureDetector(
onTap: () => _launchUrl(post.linkPreviewUrl!),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
border: Border.all(
color: AppTheme.navyBlue.withValues(alpha: 0.15),
width: 1,
),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Full-width thumbnail image
if (hasImage)
SizedBox(
width: double.infinity,
height: _imageHeight,
child: Image.network(
post.linkPreviewImageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: AppTheme.queenPink.withValues(alpha: 0.15),
child: const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
errorBuilder: (context, error, stackTrace) {
return Container(
color: AppTheme.navyBlue.withValues(alpha: 0.08),
child: Center(
child: Icon(
Icons.link_rounded,
size: 32,
color: AppTheme.textTertiary,
),
),
);
},
),
),
// Title + description + site name
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: AppTheme.navyBlue.withValues(alpha: 0.04),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Site name
if (siteName.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
siteName.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: AppTheme.textTertiary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// Title
if (title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppTheme.textPrimary,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// Description
if (description.isNotEmpty)
Text(
description,
style: TextStyle(
fontSize: 12,
color: AppTheme.textSecondary,
height: 1.4,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
),
);
}
Future<void> _launchUrl(String url) async {
final uri = Uri.tryParse(url);
if (uri != null && await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}

View file

@ -9,6 +9,7 @@ import '../theme/app_theme.dart';
import 'post/post_actions.dart';
import 'post/post_body.dart';
import 'post/post_header.dart';
import 'post/post_link_preview.dart';
import 'post/post_media.dart';
import 'post/post_menu.dart';
import 'post/post_view_mode.dart';
@ -292,6 +293,16 @@ class _sojornPostCardState extends ConsumerState<sojornPostCard> {
],
],
// Link preview card (if post has OG metadata and no image/video)
if (post.hasLinkPreview &&
(post.imageUrl == null || post.imageUrl!.isEmpty) &&
(post.videoUrl == null || post.videoUrl!.isEmpty)) ...[
Padding(
padding: EdgeInsets.symmetric(horizontal: _padding.left),
child: PostLinkPreview(post: post, mode: mode),
),
],
// NSFW warning banner with tap-to-reveal
if (_shouldBlurNsfw) ...[
GestureDetector(