**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.
159 lines
4.3 KiB
Dart
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);
|
|
}
|
|
}
|