首页
/ Rocket.Chat 状态可见性(Status Visibility):让指定的人看不到你的在线状态

Rocket.Chat 状态可见性(Status Visibility):让指定的人看不到你的在线状态

2026-09-05 10:56:24作者:裴麒琰

Rocket.Chat 近期的一个 minor 版本引入了「状态可见性」(status visibility)能力:用户可以把自己的在线状态(presence)和状态文字(status message)对选定的特定用户隐藏,被隐藏者看到的该用户与「真正离线」完全无法区分,且解除屏蔽后变更会实时生效、无需刷新页面。本文以该功能的变更集(changeset)为主线,结合仓库中的服务端服务、在线状态推送链路和核心类型定义,完整拆解这一功能的产品语义与实现原理,读完后你将理解 Rocket.Chat 如何在保持 DDP 推送架构不变的前提下,做到「按观看者(viewer)维度的 presence 差异化下发」。

一、功能来源:一个 Changeset 条目与受影响包

该功能由变更集文件 .changeset/odd-steaks-pull.md 记录,其原文描述为:

Adds status visibility, letting users hide their presence and status message from specific people they choose. Blocked people see that user as offline, indistinguishable from genuinely offline, and the block can be lifted at any time — changes apply live, without a reload.

从中可以提炼出四个产品承诺,也是后文源码验证的「验收标准」:

  1. 按人精确隐藏:隐藏对象是用户自己挑选的特定人(specific people they choose),而不是全局隐身;
  2. 伪装为离线:被隐藏者看到的不是「状态被隐藏」,而是标准的 offline 状态,且与真实离线不可区分(indistinguishable from genuinely offline);
  3. 可随时解除:屏蔽关系可任意时刻取消;
  4. 实时生效:变更立即广播到相关客户端,无需 reload。

该 changeset 同时声明了本次变更将以 minor 版本发布到的包,覆盖了从类型定义到应用层的完整链路:

  • @rocket.chat/core-services:新增状态可见性服务的核心抽象(接口与服务基类);
  • @rocket.chat/core-typings:新增 presence 相关共享类型;
  • @rocket.chat/i18n:新增功能所需的多语言文案;
  • @rocket.chat/meteor:应用本体,承载服务端实现与客户端交互;
  • @rocket.chat/model-typings@rocket.chat/models:用户模型层的查询支撑;
  • @rocket.chat/rest-typings:REST API 类型,说明用户信息类接口的返回结构也感知了该特性。

这个包清单本身就勾勒出实现路径:核心逻辑下沉到 core-services/core-typings 共享包,Meteor 应用只做装配与推送,模型与 API 类型提供数据面支持

二、服务端核心:StatusVisibilityService 的内存索引设计

功能的服务端主体是 StatusVisibilityService。它通过 ServiceClassInternal 注册进 Rocket.Chat 的服务框架,protected name = 'status-visibility',并在构造时声明了对总开关的设置监听:

constructor() {
    super();

    this.onSettingChanged('Accounts_StatusVisibility_Enabled', async () => {
        await this.invalidate();
    });
}

也就是说,功能受 Accounts_StatusVisibility_Enabled 这一管理员设置控制,开关变更会触发一次全量 invalidate()——这正是「总开关即时生效」的落点。

2.1 数据结构:hiddenFromByUser

服务的核心是一张按「被隐藏者」组织的双向索引:

private hiddenFromByUser = new Map<IUser['_id'], Set<IUser['_id']>>();

键是目标用户(被隐藏状态的人),值是对其隐藏状态的观看者集合。数据源头来自用户偏好——rebuildHiddenUsers 中通过 Users.findWithStatusVisibilityConfig(targets) 读取每个用户的 settings.preferences.statusVisibilityDenied 字段,再回填进内存 Map。从源码结构看,这是一份进程内热索引:查询不再走数据库,而是直接查 Map,适合被在线状态(presence)这种高频路径反复调用。

对外暴露的查询接口与 IStatusVisibilityService 完全对应:

export interface IStatusVisibilityService {
    getHiddenFrom(viewerId: IUser['_id'] | null | undefined): Promise<IUser['_id'][]>;
    hasRestrictions(targetId: IUser['_id']): Promise<boolean>;
    getRestrictedUsers(): Promise<IUser['_id'][]>;
    refresh(targets?: IUser['_id'][]): Promise<UserPresence[]>;
    invalidate(targets?: IUser['_id'][]): Promise<UserPresence[]>;
}

四个方法的分工清晰:

  • getHiddenFrom(viewerId):回答「这个观看者能看到哪些人的状态被藏起来了」——实现里遍历 Map,筛选 targetId !== viewerId && viewers.has(viewerId) 的条目(排除自己看自己的情况)。这是 presence 推送链路的关键查询;
  • hasRestrictions(targetId):回答某个用户是否设置了任何隐藏限制,供其他模块做快速短路;
  • getRestrictedUsers():返回所有存在限制的用户 id 集合;
  • refresh(targets?):从数据库重建索引,支持增量重建(只传变更的用户 id 列表),内部用 this.lock 串行化重建过程,避免并发重建互相覆盖;
  • invalidate(targets?):refresh 之后负责「广播纠偏」,是「changes apply live」的落点。

2.2 增量重建与受影响用户集

rebuildHiddenUsers 有一段值得注意的细节:当某个用户的限制被移除(dropped)时,服务会把这些用户的 presence 字段一并查出并返回:

const dropped = previous.filter((uid) => !this.hiddenFromByUser.has(uid));

if (dropped.length) {
    users.push(...(await Users.findPresenceUsersByIds(dropped, { projection: PRESENCE_FIELDS }).toArray()));
}

PRESENCE_FIELDS 只投影 username / status / statusText / statusSource / statusExpiresAt 五个字段。这个设计的意义是:「解除屏蔽」同样是一次状态变更——之前一直看到 offline 的观看者,现在必须立刻看到该用户真实的状态,因此被恢复的用户也要进入本次广播的「受影响集合」。

2.3 invalidate:实时广播的最后一公里

async invalidate(targets?: IUser['_id'][]): Promise<UserPresence[]> {
    const previousViewers = targets && this.viewersOf(targets);
    const affected = await this.refresh(targets);
    const viewers = previousViewers && [...new Set([...previousViewers, ...this.viewersOf(targets)])];

    void api
        .broadcast('presence.invalidateVisibility', { targets, viewers })
        .catch((err) => logger.error({ msg: 'Status visibility invalidation failed', err, targets }));

    return affected;
}

这里通过 api.broadcast('presence.invalidateVisibility', { targets, viewers }) 向全实例广播一个事件,载荷同时携带 targets(状态被隐藏/恢复的用户)与 viewers(合并了「变更前」与「变更后」两代观看者的并集)。把新旧两代观看者做并集是有意的:修改前的观看者需要收到「取消隐藏」的纠偏,修改后的观看者需要收到「开始隐藏」的纠偏。客户端收到该事件后各自刷新本地 presence 缓存,配合下一节的连接级拦截,就实现了「无需 reload 的实时生效」。

服务启动时(started() 钩子)会先 refresh() 一次,保证进程重启后内存索引与数据库一致。

三、在线状态推送链路中的「按观看者拦截」

presence 数据在 Rocket.Chat 中是经 DDP 流(user-presence 流)逐连接下发的。状态可见性对这条链路的改造集中在 UserPresence 连接级控制器

每个 user-presence publication 会话对应一个 UserPresence 实例,其中有一个关键成员:

// map value as true marks a pending correction
private hiddenFrom = new Map<IUser['_id'], true | undefined>();

private stale = true;

hiddenFrom 记录当前这个观看者(连接所属用户)视角下哪些用户被隐藏。refreshHiddenFrom() 调用 StatusVisibility.getHiddenFrom(session.userId) 拉取本观看者的隐藏名单,并把「新出现且已有监听」的条目标记为 true(pending correction),表示「该用户的首帧需要按 offline 下发一次,然后恢复正常转发」。

真正的拦截发生在流事件回调 run 中:

const visiblePresence: UserPresenceStreamArgs = hidden
    ? { uid: args.uid, args: [[args.args[0][0], USER_STATUS_TO_PRESENCE_CODE[UserStatus.OFFLINE]]] }
    : args;

当某 uid 命中本观看者的隐藏名单时,原始 presence 载荷被替换USER_STATUS_TO_PRESENCE_CODE[UserStatus.OFFLINE]——即标准离线码。这与产品承诺「blocked people see that user as offline, indistinguishable from genuinely offline」逐字对应:客户端收到的就是与真实离线完全一致的协议帧,不存在「status hidden」这类额外标记可供嗅探。首次命中时标记会被清除(hiddenFrom.set(args.uid, undefined)),后续该用户的事件恢复原样转发(因为首帧离线已经送达,之后无新事件也不会暴露)。

另外两个细节体现了工程上的严谨:

  • 故障偏向可见refreshHiddenFrom 若抛错会把 stale 置回 true,并先经 statusVisibilityGate.ensureEnabled() 判断总开关——开关关闭时直接清空 hiddenFrom 并标记非 stale,即「功能关闭 = 行为回退到旧版 presence」;
  • 会话安全:发送前检查 Streamer.isPublicationActive(this.publication),代码注释明确说明 Meteor 3.4.1 之后断连时 session 会立即置空,await 之后必须重新校验,避免向已断开的连接写入。

四、Gate 层:面向其他服务模块的同步门面

除了 presence 主链路,其他需要判断「某用户是否有隐藏限制」的服务模块走 StatusVisibilityGate。它是对 StatusVisibilityService 的轻量适配层,导出单例 statusVisibilityGate,设计上有两点值得说明:

  1. fail-open 的开关查询ensureEnabled() 在读取 Accounts_StatusVisibility_Enabled 失败时返回 true(并清空缓存让下次重试)。从源码结构看,这意味着读配置异常时系统宁可多做一次限制同步,也不阻塞调用方;
  2. 首询即同步hasRestrictions 在限制集合尚未同步完成时返回 true(假定受限)并异步触发 syncRestrictedUsers(),同样偏向「先隐藏、后纠正」的安全姿态。

这一层把异步的 getRestrictedUsers() 结果缓存为进程内 Set,使下游模块可以同步判断,同时避免了每个模块各自轮询数据库。

五、类型与数据面:UserPresence、模型查询与 API 面

5.1 共享类型

本次 changeset 中 @rocket.chat/core-typings 的 minor 升级带来了 presence 的共享类型 UserPresence

export type UserPresence = Readonly<
    Partial<
        Pick<IUser, 'name' | 'status' | 'utcOffset' | 'statusText' | 'statusSource' | 'statusExpiresAt' | 'avatarETag' | 'roles' | 'username'>
    > &
        Required<Pick<IUser, '_id'>>
>;

注意它是 IUser窄化投影:只含状态与展示必需字段,且 _id 必填、其余可选。服务层返回 UserPresence[](而非完整 IUser[])作为 invalidate 的受影响集合,天然限制了内部信息在广播与日志路径上的暴露面。

5.2 模型与 API 面

服务通过 Users.findWithStatusVisibilityConfig 拉取用户偏好中的 statusVisibilityDenied@rocket.chat/models@rocket.chat/model-typings 的 minor 升级即在此层),通过 Users.findPresenceUsersByIds 做窄投影读取。@rocket.chat/rest-typings 出现在发布清单中,说明 REST 用户信息类接口的类型定义同步感知了该字段——例如用户信息与在线状态接口(users.tsgetUserInfo 工具 所在目录)均出现了 statusVisibility 相关引用,外部集成方拉取用户数据时可据此感知用户的可见性限制。

六、客户端入口与端到端体验

客户端的交互入口是用户菜单中的 EditStatusVisibilityModal(配套 hook 见 useStatusItems),用户在此挑选「对谁隐藏」。设置保存在用户 settings.preferences.statusVisibilityDenied,随后由服务端 refresh 增量重建索引并经 presence.invalidateVisibility 广播。@rocket.chat/i18n 包的 minor 升级对应弹窗与设置项的多语言文案。

把三层串起来,「changes apply live, without a reload」的完整通路是:

  1. 用户在弹窗中增删隐藏对象 → 用户偏好落库;
  2. 服务端 StatusVisibilityService.invalidate(targets) 增量重建内存索引,合并新旧观看者并集,广播 presence.invalidateVisibility
  3. 每个受影响客户端(及其 UserPresence 连接控制器)刷新本观看者的 hiddenFrom 名单,presence 流随即按新名单拦截/恢复转发;
  4. 期间该用户的任何状态事件(在线、离线、状态文字变更)都经 run 回调实时改写为 offline 或原样透传。

七、验证与适用边界

服务端服务配有测试 service.spec.ts,用于锁定索引重建与纠偏集合的行为。使用时需注意两点适用前提:

  • 该功能受 Accounts_StatusVisibility_Enabled 总开关控制(见 StatusVisibilityGate 中的 STATUS_VISIBILITY_SETTING_ID 常量),关闭后服务会清空索引并回退到原始 presence 行为;
  • 隐藏语义是「按观看者连接」判定的:同一用户在不同客户端连接中的展示互不影响,且隐藏仅作用于在线状态与状态文字,不阻断消息收发本身——从 changeset 描述与源码的 PRESENCE_FIELDS 投影范围可以确认其作用域严格限定在 presence 维度。

小结

Rocket.Chat 的状态可见性功能是一个「小功能、深链路」的典型样本:changeset 一句话的产品承诺背后,是 core-services 中的服务接口抽象(IStatusVisibilityService)、模型层的偏好查询、presence 推送链路的连接级拦截(把命中隐藏的事件改写为离线协议帧)、以及以观看者并集广播实现的无刷新实时纠偏。其实现上的两个亮点——增量索引重建 + 新旧观看者并集广播保证状态双向切换的即时性,协议帧级伪装保证隐藏效果与真实离线不可区分——对需要在长连接推送架构中做「按接收者定制下发内容」的场景都有参考价值。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384