mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Trim down note parser
This commit is contained in:
@@ -4,18 +4,10 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:any_link_preview/any_link_preview.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:zapstore/utils/url_utils.dart';
|
||||
|
||||
/// Enum for different media types
|
||||
enum MediaType { image, video, audio, none }
|
||||
|
||||
/// Helper function to launch URLs with robust error handling
|
||||
Future<void> _launchUrlSafely(String url, {String? context}) async {
|
||||
Future<void> _launchUrlSafely(String url) async {
|
||||
try {
|
||||
// Clean and validate the URL
|
||||
String cleanUrl = url.trim();
|
||||
if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) {
|
||||
cleanUrl = 'https://$cleanUrl';
|
||||
@@ -26,21 +18,14 @@ Future<void> _launchUrlSafely(String url, {String? context}) async {
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
// Try alternative launch mode
|
||||
try {
|
||||
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||
} catch (e2) {
|
||||
// Silently fail - both launch modes failed
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently handle URL launch errors
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// A utility for parsing Nostr note content and replacing entities with custom widgets.
|
||||
class NoteParser {
|
||||
// Regex patterns for different content types
|
||||
static final RegExp nip19Regex = RegExp(
|
||||
r'(?:nostr:)?(npub|nsec|note|nprofile|nevent|naddr|nrelay)1[02-9ac-hj-np-z]+',
|
||||
caseSensitive: false,
|
||||
@@ -51,53 +36,16 @@ class NoteParser {
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
// Hashtag pattern - matches #word (letters, numbers, underscores)
|
||||
static final RegExp _hashtagPattern = RegExp(
|
||||
r'#[a-zA-Z0-9_]+',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
// Image file extensions
|
||||
static final Set<String> _imageExtensions = {
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'svg',
|
||||
};
|
||||
|
||||
// Video file extensions
|
||||
static final Set<String> _videoExtensions = {
|
||||
'mp4',
|
||||
'webm',
|
||||
'avi',
|
||||
'mov',
|
||||
'mkv',
|
||||
};
|
||||
|
||||
// Audio file extensions (for future use)
|
||||
static final Set<String> _audioExtensions = {'mp3', 'wav', 'ogg'};
|
||||
|
||||
/// Parses note content and returns a RichText widget with custom entity replacements.
|
||||
///
|
||||
/// [context] - The build context for accessing theme
|
||||
/// [content] - The note text content to parse
|
||||
/// [onNostrEntity] - Optional callback for replacing NIP-19 entities (npub, note, etc.)
|
||||
/// [onHttpUrl] - Optional callback for replacing HTTP URLs
|
||||
/// [onMediaUrl] - Optional callback specifically for media URLs (images, videos, etc.)
|
||||
/// [onHashtag] - Optional callback for replacing hashtags (#hashtag)
|
||||
/// [onHashtagTap] - Optional callback for when a hashtag is tapped
|
||||
/// [onProfileTap] - Optional callback for when a profile is tapped
|
||||
/// [textStyle] - Default text style for regular text
|
||||
/// [linkStyle] - Text style for unhandled links (when callback returns null)
|
||||
static Widget parse(
|
||||
BuildContext context,
|
||||
String content, {
|
||||
Widget? Function(String entity)? onNostrEntity,
|
||||
Widget? Function(String httpUrl)? onHttpUrl,
|
||||
Widget? Function(String mediaUrl)? onMediaUrl,
|
||||
Widget? Function(String hashtag)? onHashtag,
|
||||
void Function(String hashtag)? onHashtagTap,
|
||||
void Function(String pubkey)? onProfileTap,
|
||||
@@ -111,54 +59,40 @@ class NoteParser {
|
||||
final List<InlineSpan> spans = [];
|
||||
final List<_EntityMatch> matches = [];
|
||||
|
||||
// Find all NIP-19 entities
|
||||
for (final match in nip19Regex.allMatches(content)) {
|
||||
final entity = match.group(0)!;
|
||||
final nip19Entity = entity.replaceFirst('nostr:', '');
|
||||
|
||||
matches.add(
|
||||
_EntityMatch(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
text: entity,
|
||||
type: _EntityType.nip19,
|
||||
cleanEntity: nip19Entity,
|
||||
),
|
||||
);
|
||||
matches.add(_EntityMatch(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
text: entity,
|
||||
type: _EntityType.nip19,
|
||||
cleanEntity: nip19Entity,
|
||||
));
|
||||
}
|
||||
|
||||
// Find all HTTP URLs
|
||||
for (final match in httpUrlPattern.allMatches(content)) {
|
||||
final url = match.group(0)!;
|
||||
final isMedia = _isMediaUrl(url);
|
||||
|
||||
matches.add(
|
||||
_EntityMatch(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
text: url,
|
||||
type: isMedia ? _EntityType.media : _EntityType.http,
|
||||
cleanEntity: url,
|
||||
),
|
||||
);
|
||||
matches.add(_EntityMatch(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
text: url,
|
||||
type: _EntityType.http,
|
||||
cleanEntity: url,
|
||||
));
|
||||
}
|
||||
|
||||
// Find all hashtags
|
||||
for (final match in _hashtagPattern.allMatches(content)) {
|
||||
final hashtag = match.group(0)!;
|
||||
|
||||
matches.add(
|
||||
_EntityMatch(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
text: hashtag,
|
||||
type: _EntityType.hashtag,
|
||||
cleanEntity: hashtag.substring(1), // Remove the # symbol
|
||||
),
|
||||
);
|
||||
matches.add(_EntityMatch(
|
||||
start: match.start,
|
||||
end: match.end,
|
||||
text: hashtag,
|
||||
type: _EntityType.hashtag,
|
||||
cleanEntity: hashtag.substring(1),
|
||||
));
|
||||
}
|
||||
|
||||
// Sort matches by position and remove overlaps (e.g. #fragment inside a URL)
|
||||
matches.sort((a, b) => a.start.compareTo(b.start));
|
||||
final filtered = <_EntityMatch>[];
|
||||
var lastEnd = -1;
|
||||
@@ -169,27 +103,20 @@ class NoteParser {
|
||||
}
|
||||
}
|
||||
|
||||
// Build spans
|
||||
int currentPos = 0;
|
||||
|
||||
for (final match in filtered) {
|
||||
// Add text before this match
|
||||
if (match.start > currentPos) {
|
||||
final textBefore = content.substring(currentPos, match.start);
|
||||
spans.add(TextSpan(text: textBefore, style: textStyle));
|
||||
}
|
||||
|
||||
// Handle the entity
|
||||
Widget? replacement;
|
||||
|
||||
switch (match.type) {
|
||||
case _EntityType.nip19:
|
||||
replacement = onNostrEntity?.call(match.cleanEntity);
|
||||
break;
|
||||
case _EntityType.media:
|
||||
replacement =
|
||||
onMediaUrl?.call(match.text) ?? onHttpUrl?.call(match.text);
|
||||
break;
|
||||
case _EntityType.http:
|
||||
replacement = onHttpUrl?.call(match.text);
|
||||
break;
|
||||
@@ -210,15 +137,12 @@ class NoteParser {
|
||||
}
|
||||
|
||||
if (replacement != null) {
|
||||
spans.add(
|
||||
WidgetSpan(
|
||||
child: replacement,
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
),
|
||||
);
|
||||
} else if (match.type == _EntityType.http ||
|
||||
match.type == _EntityType.media) {
|
||||
spans.add(WidgetSpan(
|
||||
child: replacement,
|
||||
alignment: PlaceholderAlignment.baseline,
|
||||
baseline: TextBaseline.alphabetic,
|
||||
));
|
||||
} else if (match.type == _EntityType.http) {
|
||||
final style =
|
||||
linkStyle ??
|
||||
textStyle?.copyWith(
|
||||
@@ -242,96 +166,19 @@ class NoteParser {
|
||||
currentPos = match.end;
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
if (currentPos < content.length) {
|
||||
final remainingText = content.substring(currentPos);
|
||||
spans.add(TextSpan(text: remainingText, style: textStyle));
|
||||
}
|
||||
|
||||
// If no entities found, return simple text
|
||||
if (spans.isEmpty) {
|
||||
return Text(content, style: textStyle);
|
||||
}
|
||||
|
||||
return Text.rich(TextSpan(children: spans));
|
||||
}
|
||||
|
||||
/// Checks if a URL is likely a media URL and returns the media type
|
||||
static MediaType _getMediaType(String url) {
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
final path = uri.path.toLowerCase();
|
||||
final extension = path.split('.').last;
|
||||
|
||||
if (_imageExtensions.contains(extension)) {
|
||||
return MediaType.image;
|
||||
} else if (_videoExtensions.contains(extension)) {
|
||||
return MediaType.video;
|
||||
} else if (_audioExtensions.contains(extension)) {
|
||||
return MediaType.audio;
|
||||
} else {
|
||||
return MediaType.none;
|
||||
}
|
||||
} catch (e) {
|
||||
return MediaType.none;
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a URL is likely a media URL based on file extension
|
||||
static bool _isMediaUrl(String url) {
|
||||
return _getMediaType(url) != MediaType.none;
|
||||
}
|
||||
|
||||
/// Validates if a string is a valid NIP-19 entity
|
||||
static bool isValidNip19Entity(String entity) {
|
||||
try {
|
||||
Utils.decodeShareableIdentifier(entity);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts all NIP-19 entities from text
|
||||
@protected
|
||||
@visibleForTesting
|
||||
static List<String> extractNip19Entities(String content) {
|
||||
return nip19Regex
|
||||
.allMatches(content)
|
||||
.map((match) => match.group(0)!.replaceFirst('nostr:', ''))
|
||||
.where((entity) => isValidNip19Entity(entity))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Extracts all HTTP URLs from text
|
||||
@protected
|
||||
@visibleForTesting
|
||||
static List<String> extractHttpUrls(String content) {
|
||||
return httpUrlPattern
|
||||
.allMatches(content)
|
||||
.map((match) => match.group(0)!)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Extracts all media URLs from text
|
||||
@protected
|
||||
@visibleForTesting
|
||||
static List<String> extractMediaUrls(String content) {
|
||||
return extractHttpUrls(content).where((url) => _isMediaUrl(url)).toList();
|
||||
}
|
||||
|
||||
/// Extracts all hashtags from text (without the # symbol)
|
||||
@protected
|
||||
@visibleForTesting
|
||||
static List<String> extractHashtags(String content) {
|
||||
return _hashtagPattern
|
||||
.allMatches(content)
|
||||
.map((match) => match.group(0)!.substring(1)) // Remove # symbol
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class for tracking entity matches
|
||||
class _EntityMatch {
|
||||
final int start;
|
||||
final int end;
|
||||
@@ -348,8 +195,7 @@ class _EntityMatch {
|
||||
});
|
||||
}
|
||||
|
||||
/// Internal enum for entity types
|
||||
enum _EntityType { nip19, http, media, hashtag }
|
||||
enum _EntityType { nip19, http, hashtag }
|
||||
|
||||
// Widgets
|
||||
|
||||
@@ -621,161 +467,6 @@ class GenericNip19Widget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class UrlChipWidget extends StatelessWidget {
|
||||
final String url;
|
||||
final List<Color> colorPair;
|
||||
|
||||
const UrlChipWidget({super.key, required this.url, required this.colorPair});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Fallback widget for when preview fails
|
||||
Widget fallbackWidget = GestureDetector(
|
||||
onTap: () => _launchUrlSafely(url, context: 'Fallback'),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: colorPair[0].withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Text(
|
||||
url,
|
||||
style: context.textTheme.bodyMedium!.copyWith(
|
||||
color: colorPair[0],
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4.0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: AnyLinkPreview(
|
||||
link: url,
|
||||
displayDirection: UIDirection.uiDirectionHorizontal,
|
||||
showMultimedia: true,
|
||||
bodyMaxLines: 2,
|
||||
bodyTextOverflow: TextOverflow.ellipsis,
|
||||
titleStyle: context.textTheme.bodyMedium!.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
bodyStyle: context.textTheme.bodySmall!.copyWith(
|
||||
color: Colors.grey[600],
|
||||
fontSize: 12,
|
||||
),
|
||||
cache: Duration.zero,
|
||||
backgroundColor: colorPair[0].withValues(alpha: 0.05),
|
||||
borderRadius: 8.0,
|
||||
removeElevation: true,
|
||||
boxShadow: const [],
|
||||
onTap: () => _launchUrlSafely(url, context: 'Link Preview'),
|
||||
errorWidget: fallbackWidget,
|
||||
errorBody: 'Link preview unavailable',
|
||||
errorTitle: 'Unable to load preview',
|
||||
errorImage: '',
|
||||
placeholderWidget: fallbackWidget,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MediaWidget extends StatelessWidget {
|
||||
final String url;
|
||||
final List<Color> colorPair;
|
||||
|
||||
const MediaWidget({super.key, required this.url, required this.colorPair});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaType = NoteParser._getMediaType(url);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
border: Border.all(color: colorPair[0].withValues(alpha: 0.2)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
child: switch (mediaType) {
|
||||
MediaType.image => _buildImageWidget(context),
|
||||
_ => _buildUnsupportedWidget(context),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageWidget(BuildContext context) {
|
||||
final sanitizedUrl = sanitizeHttpUrl(url);
|
||||
if (sanitizedUrl == null) {
|
||||
return _buildUnsupportedWidget(context);
|
||||
}
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: sanitizedUrl,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) =>
|
||||
SizedBox(height: 200, child: _buildContentImageLoader(context)),
|
||||
errorWidget: (context, url, error) => SizedBox(
|
||||
height: 60,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.broken_image_outlined, color: Colors.grey[400]),
|
||||
const SizedBox(width: 8.0),
|
||||
Text(
|
||||
'Image failed to load',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentImageLoader(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF2A2A2A), Color(0xFF3A3A3A), Color(0xFF2A2A2A)],
|
||||
stops: [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUnsupportedWidget(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
color: colorPair[0].withValues(alpha: 0.1),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.help_outline, color: colorPair[0]),
|
||||
const SizedBox(width: 8.0),
|
||||
Text(
|
||||
'Unsupported media type',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall!.copyWith(color: colorPair[0]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HashtagWidget extends StatelessWidget {
|
||||
final String hashtag;
|
||||
final List<Color> colorPair;
|
||||
@@ -810,7 +501,6 @@ class HashtagWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Animated loading chip for profile loading states
|
||||
class _AnimatedLoadingChip extends StatefulWidget {
|
||||
final String text;
|
||||
final List<Color> colorPair;
|
||||
|
||||
Reference in New Issue
Block a user