add matrix: URI deeplinking

Fixes #148

Needs cross-platform testing, currently only tested on Android.
This commit is contained in:
Henry Hiles 2026-11-01 11:52:13 -05:00
commit 51e3fe1007
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
9 changed files with 108 additions and 45 deletions

View file

@ -32,6 +32,13 @@
<data android:scheme="nexus.federated.nexus" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="matrix" />
</intent-filter>
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"

View file

@ -28,6 +28,7 @@
<key>CFBundleURLSchemes</key>
<array>
<string>nexus.federated.nexus</string>
<string>matrix</string>
</array>
</dict>
</array>

View file

@ -0,0 +1,21 @@
import "package:app_links/app_links.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
class LinkRequestController extends Notifier<LinkInfo?> {
@override
LinkInfo? build() {
final subscription = AppLinks().uriLinkStream.listen((uri) {
if (uri.toString().linkInfo case final linkInfo?) state = linkInfo;
});
ref.onDispose(subscription.cancel);
return null;
}
void consume() => state = null;
static final provider = NotifierProvider<LinkRequestController, LinkInfo?>(
LinkRequestController.new,
);
}

View file

@ -0,0 +1,49 @@
import "package:material_ui/material_ui.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/room_summary_sheet.dart";
extension OpenLinkInfo on WidgetRef {
Future<void> openLinkInfo(
BuildContext context,
LinkInfo linkInfo, {
String? roomId,
}) async {
final identifier = linkInfo.identifier;
if (identifier.startsWith("@")) {
context.showUserPopover(identifier, roomId: roomId);
return;
}
final request = RoomSummaryController.provider(
.new(roomIdOrAlias: identifier, via: linkInfo.via),
);
final summary = await read(request.future)
.onError((_, _) => RoomSummary(roomId: identifier));
final eventRowId = linkInfo.eventId == null
? null
: await read(
EventController.provider(
.new(roomId: summary.roomId, eventId: linkInfo.eventId!),
).selectAsync((data) => data?.rowId),
).onError((_, _) => null);
if (await focusRoom(summary.roomId, eventRowId) || !context.mounted) return;
await context.showAdaptiveSheet(
(context) => Consumer(
builder: (context, ref, _) => RoomSummarySheet(
ref.watch(request).whenOrNull(data: (data) => data) ?? summary,
via: linkInfo.via,
),
),
);
}
}

View file

@ -1,15 +1,11 @@
import "package:material_ui/material_ui.dart";
import "package:nexus/helpers/extensions/show_adaptive_sheet.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/room_summary.dart";
import "package:nexus/controllers/user.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/helpers/extensions/get_link_info.dart";
import "package:nexus/helpers/extensions/show_user_popover.dart";
import "package:nexus/helpers/extensions/open_link_info.dart";
import "package:nexus/models/content/membership.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/room_summary_sheet.dart";
class const MentionChip(final String content, final String? roomId, {super.key})
extends ConsumerWidget {
@ -28,7 +24,9 @@ class const MentionChip(final String content, final String? roomId, {super.key})
"#" || "!" =>
ref
.watch(
RoomSummaryController.provider(.new(roomIdOrAlias: mention!)),
RoomSummaryController.provider(
.new(roomIdOrAlias: mention!, via: linkInfo!.via),
),
)
.whenOrNull(data: (data) => data) ??
RoomSummary(roomId: mention),
@ -39,43 +37,7 @@ class const MentionChip(final String content, final String? roomId, {super.key})
return mention == null
? SizedBox.shrink()
: InkWell(
onTap: () async {
if (data is MembershipContent) {
context.showUserPopover(mention, roomId: roomId);
} else if (data case RoomSummary summary) {
final eventRowId = linkInfo?.eventId == null
? null
: await ref
.read(
EventController.provider(
.new(
roomId: summary.roomId,
eventId: linkInfo!.eventId!,
),
).selectAsync((data) => data?.rowId),
)
.onError((_, _) => null);
if (!await ref.focusRoom(summary.roomId, eventRowId) &&
context.mounted) {
context.showAdaptiveSheet(
(context) => Consumer(
builder: (context, ref, _) => RoomSummarySheet(
ref
.watch(
RoomSummaryController.provider(
.new(roomIdOrAlias: mention),
),
)
.whenOrNull(data: (data) => data) ??
summary,
via: linkInfo?.via,
),
),
);
}
}
},
onTap: () => ref.openLinkInfo(context, linkInfo!, roomId: roomId),
child: Text(
switch (data) {
RoomSummary summary =>

View file

@ -2,6 +2,9 @@ import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/link_request.dart";
import "package:nexus/helpers/extensions/open_link_info.dart";
import "package:nexus/main.dart";
import "package:nexus/controllers/selected_room.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/helpers/extensions/is_desktop.dart";
@ -33,6 +36,23 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
return null;
}, [notificationRequest, initComplete]);
final linkRequest = ref.watch(LinkRequestController.provider);
// Open Matrix links the OS launched Nexus with, once sync has completed
useEffect(() {
if (linkRequest == null || !initComplete) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(LinkRequestController.provider.notifier).consume();
navigatorKey.currentState?.popUntil((route) => route.isFirst);
final navigatorContext = navigatorKey.currentContext;
if (navigatorContext == null) return;
ref.openLinkInfo(navigatorContext, linkRequest).onError(showError);
});
return null;
}, [linkRequest, initComplete]);
return LayoutBuilder(
builder: (context, constraints) {
final isDesktop = context.isDesktop;

View file

@ -5,4 +5,4 @@ Icon=nexus
Terminal=false
Type=Application
NoDisplay=true
MimeType=x-scheme-handler/nexus.federated.nexus;
MimeType=x-scheme-handler/nexus.federated.nexus;x-scheme-handler/matrix;

View file

@ -38,6 +38,7 @@
<array>
<!-- your schemes -->
<string>nexus.federated.nexus</string>
<string>matrix</string>
</array>
</dict>
</array>

View file

@ -19,3 +19,5 @@ Name: "{commondesktop}\Nexus"; Filename: "{app}\nexus.exe"
[Registry]
Root: HKCU; Subkey: "Software\Classes\nexus.federated.nexus"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
Root: HKCU; Subkey: "Software\Classes\nexus.federated.nexus\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\nexus.exe"" ""%1"""
Root: HKCU; Subkey: "Software\Classes\matrix"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""
Root: HKCU; Subkey: "Software\Classes\matrix\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\nexus.exe"" ""%1"""