Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions lib/l10n/app_ja-oj.arb
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,10 @@
"unsupportedFileWithFilename": "{filename}は対応してないファイルのようですわ",
"failedFileSave": "ファイルの保存に失敗したようですわね…",

"nothingHere": "ここには何もありませんわ"
"nothingHere": "ここには何もありませんわ",

"confirmApSearch": "照会を実行いたしますこと?"


}

}
2 changes: 2 additions & 0 deletions lib/l10n/app_ja.arb
Original file line number Diff line number Diff line change
Expand Up @@ -1011,6 +1011,8 @@
"remoteServerWithoutLogin": "相手先のサーバー(ログインなし)",
"nothingHere": "なんもないで",

"confirmApSearch": "照会を実行するか?",

"deckMode": "デッキモード",
"enableDeckMode": "デッキモードにする"

Expand Down
5 changes: 3 additions & 2 deletions lib/l10n/app_zh-cn.arb
Original file line number Diff line number Diff line change
Expand Up @@ -939,5 +939,6 @@
}
}
},
"nonInvitedReversi": "好像没有收到邀请"
}
"nonInvitedReversi": "好像没有收到邀请",
"confirmApSearch": "要执行照会吗?"
}
5 changes: 3 additions & 2 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -939,5 +939,6 @@
}
}
},
"nonInvitedReversi": "好像没有收到邀请"
}
"nonInvitedReversi": "好像没有收到邀请",
"confirmApSearch": "要执行照会吗?"
}
28 changes: 28 additions & 0 deletions lib/util/ap_query.dart
Original file line number Diff line number Diff line change
@@ -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);
}
43 changes: 42 additions & 1 deletion lib/view/search_page/note_search.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -263,3 +267,40 @@ class NoteSearchList extends ConsumerWidget {
);
}
}

Future<bool> _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;
}
44 changes: 43 additions & 1 deletion lib/view/user_select_dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>()
class UserSelectDialog extends StatelessWidget implements AutoRouteWrapper {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -154,3 +159,40 @@ class UsersSelectContentList extends ConsumerWidget {
);
}
}

Future<bool> _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;
}
30 changes: 30 additions & 0 deletions test/util/ap_query_test.dart
Original file line number Diff line number Diff line change
@@ -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');
});
});
}