Add native deep links for users, chats, and settings (#661)

* Add copy deep link action to chat debug screen

* Address deep link review feedback

* Address deep link PR review follow-ups

* Scale profile QR code size
This commit is contained in:
JeffG
2026-05-15 12:34:58 +02:00
committed by GitHub
parent b8b9e05345
commit 7c543c32a0
41 changed files with 755 additions and 29 deletions
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "Gruppen-ID",
"rawDebugViewMessageCount": "{count, plural, =1{1 Nachricht} other{{count} Nachrichten}}",
"rawDebugViewCopied": "In die Zwischenablage kopiert",
"rawDebugViewCopyDeepLink": "Deep-Link kopieren",
"appLogsTitle": "App-Logs",
"appLogsViewLogs": "Logs anzeigen",
"appLogsViewLogsDescription": "Alle Logger-Ausgaben in der App anzeigen",
+4
View File
@@ -1606,6 +1606,10 @@
"@rawDebugViewCopied": {
"description": "Feedback text after copying debug data"
},
"rawDebugViewCopyDeepLink": "Copy deep link",
"@rawDebugViewCopyDeepLink": {
"description": "Button label for copying a debug chat deep link"
},
"appLogsTitle": "App Logs",
"@appLogsTitle": {
"description": "Screen title for the in-app log viewer"
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID del Grupo",
"rawDebugViewMessageCount": "{count, plural, =1{1 mensaje} other{{count} mensajes}}",
"rawDebugViewCopied": "Copiado al portapapeles",
"rawDebugViewCopyDeepLink": "Copiar enlace profundo",
"appLogsTitle": "Registros de la aplicación",
"appLogsViewLogs": "Ver registros",
"appLogsViewLogsDescription": "Ver todas las salidas del Logger en la aplicación",
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID du Groupe",
"rawDebugViewMessageCount": "{count, plural, =1{1 message} other{{count} messages}}",
"rawDebugViewCopied": "Copié dans le presse-papiers",
"rawDebugViewCopyDeepLink": "Copier le lien profond",
"appLogsTitle": "Journaux de l'application",
"appLogsViewLogs": "Voir les journaux",
"appLogsViewLogsDescription": "Afficher toutes les sorties du journal de l'application",
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID del Gruppo",
"rawDebugViewMessageCount": "{count, plural, =1{1 messaggio} other{{count} messaggi}}",
"rawDebugViewCopied": "Copiato negli appunti",
"rawDebugViewCopyDeepLink": "Copia link diretto",
"appLogsTitle": "Registri dell'app",
"appLogsViewLogs": "Visualizza registri",
"appLogsViewLogsDescription": "Visualizza tutti gli output del Logger nell'app",
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID do Grupo",
"rawDebugViewMessageCount": "{count, plural, =1{1 mensagem} other{{count} mensagens}}",
"rawDebugViewCopied": "Copiado para a área de transferência",
"rawDebugViewCopyDeepLink": "Copiar link profundo",
"appLogsTitle": "Registros do App",
"appLogsViewLogs": "Ver registros",
"appLogsViewLogsDescription": "Ver todas as saídas do Logger no app",
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "ID группы",
"rawDebugViewMessageCount": "{count, plural, =1{1 сообщение} few{{count} сообщения} many{{count} сообщений} other{{count} сообщений}}",
"rawDebugViewCopied": "Скопировано в буфер обмена",
"rawDebugViewCopyDeepLink": "Скопировать диплинк",
"appLogsTitle": "Журналы приложения",
"appLogsViewLogs": "Просмотреть логи",
"appLogsViewLogsDescription": "Просмотреть все записи Logger в приложении",
+1
View File
@@ -362,6 +362,7 @@
"rawDebugViewGroupId": "Grup Kimliği",
"rawDebugViewMessageCount": "{count, plural, =1{1 mesaj} other{{count} mesaj}}",
"rawDebugViewCopied": "Panoya kopyalandı",
"rawDebugViewCopyDeepLink": "Derin bağlantıyı kopyala",
"appLogsTitle": "Uygulama Günlükleri",
"appLogsViewLogs": "Günlüklere bak",
"appLogsViewLogsDescription": "Uygulamadaki tüm Logger çıktısını görüntüle",
+1
View File
@@ -366,6 +366,7 @@
"rawDebugViewGroupId": "群组 ID",
"rawDebugViewMessageCount": "{count, plural, =0{无消息} =1{1 条消息} other{{count} 条消息}}",
"rawDebugViewCopied": "已复制到剪贴板",
"rawDebugViewCopyDeepLink": "复制深层链接",
"appLogsTitle": "应用日志",
"appLogsViewLogs": "查看日志",
"appLogsViewLogsDescription": "在应用内查看所有日志输出",
+1
View File
@@ -366,6 +366,7 @@
"rawDebugViewGroupId": "群組 ID",
"rawDebugViewMessageCount": "{count, plural, =0{沒有訊息} =1{1 則訊息} other{{count} 則訊息}}",
"rawDebugViewCopied": "已複製到剪貼簿",
"rawDebugViewCopyDeepLink": "複製深層連結",
"appLogsTitle": "應用程式記錄",
"appLogsViewLogs": "查看記錄",
"appLogsViewLogsDescription": "在應用程式內查看所有 Logger 輸出",
@@ -2307,6 +2307,12 @@ abstract class AppLocalizations {
/// **'Copied to clipboard'**
String get rawDebugViewCopied;
/// Button label for copying a debug chat deep link
///
/// In en, this message translates to:
/// **'Copy deep link'**
String get rawDebugViewCopyDeepLink;
/// Screen title for the in-app log viewer
///
/// In en, this message translates to:
@@ -1301,6 +1301,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get rawDebugViewCopied => 'In die Zwischenablage kopiert';
@override
String get rawDebugViewCopyDeepLink => 'Deep-Link kopieren';
@override
String get appLogsTitle => 'App-Logs';
@@ -1264,6 +1264,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copied to clipboard';
@override
String get rawDebugViewCopyDeepLink => 'Copy deep link';
@override
String get appLogsTitle => 'App Logs';
@@ -1282,6 +1282,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copiado al portapapeles';
@override
String get rawDebugViewCopyDeepLink => 'Copiar enlace profundo';
@override
String get appLogsTitle => 'Registros de la aplicación';
@@ -1280,6 +1280,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copié dans le presse-papiers';
@override
String get rawDebugViewCopyDeepLink => 'Copier le lien profond';
@override
String get appLogsTitle => 'Journaux de l\'application';
@@ -1268,6 +1268,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copiato negli appunti';
@override
String get rawDebugViewCopyDeepLink => 'Copia link diretto';
@override
String get appLogsTitle => 'Registri dell\'app';
@@ -1278,6 +1278,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Copiado para a área de transferência';
@override
String get rawDebugViewCopyDeepLink => 'Copiar link profundo';
@override
String get appLogsTitle => 'Registros do App';
@@ -1298,6 +1298,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Скопировано в буфер обмена';
@override
String get rawDebugViewCopyDeepLink => 'Скопировать диплинк';
@override
String get appLogsTitle => 'Журналы приложения';
@@ -1265,6 +1265,9 @@ class AppLocalizationsTr extends AppLocalizations {
@override
String get rawDebugViewCopied => 'Panoya kopyalandı';
@override
String get rawDebugViewCopyDeepLink => 'Derin bağlantıyı kopyala';
@override
String get appLogsTitle => 'Uygulama Günlükleri';
@@ -1227,6 +1227,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get rawDebugViewCopied => '已复制到剪贴板';
@override
String get rawDebugViewCopyDeepLink => '复制深层链接';
@override
String get appLogsTitle => '应用日志';
@@ -2640,6 +2643,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get rawDebugViewCopied => '已複製到剪貼簿';
@override
String get rawDebugViewCopyDeepLink => '複製深層連結';
@override
String get appLogsTitle => '應用程式記錄';
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:whitenoise/utils/deep_links.dart';
final deepLinkSchemeProvider = FutureProvider<String>((ref) async {
final info = await PackageInfo.fromPlatform();
return deepLinkSchemeForPackageName(info.packageName);
});
String deepLinkSchemeForPackageName(String packageName) {
if (packageName.endsWith('.staging')) return DeepLinks.stagingScheme;
return DeepLinks.productionScheme;
}
+36 -1
View File
@@ -50,6 +50,7 @@ import 'package:whitenoise/screens/switch_profile_screen.dart' show SwitchProfil
import 'package:whitenoise/screens/user_search_screen.dart' show UserSearchScreen;
import 'package:whitenoise/screens/user_selection_screen.dart' show UserSelectionScreen;
import 'package:whitenoise/src/rust/api/users.dart' show User;
import 'package:whitenoise/utils/deep_links.dart' show DeepLinks;
import 'package:whitenoise/widgets/wn_slate_content_transition.dart' show WnSlateContentTransition;
final class AddRelayArgs {
@@ -116,6 +117,12 @@ abstract final class Routes {
observers: [routeObserver, ActiveChatRouteObserver(ref.read(activeChatProvider.notifier))],
redirect: (context, state) {
final pubkey = ref.read(authProvider).value;
final deepLinkLocation = DeepLinks.parse(state.uri)?.location;
if (deepLinkLocation != null) {
if (pubkey == null) return _loginLocation(redirect: deepLinkLocation);
return deepLinkLocation;
}
final isOnPublicPage = _publicRoutes.contains(state.matchedLocation);
final isAddingAccount = ref.read(isAddingAccountProvider);
@@ -498,6 +505,11 @@ abstract final class Routes {
GoRouter.of(context).go(_login);
}
static void goToPostAuthDestination(BuildContext context) {
final redirect = _safeRedirect(GoRouterState.of(context).uri.queryParameters['redirect']);
GoRouter.of(context).go(redirect ?? _chatList);
}
static void pushToLogin(BuildContext context) {
GoRouter.of(context).push(_login);
}
@@ -630,8 +642,9 @@ abstract final class Routes {
required String pubkey,
required bool isExternalSigner,
}) {
final redirect = _safeRedirect(GoRouterState.of(context).uri.queryParameters['redirect']);
GoRouter.of(context).push(
_relayResolution,
_relayResolutionLocation(redirect: redirect),
extra: RelayResolutionArgs(pubkey: pubkey, isExternalSigner: isExternalSigner),
);
}
@@ -698,4 +711,26 @@ abstract final class Routes {
static void pushToChatRawDebug(BuildContext context, String groupId) {
GoRouter.of(context).pushNamed('chatRawDebug', pathParameters: {'groupId': groupId});
}
static String _loginLocation({String? redirect}) {
if (redirect == null) return _login;
return Uri(path: _login, queryParameters: {'redirect': redirect}).toString();
}
static String _relayResolutionLocation({String? redirect}) {
if (redirect == null) return _relayResolution;
return Uri(path: _relayResolution, queryParameters: {'redirect': redirect}).toString();
}
static String? _safeRedirect(String? location) {
if (location == null || location.isEmpty) return null;
final uri = Uri.tryParse(location);
if (uri == null) return null;
if (uri.hasScheme || uri.hasAuthority) return null;
if (!uri.path.startsWith('/')) return null;
if (_publicRoutes.contains(uri.path)) return null;
return location;
}
}
+47 -2
View File
@@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:whitenoise/hooks/use_chat_messages.dart';
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
import 'package:whitenoise/providers/deep_link_provider.dart';
import 'package:whitenoise/providers/message_debug_log_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/src/rust/api/groups.dart';
@@ -15,8 +16,10 @@ import 'package:whitenoise/src/rust/api/media_files.dart';
import 'package:whitenoise/src/rust/api/messages.dart';
import 'package:whitenoise/src/rust/api/metadata.dart';
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/deep_links.dart';
import 'package:whitenoise/widgets/debug_key_value_row.dart';
import 'package:whitenoise/widgets/debug_section_card.dart';
import 'package:whitenoise/widgets/wn_icon.dart';
import 'package:whitenoise/widgets/wn_pill.dart';
import 'package:whitenoise/widgets/wn_slate.dart';
import 'package:whitenoise/widgets/wn_slate_navigation_header.dart';
@@ -153,7 +156,7 @@ class ChatRawDebugScreen extends HookConsumerWidget {
}
}
class _DebugHeader extends StatelessWidget {
class _DebugHeader extends ConsumerWidget {
const _DebugHeader({
required this.groupId,
required this.messageCount,
@@ -167,7 +170,13 @@ class _DebugHeader extends StatelessWidget {
final String? latestMessagePubkey;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final colors = context.colors;
final typography = context.typographyScaled;
final deepLinkSchemeState = ref.watch(deepLinkSchemeProvider);
final chatDeepLink = deepLinkSchemeState.hasValue
? DeepLinks.chatUri(groupId, scheme: deepLinkSchemeState.value!)
: null;
final copyText = [
'group_id: $groupId',
'message_count: $messageCount',
@@ -187,6 +196,42 @@ class _DebugHeader extends StatelessWidget {
value: groupId,
valueKey: const Key('debug_group_id'),
),
if (chatDeepLink != null) ...[
SizedBox(height: 8.h),
Material(
color: Colors.transparent,
child: InkWell(
key: const Key('debug_chat_deep_link_copy_button'),
borderRadius: BorderRadius.circular(8.r),
onTap: () => _copyDebugText(context, chatDeepLink),
child: Ink(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h),
decoration: BoxDecoration(
color: colors.fillSecondary,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: colors.borderTertiary),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
WnIcon(
WnIcons.copy,
size: 14.w,
color: colors.backgroundContentSecondary,
),
SizedBox(width: 6.w),
Text(
context.l10n.rawDebugViewCopyDeepLink,
style: typography.medium10.copyWith(
color: colors.backgroundContentSecondary,
),
),
],
),
),
),
),
],
SizedBox(height: 4.h),
DebugKeyValueRow(
label: 'message_count',
+1 -1
View File
@@ -74,7 +74,7 @@ void _handleLoginResult(
if (!context.mounted) return;
if (result.status == LoginStatus.complete) {
Routes.goToChatList(context);
Routes.goToPostAuthDestination(context);
} else if (result.status == LoginStatus.needsRelayLists) {
Routes.pushToRelayResolution(
context,
+2 -2
View File
@@ -69,14 +69,14 @@ class RelayResolutionScreen extends HookConsumerWidget {
Future<void> onPublishDefaults() async {
final success = await publishDefaults();
if (success && context.mounted) {
Routes.goToChatList(context);
Routes.goToPostAuthDestination(context);
}
}
Future<void> onTryCustomRelay() async {
final success = await tryCustomRelay();
if (success && context.mounted) {
Routes.goToChatList(context);
Routes.goToPostAuthDestination(context);
}
}
+9
View File
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:gap/gap.dart' show Gap;
import 'package:go_router/go_router.dart' show GoRouter;
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/routes.dart' show Routes;
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/deep_links.dart' show DeepLinks;
import 'package:whitenoise/utils/encoding.dart' show hexFromNpub;
import 'package:whitenoise/widgets/qr_scanner.dart' show QrScanner;
import 'package:whitenoise/widgets/wn_slate.dart';
@@ -20,6 +22,13 @@ class ScanNpubScreen extends HookWidget {
final showInvalidNpubError = useState(false);
void onBarcodeDetected(String value) {
final deepLinkTarget = DeepLinks.parseString(value);
if (deepLinkTarget != null) {
Routes.goBack(context);
GoRouter.of(context).push(deepLinkTarget.location);
return;
}
final hexPubkey = hexFromNpub(value);
if (hexPubkey != null) {
Routes.goBack(context);
+29 -20
View File
@@ -7,8 +7,10 @@ import 'package:whitenoise/hooks/use_system_notice.dart';
import 'package:whitenoise/hooks/use_user_metadata.dart';
import 'package:whitenoise/l10n/l10n.dart';
import 'package:whitenoise/providers/account_pubkey_provider.dart';
import 'package:whitenoise/providers/deep_link_provider.dart';
import 'package:whitenoise/routes.dart';
import 'package:whitenoise/theme.dart';
import 'package:whitenoise/utils/deep_links.dart';
import 'package:whitenoise/utils/formatting.dart';
import 'package:whitenoise/utils/metadata.dart';
import 'package:whitenoise/widgets/wn_avatar.dart';
@@ -29,6 +31,10 @@ class ShareProfileScreen extends HookConsumerWidget {
final pubkey = ref.watch(accountPubkeyProvider);
final metadataSnapshot = useUserMetadata(context, pubkey);
final npub = npubFromHex(pubkey);
final deepLinkSchemeState = ref.watch(deepLinkSchemeProvider);
final profileDeepLink = npub != null && deepLinkSchemeState.hasValue
? DeepLinks.userUri(npub, scheme: deepLinkSchemeState.value!)
: null;
final (:noticeMessage, :noticeType, :showSuccessNotice, :showErrorNotice, :dismissNotice) =
useSystemNotice();
@@ -84,30 +90,33 @@ class ShareProfileScreen extends HookConsumerWidget {
snapToWords: true,
),
),
Gap(36.h),
ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: SizedBox.square(
dimension: 256.w,
child: FittedBox(
fit: BoxFit.fill,
child: QrImageView(
data: npub,
size: 98,
padding: EdgeInsets.zero,
backgroundColor: colors.backgroundSecondary,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: colors.backgroundContentPrimary,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: colors.backgroundContentPrimary,
if (profileDeepLink != null) ...[
Gap(36.h),
ClipRRect(
borderRadius: BorderRadius.circular(12.r),
child: SizedBox.square(
dimension: 256.w,
child: FittedBox(
fit: BoxFit.fill,
child: QrImageView(
key: ValueKey<String>(profileDeepLink),
data: profileDeepLink,
size: 98.w,
padding: EdgeInsets.zero,
backgroundColor: colors.backgroundSecondary,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: colors.backgroundContentPrimary,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: colors.backgroundContentPrimary,
),
),
),
),
),
),
],
] else
Gap(32.h),
Gap(12.h),
+109
View File
@@ -0,0 +1,109 @@
import 'package:whitenoise/utils/encoding.dart' show hexFromNpub;
enum DeepLinkTargetType { user, chat, settings }
class DeepLinkTarget {
const DeepLinkTarget({
required this.type,
required this.location,
});
final DeepLinkTargetType type;
final String location;
}
abstract final class DeepLinks {
static const productionScheme = 'whitenoise';
static const stagingScheme = 'whitenoise-staging';
static const _settingsLocations = {
'settings': '/settings',
'settings/share-profile': '/share-profile',
'settings/switch-profile': '/switch-profile',
'settings/edit-profile': '/edit-profile',
'settings/profile-keys': '/profile-keys',
'settings/network': '/network',
'settings/privacy-security': '/privacy-security',
'settings/appearance': '/appearance',
'settings/notifications': '/notification-settings',
'settings/report-bug': '/report-bug',
'settings/donate': '/donate',
'settings/developer': '/developer-settings',
'settings/developer/key-packages': '/key-package-management',
'settings/developer/relay-state': '/relay-control-state',
'settings/developer/app-logs': '/app-logs',
};
static String userUri(String npub, {String scheme = productionScheme}) {
return Uri(scheme: scheme, host: 'user', pathSegments: [npub]).toString();
}
static String chatUri(String groupId, {String scheme = productionScheme}) {
return Uri(scheme: scheme, host: 'chat', pathSegments: [groupId]).toString();
}
static DeepLinkTarget? parse(Uri uri) {
if (!_isSupportedScheme(uri.scheme)) return null;
final segments = _segments(uri);
if (segments.isEmpty) return null;
return switch (segments.first.toLowerCase()) {
'user' => _parseUser(segments),
'chat' => _parseChat(segments),
'settings' => _parseSettings(segments),
_ => null,
};
}
static DeepLinkTarget? parseString(String value) {
final uri = Uri.tryParse(value.trim());
if (uri == null) return null;
return parse(uri);
}
static bool _isSupportedScheme(String scheme) {
return scheme == productionScheme || scheme == stagingScheme;
}
static List<String> _segments(Uri uri) {
return [
if (uri.host.isNotEmpty) uri.host,
...uri.pathSegments,
].where((segment) => segment.isNotEmpty).toList(growable: false);
}
static DeepLinkTarget? _parseUser(List<String> segments) {
if (segments.length != 2) return null;
final pubkey = hexFromNpub(segments[1]);
if (pubkey == null) return null;
return DeepLinkTarget(
type: DeepLinkTargetType.user,
location: '/start-chat/${Uri.encodeComponent(pubkey)}',
);
}
static DeepLinkTarget? _parseChat(List<String> segments) {
if (segments.length != 2) return null;
final groupId = segments[1];
if (groupId.isEmpty) return null;
return DeepLinkTarget(
type: DeepLinkTargetType.chat,
location: '/chats/${Uri.encodeComponent(groupId)}',
);
}
static DeepLinkTarget? _parseSettings(List<String> segments) {
final location = _settingsLocations[segments.join('/')];
if (location == null) return null;
return DeepLinkTarget(
type: DeepLinkTargetType.settings,
location: location,
);
}
}