Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions doc/spec-x5-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 時的退回路徑由原有案例涵蓋。
- 未實測:真的發出一則 @ 並確認對方收到提醒(會對測試帳號以外的人產生提醒,未做)。

31 changes: 30 additions & 1 deletion lib/features/editor/bloc/user_mention_cubit.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:dart_mappable/dart_mappable.dart';
import 'package:fpdart/fpdart.dart';
Expand Down Expand Up @@ -41,17 +43,44 @@ final class UserMentionCubit extends Cubit<UserMentionState> 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<void> _search() async {
final k = state.keyword;
if (!state.siteSearch ||
k.isEmpty ||
(state.searchKeyword == k && state.searchStatus != UserMentionStatus.failure)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset abandoned searches before deduplicating keywords

When a user starts searching for a term, clears the field before the response arrives, waits for that response to be discarded at line 75, and then enters the same term again, searchKeyword still equals the term and searchStatus remains loading. This guard therefore suppresses the new request and leaves the site-search section showing a progress indicator indefinitely. Clearing the keyword should invalidate/reset the pending search state, or requests should use a generation token rather than this state-based deduplication.

Useful? React with 👍 / 👎.

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));
}
}
}
38 changes: 37 additions & 1 deletion lib/features/editor/bloc/user_mention_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Friend> 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<Friend> searchResults;

bool _matches(String name) => keyword.isEmpty || name.toLowerCase().contains(keyword.toLowerCase());

/// Friends matching the keyword.
List<Friend> get visibleFriends => friends.where((e) => _matches(e.username)).toList();

/// Recently mentioned users matching the keyword.
List<Friend> 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<Friend> 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<String> 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));
}
}
74 changes: 71 additions & 3 deletions lib/features/editor/repository/mention_repository.dart
Original file line number Diff line number Diff line change
@@ -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<Friend> 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<Friend> 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<String> others;

/// Why the friends list could not be read: the privacy or login notice of the page, or the error of the request.
Expand All @@ -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;
Expand Down Expand Up @@ -92,16 +117,59 @@ 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<Object?> _atplus(Map<String, String> data) async {
switch (await getIt.get<NetClientProvider>().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<List<Friend>> 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<MentionCandidates> loadCandidates({required String? selfUid, bool force = false}) =>
AsyncEither(() async {
final cached = _cache;
if (cached != null && !force && _cachedUid == selfUid) {
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.
Expand Down
61 changes: 56 additions & 5 deletions lib/features/editor/utils/mention.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the legacy send format when atplus is absent

When the atplus plugin is genuinely not installed or has been disabled, loadCandidates explicitly falls back to the stock friends and getatuser sources, but every selected chip is still unconditionally serialized with the atplus-only U+2063 markers here. Stock Discuz expects the previous bare @name form, so the documented no-plugin fallback presents valid candidates while posts made from them no longer generate mention notifications. The selected output format therefore needs to follow the detected plugin capability rather than always using the marked form.

Useful? React with 👍 / 👎.

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<Friend>? parseAtplusUsers(Object? json, String key, {required String base}) {
if (json is! Map<String, dynamic> || json['ok'] != 1 && json['ok'] != true) {
return null;
}
final list = json[key];
if (list is! List) {
return const [];
}
final users = <Friend>[];
final seen = <String>{};
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
Expand Down
Loading
Loading