From 8a4d5d8fc2ef1c6b5038f6ae569bbcb122cdb27f Mon Sep 17 00:00:00 2001 From: Carinoasd Date: Thu, 1 Oct 2026 20:26:48 +0800 Subject: [PATCH] fix: keep unread public messages whatever their time; show the new members tab of the homepage - #154: the forum delivers a public message to the members in batches and stamps it with the time it was written, so it reached a running app with a time before its fetch window and was dropped for good. Unread broadcast messages are now kept whatever their time; stored ones are still told apart by pmid, so they notify once. - #155: the Kahrpba tab block is back on the forum homepage. Its new members tab has one link per row (a user space), which the parser dropped, leaving an empty card as tall as the others. Such rows are parsed, empty tabs are left out, and the rank tab is marked instead of being found by index. Refs #154 Refs #155 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + doc/spec-x5-features.md | 17 ++ lib/features/homepage/bloc/homepage_bloc.dart | 107 ++++--- .../homepage/models/pinned_thread_group.dart | 5 +- .../homepage/widgets/pin_section.dart | 6 +- .../notification/models/notification_v2.dart | 10 +- test/data/homepage_kahrpba_tabs_x5.html | 279 ++++++++++++++++++ ...test_185_broadcast_and_home_tabs_test.dart | 130 ++++++++ 8 files changed, 518 insertions(+), 41 deletions(-) create mode 100644 test/data/homepage_kahrpba_tabs_x5.html create mode 100644 test/regression/test_185_broadcast_and_home_tabs_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9de7bc2..f3037fec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- 公共消息:App 开着时论坛发出的公共消息会收不到。论坛分批把公共消息送达各账号,送到时显示的仍是撰写时间,App 以「上次检查之后」筛选就会把它当成旧消息丢掉;现在未读的公共消息不论时间都会收进来并提醒,已读的旧消息照旧不重复抓。(#154) +- 首页:论坛首页的分页区块恢复后,「见习天使」分页(新成员欢迎)显示成大块空白;现在会列出新成员,点一下打开其个人资料,没有内容的分页不再显示空卡片。(#155) + ## [1.29.3] - 2026-09-30 ### Fixed diff --git a/doc/spec-x5-features.md b/doc/spec-x5-features.md index c6de65f11..ac92b0d4d 100644 --- a/doc/spec-x5-features.md +++ b/doc/spec-x5-features.md @@ -1321,3 +1321,20 @@ B. 論壇提醒屏蔽規則 - test_022、test_049 更新為帶標記格式;test_049 新增 atplus 群組(解析、插件優先且不問舊來源、訪客不問、cubit 搜尋、選人面板挑選全站搜尋結果)。插件回 404 時的退回路徑由原有案例涵蓋。 - 未實測:真的發出一則 @ 並確認對方收到提醒(會對測試帳號以外的人產生提醒,未做)。 +## 45. 公共消息漏收、首頁分頁區塊(GitHub #154、#155,2026-10-01) + +### 45.1 公共消息(#154) + +- 現象:公共消息發出時 App 開著就收不到;關掉 App 一段時間再開、或下拉重新整理(視窗退回 3 天)才會出現。原版 r0 也有。 +- 事實(2026-10-01 測試帳號唯讀):`home.php?mod=space&do=pm&filter=announcepm` 的 `dl#gpmlist_5` 帶 `newpm` 與 `div.newpm_avt`,時間 `9 小時前` 是撰寫時間。回報者日誌 13:34–13:37 每分鐘抓取,since 都在 11:16 之後,bm=0;14:49 以 3 天視窗抓才 bm=1。 +- 推斷:論壇分批把公共消息寫給各會員,送達某帳號時時間仍是撰寫時間;執行中的 App 視窗早已越過那個時間,就永遠濾掉。 +- 修正:`NotificationV2.fromDocuments` 對公共消息改為「未讀一律保留,已讀才依 since 過濾」。新舊判斷仍由 `freshNotifications` 以 pmid 比對儲存,提醒只發一次;儲存為 upsert,重複抓取不會多出資料。 +- 未驗證:沒有辦法觸發一則新公共消息實測,需等站方下次發送時由測試者確認。 + +### 45.2 首頁分頁區塊(#155) + +- 論壇首頁的 `Kahrpba` 分頁區塊(`td#Kahrpba_nav`/`td#Kahrpba_show`)又出現了,7 個分頁。「見習天使」每列只有一個連結:`欢迎NAME加入~`。 +- 原解析器要求每列兩個連結(帖子+作者),整個分頁變空,`PinSection` 的格子固定高度,於是出現大片空白卡片。 +- 修正:單一連結且指向個人空間的列解析成「成員」(名字取自「欢迎…加入」,點開個人資料);沒有任何可顯示列的分頁不畫;排行分頁改用 `PinnedThreadGroup.isRank` 標記,不再靠索引 6(拿掉空分頁後索引會變)。解析抽成 `HomepageBloc.parsePinnedThreadGroups`。 +- 驗證:test_185(`test/data/homepage_kahrpba_tabs_x5.html`,2026-10-01 實際區塊,使用者已去識別化)。 + diff --git a/lib/features/homepage/bloc/homepage_bloc.dart b/lib/features/homepage/bloc/homepage_bloc.dart index 7a1de4820..e1989e300 100644 --- a/lib/features/homepage/bloc/homepage_bloc.dart +++ b/lib/features/homepage/bloc/homepage_bloc.dart @@ -127,6 +127,23 @@ class HomepageBloc extends Bloc with LoggerMixin { /// Where [element] is
node. static PinnedThread? _filterThreadAndAuthors(uh.Element element) { final allNode = element.querySelectorAll('a').toList(); + // The "见习天使" (new members) tab lists one link per row, to the new member's space: "欢迎NAME加入~" (GitHub #155). + // The member is both the row and its "author", so the tile shows the avatar and name and opens the profile. + if (allNode.length == 1) { + final url = allNode.single.attributes['href']; + final text = allNode.single.firstEndDeepText()?.trim(); + if (url == null || text == null || text.isEmpty || !url.contains('mod=space')) { + talker.info('skip pinned row with one link that is not a user space'); + return null; + } + final name = RegExp('^欢迎(.+?)加入').firstMatch(text)?.group(1)?.trim(); + return PinnedThread( + threadUrl: url, + threadTitle: text, + authorUrl: url, + authorName: name == null || name.isEmpty ? text : name, + ); + } // There should be two in children. if (allNode.length != 2) { talker.info( @@ -163,6 +180,57 @@ class HomepageBloc extends Bloc with LoggerMixin { return PinnedThread(threadUrl: threadUrl, threadTitle: threadTitle, authorUrl: authorUrl, authorName: authorName); } + /// The tabs of the homepage block of the `Kahrpba` plugin (最新活动, 见习天使, ..., 发帖排行), in the order the + /// website shows them; tabs without any row the app can show are left out (GitHub #155). + static List parsePinnedThreadGroups(uh.Document document) { + final pinnedThreadGroupList = []; + final navNameList = document + .querySelector('td#Kahrpba_nav') + ?.children + .map((e) => e.firstEndDeepText()) + .whereType() + .toList(); + final navShowList = document + .querySelector('td#Kahrpba_show') + ?.children + .where((e) => e.id.startsWith('Kahrpba_c')) + .whereType() + .toList(); + + if (navNameList != null && navShowList != null && navNameList.length == navShowList.length) { + if (navNameList.length >= 7) { + navNameList + ..swap(4, 6) + ..swap(5, 6); + } + final count = navNameList.length; + for (var i = 0; i < count; i++) { + final threadList = navShowList[i] + .querySelectorAll('div.Kahrpba_threads') + .map(_filterThreadAndAuthors) + .whereType() + .toList(); + // Kept even when empty here so the reordering below still finds every tab where it expects it; empty + // groups are left out after that. + final group = PinnedThreadGroup(title: navNameList[i], threadList: threadList); + pinnedThreadGroupList.add(group); + } + + // The sort on server side is not as displayed, fix the sort to keep the + // same with website appearance. + if (pinnedThreadGroupList.length >= 7) { + pinnedThreadGroupList + ..swap(4, 5) + ..swap(5, 6); + // The rank is the last tab once reordered; marked before empty tabs are left out and the indices move. + pinnedThreadGroupList[6] = pinnedThreadGroupList[6].copyWith(isRank: true); + } + } + // A tab without any row the app can show would be an empty card as tall as the full ones (GitHub #155). + pinnedThreadGroupList.removeWhere((e) => e.threadList.isEmpty); + return pinnedThreadGroupList; + } + Future _onHomepageLoadRequested(HomepageLoadRequested event, Emitter emit) async { _pageGeneration++; if (_forumHomeRepository.hasCache()) { @@ -412,44 +480,7 @@ class HomepageBloc extends Bloc with LoggerMixin { avatarUrl: loggedUserAvatar, ); - final navNameList = document - .querySelector('td#Kahrpba_nav') - ?.children - .map((e) => e.firstEndDeepText()) - .whereType() - .toList(); - final navShowList = document - .querySelector('td#Kahrpba_show') - ?.children - .where((e) => e.id.startsWith('Kahrpba_c')) - .whereType() - .toList(); - - if (navNameList != null && navShowList != null && navNameList.length == navShowList.length) { - if (navNameList.length >= 7) { - navNameList - ..swap(4, 6) - ..swap(5, 6); - } - final count = navNameList.length; - for (var i = 0; i < count; i++) { - final threadList = navShowList[i] - .querySelectorAll('div.Kahrpba_threads') - .map(_filterThreadAndAuthors) - .whereType() - .toList(); - final group = PinnedThreadGroup(title: navNameList[i], threadList: threadList); - pinnedThreadGroupList.add(group); - } - - // The sort on server side is not as displayed, fix the sort to keep the - // same with website appearance. - if (pinnedThreadGroupList.length >= 7) { - pinnedThreadGroupList - ..swap(4, 5) - ..swap(5, 6); - } - } + pinnedThreadGroupList.addAll(parsePinnedThreadGroups(document)); // Discuz! X5 renders the unread state in the page header; keep it so the badge can show up right away. final (unreadNoticeCount, hasUnreadMessage) = buildUnreadInfoStatus(document); return HomepageState( diff --git a/lib/features/homepage/models/pinned_thread_group.dart b/lib/features/homepage/models/pinned_thread_group.dart index a8fc57f3c..c79ed7050 100644 --- a/lib/features/homepage/models/pinned_thread_group.dart +++ b/lib/features/homepage/models/pinned_thread_group.dart @@ -4,11 +4,14 @@ part of 'models.dart'; @MappableClass() final class PinnedThreadGroup with PinnedThreadGroupMappable { /// Constructor. - const PinnedThreadGroup({required this.title, required this.threadList}); + const PinnedThreadGroup({required this.title, required this.threadList, this.isRank = false}); /// Title of this thread group. final String title; /// List of threads in this group. final List threadList; + + /// The posting rank tab ("发帖排行"): each row is a user and a post count, not a thread and its author. + final bool isRank; } diff --git a/lib/features/homepage/widgets/pin_section.dart b/lib/features/homepage/widgets/pin_section.dart index aff96d935..429988a23 100644 --- a/lib/features/homepage/widgets/pin_section.dart +++ b/lib/features/homepage/widgets/pin_section.dart @@ -81,7 +81,11 @@ class PinSection extends StatelessWidget with LoggerMixin { for (var i = 0; i < count; i++) { final sectionName = pinnedThreadGroup[i].title; - final threadWidgetList = _buildSectionThreads(context, pinnedThreadGroup[i].threadList, reverseTitle: i == 6); + final threadWidgetList = _buildSectionThreads( + context, + pinnedThreadGroup[i].threadList, + reverseTitle: pinnedThreadGroup[i].isRank, + ); ret.add( Card( margin: EdgeInsets.zero, diff --git a/lib/features/notification/models/notification_v2.dart b/lib/features/notification/models/notification_v2.dart index a35cc2fc4..a736160eb 100644 --- a/lib/features/notification/models/notification_v2.dart +++ b/lib/features/notification/models/notification_v2.dart @@ -54,6 +54,12 @@ final class NotificationV2 with NotificationV2Mappable { /// * [broadcastMessageDoc]: `home.php?mod=space&do=pm&filter=announcepm`. /// /// Only notifications not earlier than [since] (timestamp in seconds) are kept, if provided. + /// + /// Unread broadcast messages are kept whatever their time (GitHub #154): the forum stamps a public message with the + /// time it was written, but delivers it to the members in batches, so it can reach an account hours later with that + /// old time. A running app has moved its window past that time by then and dropped the message for good. The list + /// holds a handful of messages and the stored ones are told apart by pmid, so the unread ones are simply always + /// taken. // ignore: prefer_constructors_over_static_methods static NotificationV2 fromDocuments({ required uh.Document noticeDoc, @@ -73,7 +79,9 @@ final class NotificationV2 with NotificationV2Mappable { status: 0, noticeList: since == null ? noticeList : noticeList.where((e) => e.timestamp >= since).toList(), personalMessageList: since == null ? pmList : pmList.where((e) => e.timestamp >= since).toList(), - broadcastMessageList: since == null ? bmList : bmList.where((e) => e.timestamp >= since).toList(), + broadcastMessageList: since == null + ? bmList + : bmList.where((e) => !e.alreadyRead || e.timestamp >= since).toList(), ); } diff --git a/test/data/homepage_kahrpba_tabs_x5.html b/test/data/homepage_kahrpba_tabs_x5.html new file mode 100644 index 000000000..35be415e0 --- /dev/null +++ b/test/data/homepage_kahrpba_tabs_x5.html @@ -0,0 +1,279 @@ + + + + + +
+ + + + + + + + + + + + + + + +
+ diff --git a/test/regression/test_185_broadcast_and_home_tabs_test.dart b/test/regression/test_185_broadcast_and_home_tabs_test.dart new file mode 100644 index 000000000..2926de8aa --- /dev/null +++ b/test/regression/test_185_broadcast_and_home_tabs_test.dart @@ -0,0 +1,130 @@ +/// GitHub #154 and #155. +/// +/// #154: a public message sent while the app was running never showed up. The forum stamps it with the time it was +/// written but delivers it to the members in batches, so it reaches an account with an old time; the running app had +/// moved its fetch window past that time and dropped it. Unread broadcast messages are now kept whatever their time. +/// +/// #155: the homepage block of the `Kahrpba` plugin is back, and its "见习天使" (new members) tab lists one link per +/// row ("欢迎NAME加入~"). The parser wanted a thread and an author link, dropped every row and left an empty card as +/// tall as the full ones. Such rows are parsed now, and tabs without any row are left out. +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:talker_flutter/talker_flutter.dart'; +import 'package:tsdm_client/features/homepage/bloc/homepage_bloc.dart'; +import 'package:tsdm_client/features/notification/bloc/notification_bloc.dart'; +import 'package:tsdm_client/features/notification/models/models.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/models/database/database.dart'; +import 'package:tsdm_client/shared/providers/storage_provider/storage_provider.dart'; +import 'package:universal_html/parsing.dart'; + +String _data(String name) => File('test/data/$name').readAsStringSync(); + +/// The public message list page with one message, as the forum renders it (2026-10-01, formhash removed). +String _announcePage({required bool unread, String time = '2026-10-1 11:16'}) => + ''' +
+
+
菜单
+
${unread ? '
' : ''} + +
+
+【坛庆】天使动漫16周年~跟帖送祝福发50威望50天使币,还有各种活动等你参加~~   +9 小时前  +查看 +
+
+
'''; + +void main() { + setUpAll(() => talker = TalkerFlutter.init(settings: TalkerSettings(enabled: false))); + + group('#154 public messages', () { + final empty = parseHtmlDocument(''); + final sent = DateTime(2026, 10, 1, 11, 16).millisecondsSinceEpoch ~/ 1000; + // The running app polls every minute: its window starts two hours after the message was written. + final since = sent + 2 * 3600; + + NotificationV2 fetch({required bool unread, int? since}) => NotificationV2.fromDocuments( + noticeDoc: empty, + personalMessageDoc: empty, + broadcastMessageDoc: parseHtmlDocument(_announcePage(unread: unread)), + since: since, + ); + + test('the live markup parses: pmid, time of writing, unread flag', () { + final message = fetch(unread: true).broadcastMessageList.single; + expect(message.pmid, 5); + expect(message.timestamp, sent); + expect(message.alreadyRead, isFalse); + expect(message.data, contains('坛庆')); + }); + + test('an unread message older than the fetch window is still fetched', () { + expect(fetch(unread: true, since: since).broadcastMessageList.map((e) => e.pmid), [5]); + }); + + test('a read message older than the window stays out, as before', () { + expect(fetch(unread: false, since: since).broadcastMessageList, isEmpty); + expect(fetch(unread: false, since: sent).broadcastMessageList, hasLength(1), reason: 'inside the window'); + }); + + test('it is news once: not stored yet it notifies, stored by pmid it does not', () { + final fetched = fetch(unread: true, since: since); + const none = NotificationGroup(noticeList: [], personalMessageList: [], broadcastMessageList: []); + expect(freshNotifications(fetched: fetched, stored: none).broadcastMessageList, hasLength(1)); + final stored = NotificationGroup( + noticeList: const [], + personalMessageList: const [], + broadcastMessageList: [BroadcastMessageEntity(uid: 1000, timestamp: sent, data: 'x', pmid: 5)], + ); + expect(freshNotifications(fetched: fetched, stored: stored).broadcastMessageList, isEmpty); + }); + }); + + group('#155 homepage tabs', () { + final groups = HomepageBloc.parsePinnedThreadGroups(parseHtmlDocument(_data('homepage_kahrpba_tabs_x5.html'))); + + test('every tab of the live block, in the order of the website', () { + expect(groups.map((e) => e.title), ['最新活动', '见习天使', '动漫新闻', '最新主题', '今日话题', '动漫讨论', '发帖排行']); + expect(groups.every((e) => e.threadList.isNotEmpty), isTrue); + expect(groups.map((e) => e.isRank), [false, false, false, false, false, false, true]); + }); + + test('the new members tab lists the members: name, profile link, welcome text', () { + final members = groups[1].threadList; + expect(members, hasLength(9)); + final first = members.first; + expect(first.authorName, 'user16'); + expect(first.threadTitle, '欢迎user16加入~'); + expect(first.authorUrl, contains('mod=space')); + expect(first.threadUrl, first.authorUrl, reason: 'tapping the row opens the profile'); + }); + + test('the rank rows keep a user and a post count', () { + final rank = groups.last.threadList.first; + expect(rank.threadUrl, contains('mod=space')); + expect(rank.authorName, startsWith('今日共发')); + }); + + test('a tab without any row the app can show is left out instead of an empty card', () { + final html = _data('homepage_kahrpba_tabs_x5.html').replaceAllMapped( + RegExp('(
]*>)(.*?)(
'${m.group(1)}
${m.group(3)}', + ); + final left = HomepageBloc.parsePinnedThreadGroups(parseHtmlDocument(html)); + expect(left.map((e) => e.title), isNot(contains('动漫新闻'))); + expect(left, hasLength(6)); + expect(left.last.isRank, isTrue, reason: 'the rank is still known after the empty tab is left out'); + expect(left.where((e) => e.isRank), hasLength(1)); + }); + + test('a page without the block has no tabs', () { + expect(HomepageBloc.parsePinnedThreadGroups(parseHtmlDocument('')), isEmpty); + }); + }); +}