diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart index cddd69c..1e0d026 100644 --- a/lib/screens/app_stack_screen.dart +++ b/lib/screens/app_stack_screen.dart @@ -1,11 +1,11 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/comments_section.dart'; @@ -280,11 +280,7 @@ class _StackHeader extends StatelessWidget { oneLine: true, size: 14, isLoading: isAuthorLoading, - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$first/user/${stack.pubkey}'); - }, + onTap: () => pushUser(context, stack.pubkey), ), const SizedBox(height: 4), // Last updated timestamp diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index e1a977f..f5d53e2 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -17,6 +17,7 @@ import 'package:zapstore/main.dart'; import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/widgets/common/profile_identity_row.dart'; import 'package:zapstore/widgets/app_card.dart'; import 'package:zapstore/theme.dart'; @@ -285,11 +286,7 @@ class _StackMigrationWarning extends ConsumerWidget { return Padding( padding: const EdgeInsets.only(top: 12), child: InkWell( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$first/stacks'); - }, + onTap: () => pushStacks(context), borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index bd7b3d2..d9f5fd3 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/utils/nostr_route.dart'; import '../widgets/app_stack_container.dart'; import '../widgets/latest_releases_container.dart'; @@ -30,24 +30,11 @@ class SearchScreen extends HookConsumerWidget { final performSearch = useCallback((String query) { final trimmed = query.trim(); - final cleaned = trimmed - .replaceFirst('nostr:', '') - .replaceFirst(RegExp(r'https?://zapstore\.dev/(?:apps|stacks)/'), ''); - try { - final decoded = Utils.decodeShareableIdentifier(cleaned); - final path = switch (decoded) { - AddressData(:final kind) => - '/search/${kind == 30267 ? 'stack' : 'app'}/$cleaned', - ProfileData(:final pubkey) => '/search/user/$pubkey', - _ => null, - }; - if (path != null) { - searchController.clear(); - searchQuery.value = ''; - context.push(path); - return; - } - } catch (_) {} + if (navigateToContent(context, trimmed, fallbackLaunch: false)) { + searchController.clear(); + searchQuery.value = ''; + return; + } if (trimmed.length < 3) { searchFocusNode.requestFocus(); return; diff --git a/lib/screens/user_screen.dart b/lib/screens/user_screen.dart index a18050d..a530fcc 100644 --- a/lib/screens/user_screen.dart +++ b/lib/screens/user_screen.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -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:zapstore/utils/nostr_route.dart'; import '../theme.dart'; import '../widgets/common/note_parser.dart'; import '../widgets/common/profile_identity_row.dart'; @@ -273,20 +273,12 @@ class _StackLinkCard extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: InkWell( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - // Build naddr for the stack - final naddr = Utils.encodeShareableIdentifier( - AddressInput( - identifier: stack.identifier, - author: pubkey, - kind: stack.event.kind, - relays: const [], - ), - ); - context.push('/$first/stack/$naddr'); - }, + onTap: () => pushStack( + context, + stack.identifier, + author: pubkey, + kind: stack.event.kind, + ), borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(16), @@ -370,12 +362,6 @@ class _UserBio extends HookWidget { Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.secondary, ], - onProfileTap: (pubkey) { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$first/user/$pubkey'); - }, - onHashtagTap: (hashtag) => context.push('/search?q=$hashtag'), ), ); diff --git a/lib/services/deep_link_resolver.dart b/lib/services/deep_link_resolver.dart index d98a21d..2fa57ca 100644 --- a/lib/services/deep_link_resolver.dart +++ b/lib/services/deep_link_resolver.dart @@ -4,19 +4,24 @@ import 'package:flutter/foundation.dart'; /// the URI is not a recognized deep link. /// /// Handles: -/// - `https://zapstore.dev/apps/{id}` (full URI from app_links) -/// - `/apps/{id}` (path-only, as seen by GoRouter's onException) +/// - `https://zapstore.dev/apps/{id}` and `.../stacks/{id}` +/// - `/apps/{id}` and `/stacks/{id}` (path-only, as seen by GoRouter's onException) /// - `market://details?id=com.example.app` /// - `market://search?q=search+query` String? resolveDeepLinkPath(Uri uri) { // https://zapstore.dev/apps/ OR bare /apps/ from GoRouter - if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'apps') { - final isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev'; - final isBarePath = uri.host.isEmpty; - if (isFullUri || isBarePath) { - final id = uri.pathSegments[1]; - if (id.isNotEmpty) { - return '/search/app/$id'; + // https://zapstore.dev/stacks/ OR bare /stacks/ + if (uri.pathSegments.length == 2) { + final section = uri.pathSegments[0]; + if (section == 'apps' || section == 'stacks') { + final isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev'; + final isBarePath = uri.host.isEmpty; + if (isFullUri || isBarePath) { + final id = uri.pathSegments[1]; + if (id.isNotEmpty) { + final route = section == 'apps' ? 'app' : 'stack'; + return '/search/$route/$id'; + } } } } diff --git a/lib/utils/nostr_route.dart b/lib/utils/nostr_route.dart new file mode 100644 index 0000000..52c8349 --- /dev/null +++ b/lib/utils/nostr_route.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:models/models.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:zapstore/constants/app_constants.dart'; + +/// Regex for zapstore.dev app/stack deep links. +final _zapstoreUrlPattern = RegExp( + r'https?://zapstore\.dev/(apps|stacks)/(.+)', + caseSensitive: false, +); + +/// Resolves the current navigation branch from the GoRouter state so that +/// pushed routes stay within the active tab (search / updates / profile). +String _currentBranch(BuildContext context) { + final segments = GoRouterState.of(context).uri.pathSegments; + return segments.isNotEmpty ? segments.first : 'search'; +} + +/// Push to a user profile screen within the current branch. +void pushUser(BuildContext context, String pubkey) { + context.push('/${_currentBranch(context)}/user/$pubkey'); +} + +/// Push to an app detail screen within the current branch. +/// +/// When [author] is provided an naddr is built so the detail screen can +/// uniquely identify the app (identifier + author). +void pushApp(BuildContext context, String identifier, {String? author, int kind = 32267}) { + final id = author != null + ? Utils.encodeShareableIdentifier( + AddressInput( + identifier: identifier, + author: author, + kind: kind, + relays: const [kDefaultRelay], + ), + ) + : identifier; + context.push('/${_currentBranch(context)}/app/$id'); +} + +/// Push to a stack detail screen within the current branch. +void pushStack(BuildContext context, String identifier, {String? author, int kind = 30267}) { + final id = author != null + ? Utils.encodeShareableIdentifier( + AddressInput( + identifier: identifier, + author: author, + kind: kind, + relays: const [], + ), + ) + : identifier; + context.push('/${_currentBranch(context)}/stack/$id'); +} + +/// Push to the all-stacks screen within the current branch. +void pushStacks(BuildContext context) { + context.push('/${_currentBranch(context)}/stacks'); +} + +/// Attempt to navigate in-app for a URL or Nostr identifier. +/// +/// Handles: +/// - `https://zapstore.dev/apps/` and `.../stacks/` +/// - `nostr:naddr1...`, `nostr:npub1...`, bare NIP-19 tokens +/// +/// Returns `true` if navigation was handled in-app, `false` otherwise. +/// When [fallbackLaunch] is true (the default), unrecognised URLs are opened +/// in an external browser. +bool navigateToContent( + BuildContext context, + String input, { + bool fallbackLaunch = true, +}) { + final cleaned = input.trim(); + + // 1. zapstore.dev deep links + final zapstoreMatch = _zapstoreUrlPattern.firstMatch(cleaned); + if (zapstoreMatch != null) { + final type = zapstoreMatch.group(1)!; // "apps" or "stacks" + final id = zapstoreMatch.group(2)!; + if (type == 'apps') { + _navigateNip19OrIdentifier(context, id, fallbackKind: 'app'); + } else { + _navigateNip19OrIdentifier(context, id, fallbackKind: 'stack'); + } + return true; + } + + // 2. nostr: prefix or bare NIP-19 token + final nip19 = cleaned.replaceFirst('nostr:', ''); + if (_tryNavigateNip19(context, nip19)) return true; + + // 3. Not a recognised in-app link + if (fallbackLaunch) { + _launchExternal(cleaned); + } + return false; +} + +/// Try to decode a NIP-19 token and navigate. Returns true on success. +bool _tryNavigateNip19(BuildContext context, String token) { + try { + final decoded = Utils.decodeShareableIdentifier(token); + switch (decoded) { + case AddressData(:final kind): + if (kind == 30267) { + pushStack(context, token); + } else { + pushApp(context, token); + } + return true; + case ProfileData(:final pubkey): + pushUser(context, pubkey); + return true; + case EventData(): + return false; + } + } catch (_) {} + return false; +} + +/// The id might be an naddr or a plain identifier (e.g. `com.example.app`). +void _navigateNip19OrIdentifier(BuildContext context, String id, {required String fallbackKind}) { + if (_tryNavigateNip19(context, id)) return; + final branch = _currentBranch(context); + context.push('/$branch/$fallbackKind/$id'); +} + +void _launchExternal(String url) async { + try { + var cleanUrl = url; + if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) { + cleanUrl = 'https://$cleanUrl'; + } + final uri = Uri.parse(cleanUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + try { + await launchUrl(uri, mode: LaunchMode.platformDefault); + } catch (_) {} + } + } catch (_) {} +} diff --git a/lib/widgets/app_card.dart b/lib/widgets/app_card.dart index d78bb5b..2462ef2 100644 --- a/lib/widgets/app_card.dart +++ b/lib/widgets/app_card.dart @@ -3,10 +3,10 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:go_router/go_router.dart'; import 'package:markdown/markdown.dart' as md; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/widgets/zap_widgets.dart'; @@ -55,22 +55,7 @@ class AppCard extends HookConsumerWidget { : 'No description available'; Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - // Prefer naddr so the detail screen can uniquely identify the app - // (identifier + author) and not accidentally resolve to a different - // publisher's app with the same identifier. - final naddr = Utils.encodeShareableIdentifier( - AddressInput( - identifier: app!.identifier, - author: app!.pubkey, - kind: app!.event.kind, - relays: const [kDefaultRelay], - ), - ); - context.push('/$first/app/$naddr'); - }, + onTap: () => pushApp(context, app!.identifier, author: app!.pubkey, kind: app!.event.kind), child: Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), padding: const EdgeInsets.all(16), diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 7767b3c..d396089 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -7,7 +7,6 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/notification_service.dart'; @@ -49,11 +48,6 @@ class ReleaseNotes extends StatelessWidget { } return ExpandableMarkdown( data: release.releaseNotes!, - onTapLink: (text, url, title) { - if (url != null) { - launchUrl(Uri.parse(url)); - } - }, styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( blockquoteDecoration: BoxDecoration( color: const Color(0xFF1E3A5F), // Dark blue diff --git a/lib/widgets/app_stack_container.dart b/lib/widgets/app_stack_container.dart index 3015316..8d1622d 100644 --- a/lib/widgets/app_stack_container.dart +++ b/lib/widgets/app_stack_container.dart @@ -1,9 +1,9 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/utils/nostr_route.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:skeletonizer/skeletonizer.dart'; import '../utils/extensions.dart'; @@ -268,11 +268,7 @@ class _SeeMoreCard extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$first/stacks'); - }, + onTap: () => pushStacks(context), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -378,19 +374,12 @@ class StackCard extends StatelessWidget { ); return GestureDetector( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - final naddr = Utils.encodeShareableIdentifier( - AddressInput( - identifier: stack.identifier, - author: stack.pubkey, - kind: stack.event.kind, - relays: const [], - ), - ); - context.push('/$first/stack/$naddr'); - }, + onTap: () => pushStack( + context, + stack.identifier, + author: stack.pubkey, + kind: stack.event.kind, + ), child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainerHighest, diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart index 6d60ecc..37f39da 100644 --- a/lib/widgets/comments_section.dart +++ b/lib/widgets/comments_section.dart @@ -1,7 +1,6 @@ import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:models/models.dart'; @@ -34,7 +33,6 @@ class CommentsSection extends HookConsumerWidget { }, source: LocalAndRemoteSource(stream: true, relays: 'social'), subscriptionPrefix: 'app-comments', - and: (comment) => {comment.replies.query()}, ), ); @@ -76,7 +74,6 @@ class StackCommentsSection extends HookConsumerWidget { }, source: LocalAndRemoteSource(stream: true, relays: 'social'), subscriptionPrefix: 'app-stack-comments', - and: (comment) => {comment.replies.query()}, ), ); @@ -401,11 +398,6 @@ class _ThreadedCommentCard extends HookConsumerWidget { Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.secondary, ], - onProfileTap: (pubkey) { - final segments = GoRouterState.of(context).uri.pathSegments; - final branch = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$branch/user/$pubkey'); - }, ), ), if (isSignedIn) ...[ diff --git a/lib/widgets/common/note_parser.dart b/lib/widgets/common/note_parser.dart index 686ada1..ad0c209 100644 --- a/lib/widgets/common/note_parser.dart +++ b/lib/widgets/common/note_parser.dart @@ -1,29 +1,9 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -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:url_launcher/url_launcher.dart'; - -Future _launchUrlSafely(String url) async { - try { - String cleanUrl = url.trim(); - if (!cleanUrl.startsWith('http://') && !cleanUrl.startsWith('https://')) { - cleanUrl = 'https://$cleanUrl'; - } - - final uri = Uri.parse(cleanUrl); - - if (await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - try { - await launchUrl(uri, mode: LaunchMode.platformDefault); - } catch (_) {} - } - } catch (_) {} -} +import 'package:zapstore/utils/nostr_route.dart'; class NoteParser { static final RegExp nip19Regex = RegExp( @@ -48,7 +28,6 @@ class NoteParser { Widget? Function(String httpUrl)? onHttpUrl, Widget? Function(String hashtag)? onHashtag, void Function(String hashtag)? onHashtagTap, - void Function(String pubkey)? onProfileTap, TextStyle? textStyle, TextStyle? linkStyle, }) { @@ -152,7 +131,7 @@ class NoteParser { text: match.text, style: style, recognizer: TapGestureRecognizer() - ..onTap = () => _launchUrlSafely(match.text), + ..onTap = () => navigateToContent(context, match.text), )); } else { final style = @@ -202,15 +181,11 @@ enum _EntityType { nip19, http, hashtag } class NostrEntityWidget extends StatelessWidget { final String entity; final List colorPair; - final void Function(String pubkey)? onProfileTap; - final void Function(String hashtag)? onHashtagTap; const NostrEntityWidget({ super.key, required this.entity, required this.colorPair, - this.onProfileTap, - this.onHashtagTap, }); @override @@ -222,7 +197,6 @@ class NostrEntityWidget extends StatelessWidget { ProfileData() => ProfileEntityWidget( profileData: decoded, colorPair: colorPair, - onProfileTap: onProfileTap, ), EventData() => EventEntityWidget( eventData: decoded, @@ -242,13 +216,11 @@ class NostrEntityWidget extends StatelessWidget { class ProfileEntityWidget extends ConsumerWidget { final ProfileData profileData; final List colorPair; - final void Function(String pubkey)? onProfileTap; const ProfileEntityWidget({ super.key, required this.profileData, required this.colorPair, - this.onProfileTap, }); @override @@ -264,15 +236,7 @@ class ProfileEntityWidget extends ConsumerWidget { ), ); - void handleTap() { - if (onProfileTap != null) { - onProfileTap!(profileData.pubkey); - } else { - final segments = GoRouterState.of(context).uri.pathSegments; - final branch = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$branch/user/${profileData.pubkey}'); - } - } + void handleTap() => pushUser(context, profileData.pubkey); return switch (profileState) { StorageLoading() => GestureDetector( @@ -294,20 +258,8 @@ class ProfileEntityWidget extends ConsumerWidget { } Widget _buildProfileWidget(BuildContext context, Profile profile) { - final displayName = profile.nameOrNpub; - - void handleTap() { - if (onProfileTap != null) { - onProfileTap!(profileData.pubkey); - } else { - final segments = GoRouterState.of(context).uri.pathSegments; - final branch = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$branch/user/${profileData.pubkey}'); - } - } - return GestureDetector( - onTap: handleTap, + onTap: () => pushUser(context, profileData.pubkey), child: DecoratedBox( decoration: BoxDecoration( color: colorPair[0].withValues(alpha: 0.1), @@ -316,7 +268,7 @@ class ProfileEntityWidget extends ConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Text( - displayName, + profile.nameOrNpub, style: context.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.w500, color: colorPair[0], @@ -411,10 +363,11 @@ class AddressEntityWidget extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector( onTap: () { - final segment = addressData.kind == 30267 ? 'stack' : 'app'; - final segments = GoRouterState.of(context).uri.pathSegments; - final branch = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$branch/$segment/${addressData.identifier}'); + if (addressData.kind == 30267) { + pushStack(context, addressData.identifier); + } else { + pushApp(context, addressData.identifier); + } }, child: DecoratedBox( decoration: BoxDecoration( diff --git a/lib/widgets/expandable_markdown.dart b/lib/widgets/expandable_markdown.dart index 62ee87d..e15847a 100644 --- a/lib/widgets/expandable_markdown.dart +++ b/lib/widgets/expandable_markdown.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; class ExpandableMarkdown extends HookWidget { const ExpandableMarkdown({ @@ -21,8 +22,6 @@ class ExpandableMarkdown extends HookWidget { const maxHeight = 170.0; bool isLikelyLong(String text) { - // Simple heuristic to avoid expensive measurement - // - Many words OR many newlines => likely long content final trimmed = text.trim(); if (trimmed.isEmpty) return false; @@ -30,16 +29,19 @@ class ExpandableMarkdown extends HookWidget { final newlineCount = '\n'.allMatches(trimmed).length; final charCount = trimmed.length; - // Thresholds tuned for typical article blurbs - // Show collapse if any of these exceed limits return wordCount > 90 || newlineCount > 6 || charCount > 600; } final shouldCollapse = !expanded.value && isLikelyLong(data); + final effectiveTapLink = onTapLink ?? + (String text, String? href, String? title) { + if (href != null) navigateToContent(context, href); + }; + final markdown = MarkdownBody( data: data, - onTapLink: onTapLink, + onTapLink: effectiveTapLink, styleSheet: styleSheet, ); diff --git a/lib/widgets/floating_overflow_menu.dart b/lib/widgets/floating_overflow_menu.dart index 4cf8361..da64bda 100644 --- a/lib/widgets/floating_overflow_menu.dart +++ b/lib/widgets/floating_overflow_menu.dart @@ -1,7 +1,6 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:share_plus/share_plus.dart'; @@ -10,6 +9,7 @@ import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; /// Floating three-dot overflow menu reusable across detail screens. /// @@ -154,9 +154,7 @@ class FloatingOverflowMenu extends HookConsumerWidget { } void _viewPublisher(BuildContext context) { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - context.push('/$first/user/$publisherPubkey'); + pushUser(context, publisherPubkey); } Future _openInBrowser(BuildContext context) async { diff --git a/lib/widgets/search_app_card.dart b/lib/widgets/search_app_card.dart index b1e3169..e9b9904 100644 --- a/lib/widgets/search_app_card.dart +++ b/lib/widgets/search_app_card.dart @@ -1,11 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:markdown/markdown.dart' as md; import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/utils/nostr_route.dart'; import 'package:zapstore/utils/url_utils.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import '../theme.dart'; @@ -31,19 +31,12 @@ class SearchAppCard extends ConsumerWidget { const iconSize = 48.0; return GestureDetector( - onTap: () { - final segments = GoRouterState.of(context).uri.pathSegments; - final first = segments.isNotEmpty ? segments.first : 'search'; - final naddr = Utils.encodeShareableIdentifier( - AddressInput( - identifier: app!.identifier, - author: app!.pubkey, - kind: app!.event.kind, - relays: const [], - ), - ); - context.push('/$first/app/$naddr'); - }, + onTap: () => pushApp( + context, + app!.identifier, + author: app!.pubkey, + kind: app!.event.kind, + ), child: Container( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), padding: const EdgeInsets.all(16), @@ -164,8 +157,17 @@ class SearchAppCard extends ConsumerWidget { } if (node is md.Element) { final isBlock = const { - 'p', 'li', 'ul', 'ol', 'blockquote', - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'p', + 'li', + 'ul', + 'ol', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', }.contains(node.tag); if (node.tag == 'br') buffer.write('\n'); for (final child in node.children ?? []) { @@ -193,8 +195,9 @@ class SearchAppCard extends ConsumerWidget { borderRadius: BorderRadius.circular(20), color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6), border: Border.all( - color: - Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), width: 1, ), ),