sojorn/sojorn_app/lib/utils/external_link_controller.dart
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

159 lines
4.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../widgets/safety_redirect_sheet.dart';
/// External Link Traffic Controller
///
/// Provides safe URL routing with a domain whitelist.
/// Only domains in the whitelist are allowed without safety warnings.
/// All other domains will show a confirmation sheet before opening.
///
/// This is a "whitelist-only" approach: everything not explicitly trusted
/// requires user confirmation.
class ExternalLinkController {
/// Whitelist of safe domains that open without any warning
/// These are trusted sources with established editorial standards
static const List<String> _whitelist = [
// sojorn & Core Platforms
'sojorn.com',
'youtube.com',
'youtu.be',
'wikipedia.org',
'wikimedia.org',
'github.com',
'instagram.com',
'linkedin.com',
'medium.com',
'reddit.com',
'vimeo.com',
'spotify.com',
'apple.com',
'google.com',
'maps.google.com',
'openstreetmap.org',
// Established Progressive/Left Sources
'msnow.com',
'huffpost.com',
'jacobin.com',
'wsws.org',
'counterpunch.org',
'truthout.org',
'commondreams.org',
'theintercept.com',
'leftvoice.org',
'liberationnews.org',
'inthesetimes.com',
'monthlyreview.org',
'currentaffairs.org',
'alternet.org',
'rawstory.com',
'therealnews.com',
'popularresistance.org',
'blackagendareport.com',
'socialistworker.org',
'peoplesworld.org',
'marxists.org',
'roarmag.org',
'versobooks.com',
'tribunemag.co.uk',
'novaramedia.com',
'opendemocracy.net',
'redpepper.org.uk',
'thenation.com',
'dissentmagazine.org',
'democracynow.org',
'fifthestate.org',
'crimethinc.com',
];
/// Handles URL routing with whitelist safety checks.
///
/// [context] - BuildContext for showing dialogs/sheets
/// [url] - The URL to open
///
/// Flow:
/// 1. Parse the URL and extract the host (domain)
/// 2. Check if host ends with any whitelisted domain (case-insensitive)
/// 3. If whitelisted: launch immediately
/// 4. If not whitelisted: show SafetyRedirectSheet for user confirmation
static Future<void> handleUrl(BuildContext context, String url) async {
if (url.trim().isEmpty) return;
final Uri? uri = Uri.tryParse(url);
if (uri == null) return;
// Extract domain from URL
final String host = uri.host.toLowerCase();
// Check if domain is in whitelist
if (_isWhitelisted(host)) {
await _launchUrl(context, uri);
} else {
_showSafetyRedirectSheet(context, uri);
}
}
/// Check if the domain is in the whitelist
static bool _isWhitelisted(String host) {
return _whitelist.any((domain) => host.endsWith(domain));
}
/// Launch URL using url_launcher
static Future<void> _launchUrl(BuildContext context, Uri uri) async {
try {
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
} else {
_showError(context, 'Could not open link.');
}
} catch (e) {
_showError(context, 'Error opening link: $e');
}
}
/// Show the Safety Redirect Sheet for non-whitelisted domains
static void _showSafetyRedirectSheet(BuildContext context, Uri uri) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => SafetyRedirectSheet(
url: uri.toString(),
domain: uri.host,
),
);
}
/// Show error snackbar
static void _showError(BuildContext context, String message) {
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red.shade700,
),
);
}
/// Add a domain to the whitelist at runtime
static void addToWhitelist(String domain) {
final normalizedDomain = domain.toLowerCase();
if (!_whitelist.contains(normalizedDomain)) {
_whitelist.add(normalizedDomain);
}
}
/// Check if a domain is currently whitelisted
static bool isWhitelisted(String domain) {
return _isWhitelisted(domain.toLowerCase());
}
/// Get all whitelisted domains (for debugging/admin purposes)
static List<String> getWhitelist() {
return List.unmodifiable(_whitelist);
}
}