diff --git a/lib/l10n/app_ja-oj.arb b/lib/l10n/app_ja-oj.arb index e10be7c48..e988673ef 100644 --- a/lib/l10n/app_ja-oj.arb +++ b/lib/l10n/app_ja-oj.arb @@ -111,8 +111,10 @@ "unsupportedFileWithFilename": "{filename}は対応してないファイルのようですわ", "failedFileSave": "ファイルの保存に失敗したようですわね…", - "nothingHere": "ここには何もありませんわ" + "nothingHere": "ここには何もありませんわ", + "confirmApSearch": "照会を実行いたしますこと?" -} \ No newline at end of file + +} diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 450abd219..1825fcd11 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -1011,6 +1011,8 @@ "remoteServerWithoutLogin": "相手先のサーバー(ログインなし)", "nothingHere": "なんもないで", + "confirmApSearch": "照会を実行するか?", + "deckMode": "デッキモード", "enableDeckMode": "デッキモードにする" diff --git a/lib/l10n/app_zh-cn.arb b/lib/l10n/app_zh-cn.arb index 0e7bda852..319bf3fd1 100644 --- a/lib/l10n/app_zh-cn.arb +++ b/lib/l10n/app_zh-cn.arb @@ -939,5 +939,6 @@ } } }, - "nonInvitedReversi": "好像没有收到邀请" -} \ No newline at end of file + "nonInvitedReversi": "好像没有收到邀请", + "confirmApSearch": "要执行照会吗?" +} diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0e7bda852..319bf3fd1 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -939,5 +939,6 @@ } } }, - "nonInvitedReversi": "好像没有收到邀请" -} \ No newline at end of file + "nonInvitedReversi": "好像没有收到邀请", + "confirmApSearch": "要执行照会吗?" +} diff --git a/lib/util/ap_query.dart b/lib/util/ap_query.dart new file mode 100644 index 000000000..4a98a2721 --- /dev/null +++ b/lib/util/ap_query.dart @@ -0,0 +1,28 @@ + +/// Returns true if the text looks like a query for ActivityPub object. +/// It matches strings that start with 'http://' or 'https://', +/// or strings that start with '@' and contain another '@'. +bool isApQuery(String text) { + final trimmed = text.trim(); + return RegExp(r'^https?://').hasMatch(trimmed) || + (trimmed.startsWith('@') && '@'.allMatches(trimmed).length >= 2); +} + +/// Converts an AP query string to [Uri]. +/// If the input is a URL, it is returned directly. +/// If the input is a handle like '@user@example.com', +/// it will be converted to 'https://example.com/@user'. +Uri apQueryToUri(String text) { + final trimmed = text.trim(); + if (RegExp(r'^https?://').hasMatch(trimmed)) { + return Uri.parse(trimmed); + } + if (trimmed.startsWith('@') && '@'.allMatches(trimmed).length >= 2) { + final withoutAt = trimmed.substring(1); + final firstAt = withoutAt.indexOf('@'); + final user = withoutAt.substring(0, firstAt); + final host = withoutAt.substring(firstAt + 1); + return Uri.parse('https://$host/@$user'); + } + throw FormatException('Invalid AP query', text); +} diff --git a/lib/view/search_page/note_search.dart b/lib/view/search_page/note_search.dart index 46f214df8..325890b17 100644 --- a/lib/view/search_page/note_search.dart +++ b/lib/view/search_page/note_search.dart @@ -11,6 +11,7 @@ import "package:miria/view/common/misskey_notes/misskey_note.dart"; import "package:miria/view/common/pushable_listview.dart"; import "package:miria/view/user_page/user_list_item.dart"; import "package:misskey_dart/misskey_dart.dart"; +import "package:miria/util/ap_query.dart"; class NoteSearch extends HookConsumerWidget { final NoteSearchCondition? initialCondition; @@ -50,7 +51,10 @@ class NoteSearch extends HookConsumerWidget { focusNode: focusNode, autofocus: true, textInputAction: TextInputAction.done, - onSubmitted: (value) => searchQuery.value = value, + onSubmitted: (value) async { + if (await _handleApSearch(context, ref, value)) return; + searchQuery.value = value; + }, ), ), IconButton( @@ -263,3 +267,40 @@ class NoteSearchList extends ConsumerWidget { ); } } + +Future _handleApSearch( + BuildContext context, + WidgetRef ref, + String value, +) async { + if (!isApQuery(value)) return false; + final dialogValue = await ref.read(dialogStateNotifierProvider.notifier).showDialog( + message: (c) => S.of(c).confirmApSearch, + actions: (c) => [S.of(c).done, S.of(c).cancel], + ); + if (dialogValue != 0) return false; + + final response = await ref.read(dialogStateNotifierProvider.notifier).guard( + () async => await ref + .read(misskeyGetContextProvider) + .ap + .show(ApShowRequest(uri: apQueryToUri(value))), + ); + final res = response.valueOrNull; + if (res == null) return true; + if (res.type.toLowerCase() == 'note') { + final note = Note.fromJson(res.object); + await ref + .read(misskeyNoteNotifierProvider.notifier) + .navigateToNoteDetailPage(note); + return true; + } + if (res.type.toLowerCase() == 'user') { + final user = UserDetailed.fromJson(res.object); + await ref + .read(misskeyNoteNotifierProvider.notifier) + .navigateToUserPage(user); + return true; + } + return true; +} diff --git a/lib/view/user_select_dialog.dart b/lib/view/user_select_dialog.dart index 15ace18b1..8a47072f2 100644 --- a/lib/view/user_select_dialog.dart +++ b/lib/view/user_select_dialog.dart @@ -8,6 +8,8 @@ import "package:miria/view/common/account_scope.dart"; import "package:miria/view/common/pushable_listview.dart"; import "package:miria/view/user_page/user_list_item.dart"; import "package:misskey_dart/misskey_dart.dart"; +import "package:miria/util/ap_query.dart"; +import "package:miria/state_notifier/common/misskey_notes/misskey_note_notifier.dart"; @RoutePage() class UserSelectDialog extends StatelessWidget implements AutoRouteWrapper { @@ -60,7 +62,10 @@ class UserSelectContent extends HookConsumerWidget { focusNode: focusNode, autofocus: true, decoration: const InputDecoration(prefixIcon: Icon(Icons.search)), - onSubmitted: (value) => searchQuery.value = value, + onSubmitted: (value) async { + if (await _handleApSearch(context, ref, value)) return; + searchQuery.value = value; + }, ), const Padding(padding: EdgeInsets.only(bottom: 10)), LayoutBuilder( @@ -154,3 +159,40 @@ class UsersSelectContentList extends ConsumerWidget { ); } } + +Future _handleApSearch( + BuildContext context, + WidgetRef ref, + String value, +) async { + if (!isApQuery(value)) return false; + final dialogValue = await ref.read(dialogStateNotifierProvider.notifier).showDialog( + message: (c) => S.of(c).confirmApSearch, + actions: (c) => [S.of(c).done, S.of(c).cancel], + ); + if (dialogValue != 0) return false; + + final response = await ref.read(dialogStateNotifierProvider.notifier).guard( + () async => await ref + .read(misskeyGetContextProvider) + .ap + .show(ApShowRequest(uri: apQueryToUri(value))), + ); + final res = response.valueOrNull; + if (res == null) return true; + if (res.type.toLowerCase() == 'note') { + final note = Note.fromJson(res.object); + await ref + .read(misskeyNoteNotifierProvider.notifier) + .navigateToNoteDetailPage(note); + return true; + } + if (res.type.toLowerCase() == 'user') { + final user = UserDetailed.fromJson(res.object); + await ref + .read(misskeyNoteNotifierProvider.notifier) + .navigateToUserPage(user); + return true; + } + return true; +} diff --git a/test/util/ap_query_test.dart b/test/util/ap_query_test.dart new file mode 100644 index 000000000..517c82f89 --- /dev/null +++ b/test/util/ap_query_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:miria/util/ap_query.dart'; + +void main() { + group('isApQuery', () { + test('returns true for url', () { + expect(isApQuery('https://example.com/note/1'), isTrue); + }); + + test('returns true for handle', () { + expect(isApQuery('@user@example.com'), isTrue); + }); + + test('returns false for normal text', () { + expect(isApQuery('hello'), isFalse); + }); + }); + + group('apQueryToUri', () { + test('returns same url', () { + final uri = apQueryToUri('https://server/notes/1'); + expect(uri.toString(), 'https://server/notes/1'); + }); + + test('converts handle', () { + final uri = apQueryToUri('@name@example.com'); + expect(uri.toString(), 'https://example.com/@name'); + }); + }); +}