diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9de7bc..f3037fec 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 c6de65f1..ac92b0d4 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 7a1de482..e1989e30 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 a8fc57f3..c79ed705 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 aff96d93..429988a2 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 a35cc2fc..a736160e 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 00000000..35be415e --- /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 00000000..2926de8a --- /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); + }); + }); +}