Skip to content

fix: start notification expire timer only after bubble is displayed - #1691

Open
Ivy233 wants to merge 1 commit into
linuxdeepin:masterfrom
Ivy233:fix/notification-expire-after-display
Open

fix: start notification expire timer only after bubble is displayed#1691
Ivy233 wants to merge 1 commit into
linuxdeepin:masterfrom
Ivy233:fix/notification-expire-after-display

Conversation

@Ivy233

@Ivy233 Ivy233 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
  1. Remove the immediate pushPendingEntity call from Notify(), so the expire timer no longer starts as soon as a notification is received
  2. Add a bubbleDisplayed signal to BubbleModel, emitted when a bubble is actually inserted into the display model
  3. Connect bubbleDisplayed in BubblePanel and forward it to the notification server through notificationDisplayed
  4. Add a timeout() accessor to NotifyEntity to read the client provided expire timeout
  5. Implement NotificationManager::notificationDisplayed to schedule the timeout only when the bubble is shown, avoiding premature expiry while notifications are still queued
  6. Forward notificationDisplayed via Qt::QueuedConnection in NotifyServerApplet so the pending timeout timer is started in the worker thread it belongs to
  7. Add unit tests for notificationDisplayed

Log: Defer the notification expire timer until the bubble is actually displayed on screen

Influence:

  1. Verify a notification disappears after the default 5 seconds when displayed normally
  2. Verify a notification no longer expires before it is shown when many notifications are queued
  3. Verify hovering over a bubble prevents it from expiring
  4. Run the notification server unit tests

fix: 通知气泡显示后才启动过期计时

  1. 移除 Notify() 中立即调用 pushPendingEntity 的逻辑,通知收到后不再 马上启动过期计时
  2. BubbleModel 中新增 bubbleDisplayed 信号,在气泡实际插入显示模型 时发出
  3. BubblePanel 中连接 bubbleDisplayed,通过 notificationDisplayed 转发给通知服务端
  4. NotifyEntity 新增 timeout() 访问器,用于读取客户端传入的过期时间
  5. 实现 NotificationManager::notificationDisplayed,仅在气泡显示时才调度 超时,避免通知在排队期间提前过期
  6. NotifyServerApplet 中通过 Qt::QueuedConnection 转发 notificationDisplayed,确保过期定时器在其所属的 worker 线程中启动
  7. notificationDisplayed 补充单元测试

Log: 将通知过期计时推迟到气泡真正显示之后

Influence:

  1. 验证正常显示的通知在默认 5 秒后消失
  2. 验证大量通知排队时,通知不会在显示前提前过期
  3. 验证鼠标悬停气泡时通知不会过期消失
  4. 运行通知服务端单元测试

PMS: BUG-372279

Summary by Sourcery

Defer starting notification expiry timers until bubbles are actually displayed on screen.

New Features:

  • Emit a BubbleModel::bubbleDisplayed signal when a notification bubble is inserted or replaced in the display model.
  • Expose NotifyEntity::timeout() to retrieve the client-provided expiry timeout.
  • Add a NotifyServerApplet::notificationDisplayed slot and forward display events to NotificationManager via queued invocation.

Bug Fixes:

  • Prevent notifications from expiring while still queued by scheduling expiry only after the bubble is shown.
  • Ensure notification expiry timers run in the correct worker thread so they fire reliably.

Enhancements:

  • Introduce NotificationManager::notificationDisplayed to start pending expiry timers based on actual display events instead of receipt time.

Tests:

  • Add unit tests covering NotifyServerApplet::notificationDisplayed, including edge-case IDs.

@deepin-ci-robot

Copy link
Copy Markdown

Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with /test all

@deepin-ci-robot

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: Ivy233

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR defers starting the notification expiration timer until a bubble is actually inserted into the UI model, wiring a new bubbleDisplayed signal through BubblePanel to NotificationManager, which now schedules timeouts based on the stored client expire timeout only when notifications are displayed, with thread-safe forwarding from the applet and added unit tests.

Sequence diagram for deferred notification timeout start when bubble is displayed

sequenceDiagram
    participant BubbleModel
    participant BubblePanel
    participant NotifyServerApplet
    participant NotificationManager

    BubbleModel->>BubbleModel: insertBubble / replaceBubble
    BubbleModel-->>BubblePanel: bubbleDisplayed(id)
    BubblePanel->>NotifyServerApplet: notificationDisplayed(id)
    NotifyServerApplet->>NotificationManager: notificationDisplayed(id)
    NotificationManager->>NotificationManager: fetchEntity(id)
    NotificationManager->>NotificationManager: pushPendingEntity(entity, entity.timeout())
Loading

File-Level Changes

Change Details Files
Start notification expiration only when the bubble is displayed instead of on receipt.
  • Removed immediate scheduling of pending notification timeouts in Notify() based on hints expireTimeout.
  • Added a notificationDisplayed(qint64) slot in NotificationManager that fetches the entity, checks validity/urgency, reads its timeout(), and calls pushPendingEntity only for non-critical, expiring notifications.
  • Introduced a timeout() accessor on NotifyEntity to expose the stored expire timeout instead of passing it around separately.
panels/notification/server/notificationmanager.cpp
panels/notification/server/notificationmanager.h
panels/notification/common/notifyentity.cpp
panels/notification/common/notifyentity.h
Signal when a bubble is actually shown and propagate that to the notification server with correct threading semantics.
  • Added a bubbleDisplayed(qint64) signal to BubbleModel and emit it when inserting or replacing bubbles in the model.
  • Connected BubbleModel::bubbleDisplayed in BubblePanel to forward the ID to the notification server via notificationDisplayed using a direct connection.
  • Implemented NotifyServerApplet::notificationDisplayed to forward the call into NotificationManager::notificationDisplayed using Qt::QueuedConnection so the timeout QTimer starts on the worker thread.
panels/notification/bubble/bubblemodel.h
panels/notification/bubble/bubblemodel.cpp
panels/notification/bubble/bubblepanel.cpp
panels/notification/server/notifyserverapplet.h
panels/notification/server/notifyserverapplet.cpp
Add unit coverage for the new notificationDisplayed path to ensure robustness for various IDs.
  • Added a basic NotifyServerApplet test that calls notificationDisplayed with a valid ID to ensure no crashes.
  • Added edge-case tests that call notificationDisplayed with 0, -1, and max qint64, verifying the applet handles these IDs without throwing.
tests/panels/notification/server/notifyserverapplet_test.cpp

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@Ivy233
Ivy233 marked this pull request as ready for review August 6, 2026 06:59

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @Ivy233, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from 430dd63 to fcf70d8 Compare August 6, 2026 11:52
if (interval <= 0)
return;

auto *timer = new QTimer(this);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这样每个通知都new 个超时的timer?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已修改。

timer->setSingleShot(true);
timer->setInterval(interval);
connect(timer, &QTimer::timeout, this, [this, id, bubbleId = bubble->bubbleId()] {
m_timeoutTimers.remove(id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

timer没delete?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已修改。

}
}

void BubbleModel::setBlockedId(qint64 id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这样的话,进入了暂存区域的通知没有超时的机制了,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已解决。暂存区模型复用同一个 ExpireTimer 单例,NotifyStagingModel::push 时同样调用 ExpireTimer::push 按实体超时启动倒计时,暂存区通知到期后照常超时并移出暂存区。

@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch 2 times, most recently from 0b0eb8c to 2ae625f Compare August 7, 2026 06:21
@Ivy233
Ivy233 requested a review from 18202781743 August 7, 2026 06:25
@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from 2ae625f to 5ef467e Compare August 7, 2026 08:07

Q_EMIT NotificationStateChanged(entity.id(), entity.processedType());

bool critical = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这个逻辑去掉了,那定时器没拿这个参数?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

服务端定时器逻辑整体移除后,超时计算完全由前端 ExpireTimer 负责,ExpireTimer 内部通过 NotifyEntity::timeout()/urgency() 自行推导有效超时时间(-1 默认 5000ms,0/Critical 永不过期),因此无需再把 expireTimeout 参数单独传给定时器。


bool contains(qint64 key) const;
// Milliseconds left for key, or 0 when it is not tracked.
int remaining(qint64 key) const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

按操作来定义接口吧,不用搞这么通用的,然后让调用者去组合,
这里只有start,stop,clear吧,resume类似传递需要停止的entity,逻辑在内部封装,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已修改。

* that key. All bookkeeping lives in hash maps, so no QTimer is allocated per
* key and nothing leaks when a key expires or is stopped.
*/
class ExpireTimer : public QObject

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

暂存区和通知横幅的是不是共用同一个定时器管理的呀,不然这里会不会一个通知有两个定时器在弄呀,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

是共用的。ExpireTimer 是进程级单例,仅一个共享 QTimer,气泡与暂存区都通过它管理。同一通知 id 同时在气泡和暂存区显示时,ExpireTimer::push 发现 id 已在跟踪中会保持原截止时间而不重建倒计时,不会出现一个通知被两个定时器同时计时的现象。

@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from 5ef467e to aa5a30c Compare August 10, 2026 03:27
@Ivy233
Ivy233 requested a review from 18202781743 August 10, 2026 05:11
@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from aa5a30c to fadfda6 Compare August 11, 2026 09:25
return &expireTimer;
}

void ExpireTimer::start(qint64 key, const NotifyEntity &entity)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这里里的key就是entity的id吧,直接从entity里获取就行了,这个key名称换成id吧,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已修改。

if (id == m_blockedId)
return;

if (m_blockedId != NotifyEntity::InvalidId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这个hover就blocked的逻辑可以放在ExpirreTimer里实现吧,不暴露resume和pause了,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已经把block逻辑迁移到expiretimer里面,并替代掉resume和pause。

// A bubble that has been pushed off the display (overflow) is no longer
// in the model, but its countdown still finishes and the notification
// should still be closed, so fall back to the persisted bubble id.
Q_EMIT bubbleExpired(id, DataAccessorProxy::instance()->fetchEntity(id).bubbleId());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

既然bubbleExpired需要bubbleId,那在ExpireTimer::expired里就直接添加这个参数就行了,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已按建议处理。ExpireTimer::expired 的签名就是 expired(qint64 id, uint bubbleId),到期时从实体读取 bubbleId 随信号一并发出,BubbleModel::bubbleExpired(id, bubbleId) 直接转发,无需自行拼接。

if (it == m_deadlines.cend())
return;

m_paused.insert(key, static_cast<int>(qMax<qint64>(0, it.value() - QDateTime::currentMSecsSinceEpoch())));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pause的只会是一个吧,最起码目前可以只是一个,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已经替换成只有一个。

if (m_deadlines.contains(key) || m_paused.contains(key))
return;

m_deadlines.insert(key, QDateTime::currentMSecsSinceEpoch() + interval);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这个还是解决不了在通知横幅定时器跑了一半的时候,再切到暂存区定时器又重新计时的问题呀,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已解决。ExpireTimer::remove 在气泡移出模型时挂起该 id 的倒计时(把绝对截止时间记入 m_retired),随后暂存区 NotifyStagingModel::push 调用 ExpireTimer::push 时从 m_retired 恢复原截止时间而不是重新计时;若暂停期间已过截止点,恢复时会按当前时间立即到期,不再从横幅跑了一半的地方重头开始。

return;

// Critical notifications must not disappear on their own.
if (reason == NotifyEntity::Expired && entity.urgency() == NotifyEntity::Critical)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Expired的不会是Critical类型的吧,不需要这个判断吧,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已修复。

@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from fadfda6 to e12655f Compare August 11, 2026 14:25
@Ivy233
Ivy233 requested a review from 18202781743 August 11, 2026 14:25
if (id == m_blockedId)
return;

m_blockedId = id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

BubbleModel里不需要m_blockedId了吧,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已删除。悬停冻结逻辑整体下沉到 ExpireTimer::setBlockId(内部维护单一悬停 id),BubblePanel::setHoveredId 直接调用 ExpireTimer::instance()->setBlockId(id),BubbleModel 不再保存 m_blockedId。

// times out, close it and notify the server so it moves the notification
// from the in-memory store to the center database and emits the signals.
connect(m_bubbles, &BubbleModel::bubbleExpired, this, [this](qint64 id, uint bubbleId) {
closeBubble(id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

不需要这里close吧,它会被server发送过来的吧,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已去掉。到期时 BubblePanel 只向服务端发送 notificationClosed(id, bubbleId, Expired),服务端处理后发出 NotificationStateChanged,气泡经由 onNotificationStateChanged 的正常流程关闭,不再本地 closeBubble。

const auto replaceIndex = replaceBubbleIndex(bubble);
const auto oldBubble = m_bubbles[replaceIndex];

ExpireTimer::instance()->stop(oldBubble->id());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

这个替换的逻辑,放在ExpireTimer里吧,只需要跟insert一样,push进去就行,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已迁移。replaceBubble 现在与 insertBubble 一样只调用 ExpireTimer::push;替换逻辑(取消同一气泡槽位旧通知的倒计时、悬停块转移给新通知)封装在 push 内部的 cancelReplacement 中统一处理。

// Absolute deadlines of ids stopped by stop(); restored by start() so a
// context switch (bubble <-> staging) resumes the countdown.
QHash<qint64, qint64> m_retired;
QHash<qint64, uint> m_bubbleIds;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

不需要m_bubbleIds和m_deadlines两个吧,放一个QHash<qint64, NotifyEntity>是不是就可以了,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已合并。m_bubbleIds 已删除,m_deadlines 改为 QHash<qint64, Deadline>,Deadline 同时保存实体与绝对截止时间(point),到期时所需的 bubbleId 直接从实体读取。

@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from e12655f to 0c427f4 Compare August 12, 2026 10:34
@Ivy233
Ivy233 requested a review from 18202781743 August 13, 2026 04:06
connect(NotifyAccessor::instance(), &NotifyAccessor::stagingEntityClosed, this, &NotifyStagingModel::onEntityClosed);
connect(NotifySetting::instance(), &NotifySetting::contentRowCountChanged, this, &NotifyStagingModel::updateContentRowCount);

connect(ExpireTimer::instance(), &ExpireTimer::expired, this, [this](qint64 id, uint bubbleId) {

@18202781743 18202781743 Aug 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

一个通知发送了expired后,会不会被通知横幅和暂存区都close一次呀,
是不是可以直接由ExpireTimer调用server的close呀,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已处理。

  1. 关于"会不会 close 两次":确实存在这个竞态,已修复。之前暂存区在 ExpireTimer::expired 时本地 remove + 补位,而服务端关闭是异步投递到 worker 线程的,导致刚过期的通知在补位时仍被当作 NotProcessed 重新插入并重新计时,一个倒计时周期后再次触发 notificationClosed(被幂等检查挡住,但会多打一条日志、多一次空跑)。现在暂存区不再响应 expired,只在服务端 stagingEntityClosed 回环时移除行,此时实体已标记 Processed,不会被补位重新选中。

  2. 关于"由 ExpireTimer 直接调用 server 的 close":采用等价且不破坏分层的做法——ExpireTimer 保持纯前端、不感知服务端,由 NotifyServerApplet 连接 ExpireTimer::expired,通过 Qt::QueuedConnection 统一转发给 NotificationManager::notificationClosed,作为过期关闭的唯一入口;notificationClosed 保留幂等检查兜底。横幅和暂存区各自只移除自己的视图,服务端每个通知只处理一次。

@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch 2 times, most recently from a26e115 to a5757b6 Compare August 14, 2026 07:20
1. Move the expire countdown from NotificationManager to the frontend
   ExpireTimer singleton, which tracks every displayed notification (bubble and
   staging area) with one shared QTimer and per-id absolute deadlines
2. Start the countdown in BubbleModel::insertBubble/replaceBubble and
   NotifyStagingModel::push/open, i.e. only when the bubble is actually shown,
   so queued notifications no longer expire before they are displayed
3. Let the bubble and the staging area share one countdown per id: push() of an
   already tracked id keeps the original deadline, and remove() suspends it via
   m_retired so switching between the two never restarts the countdown
4. Move hover handling into ExpireTimer::setBlockId, freezing the hovered
   bubble's countdown and resuming it with a short grace period afterwards
5. Make the server the single owner of the close on expiry: NotifyServerApplet
   forwards ExpireTimer::expired to NotificationManager::notificationClosed via
   Qt::QueuedConnection, whose idempotency check guards against duplicate closes
6. Add NotifyEntity::timeout()/urgency() accessors so ExpireTimer derives the
   effective timeout itself (0/Critical never expires, -1 defaults to 5000 ms)
7. Fix a race in NotifyStagingModel where a local remove+refill after expiry
   could re-insert the just-expired notification with a fresh countdown; the
   staging row is now dropped only via the server's stagingEntityClosed round
   trip
8. Remove the now-unused pending-timeout machinery and tests from
   NotificationManager and NotifyServerApplet

Log: Defer the notification expire countdown until the bubble is displayed,
and close expired notifications once through the server

Influence:
1. Verify a notification disappears after the default 5 seconds when displayed
   normally
2. Verify a notification no longer expires before it is shown when many
   notifications are queued
3. Verify hovering over a bubble prevents it from expiring
4. Verify a notification shown in both the bubble and the staging area expires
   at its original deadline without restarting the countdown, and the server
   closes it only once
5. Run the notification server unit tests

fix: 通知气泡显示后才开始过期计时

1. 将过期计时从 NotificationManager 迁移到前端 ExpireTimer 单例,用一个共享
   QTimer 和每个 id 的绝对截止时间管理所有已显示通知(气泡与暂存区)的倒计时
2. 在 BubbleModel::insertBubble/replaceBubble 与 NotifyStagingModel::push/open
   中启动倒计时,即气泡真正显示后才开始计时,排队中的通知不再提前过期
3. 气泡与暂存区对同一 id 共享一个倒计时:重复 push 保持原截止时间,
   remove() 通过 m_retired 挂起,切换显示位置不会重新计时
4. 悬停处理下沉到 ExpireTimer::setBlockId,冻结悬停气泡的倒计时,移开后
   保留短暂宽限期再恢复
5. 由服务端统一负责过期关闭:NotifyServerApplet 通过 Qt::QueuedConnection
   将 ExpireTimer::expired 转发给 NotificationManager::notificationClosed,
   幂等检查防止重复关闭
6. 新增 NotifyEntity::timeout()/urgency() 访问器,由 ExpireTimer 自行推导
   有效超时(0/Critical 永不过期,-1 默认 5000ms)
7. 修复 NotifyStagingModel 过期后本地 remove+补位可能把刚过期的通知重新插入
   并重新计时的竞态,暂存区行改为仅通过服务端 stagingEntityClosed 回环移除
8. 移除 NotificationManager 与 NotifyServerApplet 中不再使用的挂起计时逻辑
   及对应测试

Log: 将通知过期倒计时推迟到气泡显示之后,并由服务端统一关闭过期通知

Influence:
1. 验证正常显示的通知在默认 5 秒后消失
2. 验证大量通知排队时,通知不会在显示前提前过期
3. 验证鼠标悬停气泡时通知不会过期消失
4. 验证同时显示在气泡与暂存区的通知按原始截止时间过期、不重新计时,
   且服务端只关闭一次
5. 运行通知服务端单元测试

PMS: BUG-372279
@Ivy233
Ivy233 force-pushed the fix/notification-expire-after-display branch from a5757b6 to c7b598c Compare August 14, 2026 07:28
@deepin-ci-robot

Copy link
Copy Markdown

deepin pr auto review

★ 总体评分:95分

■ 【总体评价】

代码通过引入ExpireTimer单例类重构了通知过期管理逻辑,解决了原有多定时器状态不同步和线程安全问题
逻辑清晰且修复了线程竞态条件,代码质量高且无安全漏洞,仅因部分复杂状态管理边界场景扣少量分数

■ 【详细分析】

  • 1.语法逻辑(基本正确)✓

ExpireTimer的push、remove、setBlockId和onTimeout方法逻辑闭环,正确处理了替换、悬停阻塞和挂起恢复等状态。NotifyStagingModel::replace中将item->id() == entity.bubbleId()修改为item->id() == entity.id()修复了实体匹配错误。NotifyServerApplet::notificationClosed将Qt::DirectConnection改为Qt::QueuedConnection,正确处理了跨线程调用问题。
潜在问题:ExpireTimer::cancelReplacement中遍历QHash并修改的操作较多,若在极端高频替换场景下可能产生逻辑遗漏
建议:增加针对高频替换和并发悬停的单元测试覆盖

  • 2.代码质量(优秀)✓

注释非常详尽,清晰地解释了设计意图和边界条件处理。移除了NotificationManager中复杂的m_pendingTimeoutEntities逻辑,大幅降低了代码复杂度。MaxRememberedIds常量的引入有效防止了内存泄漏。
潜在问题:ExpireTimer的m_paused状态管理较为隐晦,依赖调用方保证同一时间只有一个悬停ID
建议:在setBlockId的注释中进一步强调UI层必须保证单悬停的约束

  • 3.代码性能(良好)✓

使用单一QTimer管理所有通知的过期时间,避免了为每个通知创建独立QTimer的开销。schedule()函数中通过std::min_element寻找最近过期时间,在通知数量正常的情况下性能完全满足要求。
潜在问题:schedule()每次触发都需要O(N)时间寻找最小值,若通知中心积压大量通知可能产生轻微开销
建议:若未来通知量极大,可考虑使用优先队列(如std::priority_queue)替代QHash管理截止时间

  • 4.代码安全(存在0个安全漏洞)✓

漏洞对比统计:新增漏洞 0 个,减少漏洞 0 个,持平 0 个
代码修复了原有的跨线程直接调用导致的竞态条件,未引入新的安全漏洞,无外部输入注入风险

  • 建议:继续保持对跨线程调用的严格审查,确保所有涉及m_manager的调用均通过队列连接

■ 【改进建议代码示例】

// 针对 ExpireTimer::schedule() 的轻微性能优化建议(使用优先队列替代线性查找)
// expiretimer.h
#include <queue>
#include <vector>

// 在 private 部分添加
struct DeadlineCompare {
    bool operator()(const Deadline &lhs, const Deadline &rhs) const {
        return lhs.point > rhs.point; // 最小堆
    }
};

std::priority_queue<Deadline, std::vector<Deadline>, DeadlineCompare> m_deadlineQueue;
QHash<qint64, Deadline> m_deadlines; // 保留用于快速查找

// expiretimer.cpp
void ExpireTimer::schedule()
{
    if (m_deadlines.isEmpty()) {
        m_timer->stop();
        return;
    }
    
    // 清理已过期的无效元素
    while (!m_deadlineQueue.empty() && !m_deadlines.contains(m_deadlineQueue.top().entity.id())) {
        m_deadlineQueue.pop();
    }
    
    if (m_deadlineQueue.empty()) {
        m_timer->stop();
        return;
    }
    
    const auto &top = m_deadlineQueue.top();
    const qint64 remaining = qMax<qint64>(0, top.point - QDateTime::currentMSecsSinceEpoch());
    m_timer->start(static_cast<int>(remaining));
}

@Ivy233
Ivy233 requested a review from 18202781743 August 14, 2026 07:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants