mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Centralize navigation logic
This commit is contained in:
@@ -1,11 +1,11 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.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/app_card.dart';
|
||||||
import 'package:zapstore/widgets/author_container.dart';
|
import 'package:zapstore/widgets/author_container.dart';
|
||||||
import 'package:zapstore/widgets/comments_section.dart';
|
import 'package:zapstore/widgets/comments_section.dart';
|
||||||
@@ -280,11 +280,7 @@ class _StackHeader extends StatelessWidget {
|
|||||||
oneLine: true,
|
oneLine: true,
|
||||||
size: 14,
|
size: 14,
|
||||||
isLoading: isAuthorLoading,
|
isLoading: isAuthorLoading,
|
||||||
onTap: () {
|
onTap: () => pushUser(context, stack.pubkey),
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
|
||||||
context.push('/$first/user/${stack.pubkey}');
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Last updated timestamp
|
// Last updated timestamp
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import 'package:zapstore/main.dart';
|
|||||||
import 'package:zapstore/services/bookmarks_service.dart';
|
import 'package:zapstore/services/bookmarks_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.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/common/profile_identity_row.dart';
|
||||||
import 'package:zapstore/widgets/app_card.dart';
|
import 'package:zapstore/widgets/app_card.dart';
|
||||||
import 'package:zapstore/theme.dart';
|
import 'package:zapstore/theme.dart';
|
||||||
@@ -285,11 +286,7 @@ class _StackMigrationWarning extends ConsumerWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 12),
|
padding: const EdgeInsets.only(top: 12),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () => pushStacks(context),
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
|
||||||
context.push('/$first/stacks');
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
|
|
||||||
import '../widgets/app_stack_container.dart';
|
import '../widgets/app_stack_container.dart';
|
||||||
import '../widgets/latest_releases_container.dart';
|
import '../widgets/latest_releases_container.dart';
|
||||||
@@ -30,24 +30,11 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
final performSearch = useCallback((String query) {
|
final performSearch = useCallback((String query) {
|
||||||
final trimmed = query.trim();
|
final trimmed = query.trim();
|
||||||
final cleaned = trimmed
|
if (navigateToContent(context, trimmed, fallbackLaunch: false)) {
|
||||||
.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();
|
searchController.clear();
|
||||||
searchQuery.value = '';
|
searchQuery.value = '';
|
||||||
context.push(path);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
|
||||||
if (trimmed.length < 3) {
|
if (trimmed.length < 3) {
|
||||||
searchFocusNode.requestFocus();
|
searchFocusNode.requestFocus();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
import '../widgets/common/note_parser.dart';
|
import '../widgets/common/note_parser.dart';
|
||||||
import '../widgets/common/profile_identity_row.dart';
|
import '../widgets/common/profile_identity_row.dart';
|
||||||
@@ -273,20 +273,12 @@ class _StackLinkCard extends StatelessWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () => pushStack(
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
context,
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
stack.identifier,
|
||||||
// Build naddr for the stack
|
|
||||||
final naddr = Utils.encodeShareableIdentifier(
|
|
||||||
AddressInput(
|
|
||||||
identifier: stack.identifier,
|
|
||||||
author: pubkey,
|
author: pubkey,
|
||||||
kind: stack.event.kind,
|
kind: stack.event.kind,
|
||||||
relays: const [],
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
context.push('/$first/stack/$naddr');
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -370,12 +362,6 @@ class _UserBio extends HookWidget {
|
|||||||
Theme.of(context).colorScheme.primary,
|
Theme.of(context).colorScheme.primary,
|
||||||
Theme.of(context).colorScheme.secondary,
|
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'),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,24 @@ import 'package:flutter/foundation.dart';
|
|||||||
/// the URI is not a recognized deep link.
|
/// the URI is not a recognized deep link.
|
||||||
///
|
///
|
||||||
/// Handles:
|
/// Handles:
|
||||||
/// - `https://zapstore.dev/apps/{id}` (full URI from app_links)
|
/// - `https://zapstore.dev/apps/{id}` and `.../stacks/{id}`
|
||||||
/// - `/apps/{id}` (path-only, as seen by GoRouter's onException)
|
/// - `/apps/{id}` and `/stacks/{id}` (path-only, as seen by GoRouter's onException)
|
||||||
/// - `market://details?id=com.example.app`
|
/// - `market://details?id=com.example.app`
|
||||||
/// - `market://search?q=search+query`
|
/// - `market://search?q=search+query`
|
||||||
String? resolveDeepLinkPath(Uri uri) {
|
String? resolveDeepLinkPath(Uri uri) {
|
||||||
// https://zapstore.dev/apps/<id> OR bare /apps/<id> from GoRouter
|
// https://zapstore.dev/apps/<id> OR bare /apps/<id> from GoRouter
|
||||||
if (uri.pathSegments.length == 2 && uri.pathSegments[0] == 'apps') {
|
// https://zapstore.dev/stacks/<id> OR bare /stacks/<id>
|
||||||
|
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 isFullUri = uri.scheme == 'https' && uri.host == 'zapstore.dev';
|
||||||
final isBarePath = uri.host.isEmpty;
|
final isBarePath = uri.host.isEmpty;
|
||||||
if (isFullUri || isBarePath) {
|
if (isFullUri || isBarePath) {
|
||||||
final id = uri.pathSegments[1];
|
final id = uri.pathSegments[1];
|
||||||
if (id.isNotEmpty) {
|
if (id.isNotEmpty) {
|
||||||
return '/search/app/$id';
|
final route = section == 'apps' ? 'app' : 'stack';
|
||||||
|
return '/search/$route/$id';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/<id>` and `.../stacks/<id>`
|
||||||
|
/// - `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 (_) {}
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.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:markdown/markdown.dart' as md;
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
import 'package:zapstore/utils/url_utils.dart';
|
import 'package:zapstore/utils/url_utils.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/widgets/zap_widgets.dart';
|
import 'package:zapstore/widgets/zap_widgets.dart';
|
||||||
@@ -55,22 +55,7 @@ class AppCard extends HookConsumerWidget {
|
|||||||
: 'No description available';
|
: 'No description available';
|
||||||
|
|
||||||
Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector(
|
Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector(
|
||||||
onTap: () {
|
onTap: () => pushApp(context, app!.identifier, author: app!.pubkey, kind: app!.event.kind),
|
||||||
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');
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import 'package:zapstore/services/bookmarks_service.dart';
|
import 'package:zapstore/services/bookmarks_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/services/notification_service.dart';
|
import 'package:zapstore/services/notification_service.dart';
|
||||||
@@ -49,11 +48,6 @@ class ReleaseNotes extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
return ExpandableMarkdown(
|
return ExpandableMarkdown(
|
||||||
data: release.releaseNotes!,
|
data: release.releaseNotes!,
|
||||||
onTapLink: (text, url, title) {
|
|
||||||
if (url != null) {
|
|
||||||
launchUrl(Uri.parse(url));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(
|
||||||
blockquoteDecoration: BoxDecoration(
|
blockquoteDecoration: BoxDecoration(
|
||||||
color: const Color(0xFF1E3A5F), // Dark blue
|
color: const Color(0xFF1E3A5F), // Dark blue
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import '../utils/extensions.dart';
|
import '../utils/extensions.dart';
|
||||||
@@ -268,11 +268,7 @@ class _SeeMoreCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () => pushStacks(context),
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
|
||||||
context.push('/$first/stacks');
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
@@ -378,19 +374,12 @@ class StackCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () => pushStack(
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
context,
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
stack.identifier,
|
||||||
final naddr = Utils.encodeShareableIdentifier(
|
|
||||||
AddressInput(
|
|
||||||
identifier: stack.identifier,
|
|
||||||
author: stack.pubkey,
|
author: stack.pubkey,
|
||||||
kind: stack.event.kind,
|
kind: stack.event.kind,
|
||||||
relays: const [],
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
context.push('/$first/stack/$naddr');
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:async_button_builder/async_button_builder.dart';
|
import 'package:async_button_builder/async_button_builder.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
@@ -34,7 +33,6 @@ class CommentsSection extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||||
subscriptionPrefix: 'app-comments',
|
subscriptionPrefix: 'app-comments',
|
||||||
and: (comment) => {comment.replies.query()},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,7 +74,6 @@ class StackCommentsSection extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
source: LocalAndRemoteSource(stream: true, relays: 'social'),
|
||||||
subscriptionPrefix: 'app-stack-comments',
|
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.primary,
|
||||||
Theme.of(context).colorScheme.secondary,
|
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) ...[
|
if (isSignedIn) ...[
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
|
|
||||||
Future<void> _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 (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
class NoteParser {
|
class NoteParser {
|
||||||
static final RegExp nip19Regex = RegExp(
|
static final RegExp nip19Regex = RegExp(
|
||||||
@@ -48,7 +28,6 @@ class NoteParser {
|
|||||||
Widget? Function(String httpUrl)? onHttpUrl,
|
Widget? Function(String httpUrl)? onHttpUrl,
|
||||||
Widget? Function(String hashtag)? onHashtag,
|
Widget? Function(String hashtag)? onHashtag,
|
||||||
void Function(String hashtag)? onHashtagTap,
|
void Function(String hashtag)? onHashtagTap,
|
||||||
void Function(String pubkey)? onProfileTap,
|
|
||||||
TextStyle? textStyle,
|
TextStyle? textStyle,
|
||||||
TextStyle? linkStyle,
|
TextStyle? linkStyle,
|
||||||
}) {
|
}) {
|
||||||
@@ -152,7 +131,7 @@ class NoteParser {
|
|||||||
text: match.text,
|
text: match.text,
|
||||||
style: style,
|
style: style,
|
||||||
recognizer: TapGestureRecognizer()
|
recognizer: TapGestureRecognizer()
|
||||||
..onTap = () => _launchUrlSafely(match.text),
|
..onTap = () => navigateToContent(context, match.text),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
final style =
|
final style =
|
||||||
@@ -202,15 +181,11 @@ enum _EntityType { nip19, http, hashtag }
|
|||||||
class NostrEntityWidget extends StatelessWidget {
|
class NostrEntityWidget extends StatelessWidget {
|
||||||
final String entity;
|
final String entity;
|
||||||
final List<Color> colorPair;
|
final List<Color> colorPair;
|
||||||
final void Function(String pubkey)? onProfileTap;
|
|
||||||
final void Function(String hashtag)? onHashtagTap;
|
|
||||||
|
|
||||||
const NostrEntityWidget({
|
const NostrEntityWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.entity,
|
required this.entity,
|
||||||
required this.colorPair,
|
required this.colorPair,
|
||||||
this.onProfileTap,
|
|
||||||
this.onHashtagTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -222,7 +197,6 @@ class NostrEntityWidget extends StatelessWidget {
|
|||||||
ProfileData() => ProfileEntityWidget(
|
ProfileData() => ProfileEntityWidget(
|
||||||
profileData: decoded,
|
profileData: decoded,
|
||||||
colorPair: colorPair,
|
colorPair: colorPair,
|
||||||
onProfileTap: onProfileTap,
|
|
||||||
),
|
),
|
||||||
EventData() => EventEntityWidget(
|
EventData() => EventEntityWidget(
|
||||||
eventData: decoded,
|
eventData: decoded,
|
||||||
@@ -242,13 +216,11 @@ class NostrEntityWidget extends StatelessWidget {
|
|||||||
class ProfileEntityWidget extends ConsumerWidget {
|
class ProfileEntityWidget extends ConsumerWidget {
|
||||||
final ProfileData profileData;
|
final ProfileData profileData;
|
||||||
final List<Color> colorPair;
|
final List<Color> colorPair;
|
||||||
final void Function(String pubkey)? onProfileTap;
|
|
||||||
|
|
||||||
const ProfileEntityWidget({
|
const ProfileEntityWidget({
|
||||||
super.key,
|
super.key,
|
||||||
required this.profileData,
|
required this.profileData,
|
||||||
required this.colorPair,
|
required this.colorPair,
|
||||||
this.onProfileTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -264,15 +236,7 @@ class ProfileEntityWidget extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
void handleTap() {
|
void handleTap() => pushUser(context, profileData.pubkey);
|
||||||
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 switch (profileState) {
|
return switch (profileState) {
|
||||||
StorageLoading() => GestureDetector(
|
StorageLoading() => GestureDetector(
|
||||||
@@ -294,20 +258,8 @@ class ProfileEntityWidget extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProfileWidget(BuildContext context, Profile profile) {
|
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(
|
return GestureDetector(
|
||||||
onTap: handleTap,
|
onTap: () => pushUser(context, profileData.pubkey),
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: colorPair[0].withValues(alpha: 0.1),
|
color: colorPair[0].withValues(alpha: 0.1),
|
||||||
@@ -316,7 +268,7 @@ class ProfileEntityWidget extends ConsumerWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
displayName,
|
profile.nameOrNpub,
|
||||||
style: context.textTheme.bodyMedium!.copyWith(
|
style: context.textTheme.bodyMedium!.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: colorPair[0],
|
color: colorPair[0],
|
||||||
@@ -411,10 +363,11 @@ class AddressEntityWidget extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final segment = addressData.kind == 30267 ? 'stack' : 'app';
|
if (addressData.kind == 30267) {
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
pushStack(context, addressData.identifier);
|
||||||
final branch = segments.isNotEmpty ? segments.first : 'search';
|
} else {
|
||||||
context.push('/$branch/$segment/${addressData.identifier}');
|
pushApp(context, addressData.identifier);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
|
|
||||||
class ExpandableMarkdown extends HookWidget {
|
class ExpandableMarkdown extends HookWidget {
|
||||||
const ExpandableMarkdown({
|
const ExpandableMarkdown({
|
||||||
@@ -21,8 +22,6 @@ class ExpandableMarkdown extends HookWidget {
|
|||||||
const maxHeight = 170.0;
|
const maxHeight = 170.0;
|
||||||
|
|
||||||
bool isLikelyLong(String text) {
|
bool isLikelyLong(String text) {
|
||||||
// Simple heuristic to avoid expensive measurement
|
|
||||||
// - Many words OR many newlines => likely long content
|
|
||||||
final trimmed = text.trim();
|
final trimmed = text.trim();
|
||||||
if (trimmed.isEmpty) return false;
|
if (trimmed.isEmpty) return false;
|
||||||
|
|
||||||
@@ -30,16 +29,19 @@ class ExpandableMarkdown extends HookWidget {
|
|||||||
final newlineCount = '\n'.allMatches(trimmed).length;
|
final newlineCount = '\n'.allMatches(trimmed).length;
|
||||||
final charCount = 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;
|
return wordCount > 90 || newlineCount > 6 || charCount > 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
final shouldCollapse = !expanded.value && isLikelyLong(data);
|
final shouldCollapse = !expanded.value && isLikelyLong(data);
|
||||||
|
|
||||||
|
final effectiveTapLink = onTapLink ??
|
||||||
|
(String text, String? href, String? title) {
|
||||||
|
if (href != null) navigateToContent(context, href);
|
||||||
|
};
|
||||||
|
|
||||||
final markdown = MarkdownBody(
|
final markdown = MarkdownBody(
|
||||||
data: data,
|
data: data,
|
||||||
onTapLink: onTapLink,
|
onTapLink: effectiveTapLink,
|
||||||
styleSheet: styleSheet,
|
styleSheet: styleSheet,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:share_plus/share_plus.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/notification_service.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
|
|
||||||
/// Floating three-dot overflow menu reusable across detail screens.
|
/// Floating three-dot overflow menu reusable across detail screens.
|
||||||
///
|
///
|
||||||
@@ -154,9 +154,7 @@ class FloatingOverflowMenu extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _viewPublisher(BuildContext context) {
|
void _viewPublisher(BuildContext context) {
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
pushUser(context, publisherPubkey);
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
|
||||||
context.push('/$first/user/$publisherPubkey');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openInBrowser(BuildContext context) async {
|
Future<void> _openInBrowser(BuildContext context) async {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:markdown/markdown.dart' as md;
|
import 'package:markdown/markdown.dart' as md;
|
||||||
import 'package:models/models.dart';
|
import 'package:models/models.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:zapstore/utils/extensions.dart';
|
import 'package:zapstore/utils/extensions.dart';
|
||||||
|
import 'package:zapstore/utils/nostr_route.dart';
|
||||||
import 'package:zapstore/utils/url_utils.dart';
|
import 'package:zapstore/utils/url_utils.dart';
|
||||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
@@ -31,19 +31,12 @@ class SearchAppCard extends ConsumerWidget {
|
|||||||
const iconSize = 48.0;
|
const iconSize = 48.0;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () => pushApp(
|
||||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
context,
|
||||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
app!.identifier,
|
||||||
final naddr = Utils.encodeShareableIdentifier(
|
|
||||||
AddressInput(
|
|
||||||
identifier: app!.identifier,
|
|
||||||
author: app!.pubkey,
|
author: app!.pubkey,
|
||||||
kind: app!.event.kind,
|
kind: app!.event.kind,
|
||||||
relays: const [],
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
context.push('/$first/app/$naddr');
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -164,8 +157,17 @@ class SearchAppCard extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (node is md.Element) {
|
if (node is md.Element) {
|
||||||
final isBlock = const {
|
final isBlock = const {
|
||||||
'p', 'li', 'ul', 'ol', 'blockquote',
|
'p',
|
||||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
'li',
|
||||||
|
'ul',
|
||||||
|
'ol',
|
||||||
|
'blockquote',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
}.contains(node.tag);
|
}.contains(node.tag);
|
||||||
if (node.tag == 'br') buffer.write('\n');
|
if (node.tag == 'br') buffer.write('\n');
|
||||||
for (final child in node.children ?? []) {
|
for (final child in node.children ?? []) {
|
||||||
@@ -193,8 +195,9 @@ class SearchAppCard extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color:
|
color: Theme.of(
|
||||||
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
|
context,
|
||||||
|
).colorScheme.outline.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user