sojorn/sojorn_app/lib/utils/url_launcher_helper.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

181 lines
5.7 KiB
Dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../theme/app_theme.dart';
/// Helper class for safely launching URLs with user warnings for unknown sites
class UrlLauncherHelper {
// List of known safe domains
static const List<String> _safeDomains = [
'mp.ls', 'www.mp.ls', 'patrick.mp.ls'
'gosojorn.com', 'www.gosojorn.com'
'youtube.com', 'www.youtube.com', 'youtu.be',
'instagram.com', 'www.instagram.com',
'twitter.com', 'www.twitter.com', 'x.com', 'www.x.com',
'facebook.com', 'www.facebook.com',
'tiktok.com', 'www.tiktok.com',
'linkedin.com', 'www.linkedin.com',
'github.com', 'www.github.com',
'twitch.tv', 'www.twitch.tv',
'reddit.com', 'www.reddit.com',
'medium.com', 'www.medium.com',
'substack.com',
'patreon.com', 'www.patreon.com',
'discord.com', 'www.discord.com', 'discord.gg',
'spotify.com', 'www.spotify.com',
'pinterest.com', 'www.pinterest.com',
'snapchat.com', 'www.snapchat.com',
'telegram.org', 'www.telegram.org', 't.me',
];
/// Check if a URL is from a known safe domain
static bool isKnownSafeDomain(String url) {
try {
final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url');
final host = uri.host.toLowerCase();
return _safeDomains.any((domain) =>
host == domain || host.endsWith('.$domain')
);
} catch (e) {
return false;
}
}
/// Safely launch a URL with user confirmation for unknown sites
static Future<void> launchUrlSafely(
BuildContext context,
String url, {
bool forceWarning = false,
}) async {
// Validate URL scheme to prevent shell escaping
try {
final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url');
if (!['http:', 'https:'].contains(uri.scheme)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid URL scheme. Only HTTP and HTTPS URLs are allowed.'),
backgroundColor: AppTheme.error,
),
);
return;
}
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid URL format.'),
backgroundColor: AppTheme.error,
),
);
return;
}
final isSafe = isKnownSafeDomain(url);
// Show warning dialog for unknown sites
if (!isSafe || forceWarning) {
final shouldLaunch = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('External Link'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'You are about to visit an external website:',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.egyptianBlue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
url,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
const SizedBox(height: 16),
if (!isSafe) ...[
Row(
children: [
Icon(
Icons.warning_amber,
color: Colors.orange,
size: 20,
),
const SizedBox(width: 8),
const Expanded(
child: Text(
'This site is not recognized as a known safe website. Proceed with caution.',
style: TextStyle(
fontSize: 13,
color: Colors.orange,
),
),
),
],
),
const SizedBox(height: 8),
],
const Text(
'Always be careful when visiting external links and never share your password or personal information.',
style: TextStyle(fontSize: 13),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: isSafe ? AppTheme.royalPurple : Colors.orange,
),
child: const Text('Continue'),
),
],
),
);
if (shouldLaunch != true) return;
}
// Launch the URL
try {
final uri = Uri.parse(url.startsWith('http') ? url : 'https://$url');
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Could not open link: $url'),
backgroundColor: AppTheme.error,
),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error opening link: ${e.toString()}'),
backgroundColor: AppTheme.error,
),
);
}
}
}
}