@qiqb/message-domain 0.0.0-stage → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -2
- package/build/components/DefaultMessageReminderRenderer.d.ts +10 -0
- package/build/components/DefaultMessageReminderRenderer.js +29 -0
- package/build/components/MessageNavigation.d.ts +7 -0
- package/build/components/MessageNavigation.js +192 -0
- package/build/components/MessageReminderDisplay.d.ts +21 -0
- package/build/components/MessageReminderDisplay.js +353 -0
- package/build/components/MessageWorkspace.d.ts +14 -0
- package/build/components/MessageWorkspace.js +26 -0
- package/build/components/message-navigation.less +89 -0
- package/build/components/message-reminder-display.less +367 -0
- package/build/components/message-workspace.less +150 -0
- package/build/index.d.ts +20 -0
- package/build/index.js +17 -0
- package/build/pages/inbox/InboxPage.d.ts +2 -0
- package/build/pages/inbox/InboxPage.js +257 -0
- package/build/pages/inbox/inbox.less +293 -0
- package/build/pages/subscription/PersonalSubscriptionPage.d.ts +1 -0
- package/build/pages/subscription/PersonalSubscriptionPage.js +6 -0
- package/build/pages/subscription/RoleSubscriptionPage.d.ts +1 -0
- package/build/pages/subscription/RoleSubscriptionPage.js +5 -0
- package/build/pages/subscription/SubscriptionForm.d.ts +16 -0
- package/build/pages/subscription/SubscriptionForm.js +111 -0
- package/build/pages/subscription/SubscriptionPage.d.ts +5 -0
- package/build/pages/subscription/SubscriptionPage.js +273 -0
- package/build/pages/subscription/subscription-form.less +341 -0
- package/build/pages/subscription/subscription.less +250 -0
- package/build/pages/topic/TopicPage.d.ts +1 -0
- package/build/pages/topic/TopicPage.js +275 -0
- package/build/routes.cjs +38 -0
- package/build/routes.d.cts +22 -0
- package/build/routes.d.ts +22 -0
- package/build/routes.js +34 -0
- package/build/runtime/MessageReminderLayout.d.ts +14 -0
- package/build/runtime/MessageReminderLayout.js +25 -0
- package/build/runtime/MessageReminders.d.ts +16 -0
- package/build/runtime/MessageReminders.js +364 -0
- package/build/runtime/index.d.ts +77 -0
- package/build/runtime/index.js +103 -0
- package/build/runtime/message-reminder-layout.less +40 -0
- package/build/runtime/messageRealtime.d.ts +15 -0
- package/build/runtime/messageRealtime.js +99 -0
- package/build/runtime/reminderEvents.d.ts +18 -0
- package/build/runtime/reminderEvents.js +53 -0
- package/build/runtime/reminderOwnership.d.ts +35 -0
- package/build/runtime/reminderOwnership.js +135 -0
- package/build/runtime/reminderPresentation.d.ts +34 -0
- package/build/runtime/reminderPresentation.js +57 -0
- package/build/runtime/reminderQuery.d.ts +18 -0
- package/build/runtime/reminderQuery.js +33 -0
- package/build/runtime/reminderQueue.d.ts +25 -0
- package/build/runtime/reminderQueue.js +88 -0
- package/build/services/MessageController.d.ts +108 -0
- package/build/services/MessageController.js +42 -0
- package/build/types/index.d.ts +12 -0
- package/build/types/index.js +1 -0
- package/package.json +95 -4
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 在线事件只唤醒 HTTP 投影;连接正常时低频对账,未接入或断线时保留原五秒补偿。
|
|
3
|
+
* 同一查询进行中收到事件会合并为下一次刷新,不能丢掉在途请求之后的新收件/已读变化。
|
|
4
|
+
*/
|
|
5
|
+
export function startMessageRealtimeRefresh(options) {
|
|
6
|
+
let active = true;
|
|
7
|
+
let running = false;
|
|
8
|
+
let pending = false;
|
|
9
|
+
let complete = !!options.realtime;
|
|
10
|
+
let connected = false;
|
|
11
|
+
let timer;
|
|
12
|
+
let lease;
|
|
13
|
+
let offChanged;
|
|
14
|
+
let offState;
|
|
15
|
+
const poll = async () => {
|
|
16
|
+
if (!active || document.visibilityState === 'hidden')
|
|
17
|
+
return;
|
|
18
|
+
if (running) {
|
|
19
|
+
pending = true;
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
running = true;
|
|
23
|
+
pending = false;
|
|
24
|
+
const full = complete || connected;
|
|
25
|
+
complete = false;
|
|
26
|
+
try {
|
|
27
|
+
await options.refresh(full);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// 界面查询负责展示自己的错误;调度仍继续补偿,不能留下未处理的 Promise 拒绝。
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
running = false;
|
|
34
|
+
if (!active)
|
|
35
|
+
return;
|
|
36
|
+
if (pending) {
|
|
37
|
+
void poll();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
timer = setTimeout(() => void poll(), connected ? 60000 : 5000);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const wake = () => {
|
|
44
|
+
if (!active)
|
|
45
|
+
return;
|
|
46
|
+
if (timer)
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timer = undefined;
|
|
49
|
+
void poll();
|
|
50
|
+
};
|
|
51
|
+
if (options.realtime) {
|
|
52
|
+
try {
|
|
53
|
+
lease = options.realtime.acquire();
|
|
54
|
+
connected = lease.getState() === 'connected';
|
|
55
|
+
offChanged = lease.onChanged((change) => {
|
|
56
|
+
if (!active)
|
|
57
|
+
return;
|
|
58
|
+
options.onChanged?.(change);
|
|
59
|
+
// 变化事件没有正文,完整补查还可移除其它设备已经读过的队列记录。
|
|
60
|
+
complete = true;
|
|
61
|
+
wake();
|
|
62
|
+
});
|
|
63
|
+
offState = lease.onStateChange((state) => {
|
|
64
|
+
if (!active)
|
|
65
|
+
return;
|
|
66
|
+
const nextConnected = state === 'connected';
|
|
67
|
+
if (nextConnected === connected)
|
|
68
|
+
return;
|
|
69
|
+
connected = nextConnected;
|
|
70
|
+
complete = true;
|
|
71
|
+
wake();
|
|
72
|
+
}, false);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// 宿主扩展初始化失败时继续 HTTP 补偿,不让可选实时能力阻止消息入口挂载。
|
|
76
|
+
offChanged?.();
|
|
77
|
+
offState?.();
|
|
78
|
+
lease?.release();
|
|
79
|
+
lease = undefined;
|
|
80
|
+
connected = false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
document.addEventListener('visibilitychange', wake);
|
|
84
|
+
wake();
|
|
85
|
+
return {
|
|
86
|
+
wake,
|
|
87
|
+
release() {
|
|
88
|
+
if (!active)
|
|
89
|
+
return;
|
|
90
|
+
active = false;
|
|
91
|
+
if (timer)
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
document.removeEventListener('visibilitychange', wake);
|
|
94
|
+
offChanged?.();
|
|
95
|
+
offState?.();
|
|
96
|
+
lease?.release();
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { InboxMessage } from '../services/MessageController.js';
|
|
2
|
+
/** 默认平台事件名称;实际派发/订阅通过运行时 namespace,允许 BIP 保持旧事件上下文。 */
|
|
3
|
+
export declare const MESSAGE_REMINDER_PREVIEW_EVENT: "qiqb-platform:message-reminder-preview";
|
|
4
|
+
export declare const MESSAGE_RECEIPT_READ_EVENT: "qiqb-platform:message-receipt-read";
|
|
5
|
+
export type MessageReminderPreviewEvent = CustomEvent<InboxMessage>;
|
|
6
|
+
export interface MessageReceiptReadDetail {
|
|
7
|
+
id: string;
|
|
8
|
+
readAt: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getMessageReminderEventNames(): {
|
|
11
|
+
preview: string;
|
|
12
|
+
read: string;
|
|
13
|
+
};
|
|
14
|
+
/** 预览仅使用浏览器本地事件,不保存主题、不发布消息、不更改阅读状态。 */
|
|
15
|
+
export declare function dispatchMessageReminderPreview(record: InboxMessage): void;
|
|
16
|
+
export declare function dispatchMessageReceiptRead(id: string, readAt?: string): void;
|
|
17
|
+
export declare function subscribeMessageReminderPreview(listener: (record: InboxMessage) => void): () => void;
|
|
18
|
+
export declare function subscribeMessageReceiptRead(listener: (detail: MessageReceiptReadDetail) => void): () => void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { getMessageDomainRuntime } from './index.js';
|
|
2
|
+
/** 默认平台事件名称;实际派发/订阅通过运行时 namespace,允许 BIP 保持旧事件上下文。 */
|
|
3
|
+
export const MESSAGE_REMINDER_PREVIEW_EVENT = 'qiqb-platform:message-reminder-preview';
|
|
4
|
+
export const MESSAGE_RECEIPT_READ_EVENT = 'qiqb-platform:message-receipt-read';
|
|
5
|
+
export function getMessageReminderEventNames() {
|
|
6
|
+
const namespace = getMessageDomainRuntime().eventNamespace;
|
|
7
|
+
return {
|
|
8
|
+
preview: `${namespace}:message-reminder-preview`,
|
|
9
|
+
read: `${namespace}:message-receipt-read`,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** 预览仅使用浏览器本地事件,不保存主题、不发布消息、不更改阅读状态。 */
|
|
13
|
+
export function dispatchMessageReminderPreview(record) {
|
|
14
|
+
if (typeof window === 'undefined')
|
|
15
|
+
return;
|
|
16
|
+
window.dispatchEvent(new CustomEvent(getMessageReminderEventNames().preview, {
|
|
17
|
+
detail: record,
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
export function dispatchMessageReceiptRead(id, readAt = new Date().toISOString()) {
|
|
21
|
+
if (typeof window === 'undefined')
|
|
22
|
+
return;
|
|
23
|
+
window.dispatchEvent(new CustomEvent(getMessageReminderEventNames().read, { detail: { id, readAt } }));
|
|
24
|
+
}
|
|
25
|
+
export function subscribeMessageReminderPreview(listener) {
|
|
26
|
+
if (typeof window === 'undefined')
|
|
27
|
+
return () => undefined;
|
|
28
|
+
const name = getMessageReminderEventNames().preview;
|
|
29
|
+
const receive = (event) => {
|
|
30
|
+
const record = event.detail;
|
|
31
|
+
if (record &&
|
|
32
|
+
typeof (record.receiptId || record.id) === 'string' &&
|
|
33
|
+
typeof record.title === 'string' &&
|
|
34
|
+
typeof record.content === 'string')
|
|
35
|
+
listener(record);
|
|
36
|
+
};
|
|
37
|
+
window.addEventListener(name, receive);
|
|
38
|
+
return () => window.removeEventListener(name, receive);
|
|
39
|
+
}
|
|
40
|
+
export function subscribeMessageReceiptRead(listener) {
|
|
41
|
+
if (typeof window === 'undefined')
|
|
42
|
+
return () => undefined;
|
|
43
|
+
const name = getMessageReminderEventNames().read;
|
|
44
|
+
const receive = (event) => {
|
|
45
|
+
const detail = event.detail;
|
|
46
|
+
if (detail &&
|
|
47
|
+
typeof detail.id === 'string' &&
|
|
48
|
+
typeof detail.readAt === 'string')
|
|
49
|
+
listener(detail);
|
|
50
|
+
};
|
|
51
|
+
window.addEventListener(name, receive);
|
|
52
|
+
return () => window.removeEventListener(name, receive);
|
|
53
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface MessageReminderLease {
|
|
2
|
+
isOwner(): boolean;
|
|
3
|
+
remembered(): string[];
|
|
4
|
+
remember(ids: string[]): void;
|
|
5
|
+
reportRead(id: string, readAt: string): void;
|
|
6
|
+
reportConnectionFailed(failed: boolean): void;
|
|
7
|
+
release(): void;
|
|
8
|
+
}
|
|
9
|
+
export interface MessageReminderOwnershipOptions {
|
|
10
|
+
apiPrefix: string;
|
|
11
|
+
accountId: string;
|
|
12
|
+
priority?: number;
|
|
13
|
+
onOwnerChange: (owner: boolean) => void;
|
|
14
|
+
onRead?: (id: string, readAt: string) => void;
|
|
15
|
+
/** 导航重试只唤醒当前 owner,其他入口不启动新的正式提醒轮询。 */
|
|
16
|
+
onRetry?: () => void;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 铃铛只订阅同源、同账户的连接状态,不加入 owner 选举,也不保留正式消息队列。
|
|
20
|
+
* 使用现有跨 bundle 注册表而非 namespace 事件,宿主与微应用都能感知实际 owner。
|
|
21
|
+
*/
|
|
22
|
+
export declare function subscribeMessageReminderConnection(source: {
|
|
23
|
+
apiPrefix: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
}, listener: (failed: boolean) => void): () => void;
|
|
26
|
+
/** 重试始终解析当前 owner;旧入口释放后不保留可调用的轮询回调。 */
|
|
27
|
+
export declare function retryMessageReminderConnection(source: {
|
|
28
|
+
apiPrefix: string;
|
|
29
|
+
accountId: string;
|
|
30
|
+
}): void;
|
|
31
|
+
/**
|
|
32
|
+
* 一份后端消息源、同一账号在同一 document 中只允许一个正式提醒入口。
|
|
33
|
+
* namespace 不参与选举:平台宿主与业务微应用可保留各自预览/存储命名空间,但不会同时弹正式消息。
|
|
34
|
+
*/
|
|
35
|
+
export declare function acquireMessageReminderOwnership(options: MessageReminderOwnershipOptions): MessageReminderLease;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// document.defaultView 是 qiankun 沙箱之外的同一文档窗口,Symbol.for 在不同 bundle 中指向同一键。
|
|
2
|
+
const registrySymbol = Symbol.for('@qiqb/message-domain/reminder-owner:v1');
|
|
3
|
+
function getRegistry() {
|
|
4
|
+
const view = typeof document === 'undefined' ? undefined : document.defaultView;
|
|
5
|
+
if (!view)
|
|
6
|
+
return;
|
|
7
|
+
const shared = view;
|
|
8
|
+
return (shared[registrySymbol] || (shared[registrySymbol] = { nextId: 0, entries: new Map() }));
|
|
9
|
+
}
|
|
10
|
+
const sourceKey = (source) => JSON.stringify([source.apiPrefix.replace(/\/+$/, ''), source.accountId]);
|
|
11
|
+
function reportConnectionFailed(registry, key, entry, failed) {
|
|
12
|
+
if (!!entry.connectionFailed === failed)
|
|
13
|
+
return;
|
|
14
|
+
entry.connectionFailed = failed;
|
|
15
|
+
for (const listener of [...(registry.connectionListeners?.get(key) || [])])
|
|
16
|
+
listener(failed);
|
|
17
|
+
}
|
|
18
|
+
function elect(registry, key, entry) {
|
|
19
|
+
const winner = [...entry.members.values()].sort((a, b) => b.priority - a.priority || a.id - b.id)[0]?.id;
|
|
20
|
+
if (winner === entry.owner)
|
|
21
|
+
return;
|
|
22
|
+
const previous = entry.owner;
|
|
23
|
+
entry.owner = winner;
|
|
24
|
+
// 断开状态属于实际查询的 owner;交接后等待新 owner 的结果,不继承旧入口的失败。
|
|
25
|
+
reportConnectionFailed(registry, key, entry, false);
|
|
26
|
+
// 先让旧 owner 停止展示/轮询,再激活新 owner;同优先级按登记先后稳定选举。
|
|
27
|
+
if (previous !== undefined)
|
|
28
|
+
entry.members.get(previous)?.onOwnerChange(false);
|
|
29
|
+
if (winner !== undefined)
|
|
30
|
+
entry.members.get(winner)?.onOwnerChange(true);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 铃铛只订阅同源、同账户的连接状态,不加入 owner 选举,也不保留正式消息队列。
|
|
34
|
+
* 使用现有跨 bundle 注册表而非 namespace 事件,宿主与微应用都能感知实际 owner。
|
|
35
|
+
*/
|
|
36
|
+
export function subscribeMessageReminderConnection(source, listener) {
|
|
37
|
+
const registry = getRegistry();
|
|
38
|
+
if (!registry)
|
|
39
|
+
return () => undefined;
|
|
40
|
+
const key = sourceKey(source);
|
|
41
|
+
const subscriptions = (registry.connectionListeners || (registry.connectionListeners = new Map()));
|
|
42
|
+
let listeners = subscriptions.get(key);
|
|
43
|
+
if (!listeners) {
|
|
44
|
+
listeners = new Set();
|
|
45
|
+
subscriptions.set(key, listeners);
|
|
46
|
+
}
|
|
47
|
+
listeners.add(listener);
|
|
48
|
+
// 导航晚于提醒入口挂载时立即回放,避免已断开的连接要等下一轮失败才显示。
|
|
49
|
+
listener(!!registry.entries.get(key)?.connectionFailed);
|
|
50
|
+
return () => {
|
|
51
|
+
listeners.delete(listener);
|
|
52
|
+
if (!listeners.size)
|
|
53
|
+
subscriptions.delete(key);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** 重试始终解析当前 owner;旧入口释放后不保留可调用的轮询回调。 */
|
|
57
|
+
export function retryMessageReminderConnection(source) {
|
|
58
|
+
const entry = getRegistry()?.entries.get(sourceKey(source));
|
|
59
|
+
if (entry?.owner !== undefined)
|
|
60
|
+
entry.members.get(entry.owner)?.onRetry?.();
|
|
61
|
+
}
|
|
62
|
+
function remember(entry, ids) {
|
|
63
|
+
for (const id of ids)
|
|
64
|
+
if (id && !id.startsWith('preview:'))
|
|
65
|
+
entry.remembered.add(id);
|
|
66
|
+
if (entry.remembered.size > 5000)
|
|
67
|
+
entry.remembered = new Set([...entry.remembered].slice(-5000));
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 一份后端消息源、同一账号在同一 document 中只允许一个正式提醒入口。
|
|
71
|
+
* namespace 不参与选举:平台宿主与业务微应用可保留各自预览/存储命名空间,但不会同时弹正式消息。
|
|
72
|
+
*/
|
|
73
|
+
export function acquireMessageReminderOwnership(options) {
|
|
74
|
+
const registry = getRegistry();
|
|
75
|
+
if (!registry) {
|
|
76
|
+
options.onOwnerChange(false);
|
|
77
|
+
return {
|
|
78
|
+
isOwner: () => false,
|
|
79
|
+
remembered: () => [],
|
|
80
|
+
remember: () => undefined,
|
|
81
|
+
reportRead: () => undefined,
|
|
82
|
+
reportConnectionFailed: () => undefined,
|
|
83
|
+
release: () => undefined,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const key = sourceKey(options);
|
|
87
|
+
let entry = registry.entries.get(key);
|
|
88
|
+
if (!entry) {
|
|
89
|
+
entry = { members: new Map(), remembered: new Set() };
|
|
90
|
+
registry.entries.set(key, entry);
|
|
91
|
+
}
|
|
92
|
+
const id = ++registry.nextId;
|
|
93
|
+
let released = false;
|
|
94
|
+
const member = {
|
|
95
|
+
...options,
|
|
96
|
+
id,
|
|
97
|
+
priority: Number.isFinite(options.priority) ? options.priority : 0,
|
|
98
|
+
};
|
|
99
|
+
entry.members.set(id, member);
|
|
100
|
+
member.onOwnerChange(false);
|
|
101
|
+
elect(registry, key, entry);
|
|
102
|
+
const currentEntry = entry;
|
|
103
|
+
return {
|
|
104
|
+
isOwner: () => !released && currentEntry.owner === id,
|
|
105
|
+
remembered: () => [...currentEntry.remembered],
|
|
106
|
+
remember: (ids) => {
|
|
107
|
+
if (!released)
|
|
108
|
+
remember(currentEntry, ids);
|
|
109
|
+
},
|
|
110
|
+
reportRead: (receiptId, readAt) => {
|
|
111
|
+
// 即使确认请求返回时旧 owner 已卸载,服务端已读事实仍可通知接管同一账号的入口。
|
|
112
|
+
const active = registry.entries.get(key);
|
|
113
|
+
if (!active)
|
|
114
|
+
return;
|
|
115
|
+
remember(active, [receiptId]);
|
|
116
|
+
for (const receiver of [...active.members.values()])
|
|
117
|
+
receiver.onRead?.(receiptId, readAt);
|
|
118
|
+
},
|
|
119
|
+
reportConnectionFailed: (failed) => {
|
|
120
|
+
// 迟到响应和失去所有权的入口不能覆盖当前账户正在使用的连接状态。
|
|
121
|
+
if (!released && currentEntry.owner === id)
|
|
122
|
+
reportConnectionFailed(registry, key, currentEntry, failed);
|
|
123
|
+
},
|
|
124
|
+
release: () => {
|
|
125
|
+
if (released)
|
|
126
|
+
return;
|
|
127
|
+
released = true;
|
|
128
|
+
member.onOwnerChange(false);
|
|
129
|
+
currentEntry.members.delete(id);
|
|
130
|
+
elect(registry, key, currentEntry);
|
|
131
|
+
if (!currentEntry.members.size)
|
|
132
|
+
registry.entries.delete(key);
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ComponentType } from 'react';
|
|
2
|
+
import type { MessageReminderDisplayProps } from '../components/MessageReminderDisplay.js';
|
|
3
|
+
import type { InboxMessage } from '../services/MessageController.js';
|
|
4
|
+
import type { MessageReminderLease } from './reminderOwnership.js';
|
|
5
|
+
import { ReminderQueue } from './reminderQueue.js';
|
|
6
|
+
/**
|
|
7
|
+
* 项目只替换展示组件,查询、账户隔离、所有者选举和已读接口仍由平台运行时负责。
|
|
8
|
+
* 三种提醒方式与消息级别来自发布快照;组件可以完整重画,也可以包装默认组件。
|
|
9
|
+
*/
|
|
10
|
+
export interface MessageReminderRendererProps extends MessageReminderDisplayProps {
|
|
11
|
+
accountId?: string;
|
|
12
|
+
connectionFailed: boolean;
|
|
13
|
+
onRetry: () => void;
|
|
14
|
+
onClearError: () => void;
|
|
15
|
+
/** 必须在消息实际可见时报告;传入列表、隐藏项或排队项不代表用户已经看到。 */
|
|
16
|
+
onDisplayed: (record: InboxMessage) => void;
|
|
17
|
+
}
|
|
18
|
+
/** 使用 React 组件而非普通 render 回调,项目组件可以正常使用自己的 Hooks。 */
|
|
19
|
+
export type MessageReminderRenderer = ComponentType<MessageReminderRendererProps>;
|
|
20
|
+
export type MessageReminderActions = Pick<MessageReminderRendererProps, 'onDismiss' | 'onOpen' | 'onConfirm' | 'onDisplayed'>;
|
|
21
|
+
/** 内部展示快照:自定义组件可能保留动画/定时器回调,不能让旧入口操作新账户队列。 */
|
|
22
|
+
export interface ReminderPresentationSnapshot {
|
|
23
|
+
sourceKey: string;
|
|
24
|
+
previewKey: string;
|
|
25
|
+
queue: ReminderQueue;
|
|
26
|
+
lease?: MessageReminderLease;
|
|
27
|
+
preview?: InboxMessage;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 回调在生成时捕获快照,在执行时读取当前状态。正式消息还必须属于当前 owner 的队列。
|
|
31
|
+
* 使用队列中的原始快照处理确认/展示,防止展示组件传入改过提醒方式的临时对象影响去重。
|
|
32
|
+
* 预览独立校验其当前标识,不依赖正式 owner,也不会执行真实消息的阅读接口。
|
|
33
|
+
*/
|
|
34
|
+
export declare function createReminderPresentationActions(messages: InboxMessage[], snapshot: ReminderPresentationSnapshot, getCurrent: () => ReminderPresentationSnapshot, handlers: MessageReminderActions): MessageReminderActions;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { reminderReceiptId } from './reminderQueue.js';
|
|
2
|
+
/**
|
|
3
|
+
* 回调在生成时捕获快照,在执行时读取当前状态。正式消息还必须属于当前 owner 的队列。
|
|
4
|
+
* 使用队列中的原始快照处理确认/展示,防止展示组件传入改过提醒方式的临时对象影响去重。
|
|
5
|
+
* 预览独立校验其当前标识,不依赖正式 owner,也不会执行真实消息的阅读接口。
|
|
6
|
+
*/
|
|
7
|
+
export function createReminderPresentationActions(messages, snapshot, getCurrent, handlers) {
|
|
8
|
+
const ids = new Set(messages.map(reminderReceiptId));
|
|
9
|
+
const currentRecord = (id) => {
|
|
10
|
+
if (!ids.has(id))
|
|
11
|
+
return;
|
|
12
|
+
const current = getCurrent();
|
|
13
|
+
if (current.sourceKey !== snapshot.sourceKey ||
|
|
14
|
+
current.previewKey !== snapshot.previewKey)
|
|
15
|
+
return;
|
|
16
|
+
if (id.startsWith('preview:')) {
|
|
17
|
+
return snapshot.preview &&
|
|
18
|
+
current.preview &&
|
|
19
|
+
reminderReceiptId(snapshot.preview) === id &&
|
|
20
|
+
reminderReceiptId(current.preview) === id
|
|
21
|
+
? current.preview
|
|
22
|
+
: undefined;
|
|
23
|
+
}
|
|
24
|
+
if (current.queue !== snapshot.queue ||
|
|
25
|
+
current.lease !== snapshot.lease ||
|
|
26
|
+
!snapshot.lease?.isOwner())
|
|
27
|
+
return;
|
|
28
|
+
return current.queue
|
|
29
|
+
.messages()
|
|
30
|
+
.find((record) => reminderReceiptId(record) === id);
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
onDismiss: (id) => {
|
|
34
|
+
const record = currentRecord(id);
|
|
35
|
+
// 自定义组件同样不能通过关闭回调绕过全屏确认;预览仍可以直接结束。
|
|
36
|
+
if (record &&
|
|
37
|
+
(id.startsWith('preview:') || record.reminderMode !== 'FULLSCREEN'))
|
|
38
|
+
handlers.onDismiss(id);
|
|
39
|
+
},
|
|
40
|
+
onOpen: (record) => {
|
|
41
|
+
const current = currentRecord(reminderReceiptId(record));
|
|
42
|
+
if (current)
|
|
43
|
+
handlers.onOpen(current);
|
|
44
|
+
},
|
|
45
|
+
onConfirm: (record) => {
|
|
46
|
+
const current = currentRecord(reminderReceiptId(record));
|
|
47
|
+
return current
|
|
48
|
+
? handlers.onConfirm(current)
|
|
49
|
+
: Promise.reject(new Error('消息提醒已更新,请使用当前消息操作'));
|
|
50
|
+
},
|
|
51
|
+
onDisplayed: (record) => {
|
|
52
|
+
const current = currentRecord(reminderReceiptId(record));
|
|
53
|
+
if (current)
|
|
54
|
+
handlers.onDisplayed(current);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { InboxMessage } from '../services/MessageController.js';
|
|
2
|
+
interface ReminderPage {
|
|
3
|
+
total?: number;
|
|
4
|
+
records?: InboxMessage[];
|
|
5
|
+
}
|
|
6
|
+
interface ReminderScanOptions {
|
|
7
|
+
complete: boolean;
|
|
8
|
+
scanPage: number;
|
|
9
|
+
loadPage: (current: number) => Promise<ReminderPage | undefined>;
|
|
10
|
+
accept: (records: InboxMessage[]) => void;
|
|
11
|
+
isCurrent: () => boolean;
|
|
12
|
+
}
|
|
13
|
+
/** 无实时连接时沿用两页历史轮转;完整同步成功后才返回全量收件标识用于已读对账。 */
|
|
14
|
+
export declare function scanMessageReminderPages(options: ReminderScanOptions): Promise<{
|
|
15
|
+
unreadIds: string[];
|
|
16
|
+
scanPage: number;
|
|
17
|
+
} | undefined>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** 无实时连接时沿用两页历史轮转;完整同步成功后才返回全量收件标识用于已读对账。 */
|
|
2
|
+
export async function scanMessageReminderPages(options) {
|
|
3
|
+
const first = await options.loadPage(1);
|
|
4
|
+
if (!options.isCurrent())
|
|
5
|
+
return;
|
|
6
|
+
if (!first)
|
|
7
|
+
throw new Error('消息提醒分页未返回数据');
|
|
8
|
+
const unreadIds = [];
|
|
9
|
+
const accept = (page) => {
|
|
10
|
+
const records = page?.records || [];
|
|
11
|
+
unreadIds.push(...records.map((record) => record.receiptId || record.id));
|
|
12
|
+
options.accept(records);
|
|
13
|
+
};
|
|
14
|
+
accept(first);
|
|
15
|
+
const lastPage = Math.ceil((first?.total || 0) / 100);
|
|
16
|
+
let scanPage = options.scanPage;
|
|
17
|
+
const count = options.complete
|
|
18
|
+
? Math.max(0, lastPage - 1)
|
|
19
|
+
: Math.min(2, Math.max(0, lastPage - 1));
|
|
20
|
+
for (let extra = 0; extra < count; extra++) {
|
|
21
|
+
if (!options.isCurrent())
|
|
22
|
+
return;
|
|
23
|
+
if (scanPage > lastPage)
|
|
24
|
+
scanPage = 2;
|
|
25
|
+
const page = await options.loadPage(options.complete ? extra + 2 : scanPage++);
|
|
26
|
+
if (!options.isCurrent())
|
|
27
|
+
return;
|
|
28
|
+
if (!page)
|
|
29
|
+
throw new Error('消息提醒分页未返回数据');
|
|
30
|
+
accept(page);
|
|
31
|
+
}
|
|
32
|
+
return { unreadIds, scanPage };
|
|
33
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { InboxMessage } from '../services/MessageController.js';
|
|
2
|
+
export declare const reminderReceiptId: (record: InboxMessage) => string;
|
|
3
|
+
export declare const isReminderPreview: (record: InboxMessage) => boolean;
|
|
4
|
+
/** 按本地浏览器时区解释后端的无时区时间,和收件箱的时间展示保持一致。 */
|
|
5
|
+
export declare function reminderHasExpired(record: InboxMessage, now?: number): boolean;
|
|
6
|
+
/** 级别只影响队列优先级,不改变客户配置的展示方式。 */
|
|
7
|
+
export declare function compareReminders(left: InboxMessage, right: InboxMessage): number;
|
|
8
|
+
/**
|
|
9
|
+
* 独立于 React 的当前账号提醒队列。
|
|
10
|
+
* 轮询重复返回同一收件记录时不会重复创建提醒,尚未真正显示的排队消息不能提前记为已提醒。
|
|
11
|
+
*/
|
|
12
|
+
export declare class ReminderQueue {
|
|
13
|
+
private capacity;
|
|
14
|
+
private records;
|
|
15
|
+
private seen;
|
|
16
|
+
constructor(remembered?: string[], capacity?: number);
|
|
17
|
+
ingest(messages: InboxMessage[], now?: number): void;
|
|
18
|
+
messages(now?: number): InboxMessage[];
|
|
19
|
+
/** 完整未读投影查询成功后移除缺失记录;分页中途失败时调用方不能进行这一步。 */
|
|
20
|
+
retainUnread(ids: Iterable<string>): void;
|
|
21
|
+
presented(record: InboxMessage): boolean;
|
|
22
|
+
dismiss(id: string): void;
|
|
23
|
+
preview(record: InboxMessage): void;
|
|
24
|
+
remembered(): string[];
|
|
25
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const reminderReceiptId = (record) => record.receiptId || record.id;
|
|
2
|
+
export const isReminderPreview = (record) => reminderReceiptId(record).startsWith('preview:');
|
|
3
|
+
/** 按本地浏览器时区解释后端的无时区时间,和收件箱的时间展示保持一致。 */
|
|
4
|
+
export function reminderHasExpired(record, now = Date.now()) {
|
|
5
|
+
return (!!record.expiresAt &&
|
|
6
|
+
new Date(record.expiresAt.replace(' ', 'T')).getTime() <= now);
|
|
7
|
+
}
|
|
8
|
+
const levelOrder = { NORMAL: 0, IMPORTANT: 1, SEVERE: 2 };
|
|
9
|
+
const timeOf = (record) => {
|
|
10
|
+
const value = new Date((record.createTime || '').replace(' ', 'T')).getTime();
|
|
11
|
+
return Number.isNaN(value) ? 0 : value;
|
|
12
|
+
};
|
|
13
|
+
/** 级别只影响队列优先级,不改变客户配置的展示方式。 */
|
|
14
|
+
export function compareReminders(left, right) {
|
|
15
|
+
return (Number(isReminderPreview(right)) - Number(isReminderPreview(left)) ||
|
|
16
|
+
(levelOrder[right.messageLevel || 'NORMAL'] || 0) -
|
|
17
|
+
(levelOrder[left.messageLevel || 'NORMAL'] || 0) ||
|
|
18
|
+
timeOf(right) - timeOf(left) ||
|
|
19
|
+
reminderReceiptId(left).localeCompare(reminderReceiptId(right)));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 独立于 React 的当前账号提醒队列。
|
|
23
|
+
* 轮询重复返回同一收件记录时不会重复创建提醒,尚未真正显示的排队消息不能提前记为已提醒。
|
|
24
|
+
*/
|
|
25
|
+
export class ReminderQueue {
|
|
26
|
+
constructor(remembered = [], capacity = 1000) {
|
|
27
|
+
this.capacity = capacity;
|
|
28
|
+
this.records = new Map();
|
|
29
|
+
this.seen = new Set(remembered);
|
|
30
|
+
}
|
|
31
|
+
ingest(messages, now = Date.now()) {
|
|
32
|
+
for (const record of messages) {
|
|
33
|
+
const id = reminderReceiptId(record);
|
|
34
|
+
if (!id)
|
|
35
|
+
continue;
|
|
36
|
+
if (record.readAt || reminderHasExpired(record, now)) {
|
|
37
|
+
this.records.delete(id);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!this.records.has(id) && this.seen.has(id))
|
|
41
|
+
continue;
|
|
42
|
+
this.records.set(id, record);
|
|
43
|
+
}
|
|
44
|
+
// 限制内存占用;未展示的低优先级记录被移出队列后仍未被记住,后续扫描可再次接入。
|
|
45
|
+
// 每轮继续读取最新一页,新的严重消息不会被历史普通消息堵住。
|
|
46
|
+
const ordered = this.messages(now);
|
|
47
|
+
for (const record of ordered.slice(this.capacity))
|
|
48
|
+
this.records.delete(reminderReceiptId(record));
|
|
49
|
+
}
|
|
50
|
+
messages(now = Date.now()) {
|
|
51
|
+
for (const [id, record] of this.records) {
|
|
52
|
+
if (record.readAt || reminderHasExpired(record, now))
|
|
53
|
+
this.records.delete(id);
|
|
54
|
+
}
|
|
55
|
+
return [...this.records.values()].sort(compareReminders);
|
|
56
|
+
}
|
|
57
|
+
/** 完整未读投影查询成功后移除缺失记录;分页中途失败时调用方不能进行这一步。 */
|
|
58
|
+
retainUnread(ids) {
|
|
59
|
+
const unread = new Set(ids);
|
|
60
|
+
for (const [id, record] of this.records)
|
|
61
|
+
if (!isReminderPreview(record) && !unread.has(id))
|
|
62
|
+
this.records.delete(id);
|
|
63
|
+
}
|
|
64
|
+
presented(record) {
|
|
65
|
+
// 全屏提醒必须明确确认;刷新页面不能用“曾经显示过”绕过未读确认。
|
|
66
|
+
if (isReminderPreview(record) || record.reminderMode === 'FULLSCREEN')
|
|
67
|
+
return false;
|
|
68
|
+
const id = reminderReceiptId(record);
|
|
69
|
+
const changed = !this.seen.has(id);
|
|
70
|
+
this.seen.add(id);
|
|
71
|
+
return changed;
|
|
72
|
+
}
|
|
73
|
+
dismiss(id) {
|
|
74
|
+
this.records.delete(id);
|
|
75
|
+
if (!id.startsWith('preview:'))
|
|
76
|
+
this.seen.add(id);
|
|
77
|
+
}
|
|
78
|
+
preview(record) {
|
|
79
|
+
for (const [id, item] of this.records)
|
|
80
|
+
if (isReminderPreview(item))
|
|
81
|
+
this.records.delete(id);
|
|
82
|
+
this.records.set(reminderReceiptId(record), record);
|
|
83
|
+
}
|
|
84
|
+
remembered() {
|
|
85
|
+
// 存储仅含收件标识,不存正文、账号凭证或来源业务属性。
|
|
86
|
+
return [...this.seen].slice(-5000);
|
|
87
|
+
}
|
|
88
|
+
}
|