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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions doc/spec-x5-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,時間 `<span title="2026-10-1 11:16">9 小時前</span>` 是撰寫時間。回報者日誌 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 個分頁。「見習天使」每列只有一個連結:`<a href="home.php?mod=space&uid=N">欢迎NAME加入~</a>`。
- 原解析器要求每列兩個連結(帖子+作者),整個分頁變空,`PinSection` 的格子固定高度,於是出現大片空白卡片。
- 修正:單一連結且指向個人空間的列解析成「成員」(名字取自「欢迎…加入」,點開個人資料);沒有任何可顯示列的分頁不畫;排行分頁改用 `PinnedThreadGroup.isRank` 標記,不再靠索引 6(拿掉空分頁後索引會變)。解析抽成 `HomepageBloc.parsePinnedThreadGroups`。
- 驗證:test_185(`test/data/homepage_kahrpba_tabs_x5.html`,2026-10-01 實際區塊,使用者已去識別化)。

107 changes: 69 additions & 38 deletions lib/features/homepage/bloc/homepage_bloc.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,23 @@ class HomepageBloc extends Bloc<HomepageEvent, HomepageState> with LoggerMixin {
/// Where [element] is <div class="Kahrpba_threads"> 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 <a> in children.
if (allNode.length != 2) {
talker.info(
Expand Down Expand Up @@ -163,6 +180,57 @@ class HomepageBloc extends Bloc<HomepageEvent, HomepageState> 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<PinnedThreadGroup> parsePinnedThreadGroups(uh.Document document) {
final pinnedThreadGroupList = <PinnedThreadGroup>[];
final navNameList = document
.querySelector('td#Kahrpba_nav')
?.children
.map((e) => e.firstEndDeepText())
.whereType<String>()
.toList();
final navShowList = document
.querySelector('td#Kahrpba_show')
?.children
.where((e) => e.id.startsWith('Kahrpba_c'))
.whereType<uh.Element>()
.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<PinnedThread>()
.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<void> _onHomepageLoadRequested(HomepageLoadRequested event, Emitter<HomepageState> emit) async {
_pageGeneration++;
if (_forumHomeRepository.hasCache()) {
Expand Down Expand Up @@ -412,44 +480,7 @@ class HomepageBloc extends Bloc<HomepageEvent, HomepageState> with LoggerMixin {
avatarUrl: loggedUserAvatar,
);

final navNameList = document
.querySelector('td#Kahrpba_nav')
?.children
.map((e) => e.firstEndDeepText())
.whereType<String>()
.toList();
final navShowList = document
.querySelector('td#Kahrpba_show')
?.children
.where((e) => e.id.startsWith('Kahrpba_c'))
.whereType<uh.Element>()
.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<PinnedThread>()
.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(
Expand Down
5 changes: 4 additions & 1 deletion lib/features/homepage/models/pinned_thread_group.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<PinnedThread> threadList;

/// The posting rank tab ("发帖排行"): each row is a user and a post count, not a thread and its author.
final bool isRank;
}
6 changes: 5 additions & 1 deletion lib/features/homepage/widgets/pin_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion lib/features/notification/models/notification_v2.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(),
);
}

Expand Down
Loading
Loading