diff --git a/CHANGELOG.md b/CHANGELOG.md index 782e21a1..390d22d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- @ 提及:适配论坛新的 @ 插件(atplus)。论坛现在只把 @ 面板选出的名字(前后带不可见标记)当成提及,App 之前送出的 `@用户名` 会变成纯文字、对方收不到提醒;现在改为与网页面板相同的格式。 + +### Changed + +- @ 选人面板:论坛提供 @ 插件时,改用插件的名单,显示「最近 @ 过」与好友,并可输入用户名或 UID 搜索全站用户;论坛没有插件时照旧使用好友列表与 @ 名单。用户组整组 @ 暂不支持。 + ## [1.29.2] - 2026-09-29 ### Changed diff --git a/doc/spec-x5-features.md b/doc/spec-x5-features.md index d4006265..c6de65f1 100644 --- a/doc/spec-x5-features.md +++ b/doc/spec-x5-features.md @@ -1299,3 +1299,25 @@ B. 論壇提醒屏蔽規則 - `AppDialogTitle(singleLine: true)`:標題單行、過長縮字;贊助彈窗與其 GitHub 按鈕、首頁贊助卡片標題都保持單行。 - 驗證:test_172 第 4 組新增 320/360/384dp × 1/1.15/2 倍字級案例;主題模式與彈窗 6 項、首頁 1 項在修正前失敗。 +## 44. @ 提及適配 atplus 插件(2026-09-30) + +### 44.1 論壇端事實(2026-09-30 以測試帳號唯讀實測) + +- 帖子頁與回覆頁載入 `source/plugin/atplus/static/at-panel.js?v=1.3.0`,設定 `ATPLUS={"api":"plugin.php?id=atplus:search",...}`;編輯器旁有「@ 提及」按鈕。 +- 面板選到的人插入 `@` + U+2063 + 用戶名 + U+2063 + 空格;伺服器**只認**這種帶標記的 @。在輸入框直接打 `@名字` 只是普通文字(插件 1.0.3 起)。 +- 整組 @(1.1.0):`@` + U+2064 + 組名 + U+2064,伺服器再檢查權限;測試帳號 `groups` 為空。 +- API(POST,form):`op=init` → `{"ok":1,"recent":[...],"friends":[...],"groups":[...]}`;`op=search&q=` → `{"ok":1,"list":[...]}`;每筆 `{uid, username, avatar(相對路徑), group, friend}`。`op=card&uids=` 給名片用,App 未用。 +- 舊的 `misc.php?mod=getatuser` 仍在,但只剩名字。 + +### 44.2 App 端行為 + +- 送出時 `toOfficialMentions` 把編輯器的 `[@]name[/@]` 轉成 `@⁣name⁣ `(`atplusUserMark`);原本的純 `@name` 在新插件下不會成為提及。編輯舊帖時原文內的標記原樣保留,再送出仍有效。 +- `MentionRepository.loadCandidates`:已登入時先問 `op=init`,成功就用插件的「最近 @ 過」與好友(`MentionCandidates.siteSearch = true`),不再抓好友頁與 getatuser;插件沒回 JSON(未安裝、錯誤)時退回原本兩個來源。訪客不問插件。 +- `searchUsers(keyword)` → `op=search`;`UserMentionCubit.setKeyword` 在 siteSearch 時搜尋,只保留最新關鍵字的回應。選人面板依序顯示:最近 @ 過、好友、全站搜尋(排除已顯示的)、舊 @ 名單、「提醒『輸入的名字』」。 +- 未做:整組 @(U+2064)、@ 名片。 + +### 44.3 驗證 + +- test_022、test_049 更新為帶標記格式;test_049 新增 atplus 群組(解析、插件優先且不問舊來源、訪客不問、cubit 搜尋、選人面板挑選全站搜尋結果)。插件回 404 時的退回路徑由原有案例涵蓋。 +- 未實測:真的發出一則 @ 並確認對方收到提醒(會對測試帳號以外的人產生提醒,未做)。 + diff --git a/lib/features/editor/bloc/user_mention_cubit.dart b/lib/features/editor/bloc/user_mention_cubit.dart index 1d54ad6a..1fed2a7f 100644 --- a/lib/features/editor/bloc/user_mention_cubit.dart +++ b/lib/features/editor/bloc/user_mention_cubit.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:dart_mappable/dart_mappable.dart'; import 'package:fpdart/fpdart.dart'; @@ -41,17 +43,44 @@ final class UserMentionCubit extends Cubit with LoggerMixin { friends: value.friends, others: value.others, friendsMessage: value.friendsMessage, + recent: value.recent, + siteSearch: value.siteSearch, ), ); + // A keyword typed while loading is searched once the site search turns out to be available. + unawaited(_search()); } } - /// Filter the candidates by [keyword], case insensitive. + /// Filter the candidates by [keyword], case insensitive, and search the site for it when possible. void setKeyword(String keyword) { final k = keyword.trim(); if (k == state.keyword) { return; } emit(state.copyWith(keyword: k)); + unawaited(_search()); + } + + /// Search the whole site for the current keyword (`atplus`), keeping only the answer of the latest keyword. + Future _search() async { + final k = state.keyword; + if (!state.siteSearch || + k.isEmpty || + (state.searchKeyword == k && state.searchStatus != UserMentionStatus.failure)) { + return; + } + emit(state.copyWith(searchKeyword: k, searchStatus: UserMentionStatus.loading, searchResults: const [])); + final result = await _repo.searchUsers(k).run(); + if (isClosed || state.keyword != k) { + return; + } + switch (result) { + case Left(:final value): + handle(value); + emit(state.copyWith(searchStatus: UserMentionStatus.failure)); + case Right(:final value): + emit(state.copyWith(searchStatus: UserMentionStatus.success, searchResults: value)); + } } } diff --git a/lib/features/editor/bloc/user_mention_state.dart b/lib/features/editor/bloc/user_mention_state.dart index a6b28a90..2d001648 100644 --- a/lib/features/editor/bloc/user_mention_state.dart +++ b/lib/features/editor/bloc/user_mention_state.dart @@ -25,6 +25,11 @@ final class UserMentionState with UserMentionStateMappable { required this.others, required this.keyword, this.friendsMessage, + this.recent = const [], + this.siteSearch = false, + this.searchStatus = UserMentionStatus.initial, + this.searchKeyword = '', + this.searchResults = const [], }); /// Empty state. @@ -46,18 +51,49 @@ final class UserMentionState with UserMentionStateMappable { /// Why the friends list could not be read, see [MentionCandidates.friendsMessage]. final String? friendsMessage; + /// Users mentioned recently, see [MentionCandidates.recent]. + final List recent; + + /// Whether the whole site can be searched, see [MentionCandidates.siteSearch]. + final bool siteSearch; + + /// Status of the site search for [searchKeyword]. + final UserMentionStatus searchStatus; + + /// Keyword the [searchResults] belong to. + final String searchKeyword; + + /// Users of the site found for [searchKeyword]. + final List searchResults; + bool _matches(String name) => keyword.isEmpty || name.toLowerCase().contains(keyword.toLowerCase()); /// Friends matching the keyword. List get visibleFriends => friends.where((e) => _matches(e.username)).toList(); + /// Recently mentioned users matching the keyword. + List get visibleRecent => recent.where((e) => _matches(e.username)).toList(); + + /// Site search results of the current keyword that are not already shown as recent or friends. + List get visibleSearchResults { + if (searchKeyword != keyword) { + return const []; + } + final shown = {...visibleRecent.map((e) => e.uid), ...visibleFriends.map((e) => e.uid)}; + return searchResults.where((e) => !shown.contains(e.uid)).toList(); + } + /// Other names matching the keyword. List get visibleOthers => others.where(_matches).toList(); /// Whether a candidate has exactly the keyword as name (case insensitive). bool get hasExactMatch { final k = keyword.toLowerCase(); + bool same(Friend e) => e.username.toLowerCase() == k; return k.isNotEmpty && - (friends.any((e) => e.username.toLowerCase() == k) || others.any((e) => e.toLowerCase() == k)); + (friends.any(same) || + recent.any(same) || + (searchKeyword == keyword && searchResults.any(same)) || + others.any((e) => e.toLowerCase() == k)); } } diff --git a/lib/features/editor/repository/mention_repository.dart b/lib/features/editor/repository/mention_repository.dart index 0b3a46e1..e59dd1bf 100644 --- a/lib/features/editor/repository/mention_repository.dart +++ b/lib/features/editor/repository/mention_repository.dart @@ -1,22 +1,43 @@ +import 'dart:convert'; + import 'package:fpdart/fpdart.dart'; +import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/features/editor/repository/editor_repository.dart'; +import 'package:tsdm_client/features/editor/utils/mention.dart'; import 'package:tsdm_client/features/friend/models/models.dart'; import 'package:tsdm_client/features/friend/repository/friend_repository.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/utils/logger.dart'; /// Who the mention picker can offer. final class MentionCandidates { /// Constructor. - const MentionCandidates({required this.friends, required this.others, this.friendsMessage}); + const MentionCandidates({ + required this.friends, + required this.others, + this.friendsMessage, + this.recent = const [], + this.siteSearch = false, + }); /// Nothing at all. static const empty = MentionCandidates(friends: [], others: []); + /// Users the current user mentioned recently, from the `atplus` plugin; empty without it. + final List recent; + + /// Whether the forum's `atplus` plugin answered: then any user of the site can be searched by name or uid + /// ([MentionRepository.searchUsers]). + final bool siteSearch; + /// The current user's own friends, in the order of the friends list, unique by uid. final List friends; /// Names on the official `@` list (`misc.php?mod=getatuser`) that are not in [friends], compared case insensitive. + /// + /// Empty when the `atplus` plugin answered ([siteSearch]): its search replaces that list. final List others; /// Why the friends list could not be read: the privacy or login notice of the page, or the error of the request. @@ -27,7 +48,11 @@ final class MentionCandidates { /// Where the mention picker gets its candidates. /// -/// Two sources are merged, and one failing does not fail the whole load: +/// The forum's `atplus` plugin (`plugin.php?id=atplus:search`, the web @ panel since 2026-09) comes first: `op=init` +/// gives the users mentioned recently and the friends, `op=search` finds any user of the site by name or uid. When the +/// plugin does not answer (not installed, or an error), the stock sources are used instead. +/// +/// Two stock sources are merged, and one failing does not fail the whole load: /// /// * the current user's own friends list (`home.php?mod=space&uid=SELF&do=friend`, 24 per page, up to /// [maxFriendPages] pages), which carries uid, avatar and user group; @@ -92,9 +117,40 @@ final class MentionRepository with LoggerMixin { return Right((friends: friends, message: null)); }); + /// Url of the `atplus` plugin's user api. + static const atplusUrl = '$baseUrl/plugin.php?id=atplus:search'; + + /// Ask the `atplus` plugin with the form [data]; the decoded JSON, or null when the plugin did not answer with it. + Future _atplus(Map data) async { + switch (await getIt.get().postForm(atplusUrl, data: data).run()) { + case Left(:final value): + debug('mention: atplus did not answer: $value'); + return null; + case Right(:final value): + final body = value.data; + if (body is Map) { + return body; + } + try { + return jsonDecode('$body'); + } on FormatException { + return null; + } + } + } + + /// Users of the whole site whose name or uid matches [keyword], through the `atplus` plugin. + /// + /// Fails when the plugin does not answer; only meaningful when [MentionCandidates.siteSearch] is set. + AsyncEither> searchUsers(String keyword) => AsyncEither(() async { + final users = parseAtplusUsers(await _atplus({'op': 'search', 'q': keyword}), 'list', base: baseUrl); + return users == null ? Left(ServerRespondedErrorException('atplus search unavailable')) : Right(users); + }); + /// Load the candidates: own friends of [selfUid] (skipped when null, nobody logged in) and the official `@` list. /// - /// Fails only when nothing could be loaded at all. + /// The `atplus` plugin is asked first for a logged in user; its recent and friends lists replace the stock sources + /// when it answers. Fails only when nothing could be loaded at all. AsyncEither loadCandidates({required String? selfUid, bool force = false}) => AsyncEither(() async { final cached = _cache; @@ -102,6 +158,18 @@ final class MentionRepository with LoggerMixin { return Right(cached); } + if (selfUid != null) { + final init = await _atplus(const {'op': 'init'}); + final recent = parseAtplusUsers(init, 'recent', base: baseUrl); + final friends = parseAtplusUsers(init, 'friends', base: baseUrl); + if (recent != null && friends != null) { + final candidates = MentionCandidates(recent: recent, friends: friends, others: const [], siteSearch: true); + _cache = candidates; + _cachedUid = selfUid; + return Right(candidates); + } + } + // Both requests run at the same time. final friendsFuture = selfUid == null ? null : loadOwnFriends(selfUid: selfUid).run(); // The `@` list cached inside the editor repository is only reused for the uid it was loaded for. diff --git a/lib/features/editor/utils/mention.dart b/lib/features/editor/utils/mention.dart index 0084d512..74a833c6 100644 --- a/lib/features/editor/utils/mention.dart +++ b/lib/features/editor/utils/mention.dart @@ -1,3 +1,4 @@ +import 'package:tsdm_client/features/friend/models/models.dart'; import 'package:universal_html/parsing.dart'; /// `[@]name[/@]`, the mention format of the retired `amucallme_dzx` plugin that the bbcode editor still emits. @@ -6,17 +7,67 @@ import 'package:universal_html/parsing.dart'; /// ends it early so a literal unpaired `[@]` does not swallow the next chip. final RegExp _legacyMentionRe = RegExp(r'\[@\]((?:(?!\[@\])[^\r\n])+?)\[/@\]'); -/// Rewrite editor mentions in [bbcode] to the official Discuz! format. +/// Invisible mark (U+2063, INVISIBLE SEPARATOR) around a username picked in the `atplus` @ panel. /// -/// Discuz! X5 shows `[@]name[/@]` as plain text. The official mention is a bare `@name` followed by whitespace, -/// which the server turns into a profile link and a notice when the poster's user group is allowed to mention. -/// Every embed becomes `@name`, with a space appended unless whitespace already follows. +/// Since the forum's `atplus` plugin (2026-09), the server only turns `@` + mark + username + mark into a mention +/// (profile link and notice); a bare `@name` stays plain text, exactly like typing `@` in the web editor. +const atplusUserMark = '\u2063'; + +/// Rewrite editor mentions in [bbcode] to the format the forum turns into mentions. +/// +/// Discuz! X5 shows `[@]name[/@]` as plain text. With the `atplus` plugin the server only recognizes +/// `@` + [atplusUserMark] + name + [atplusUserMark], the text its @ panel inserts; the bare `@name` of stock Discuz! +/// used before is left as plain text now. Every embed becomes the marked form, with a space appended unless +/// whitespace already follows (the panel inserts one as well). String toOfficialMentions(String bbcode) => bbcode.replaceAllMapped(_legacyMentionRe, (m) { final name = m.group(1)!.trim(); final followedByWhitespace = m.end < bbcode.length && bbcode[m.end].trim().isEmpty; - return followedByWhitespace ? '@$name' : '@$name '; + final mention = '@$atplusUserMark$name$atplusUserMark'; + return followedByWhitespace ? mention : '$mention '; }); +/// Users in an answer of the `atplus` plugin (`plugin.php?id=atplus:search`), `null` when it is not one. +/// +/// ```json +/// {"ok":1,"list":[{"uid":1000,"username":"Alice","avatar":"./data/avatar/000/00/10/00_avatar_small.jpg", +/// "group":"使者","friend":0}]} +/// ``` +/// +/// [key] is the list to read: `list` for a search, `recent` or `friends` for `op=init`. Entries without a uid or a +/// name are skipped; avatars are made absolute against [base]. +List? parseAtplusUsers(Object? json, String key, {required String base}) { + if (json is! Map || json['ok'] != 1 && json['ok'] != true) { + return null; + } + final list = json[key]; + if (list is! List) { + return const []; + } + final users = []; + final seen = {}; + for (final e in list) { + if (e is! Map) { + continue; + } + final uid = '${e['uid'] ?? ''}'.trim(); + final username = '${e['username'] ?? ''}'.trim(); + if (uid.isEmpty || username.isEmpty || !seen.add(uid)) { + continue; + } + final avatar = '${e['avatar'] ?? ''}'.trim(); + final group = '${e['group'] ?? ''}'.trim(); + users.add( + Friend( + uid: uid, + username: username, + avatarUrl: avatar.isEmpty ? null : Uri.parse('$base/').resolve(avatar).toString(), + groupName: group.isEmpty ? null : group, + ), + ); + } + return users; +} + /// Usernames the current user may mention, from the official `misc.php?mod=getatuser&inajax=1`. /// /// ```xml diff --git a/lib/features/editor/widgets/mention_picker.dart b/lib/features/editor/widgets/mention_picker.dart index 3b2faf8a..5ccf7ef3 100644 --- a/lib/features/editor/widgets/mention_picker.dart +++ b/lib/features/editor/widgets/mention_picker.dart @@ -201,6 +201,39 @@ class _MentionPickerSheetState extends State { ]; } + /// Users mentioned recently (`atplus`), filtered by the keyword; nothing when there are none. + List _recentSection(UserMentionState state) { + final visible = state.visibleRecent; + if (visible.isEmpty) { + return const []; + } + return [ + _header(context.t.bbcodeEditor.userMention.recent, icon: Icons.history), + ...visible.map(_friendTile), + ]; + } + + /// Users of the whole site matching the keyword (`atplus`), shown only while a keyword is typed. + List _searchSection(UserMentionState state) { + final tr = context.t.bbcodeEditor.userMention; + if (!state.siteSearch || state.keyword.isEmpty) { + return const []; + } + final visible = state.visibleSearchResults; + final searching = state.searchKeyword != state.keyword || state.searchStatus == UserMentionStatus.loading; + return [ + _header(tr.siteSearch, icon: Icons.travel_explore), + if (searching) + const LinearProgressIndicator() + else if (state.searchStatus == UserMentionStatus.failure) + _note(context.t.general.failedToLoad) + else if (visible.isEmpty && !state.hasExactMatch) + _note(tr.noSearchResult(name: state.keyword)) + else + ...visible.map(_friendTile), + ]; + } + List _othersSection(UserMentionState state) { final visible = state.visibleOthers; if (visible.isEmpty) { @@ -259,7 +292,9 @@ class _MentionPickerSheetState extends State { builder: (context, state) => ListView( padding: edgeInsetsT4B4, children: [ + ..._recentSection(state), ..._friendsSection(state), + ..._searchSection(state), ..._othersSection(state), if (state.keyword.isNotEmpty && !state.hasExactMatch) _useTypedRow(state), ], diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 13de5a2b..0f2b3197 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1364,10 +1364,13 @@ "refreshRecommendTip": "Reload the friend list", "noFriends": "No friend on your @ list yet, type the username directly", "noMatch": "No friend matches the keyword", - "filterHint": "Filter or type a username", + "filterHint": "Filter, or search a username or UID", "others": "Other @ list", "useTyped": "Mention “${name}”", - "friendsUnavailable": "Friends list unavailable: ${message}" + "friendsUnavailable": "Friends list unavailable: ${message}", + "recent": "Recently mentioned", + "siteSearch": "Search the site", + "noSearchResult": "No user named \"${name}\" on the site" }, "customImage": { "tab": "My images", diff --git a/lib/i18n/zh-CN.i18n.json b/lib/i18n/zh-CN.i18n.json index fcde422f..991d1e51 100644 --- a/lib/i18n/zh-CN.i18n.json +++ b/lib/i18n/zh-CN.i18n.json @@ -1364,10 +1364,13 @@ "refreshRecommendTip": "重新加载好友列表", "noFriends": "@ 列表里还没有好友,可直接输入用户名", "noMatch": "好友里没有符合的用户", - "filterHint": "筛选或输入用户名", + "filterHint": "筛选、搜索用户名或 UID", "others": "其他 @ 名单", "useTyped": "提醒「${name}」", - "friendsUnavailable": "无法读取好友列表:${message}" + "friendsUnavailable": "无法读取好友列表:${message}", + "recent": "最近 @ 过", + "siteSearch": "全站搜索", + "noSearchResult": "全站没有找到「${name}」" }, "customImage": { "tab": "我的图片", diff --git a/lib/i18n/zh-TW.i18n.json b/lib/i18n/zh-TW.i18n.json index af552a00..fbe232e2 100644 --- a/lib/i18n/zh-TW.i18n.json +++ b/lib/i18n/zh-TW.i18n.json @@ -1364,10 +1364,13 @@ "refreshRecommendTip": "重新載入好友名單", "noFriends": "@ 名單裡還沒有好友,可直接輸入使用者名稱", "noMatch": "好友裡沒有符合的使用者", - "filterHint": "篩選或輸入使用者名稱", + "filterHint": "篩選、搜尋用戶名或 UID", "others": "其他 @ 名單", "useTyped": "提醒「${name}」", - "friendsUnavailable": "無法讀取好友名單:${message}" + "friendsUnavailable": "無法讀取好友名單:${message}", + "recent": "最近 @ 過", + "siteSearch": "全站搜尋", + "noSearchResult": "全站沒有找到「${name}」" }, "customImage": { "tab": "我的圖片", diff --git a/test/regression/test_022_mention_x5_test.dart b/test/regression/test_022_mention_x5_test.dart index a65e18de..aee6c8eb 100644 --- a/test/regression/test_022_mention_x5_test.dart +++ b/test/regression/test_022_mention_x5_test.dart @@ -50,20 +50,24 @@ void main() { group('official mention format', () { test('the editor embed becomes @name followed by a space', () { - expect(toOfficialMentions('hi [@]Alice[/@]!'), 'hi @Alice !'); - expect(toOfficialMentions('[@]Alice[/@] and [@]Bob[/@]'), '@Alice and @Bob '); - expect(toOfficialMentions('[@] Alice [/@]'), '@Alice '); + expect(toOfficialMentions('hi [@]Alice[/@]!'), 'hi @\u2063Alice\u2063 !'); + expect(toOfficialMentions('[@]Alice[/@] and [@]Bob[/@]'), '@\u2063Alice\u2063 and @\u2063Bob\u2063 '); + expect(toOfficialMentions('[@] Alice [/@]'), '@\u2063Alice\u2063 '); }); test('no extra space when whitespace already follows', () { - expect(toOfficialMentions('[@]Alice[/@] ok'), '@Alice ok'); - expect(toOfficialMentions('[@]Bob[/@]\nnext'), '@Bob\nnext'); + expect(toOfficialMentions('[@]Alice[/@] ok'), '@\u2063Alice\u2063 ok'); + expect(toOfficialMentions('[@]Bob[/@]\nnext'), '@\u2063Bob\u2063\nnext'); }); test('a name with brackets is kept whole', () { - expect(toOfficialMentions('hi [@][TSDM]Alice[/@]!'), 'hi @[TSDM]Alice !'); - expect(toOfficialMentions('[@]a]b[/@] [@]x[y[/@]'), '@a]b @x[y '); - expect(toOfficialMentions('[@]x and [@]Bob[/@]'), '[@]x and @Bob ', reason: 'a literal [@] is not a chip'); + expect(toOfficialMentions('hi [@][TSDM]Alice[/@]!'), 'hi @\u2063[TSDM]Alice\u2063 !'); + expect(toOfficialMentions('[@]a]b[/@] [@]x[y[/@]'), '@\u2063a]b\u2063 @\u2063x[y\u2063 '); + expect( + toOfficialMentions('[@]x and [@]Bob[/@]'), + '[@]x and @\u2063Bob\u2063 ', + reason: 'a literal [@] is not a chip', + ); }); test('other content is untouched', () { diff --git a/test/regression/test_049_mention_picker_x5_test.dart b/test/regression/test_049_mention_picker_x5_test.dart index e85eb518..282e15ea 100644 --- a/test/regression/test_049_mention_picker_x5_test.dart +++ b/test/regression/test_049_mention_picker_x5_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; @@ -40,7 +41,18 @@ String _data(String name) => File('test/data/$name').readAsStringSync(); /// Serves the friends list (first page and `page=2`) and the official `@` list, recording every request. final class _RoutingAdapter implements HttpClientAdapter { - _RoutingAdapter({this.friendsFirst = 'friend_list_own_x5.html', this.friendsStatus = 200, this.atStatus = 200}); + _RoutingAdapter({ + this.friendsFirst = 'friend_list_own_x5.html', + this.friendsStatus = 200, + this.atStatus = 200, + this.atplus = false, + }); + + /// Whether the forum has the `atplus` plugin: `plugin.php?id=atplus:search` answers its JSON. + bool atplus; + + /// Form fields of every `atplus` request, in order. + final atplusForms = >[]; static const friendsNext = 'friend_list_last_x5.html'; static const String atUsers = _atUserXml; @@ -56,6 +68,7 @@ final class _RoutingAdapter implements HttpClientAdapter { Iterable get friendRequests => requests.where((u) => u.path == '/home.php'); Iterable get atRequests => requests.where((u) => u.path == '/misc.php'); + Iterable get atplusRequests => requests.where((u) => u.path == '/plugin.php'); @override Future fetch( @@ -68,6 +81,23 @@ final class _RoutingAdapter implements HttpClientAdapter { if (gate != null) { await gate!.future; } + if (atplus && uri.path == '/plugin.php' && uri.queryParameters['id'] == 'atplus:search') { + final data = options.data; + final form = data is Map ? data.map((k, v) => MapEntry('$k', '$v')) : Uri.splitQueryString('${data ?? ''}'); + atplusForms.add(form); + final body = switch (form['op']) { + 'init' => _atplusInit, + 'search' => form['q'] == 'dave' ? _atplusSearchDave : '{"ok":1,"list":[]}', + _ => '{"ok":0}', + }; + return ResponseBody.fromString( + body, + 200, + headers: { + Headers.contentTypeHeader: ['application/json; charset=utf-8'], + }, + ); + } if (uri.path == '/misc.php' && uri.queryParameters['mod'] == 'getatuser') { return ResponseBody.fromString( atUsers, @@ -94,6 +124,17 @@ final class _RoutingAdapter implements HttpClientAdapter { void close({bool force = false}) {} } +/// `op=init` of the `atplus` plugin: one recent user, one friend (shape of the live answer, fake users). +const _atplusInit = + '{"ok":1,"recent":[{"uid":1002,"username":"Carol","avatar":"./data/avatar/000/00/10/02_avatar_small.jpg",' + '"group":"使者","friend":0}],"friends":[{"uid":1001,"username":"Bob",' + '"avatar":"./data/avatar/000/00/10/01_avatar_small.jpg","group":"梦幻","friend":1}],"groups":[]}'; + +/// `op=search` of the `atplus` plugin for `dave`. +const _atplusSearchDave = + '{"ok":1,"list":[{"uid":1003,"username":"Dave","avatar":"./data/avatar/000/00/10/03_avatar_small.jpg",' + '"group":"使者","friend":0},{"uid":1001,"username":"Bob","avatar":"","group":"梦幻","friend":1}]}'; + /// Never answers: avatars are not fetched in tests. final class _OfflineAdapter implements HttpClientAdapter { @override @@ -159,10 +200,15 @@ void main() { final again = (await repo.loadCandidates(selfUid: '1000').run()).toNullable()!; expect(again.friends.single.username, 'Bob'); - expect(adapter.requests, hasLength(2), reason: 'reopening the picker uses the cache'); + expect( + adapter.atplusRequests, + hasLength(1), + reason: 'the atplus plugin is asked first, it is not installed here', + ); + expect(adapter.requests, hasLength(3), reason: 'reopening the picker uses the cache'); await repo.loadCandidates(selfUid: '1000', force: true).run(); - expect(adapter.requests, hasLength(4), reason: 'the reload button asks both sources again'); + expect(adapter.requests, hasLength(6), reason: 'the reload button asks the plugin and both sources again'); }); test('follows the next page link and keeps friends unique by uid', () async { @@ -360,7 +406,7 @@ void main() { expect(ops[0].data, 'hi '); expect(ops[1].data, {'bbcodeUserMention': '{"username":"Alice"}'}); expect(controller.toForumBBCode(), 'hi [@]Alice[/@]'); - expect(toOfficialMentions(controller.toForumBBCode()), 'hi @Alice '); + expect(toOfficialMentions(controller.toForumBBCode()), 'hi @\u2063Alice\u2063 '); expect(controller.selection, const TextSelection.collapsed(offset: 4)); }); @@ -472,11 +518,75 @@ void main() { expect(embeds.single.data, {'bbcodeUserMention': '{"username":"$name"}'}, reason: name); expect(controller.document.toPlainText(), isNot(contains('@')), reason: name); expect(controller.toForumBBCode(), 'hi [@]$name[/@]', reason: name); - expect(toOfficialMentions(controller.toForumBBCode()), 'hi @$name ', reason: name); + expect(toOfficialMentions(controller.toForumBBCode()), 'hi @\u2063$name\u2063 ', reason: name); } }); }); + group('atplus plugin (web @ panel since 2026-09)', () { + tearDown(() async => unregisterNet()); + + test('parses its user lists, avatars made absolute; anything else is not an answer', () { + final init = jsonDecode(_atplusInit); + final friends = parseAtplusUsers(init, 'friends', base: baseUrl)!; + expect(friends.map((e) => (e.uid, e.username, e.groupName)), [('1001', 'Bob', '梦幻')]); + expect(friends.single.avatarUrl, '$baseUrl/data/avatar/000/00/10/01_avatar_small.jpg'); + expect(parseAtplusUsers(init, 'recent', base: baseUrl)!.single.username, 'Carol'); + expect(parseAtplusUsers(init, 'groups', base: baseUrl), isEmpty); + expect(parseAtplusUsers(init, 'missing', base: baseUrl), isEmpty); + expect(parseAtplusUsers(const {'ok': 0}, 'list', base: baseUrl), isNull); + expect(parseAtplusUsers('not found', 'list', base: baseUrl), isNull); + expect(parseAtplusUsers(null, 'list', base: baseUrl), isNull); + }); + + test('with the plugin: recent and friends come from it, the stock sources are not asked', () async { + await registerNet(_RoutingAdapter(atplus: true)); + final repo = MentionRepository(); + final result = (await repo.loadCandidates(selfUid: '1000').run()).toNullable()!; + expect(result.siteSearch, isTrue); + expect(result.recent.map((e) => e.username), ['Carol']); + expect(result.friends.map((e) => e.username), ['Bob']); + expect(result.others, isEmpty); + expect(adapter.atplusForms, [ + {'op': 'init'}, + ]); + expect(adapter.friendRequests, isEmpty); + expect(adapter.atRequests, isEmpty); + + final found = (await repo.searchUsers('dave').run()).toNullable()!; + expect(found.map((e) => e.username), ['Dave', 'Bob']); + expect(adapter.atplusForms.last, {'op': 'search', 'q': 'dave'}); + }); + + test('a guest does not ask the plugin', () async { + await registerNet(_RoutingAdapter(atplus: true)); + await MentionRepository().loadCandidates(selfUid: null).run(); + expect(adapter.atplusForms, isEmpty); + }); + + test('the cubit searches the site for the keyword, results not already shown', () async { + await registerNet(_RoutingAdapter(atplus: true)); + final cubit = UserMentionCubit(MentionRepository(), selfUid: '1000'); + addTearDown(cubit.close); + await cubit.load(); + cubit.setKeyword('dave'); + await pumpEventQueue(); + expect(cubit.state.searchStatus, UserMentionStatus.success); + expect( + cubit.state.visibleSearchResults.map((e) => e.username), + ['Dave', 'Bob'], + reason: 'the server matched Bob too; he is not listed under friends for this keyword, so he is shown here', + ); + expect(cubit.state.hasExactMatch, isTrue, reason: 'Dave was found'); + + cubit.setKeyword('bo'); + await pumpEventQueue(); + expect(cubit.state.visibleFriends.map((e) => e.username), ['Bob']); + expect(cubit.state.visibleSearchResults, isEmpty); + expect(adapter.atplusForms.map((e) => e['q']).whereType(), ['dave', 'bo']); + }); + }); + group('mention picker sheet', () { setUp(() async { await registerNet(_RoutingAdapter()); @@ -521,6 +631,30 @@ void main() { }; } + testWidgets('with the atplus plugin: recent, friends, and a site search result that can be picked', ( + tester, + ) async { + adapter.atplus = true; + final repo = MentionRepository(); + final picked = await pumpHost(tester, repo); + await tester.tap(find.text('open')); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + final tr = LocaleSettings.instance.currentTranslations.bbcodeEditor.userMention; + expect(find.text(tr.recent), findsOneWidget); + expect(find.text('Carol'), findsOneWidget); + expect(find.text('Bob'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'dave'); + await tester.pump(const Duration(milliseconds: 200)); + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 50))); + await tester.pumpAndSettle(); + expect(find.text(tr.siteSearch), findsOneWidget); + await tester.tap(find.text('Dave')); + await tester.pumpAndSettle(); + expect(picked(), 'Dave'); + }); + testWidgets('lists the friend under Friends, tapping it picks the name', (tester) async { final repo = MentionRepository(); // Warm the cache outside the fake async zone so the sheet renders from it.