@awiki/dsh-plugin 0.2.4 → 0.3.0-rc.1
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 +131 -22
- package/README.zh.md +110 -20
- package/THIRD_PARTY_NOTICES.md +8 -1
- package/cordis.patch.yml +5 -0
- package/lib/client.js +12534 -2647
- package/lib/client.js.map +1 -1
- package/lib/index.js +2431 -92
- package/lib/model-proxy-contract.js +96 -0
- package/lib/provider.js +4 -1
- package/lib/sdk-adapter-DLhLrLI6.mjs +886 -0
- package/lib/typert.host.js +1269 -534
- package/lib/typert.remote-client.d.ts +47 -1
- package/lib/typert.remote-client.d.ts.map +1 -1
- package/lib/typert.remote-client.js +1254 -141
- package/lib/types/attachment-cache.d.ts +28 -0
- package/lib/types/attachment-cache.d.ts.map +1 -0
- package/lib/types/attachment-cache.js +194 -0
- package/lib/types/attachment-cache.js.map +1 -0
- package/lib/types/client/AwikiGroupDetails.d.ts +16 -0
- package/lib/types/client/AwikiGroupDetails.d.ts.map +1 -0
- package/lib/types/client/AwikiGroupDetails.js +108 -0
- package/lib/types/client/AwikiGroupDetails.js.map +1 -0
- package/lib/types/client/AwikiIdentityAccess.d.ts +21 -0
- package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -0
- package/lib/types/client/AwikiIdentityAccess.js +148 -0
- package/lib/types/client/AwikiIdentityAccess.js.map +1 -0
- package/lib/types/client/AwikiIdentityPage.d.ts +11 -0
- package/lib/types/client/AwikiIdentityPage.d.ts.map +1 -0
- package/lib/types/client/AwikiIdentityPage.js +8 -0
- package/lib/types/client/AwikiIdentityPage.js.map +1 -0
- package/lib/types/client/AwikiMail.d.ts +15 -0
- package/lib/types/client/AwikiMail.d.ts.map +1 -0
- package/lib/types/client/AwikiMail.js +380 -0
- package/lib/types/client/AwikiMail.js.map +1 -0
- package/lib/types/client/AwikiOnboarding.d.ts +22 -0
- package/lib/types/client/AwikiOnboarding.d.ts.map +1 -0
- package/lib/types/client/AwikiOnboarding.js +104 -0
- package/lib/types/client/AwikiOnboarding.js.map +1 -0
- package/lib/types/client/AwikiOverlay.d.ts +10 -0
- package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
- package/lib/types/client/AwikiOverlay.js +370 -127
- package/lib/types/client/AwikiOverlay.js.map +1 -1
- package/lib/types/client/AwikiProfileCard.d.ts +9 -0
- package/lib/types/client/AwikiProfileCard.d.ts.map +1 -0
- package/lib/types/client/AwikiProfileCard.js +90 -0
- package/lib/types/client/AwikiProfileCard.js.map +1 -0
- package/lib/types/client/AwikiRecoveryForm.d.ts +24 -0
- package/lib/types/client/AwikiRecoveryForm.d.ts.map +1 -0
- package/lib/types/client/AwikiRecoveryForm.js +148 -0
- package/lib/types/client/AwikiRecoveryForm.js.map +1 -0
- package/lib/types/client/AwikiSettingsSection.d.ts +18 -4
- package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
- package/lib/types/client/AwikiSettingsSection.js +277 -23
- package/lib/types/client/AwikiSettingsSection.js.map +1 -1
- package/lib/types/client/MentionText.d.ts +7 -0
- package/lib/types/client/MentionText.d.ts.map +1 -0
- package/lib/types/client/MentionText.js +10 -0
- package/lib/types/client/MentionText.js.map +1 -0
- package/lib/types/client/RechargeComingSoonDialog.d.ts +11 -0
- package/lib/types/client/RechargeComingSoonDialog.d.ts.map +1 -0
- package/lib/types/client/RechargeComingSoonDialog.js +8 -0
- package/lib/types/client/RechargeComingSoonDialog.js.map +1 -0
- package/lib/types/client/controller.d.ts +174 -4
- package/lib/types/client/controller.d.ts.map +1 -1
- package/lib/types/client/controller.js +975 -79
- package/lib/types/client/controller.js.map +1 -1
- package/lib/types/client/image-cache.d.ts +20 -0
- package/lib/types/client/image-cache.d.ts.map +1 -0
- package/lib/types/client/image-cache.js +177 -0
- package/lib/types/client/image-cache.js.map +1 -0
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +102 -42
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/mail-list-cache.d.ts +15 -0
- package/lib/types/client/mail-list-cache.d.ts.map +1 -0
- package/lib/types/client/mail-list-cache.js +212 -0
- package/lib/types/client/mail-list-cache.js.map +1 -0
- package/lib/types/client/mentions.d.ts +51 -0
- package/lib/types/client/mentions.d.ts.map +1 -0
- package/lib/types/client/mentions.js +165 -0
- package/lib/types/client/mentions.js.map +1 -0
- package/lib/types/client/model-availability-controller.d.ts +27 -0
- package/lib/types/client/model-availability-controller.d.ts.map +1 -0
- package/lib/types/client/model-availability-controller.js +111 -0
- package/lib/types/client/model-availability-controller.js.map +1 -0
- package/lib/types/client/model-proxy-controller.d.ts +45 -0
- package/lib/types/client/model-proxy-controller.d.ts.map +1 -0
- package/lib/types/client/model-proxy-controller.js +306 -0
- package/lib/types/client/model-proxy-controller.js.map +1 -0
- package/lib/types/client/recharge-availability.d.ts +6 -0
- package/lib/types/client/recharge-availability.d.ts.map +1 -0
- package/lib/types/client/recharge-availability.js +6 -0
- package/lib/types/client/recharge-availability.js.map +1 -0
- package/lib/types/client/settings-locales.d.ts +1 -1
- package/lib/types/client/settings-locales.d.ts.map +1 -1
- package/lib/types/client/settings-locales.js +186 -8
- package/lib/types/client/settings-locales.js.map +1 -1
- package/lib/types/client/slots.d.ts +34 -2
- package/lib/types/client/slots.d.ts.map +1 -1
- package/lib/types/index.d.ts +95 -7
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +968 -37
- package/lib/types/index.js.map +1 -1
- package/lib/types/listener-state.d.ts +23 -0
- package/lib/types/listener-state.d.ts.map +1 -0
- package/lib/types/listener-state.js +124 -0
- package/lib/types/listener-state.js.map +1 -0
- package/lib/types/listener.d.ts +96 -0
- package/lib/types/listener.d.ts.map +1 -0
- package/lib/types/listener.js +575 -0
- package/lib/types/listener.js.map +1 -0
- package/lib/types/mail.d.ts +8 -0
- package/lib/types/mail.d.ts.map +1 -0
- package/lib/types/mail.js +75 -0
- package/lib/types/mail.js.map +1 -0
- package/lib/types/model-proxy-contract.d.ts +67 -0
- package/lib/types/model-proxy-contract.d.ts.map +1 -0
- package/lib/types/model-proxy-contract.js +135 -0
- package/lib/types/model-proxy-contract.js.map +1 -0
- package/lib/types/provider-api.d.ts +106 -2
- package/lib/types/provider-api.d.ts.map +1 -1
- package/lib/types/provider.d.ts.map +1 -1
- package/lib/types/provider.js +3 -0
- package/lib/types/provider.js.map +1 -1
- package/lib/types/sdk-adapter.d.ts +43 -2
- package/lib/types/sdk-adapter.d.ts.map +1 -1
- package/lib/types/sdk-adapter.js +622 -24
- package/lib/types/sdk-adapter.js.map +1 -1
- package/lib/types/sent-mail-store.d.ts +22 -0
- package/lib/types/sent-mail-store.d.ts.map +1 -0
- package/lib/types/sent-mail-store.js +246 -0
- package/lib/types/sent-mail-store.js.map +1 -0
- package/lib/types/tools.d.ts +10 -0
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/tools.js +98 -0
- package/lib/types/tools.js.map +1 -1
- package/lib/types/types.d.ts +254 -2
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js.map +1 -1
- package/package.json +31 -17
- package/lib/sdk-adapter-DK6RqV-c.mjs +0 -355
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** React-free browser controller for the deployment's one AWiki identity. */
|
|
2
|
+
import { IndexedDbAwikiBrowserImageCache, } from "./image-cache.js";
|
|
2
3
|
/** Turn a registration rejection into an actionable message without exposing remote response text. */
|
|
3
4
|
function registrationFailureMessage(failure) {
|
|
4
5
|
switch (failure.code) {
|
|
@@ -11,7 +12,7 @@ function registrationFailureMessage(failure) {
|
|
|
11
12
|
case 'challenge-expired':
|
|
12
13
|
return '验证码状态已失效,请重新获取验证码后再注册。';
|
|
13
14
|
case 'handle-unavailable':
|
|
14
|
-
return '该 Handle
|
|
15
|
+
return '该 Handle 刚刚已被注册。请返回身份入口,再按恢复流程重新获取验证码。';
|
|
15
16
|
case 'conflict':
|
|
16
17
|
return '注册冲突:服务端可能已收到上次注册请求,或该手机号 / Handle 已绑定其他身份。请保留当前页面并再次提交;若仍失败,请勿清除本机身份数据,联系管理员并提供失败时间。';
|
|
17
18
|
case 'rate-limited':
|
|
@@ -43,19 +44,40 @@ function registrationOtpFailureMessage(failure) {
|
|
|
43
44
|
return registrationFailureMessage(failure);
|
|
44
45
|
}
|
|
45
46
|
}
|
|
47
|
+
function identityAccessInspectionFailureMessage(failure) {
|
|
48
|
+
switch (failure.code) {
|
|
49
|
+
case 'invalid-request':
|
|
50
|
+
return 'Handle 格式不正确,请检查后重试。';
|
|
51
|
+
case 'network':
|
|
52
|
+
return '无法连接 AWiki 服务,暂时不能确认该 Handle 是否已经存在。';
|
|
53
|
+
case 'remote':
|
|
54
|
+
return 'AWiki 服务暂时无法确认该 Handle 的状态,请稍后重试。';
|
|
55
|
+
default:
|
|
56
|
+
return `${failure.code}:${failure.message}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
46
59
|
const INITIAL_VIEW = Object.freeze({
|
|
47
60
|
status: 'cold',
|
|
48
61
|
sessionStatus: 'unregistered',
|
|
49
62
|
identity: null,
|
|
63
|
+
profile: null,
|
|
50
64
|
conversations: Object.freeze([]),
|
|
51
65
|
conversationsHasMore: false,
|
|
52
66
|
selectedConversationId: null,
|
|
67
|
+
selectedGroup: null,
|
|
68
|
+
groupMembers: Object.freeze([]),
|
|
69
|
+
groupMembersHasMore: false,
|
|
70
|
+
groupRecovery: null,
|
|
53
71
|
messages: Object.freeze([]),
|
|
54
72
|
historyHasMore: false,
|
|
73
|
+
localPending: false,
|
|
74
|
+
refreshing: false,
|
|
55
75
|
pending: null,
|
|
56
76
|
error: null,
|
|
57
77
|
attachmentMaxBytes: 0,
|
|
58
78
|
summaries: Object.freeze({}),
|
|
79
|
+
recoveryOperationId: null,
|
|
80
|
+
recoveryProgress: null,
|
|
59
81
|
});
|
|
60
82
|
/** Turn a closed Host summary failure into one actionable Chinese message. */
|
|
61
83
|
function summaryFailureMessage(failure) {
|
|
@@ -68,12 +90,72 @@ function summaryFailureMessage(failure) {
|
|
|
68
90
|
default: return `${failure.code}:${failure.message}`;
|
|
69
91
|
}
|
|
70
92
|
}
|
|
93
|
+
/** Turn closed mail failures into safe, actionable browser messages. */
|
|
94
|
+
function mailFailureMessage(failure) {
|
|
95
|
+
switch (failure.code) {
|
|
96
|
+
case 'invalid-request': return '邮件信息不完整或格式不正确,请检查后重试。';
|
|
97
|
+
case 'not-registered': return '请先注册 AWiki 身份后使用邮箱。';
|
|
98
|
+
case 'signed-out': return '当前 AWiki 身份已退出,请重新进入后使用邮箱。';
|
|
99
|
+
case 'forbidden': return '当前 AWiki 身份没有执行此邮件操作的权限。';
|
|
100
|
+
case 'not-found': return '该邮件不存在或已经不可访问。';
|
|
101
|
+
case 'rate-limited': return '邮件请求过于频繁,请稍后重试。';
|
|
102
|
+
case 'delivery-unknown': return '发送结果未知,请先检查已发送邮件再决定是否重试。';
|
|
103
|
+
case 'network': return '无法连接 AWiki 邮件服务,请检查网络后重试。';
|
|
104
|
+
default: return 'AWiki 邮件服务暂时不可用,请稍后重试。';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/** Turn a rejected group create into a concrete next step instead of exposing Host error text. */
|
|
108
|
+
function groupCreateFailureMessage(failure) {
|
|
109
|
+
switch (failure.code) {
|
|
110
|
+
case 'invalid-request': return '群聊名称或首批成员格式不正确,请检查后重试。';
|
|
111
|
+
case 'not-registered': return '请先注册 AWiki 身份后创建群聊。';
|
|
112
|
+
case 'signed-out': return '当前 AWiki 身份已退出,请重新进入后创建群聊。';
|
|
113
|
+
case 'forbidden': return '当前 AWiki 身份没有创建群聊的权限。';
|
|
114
|
+
case 'rate-limited': return '创建群聊过于频繁,请稍后重试。';
|
|
115
|
+
case 'network': return '无法连接 AWiki 服务,请检查网络后重试。';
|
|
116
|
+
case 'conflict': return '群聊创建状态发生冲突,请刷新群聊列表后确认是否已经创建。';
|
|
117
|
+
case 'remote': return 'AWiki 服务暂时无法创建群聊,请稍后重试。';
|
|
118
|
+
default: return `${failure.code}:${failure.message}`;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Keep group authorization failures actionable without exposing raw service text. */
|
|
122
|
+
function groupReadFailureMessage(failure) {
|
|
123
|
+
switch (failure.code) {
|
|
124
|
+
case 'group-membership-required':
|
|
125
|
+
return '当前身份暂时无法访问这个群聊。若刚完成身份恢复,请返回会话列表重试群聊身份恢复;若持续出现,可能已不再是群成员。';
|
|
126
|
+
case 'group-identity-stale':
|
|
127
|
+
return '群聊身份正在恢复,请稍候后重试。';
|
|
128
|
+
case 'not-found':
|
|
129
|
+
return '这个群聊不存在,或当前身份已经无法访问。';
|
|
130
|
+
case 'forbidden':
|
|
131
|
+
return '当前身份没有访问这个群聊的权限。';
|
|
132
|
+
case 'network':
|
|
133
|
+
return '无法连接 AWiki 群聊服务,请检查网络后重试。';
|
|
134
|
+
case 'remote':
|
|
135
|
+
return 'AWiki 暂时无法读取这个群聊,请稍后重试。';
|
|
136
|
+
default:
|
|
137
|
+
return `${failure.code}:${failure.message}`;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function groupRecoveryFailureMessage(failure) {
|
|
141
|
+
return failure.code === 'network'
|
|
142
|
+
? '无法连接 AWiki 服务,暂时不能恢复旧群聊身份。'
|
|
143
|
+
: '暂时不能检查旧群聊身份,请稍后重试。';
|
|
144
|
+
}
|
|
145
|
+
function groupRecoveryView(summary) {
|
|
146
|
+
if (summary.blocked > 0) {
|
|
147
|
+
return { status: 'blocked', pending: summary.pending, blocked: summary.blocked };
|
|
148
|
+
}
|
|
149
|
+
return summary.pending > 0
|
|
150
|
+
? { status: 'pending', pending: summary.pending, blocked: 0 }
|
|
151
|
+
: null;
|
|
152
|
+
}
|
|
71
153
|
/** Flatten the carrier and business result once for every controller caller. */
|
|
72
|
-
async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`) {
|
|
154
|
+
async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`, carrierFailureMessage = message => `连接 AWiki Host 失败:${message}`) {
|
|
73
155
|
try {
|
|
74
156
|
const carried = await operation();
|
|
75
157
|
if (!carried.ok)
|
|
76
|
-
return { ok: false, error:
|
|
158
|
+
return { ok: false, error: carrierFailureMessage(carried.error.message) };
|
|
77
159
|
if (!carried.value.ok) {
|
|
78
160
|
return { ok: false, error: failureMessage(carried.value.error) };
|
|
79
161
|
}
|
|
@@ -86,6 +168,11 @@ async function call(operation, failureMessage = failure => `${failure.code}:${
|
|
|
86
168
|
};
|
|
87
169
|
}
|
|
88
170
|
}
|
|
171
|
+
function recoveryCarrierFailureMessage(message) {
|
|
172
|
+
return message.includes('business result failed boundary validation')
|
|
173
|
+
? '恢复信息已验证,但暂时无法读取恢复状态。请稍后重试。'
|
|
174
|
+
: `连接 AWiki Host 失败:${message}`;
|
|
175
|
+
}
|
|
89
176
|
/** Append unique values while retaining existing references. */
|
|
90
177
|
function appendUnique(current, incoming, id) {
|
|
91
178
|
const seen = new Set(current.map(id));
|
|
@@ -99,18 +186,75 @@ function appendUnique(current, incoming, id) {
|
|
|
99
186
|
}
|
|
100
187
|
return [...current, ...appended];
|
|
101
188
|
}
|
|
102
|
-
/**
|
|
103
|
-
function
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
189
|
+
/** Keep one last-wins value per canonical id without changing the page's order. */
|
|
190
|
+
function canonicalMessagePage(incoming) {
|
|
191
|
+
const byId = new Map();
|
|
192
|
+
for (const message of incoming)
|
|
193
|
+
byId.set(message.id, message);
|
|
194
|
+
return [...byId.values()];
|
|
195
|
+
}
|
|
196
|
+
/** Replace the newest loaded window while preserving older messages before it. */
|
|
197
|
+
function mergeLatestMessages(current, incoming) {
|
|
198
|
+
const page = canonicalMessagePage(incoming);
|
|
199
|
+
const incomingIds = new Set(page.map(message => message.id));
|
|
200
|
+
return [...current.filter(message => !incomingIds.has(message.id)), ...page];
|
|
201
|
+
}
|
|
202
|
+
/** Prepend one chronological continuation page while exact-updating any overlap. */
|
|
203
|
+
function mergeOlderMessages(current, incoming) {
|
|
204
|
+
const page = canonicalMessagePage(incoming);
|
|
205
|
+
const incomingIds = new Set(page.map(message => message.id));
|
|
206
|
+
return [...page, ...current.filter(message => !incomingIds.has(message.id))];
|
|
207
|
+
}
|
|
208
|
+
/** Append a newly committed message, or enrich its existing canonical row in place. */
|
|
209
|
+
function appendMessageById(current, incoming) {
|
|
210
|
+
const index = current.findIndex(message => message.id === incoming.id);
|
|
211
|
+
if (index < 0)
|
|
212
|
+
return [...current, incoming];
|
|
213
|
+
return current.map((message, currentIndex) => currentIndex === index ? incoming : message);
|
|
214
|
+
}
|
|
215
|
+
/** Explain a background failure without implying that visible local messages were lost. */
|
|
216
|
+
function refreshFailureMessage(messages, error) {
|
|
217
|
+
return messages.length > 0 ? `刷新失败,当前显示本地数据。${error}` : error;
|
|
218
|
+
}
|
|
219
|
+
/** Reject a page that attempts to cross the selected canonical conversation boundary. */
|
|
220
|
+
function pageBelongsToConversation(conversationId, messages) {
|
|
221
|
+
return messages.every(message => message.conversationId === conversationId);
|
|
222
|
+
}
|
|
223
|
+
function timingStart() {
|
|
224
|
+
return globalThis.performance?.now() ?? Date.now();
|
|
225
|
+
}
|
|
226
|
+
function clearConversationTimings() {
|
|
227
|
+
const performance = globalThis.performance;
|
|
228
|
+
if (performance === undefined)
|
|
229
|
+
return;
|
|
230
|
+
try {
|
|
231
|
+
for (const name of [
|
|
232
|
+
'conversation.select.local_timeline_ms',
|
|
233
|
+
'conversation.select.first_paint_ms',
|
|
234
|
+
'conversation.select.remote_history_ms',
|
|
235
|
+
])
|
|
236
|
+
performance.clearMeasures(name);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Performance measurement is optional in restricted browser/test runtimes.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/** Keep only one secret-free development measure for each selected-conversation phase. */
|
|
243
|
+
function recordTiming(name, startedAt, success) {
|
|
244
|
+
const performance = globalThis.performance;
|
|
245
|
+
if (performance === undefined)
|
|
246
|
+
return;
|
|
247
|
+
try {
|
|
248
|
+
performance.clearMeasures(name);
|
|
249
|
+
performance.measure(name, {
|
|
250
|
+
start: startedAt,
|
|
251
|
+
end: performance.now(),
|
|
252
|
+
detail: { success, count: 1 },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
// Performance measurement is optional in restricted browser/test runtimes.
|
|
112
257
|
}
|
|
113
|
-
return [...prepended, ...current];
|
|
114
258
|
}
|
|
115
259
|
/** Strip surrounding space and a leading @ from a Handle the user typed. */
|
|
116
260
|
function normalizeHandle(value) {
|
|
@@ -122,6 +266,34 @@ function sameIdentity(identity, peer) {
|
|
|
122
266
|
const target = peer.toLowerCase();
|
|
123
267
|
return own === target || own.startsWith(`${target}.`) || target.startsWith(`${own}.`);
|
|
124
268
|
}
|
|
269
|
+
/** Bounded readiness window for a newly projected group conversation. */
|
|
270
|
+
const GROUP_HISTORY_RETRY_DELAYS_MS = [250, 750, 1_500, 2_500];
|
|
271
|
+
/** Runtime-only decoded-byte budget that prevents repeat Host calls while browsing. */
|
|
272
|
+
const BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
273
|
+
const RECOVERY_OPERATION_STORAGE_KEY = 'awiki.handle-recovery.operation.v1';
|
|
274
|
+
function storedRecoveryOperation() {
|
|
275
|
+
try {
|
|
276
|
+
return globalThis.localStorage?.getItem(RECOVERY_OPERATION_STORAGE_KEY) ?? null;
|
|
277
|
+
}
|
|
278
|
+
catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function storeRecoveryOperation(operationId) {
|
|
283
|
+
try {
|
|
284
|
+
if (operationId === null)
|
|
285
|
+
globalThis.localStorage?.removeItem(RECOVERY_OPERATION_STORAGE_KEY);
|
|
286
|
+
else
|
|
287
|
+
globalThis.localStorage?.setItem(RECOVERY_OPERATION_STORAGE_KEY, operationId);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// Recovery remains durable in Core even when browser storage is unavailable.
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Wait between group-history readiness probes without retaining controller state. */
|
|
294
|
+
function delay(milliseconds) {
|
|
295
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
296
|
+
}
|
|
125
297
|
/** Keys that can identify one direct peer in the current roster. */
|
|
126
298
|
function directPeerKeys(conversation) {
|
|
127
299
|
const keys = [conversation.peerDid, conversation.title];
|
|
@@ -136,18 +308,23 @@ function findDirect(conversations, peer) {
|
|
|
136
308
|
const key = peer.toLowerCase();
|
|
137
309
|
return conversations.find((conversation) => (conversation.kind === 'direct' && directPeerKeys(conversation).includes(key)));
|
|
138
310
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
311
|
+
function groupMemberKey(member) {
|
|
312
|
+
return member.membershipId ?? member.did ?? member.credentialDid ?? member.handle ?? `${member.role ?? ''}:${member.joinedAt ?? ''}`;
|
|
313
|
+
}
|
|
314
|
+
/** True when a group title contains presentation data instead of a protocol fallback. */
|
|
315
|
+
function hasDisplayableGroupTitle(conversation) {
|
|
316
|
+
if (conversation.kind !== 'group')
|
|
317
|
+
return false;
|
|
318
|
+
const title = conversation.title.trim();
|
|
319
|
+
return title !== '' && title !== conversation.groupDid && title !== conversation.id;
|
|
320
|
+
}
|
|
321
|
+
/** True when a direct title is richer than its routing identifiers. */
|
|
322
|
+
function hasDisplayableDirectTitle(conversation) {
|
|
323
|
+
const title = conversation.title.trim().replace(/^@/u, '');
|
|
324
|
+
if (title === '')
|
|
325
|
+
return false;
|
|
326
|
+
return ![conversation.id, conversation.peerDid, conversation.peerHandle]
|
|
327
|
+
.some(value => value !== undefined && value.trim().replace(/^@/u, '') === title);
|
|
151
328
|
}
|
|
152
329
|
/** Resolve one listed conversation into the send target accepted by AWiki. */
|
|
153
330
|
function targetOf(conversation) {
|
|
@@ -158,21 +335,37 @@ function targetOf(conversation) {
|
|
|
158
335
|
/** Browser object layer for identity, conversations, history, and polling. */
|
|
159
336
|
export class AwikiController {
|
|
160
337
|
remote;
|
|
338
|
+
persistentImageCache;
|
|
161
339
|
view = INITIAL_VIEW;
|
|
162
340
|
listeners = new Set();
|
|
163
341
|
config = null;
|
|
164
342
|
conversationsCursor;
|
|
165
343
|
historyCursor;
|
|
344
|
+
groupMembersCursor;
|
|
166
345
|
timer;
|
|
167
346
|
generation = 0;
|
|
347
|
+
selectionRevision = 0;
|
|
168
348
|
disposed = false;
|
|
169
349
|
polling = false;
|
|
170
350
|
markReadInFlight = new Map();
|
|
171
351
|
unreadAtOpen = new Map();
|
|
172
352
|
summaryBaselines = new Map();
|
|
173
|
-
/**
|
|
174
|
-
|
|
353
|
+
/** Last trustworthy direct profile for the active identity, keyed by canonical peer DID. */
|
|
354
|
+
directProfiles = new Map();
|
|
355
|
+
/** Last trustworthy group title for the active identity, keyed by canonical Group DID. */
|
|
356
|
+
groupTitles = new Map();
|
|
357
|
+
/** Verified image payloads retained outside observable state for instant remounts. */
|
|
358
|
+
imageAttachments = new Map();
|
|
359
|
+
imageAttachmentCacheBytes = 0;
|
|
360
|
+
presentationCacheOwnerDid = null;
|
|
361
|
+
groupRecoveryOperation;
|
|
362
|
+
/**
|
|
363
|
+
* @param remote - generated Host Remote namespace.
|
|
364
|
+
* @param persistentImageCache - browser-origin verified preview cache.
|
|
365
|
+
*/
|
|
366
|
+
constructor(remote, persistentImageCache = new IndexedDbAwikiBrowserImageCache()) {
|
|
175
367
|
this.remote = remote;
|
|
368
|
+
this.persistentImageCache = persistentImageCache;
|
|
176
369
|
}
|
|
177
370
|
/** Return the cached immutable view. */
|
|
178
371
|
getSnapshot = () => this.view;
|
|
@@ -181,11 +374,8 @@ export class AwikiController {
|
|
|
181
374
|
this.listeners.add(listener);
|
|
182
375
|
return () => { this.listeners.delete(listener); };
|
|
183
376
|
};
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
* @returns successful readiness or one display-safe Host failure.
|
|
187
|
-
*/
|
|
188
|
-
async open() {
|
|
377
|
+
/** Load Host policy and the shared identity state without starting drawer polling. */
|
|
378
|
+
async loadSession() {
|
|
189
379
|
if (this.disposed)
|
|
190
380
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
191
381
|
this.close();
|
|
@@ -204,21 +394,52 @@ export class AwikiController {
|
|
|
204
394
|
if (!session.ok)
|
|
205
395
|
return this.fail(session.error);
|
|
206
396
|
const identity = session.value.status === 'active' ? session.value.identity : null;
|
|
397
|
+
this.activatePresentationCache(identity);
|
|
207
398
|
this.publish({
|
|
208
399
|
...this.view,
|
|
209
400
|
status: 'ready',
|
|
210
401
|
sessionStatus: session.value.status,
|
|
211
|
-
identity,
|
|
402
|
+
identity: session.value.status === 'active' ? session.value.identity : null,
|
|
212
403
|
error: null,
|
|
213
404
|
attachmentMaxBytes: config.value.attachmentMaxBytes,
|
|
405
|
+
recoveryOperationId: storedRecoveryOperation(),
|
|
214
406
|
});
|
|
215
407
|
if (identity !== null) {
|
|
216
|
-
const
|
|
408
|
+
const profile = await call(() => this.remote.getProfile());
|
|
409
|
+
if (this.current(generation) && profile.ok)
|
|
410
|
+
this.publish({ ...this.view, profile: profile.value });
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
const operationId = storedRecoveryOperation();
|
|
414
|
+
if (operationId !== null) {
|
|
415
|
+
const recovery = await call(() => this.remote.getRecoveryStatus({ operationId }));
|
|
416
|
+
if (this.current(generation) && recovery.ok) {
|
|
417
|
+
this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: recovery.value });
|
|
418
|
+
if (recovery.value.phase === 'applied') {
|
|
419
|
+
storeRecoveryOperation(null);
|
|
420
|
+
return this.loadSession();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return { ok: true, value: undefined };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Load Host policy and identity, then start polling while the drawer remains open.
|
|
429
|
+
* @returns successful readiness or one display-safe Host failure.
|
|
430
|
+
*/
|
|
431
|
+
async open() {
|
|
432
|
+
const loaded = await this.loadSession();
|
|
433
|
+
if (!loaded.ok)
|
|
434
|
+
return loaded;
|
|
435
|
+
const generation = this.generation;
|
|
436
|
+
if (this.view.identity !== null) {
|
|
437
|
+
const listed = await this.refreshConversationsAndRecoverGroups(generation);
|
|
217
438
|
if (!listed.ok)
|
|
218
439
|
return listed;
|
|
219
440
|
}
|
|
220
441
|
if (this.current(generation)) {
|
|
221
|
-
this.timer = setInterval(() => { void this.poll(generation); }, this.config
|
|
442
|
+
this.timer = setInterval(() => { void this.poll(generation); }, this.config?.pollIntervalMs ?? 3_000);
|
|
222
443
|
}
|
|
223
444
|
return { ok: true, value: undefined };
|
|
224
445
|
}
|
|
@@ -233,6 +454,7 @@ export class AwikiController {
|
|
|
233
454
|
this.conversationsCursor = undefined;
|
|
234
455
|
this.historyCursor = undefined;
|
|
235
456
|
this.summaryBaselines.clear();
|
|
457
|
+
this.clearPresentationCache();
|
|
236
458
|
this.publish({
|
|
237
459
|
...INITIAL_VIEW,
|
|
238
460
|
status: 'ready',
|
|
@@ -256,11 +478,13 @@ export class AwikiController {
|
|
|
256
478
|
/** Stop polling and invalidate all in-flight drawer work. */
|
|
257
479
|
close() {
|
|
258
480
|
this.generation += 1;
|
|
481
|
+
this.selectionRevision += 1;
|
|
259
482
|
if (this.timer !== undefined)
|
|
260
483
|
clearInterval(this.timer);
|
|
261
484
|
this.timer = undefined;
|
|
262
485
|
this.polling = false;
|
|
263
486
|
this.markReadInFlight.clear();
|
|
487
|
+
this.groupMembersCursor = undefined;
|
|
264
488
|
}
|
|
265
489
|
/**
|
|
266
490
|
* Request one phone verification challenge.
|
|
@@ -270,6 +494,10 @@ export class AwikiController {
|
|
|
270
494
|
async sendRegistrationOtp(request) {
|
|
271
495
|
return this.withPending('发送验证码', () => call(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
|
|
272
496
|
}
|
|
497
|
+
/** Classify one Handle before sending exactly one registration or recovery OTP. */
|
|
498
|
+
async inspectIdentityAccess(request) {
|
|
499
|
+
return this.withPending('检查身份', () => call(() => this.remote.inspectIdentityAccess(request), identityAccessInspectionFailureMessage));
|
|
500
|
+
}
|
|
273
501
|
/**
|
|
274
502
|
* Register the deployment identity and populate the initial conversation list.
|
|
275
503
|
* @param request - verified Handle, phone number, and one-time code.
|
|
@@ -282,8 +510,9 @@ export class AwikiController {
|
|
|
282
510
|
return result;
|
|
283
511
|
if (!this.current(generation))
|
|
284
512
|
return result;
|
|
513
|
+
this.activatePresentationCache(result.value);
|
|
285
514
|
this.publish({ ...this.view, sessionStatus: 'active', identity: result.value, error: null });
|
|
286
|
-
await this.
|
|
515
|
+
await this.refreshConversationsAndRecoverGroups(generation);
|
|
287
516
|
return result;
|
|
288
517
|
}
|
|
289
518
|
/**
|
|
@@ -305,6 +534,135 @@ export class AwikiController {
|
|
|
305
534
|
this.publish({ ...this.view, identity: result.value, error: null });
|
|
306
535
|
return result;
|
|
307
536
|
}
|
|
537
|
+
/** Save all supported public profile fields and keep identity/profile projections aligned. */
|
|
538
|
+
async updateProfile(request) {
|
|
539
|
+
const displayName = request.displayName.trim();
|
|
540
|
+
const bio = request.bio.trim();
|
|
541
|
+
const tags = request.tags.map(tag => tag.trim()).filter(tag => tag !== '');
|
|
542
|
+
if (displayName === '' || Array.from(displayName).length > 50)
|
|
543
|
+
return this.fail('昵称需要填写且不能超过 50 个字符');
|
|
544
|
+
if (Array.from(bio).length > 100)
|
|
545
|
+
return this.fail('个人简介不能超过 100 个字符');
|
|
546
|
+
if (tags.length > 5 || new Set(tags.map(tag => tag.toLocaleLowerCase())).size !== tags.length) {
|
|
547
|
+
return this.fail('最多填写 5 个不重复的标签');
|
|
548
|
+
}
|
|
549
|
+
const generation = this.generation;
|
|
550
|
+
const result = await this.withPending('保存资料', () => call(() => this.remote.updateProfile({ displayName, bio, tags })));
|
|
551
|
+
if (!result.ok || !this.current(generation))
|
|
552
|
+
return result;
|
|
553
|
+
const identity = this.view.identity === null ? null : { ...this.view.identity, displayName: result.value.displayName };
|
|
554
|
+
this.publish({ ...this.view, identity, profile: result.value, error: null });
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
/** Request a recovery OTP and persist only its secret-free operation id in the browser. */
|
|
558
|
+
async sendRecoveryOtp(request) {
|
|
559
|
+
const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage));
|
|
560
|
+
if (!result.ok)
|
|
561
|
+
return result;
|
|
562
|
+
storeRecoveryOperation(result.value.operationId);
|
|
563
|
+
this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null });
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
/** Verify the recovery OTP without attempting the remote identity mutation yet. */
|
|
567
|
+
async prepareRecovery(request) {
|
|
568
|
+
const operationId = this.view.recoveryOperationId;
|
|
569
|
+
if (operationId === null)
|
|
570
|
+
return this.fail('请先获取恢复验证码');
|
|
571
|
+
const result = await this.withPending('验证恢复信息', () => call(() => this.remote.prepareRecovery({ ...request, operationId }), registrationFailureMessage, recoveryCarrierFailureMessage));
|
|
572
|
+
if (result.ok)
|
|
573
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
574
|
+
return result;
|
|
575
|
+
}
|
|
576
|
+
/** Commit the prepared operation once. Unknown outcomes remain available through status refresh. */
|
|
577
|
+
async activateRecovery() {
|
|
578
|
+
const operationId = this.view.recoveryOperationId;
|
|
579
|
+
if (operationId === null)
|
|
580
|
+
return this.fail('没有可继续的身份恢复操作');
|
|
581
|
+
if (this.view.recoveryProgress?.phase !== 'ready_to_commit')
|
|
582
|
+
return this.fail('请先完成恢复信息验证');
|
|
583
|
+
const result = await this.withPending('恢复身份', () => call(() => this.remote.activateRecovery({ operationId }), registrationFailureMessage, recoveryCarrierFailureMessage));
|
|
584
|
+
if (!result.ok)
|
|
585
|
+
return result;
|
|
586
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
587
|
+
if (result.value.phase === 'applied') {
|
|
588
|
+
storeRecoveryOperation(null);
|
|
589
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: result.value });
|
|
590
|
+
await this.open();
|
|
591
|
+
}
|
|
592
|
+
return result;
|
|
593
|
+
}
|
|
594
|
+
/** Refresh Core status without repeating activation. */
|
|
595
|
+
async refreshRecoveryStatus() {
|
|
596
|
+
const operationId = this.view.recoveryOperationId;
|
|
597
|
+
if (operationId === null)
|
|
598
|
+
return this.fail('没有可查询的身份恢复操作');
|
|
599
|
+
const result = await this.withPending('刷新恢复状态', () => call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage));
|
|
600
|
+
if (!result.ok)
|
|
601
|
+
return result;
|
|
602
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
603
|
+
if (result.value.phase === 'applied') {
|
|
604
|
+
storeRecoveryOperation(null);
|
|
605
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: result.value });
|
|
606
|
+
await this.open();
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
/** Resume only a Core-declared retryable or uncertain phase. */
|
|
611
|
+
async resumeRecovery() {
|
|
612
|
+
const operationId = this.view.recoveryOperationId;
|
|
613
|
+
const progress = this.view.recoveryProgress;
|
|
614
|
+
if (operationId === null || progress === null)
|
|
615
|
+
return this.fail('请先刷新恢复状态');
|
|
616
|
+
if (!progress.retryable && !['remote_outcome_unknown', 'remote_committed', 'identity_transition_pending'].includes(progress.phase)) {
|
|
617
|
+
return this.fail('当前恢复状态不能重试');
|
|
618
|
+
}
|
|
619
|
+
const result = await this.withPending('继续恢复身份', () => call(() => this.remote.resumeRecovery({ operationId }), registrationFailureMessage, recoveryCarrierFailureMessage));
|
|
620
|
+
if (!result.ok)
|
|
621
|
+
return result;
|
|
622
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
623
|
+
if (result.value.phase === 'applied') {
|
|
624
|
+
storeRecoveryOperation(null);
|
|
625
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: result.value });
|
|
626
|
+
await this.open();
|
|
627
|
+
}
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
/** Discard only a pre-attempt operation. */
|
|
631
|
+
async discardRecovery() {
|
|
632
|
+
const operationId = this.view.recoveryOperationId;
|
|
633
|
+
if (operationId === null)
|
|
634
|
+
return { ok: true, value: undefined };
|
|
635
|
+
const phase = this.view.recoveryProgress?.phase;
|
|
636
|
+
if (phase !== undefined && phase !== 'awaiting_factor' && phase !== 'ready_to_commit') {
|
|
637
|
+
return this.fail('当前恢复状态不能取消');
|
|
638
|
+
}
|
|
639
|
+
const result = await this.withPending('取消身份恢复', () => call(() => this.remote.discardRecovery({ operationId })));
|
|
640
|
+
if (!result.ok)
|
|
641
|
+
return result;
|
|
642
|
+
storeRecoveryOperation(null);
|
|
643
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null });
|
|
644
|
+
return { ok: true, value: undefined };
|
|
645
|
+
}
|
|
646
|
+
/** Read the active deployment identity's public mailbox state. */
|
|
647
|
+
getMailAccount() {
|
|
648
|
+
return call(() => this.remote.getMailAccount(), mailFailureMessage);
|
|
649
|
+
}
|
|
650
|
+
/** List one browser-requested mailbox page without background polling. */
|
|
651
|
+
listMailInbox(request = {}) {
|
|
652
|
+
return call(() => this.remote.listMailInbox(request), mailFailureMessage);
|
|
653
|
+
}
|
|
654
|
+
/** Read one selected plain-text mail message without marking it read. */
|
|
655
|
+
readMail(request) {
|
|
656
|
+
return call(() => this.remote.readMail(request), mailFailureMessage);
|
|
657
|
+
}
|
|
658
|
+
/** Mark mail read only after the browser supplied an explicit selected id. */
|
|
659
|
+
markMailRead(request) {
|
|
660
|
+
return call(() => this.remote.markMailRead(request), mailFailureMessage);
|
|
661
|
+
}
|
|
662
|
+
/** Send one user-confirmed plain-text mail without retrying. */
|
|
663
|
+
sendMail(request) {
|
|
664
|
+
return call(() => this.remote.sendMail(request), mailFailureMessage);
|
|
665
|
+
}
|
|
308
666
|
/**
|
|
309
667
|
* Load another page of the conversation roster.
|
|
310
668
|
* @returns successful pagination or one display-safe failure.
|
|
@@ -317,13 +675,24 @@ export class AwikiController {
|
|
|
317
675
|
if (!this.current(generation))
|
|
318
676
|
return { ok: true, value: undefined };
|
|
319
677
|
this.conversationsCursor = result.value.nextCursor;
|
|
678
|
+
const conversations = result.value.items.map(incoming => this.cacheConversation(incoming, this.view.conversations.find(current => current.id === incoming.id)));
|
|
320
679
|
this.publish({
|
|
321
680
|
...this.view,
|
|
322
|
-
conversations: appendUnique(this.view.conversations,
|
|
681
|
+
conversations: appendUnique(this.view.conversations, conversations, value => value.id),
|
|
323
682
|
conversationsHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
324
683
|
});
|
|
325
684
|
return { ok: true, value: undefined };
|
|
326
685
|
}
|
|
686
|
+
/** Synchronize account projections, then retry Core's durable group-rebind journal. */
|
|
687
|
+
async retryGroupRebindRecovery() {
|
|
688
|
+
const generation = this.generation;
|
|
689
|
+
return this.withPending('恢复群聊身份', async () => {
|
|
690
|
+
const listed = await this.refreshConversations(generation, true);
|
|
691
|
+
if (!listed.ok)
|
|
692
|
+
return { ok: false, error: listed.error };
|
|
693
|
+
return this.resumeGroupRebindRecovery(generation);
|
|
694
|
+
});
|
|
695
|
+
}
|
|
327
696
|
/**
|
|
328
697
|
* Look up a Handle or DID, then open the matching direct conversation.
|
|
329
698
|
* @param handle - peer Handle or DID typed by the user.
|
|
@@ -360,14 +729,14 @@ export class AwikiController {
|
|
|
360
729
|
?? findDirect(this.view.conversations, resolved.value.did);
|
|
361
730
|
if (listed !== undefined)
|
|
362
731
|
return this.selectConversation(listed.id);
|
|
363
|
-
const conversation = {
|
|
732
|
+
const conversation = this.cacheConversation({
|
|
364
733
|
kind: 'direct',
|
|
365
734
|
id: resolved.value.conversationId,
|
|
366
735
|
peerDid: resolved.value.did,
|
|
367
736
|
title: resolved.value.displayName ?? resolved.value.handle ?? resolved.value.did,
|
|
368
737
|
...resolved.value.handle === undefined ? {} : { peerHandle: resolved.value.handle },
|
|
369
738
|
...resolved.value.displayName === undefined ? {} : { displayName: resolved.value.displayName },
|
|
370
|
-
};
|
|
739
|
+
});
|
|
371
740
|
this.publish({
|
|
372
741
|
...this.view,
|
|
373
742
|
conversations: [conversation, ...this.view.conversations],
|
|
@@ -375,52 +744,395 @@ export class AwikiController {
|
|
|
375
744
|
});
|
|
376
745
|
return this.selectConversation(conversation.id);
|
|
377
746
|
}
|
|
747
|
+
/**
|
|
748
|
+
* Create one group, add its initial members, and open the new canonical conversation.
|
|
749
|
+
* Group selection owns a bounded readiness retry so a fresh empty group can settle before
|
|
750
|
+
* its first history failure becomes visible.
|
|
751
|
+
* @param name - user-visible group name.
|
|
752
|
+
* @param members - Handle or DID values entered by the user.
|
|
753
|
+
* @returns the created group and settled invitation outcomes.
|
|
754
|
+
*/
|
|
755
|
+
async createGroup(name, members) {
|
|
756
|
+
if (this.disposed)
|
|
757
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
758
|
+
const normalizedName = name.trim();
|
|
759
|
+
if (normalizedName === '')
|
|
760
|
+
return this.fail('请输入群聊名称');
|
|
761
|
+
if (Array.from(normalizedName).length > 100)
|
|
762
|
+
return this.fail('群聊名称不能超过 100 个字符');
|
|
763
|
+
const normalizedMembers = [...new Set(members.map(normalizeHandle).filter(member => member !== ''))];
|
|
764
|
+
if (normalizedMembers.length > 50)
|
|
765
|
+
return this.fail('首批群成员不能超过 50 位');
|
|
766
|
+
const identity = this.view.identity;
|
|
767
|
+
if (identity === null)
|
|
768
|
+
return this.fail('请先注册 AWiki 身份');
|
|
769
|
+
if (normalizedMembers.some(member => member === identity.did || sameIdentity(identity, member))) {
|
|
770
|
+
return this.fail('群成员列表不需要包含自己');
|
|
771
|
+
}
|
|
772
|
+
const generation = this.generation;
|
|
773
|
+
const result = await this.withPending('创建群聊', () => call(() => this.remote.createGroup({
|
|
774
|
+
name: normalizedName,
|
|
775
|
+
members: normalizedMembers,
|
|
776
|
+
}), groupCreateFailureMessage));
|
|
777
|
+
if (!result.ok || !this.current(generation))
|
|
778
|
+
return result;
|
|
779
|
+
const conversation = this.cacheConversation(result.value.conversation);
|
|
780
|
+
this.publish({
|
|
781
|
+
...this.view,
|
|
782
|
+
conversations: appendUnique([conversation], this.view.conversations, value => value.id),
|
|
783
|
+
error: null,
|
|
784
|
+
});
|
|
785
|
+
const selected = await this.selectConversation(result.value.conversation.id);
|
|
786
|
+
if (!this.current(generation))
|
|
787
|
+
return result;
|
|
788
|
+
const failed = result.value.failedMembers.map(item => item.member);
|
|
789
|
+
const warning = failed.length === 0
|
|
790
|
+
? selected.ok ? null : '群聊已创建,但暂时无法打开消息历史。'
|
|
791
|
+
: `群聊已创建,但以下成员未加入:${failed.join('、')}`;
|
|
792
|
+
if (warning !== null)
|
|
793
|
+
this.publish({ ...this.view, error: warning });
|
|
794
|
+
return result;
|
|
795
|
+
}
|
|
796
|
+
/** Join one open group by its canonical DID, then select the refreshed conversation. */
|
|
797
|
+
async joinGroup(groupDidInput) {
|
|
798
|
+
const groupDid = groupDidInput.trim();
|
|
799
|
+
if (!groupDid.startsWith('did:'))
|
|
800
|
+
return this.fail('请输入有效的群 DID');
|
|
801
|
+
const generation = this.generation;
|
|
802
|
+
const result = await this.withPending('加入群聊', () => call(() => this.remote.joinGroup({ groupDid })));
|
|
803
|
+
if (!result.ok || !this.current(generation))
|
|
804
|
+
return result;
|
|
805
|
+
await this.refreshConversations(generation);
|
|
806
|
+
if (!this.current(generation))
|
|
807
|
+
return result;
|
|
808
|
+
const conversation = this.view.conversations.find(value => value.kind === 'group' && value.groupDid === groupDid)
|
|
809
|
+
?? this.cacheConversation({
|
|
810
|
+
kind: 'group',
|
|
811
|
+
id: result.value.conversationId,
|
|
812
|
+
groupDid,
|
|
813
|
+
title: result.value.title,
|
|
814
|
+
unreadCount: 0,
|
|
815
|
+
});
|
|
816
|
+
this.publish({
|
|
817
|
+
...this.view,
|
|
818
|
+
conversations: appendUnique([conversation], this.view.conversations, value => value.id),
|
|
819
|
+
});
|
|
820
|
+
await this.selectConversation(conversation.id);
|
|
821
|
+
return result;
|
|
822
|
+
}
|
|
823
|
+
/** Refresh the selected group's authoritative snapshot and first member page. */
|
|
824
|
+
async refreshSelectedGroup() {
|
|
825
|
+
const conversation = this.selectedConversation();
|
|
826
|
+
if (conversation?.kind !== 'group')
|
|
827
|
+
return this.fail('请先打开一个群聊');
|
|
828
|
+
return this.withPending('刷新群成员', async () => {
|
|
829
|
+
const refreshed = await this.loadGroupState(conversation, true);
|
|
830
|
+
return refreshed.ok ? { ok: true, value: undefined } : refreshed;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
/** Load the next authoritative member page using Core's opaque cursor. */
|
|
834
|
+
async loadMoreGroupMembers() {
|
|
835
|
+
const group = this.view.selectedGroup;
|
|
836
|
+
if (group === null || this.groupMembersCursor === undefined)
|
|
837
|
+
return { ok: true, value: undefined };
|
|
838
|
+
const result = await this.withPending('加载更多群成员', () => call(() => this.remote.listGroupMembers({
|
|
839
|
+
groupDid: group.groupDid,
|
|
840
|
+
cursor: this.groupMembersCursor,
|
|
841
|
+
limit: 50,
|
|
842
|
+
})));
|
|
843
|
+
if (!result.ok)
|
|
844
|
+
return result;
|
|
845
|
+
if (result.value.pageGroup !== undefined && result.value.pageGroup !== group.groupDid) {
|
|
846
|
+
return this.fail('群成员分页归属不一致,请刷新后重试');
|
|
847
|
+
}
|
|
848
|
+
this.groupMembersCursor = result.value.nextCursor;
|
|
849
|
+
this.publish({
|
|
850
|
+
...this.view,
|
|
851
|
+
groupMembers: appendUnique(this.view.groupMembers, result.value.items, groupMemberKey),
|
|
852
|
+
groupMembersHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
853
|
+
});
|
|
854
|
+
return { ok: true, value: undefined };
|
|
855
|
+
}
|
|
856
|
+
/** Invite one ordinary member, then replace snapshot and roster with authoritative reads. */
|
|
857
|
+
async addSelectedGroupMember(memberInput) {
|
|
858
|
+
const group = this.view.selectedGroup;
|
|
859
|
+
const member = normalizeHandle(memberInput);
|
|
860
|
+
if (group === null)
|
|
861
|
+
return this.fail('请先打开一个群聊');
|
|
862
|
+
if (member === '')
|
|
863
|
+
return this.fail('请输入成员 Handle 或 DID');
|
|
864
|
+
if (this.view.identity !== null && (member === this.view.identity.did || sameIdentity(this.view.identity, member))) {
|
|
865
|
+
return this.fail('群成员列表不需要包含自己');
|
|
866
|
+
}
|
|
867
|
+
return await this.withPending('邀请群成员', async () => {
|
|
868
|
+
const invited = await call(() => this.remote.addGroupMember({
|
|
869
|
+
groupDid: group.groupDid,
|
|
870
|
+
member,
|
|
871
|
+
}));
|
|
872
|
+
if (!invited.ok)
|
|
873
|
+
return invited;
|
|
874
|
+
const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
875
|
+
if (!refreshed.ok) {
|
|
876
|
+
return {
|
|
877
|
+
ok: false,
|
|
878
|
+
error: `已提交对 ${member} 的邀请,但成员列表刷新失败。请点击刷新查看最新状态。`,
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
return invited;
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
/** Remove one authorized member, then refresh count, roles, and roster from Core. */
|
|
885
|
+
async removeSelectedGroupMember(member) {
|
|
886
|
+
const group = this.view.selectedGroup;
|
|
887
|
+
const reference = member.did ?? member.handle;
|
|
888
|
+
if (group === null || reference === undefined)
|
|
889
|
+
return this.fail('该成员缺少可用的身份标识');
|
|
890
|
+
const result = await this.withPending('移除群成员', () => call(() => this.remote.removeGroupMember({
|
|
891
|
+
groupDid: group.groupDid,
|
|
892
|
+
member: reference,
|
|
893
|
+
})));
|
|
894
|
+
if (result.ok)
|
|
895
|
+
await this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
896
|
+
return result;
|
|
897
|
+
}
|
|
898
|
+
/** Leave the selected group; Core prevents the owner from leaving. */
|
|
899
|
+
async leaveSelectedGroup() {
|
|
900
|
+
const group = this.view.selectedGroup;
|
|
901
|
+
if (group === null)
|
|
902
|
+
return this.fail('请先打开一个群聊');
|
|
903
|
+
const generation = this.generation;
|
|
904
|
+
const result = await this.withPending('退出群聊', () => call(() => this.remote.leaveGroup({ groupDid: group.groupDid })));
|
|
905
|
+
if (!result.ok)
|
|
906
|
+
return result;
|
|
907
|
+
if (!this.current(generation))
|
|
908
|
+
return { ok: true, value: undefined };
|
|
909
|
+
this.groupMembersCursor = undefined;
|
|
910
|
+
this.publish({
|
|
911
|
+
...this.view,
|
|
912
|
+
selectedConversationId: null,
|
|
913
|
+
selectedGroup: null,
|
|
914
|
+
groupMembers: [],
|
|
915
|
+
groupMembersHasMore: false,
|
|
916
|
+
messages: [],
|
|
917
|
+
});
|
|
918
|
+
await this.refreshConversations(generation);
|
|
919
|
+
return { ok: true, value: undefined };
|
|
920
|
+
}
|
|
378
921
|
/**
|
|
379
922
|
* Select a conversation and load its newest history page.
|
|
380
923
|
* @param conversationId - selected conversation, or `null` to return to the roster.
|
|
381
924
|
* @returns successful selection or one display-safe history failure.
|
|
382
925
|
*/
|
|
383
926
|
async selectConversation(conversationId) {
|
|
927
|
+
clearConversationTimings();
|
|
928
|
+
const selectStartedAt = timingStart();
|
|
929
|
+
const previousConversationId = this.view.selectedConversationId;
|
|
930
|
+
const sameConversation = conversationId !== null && previousConversationId === conversationId;
|
|
931
|
+
const selectionRevision = ++this.selectionRevision;
|
|
384
932
|
this.historyCursor = undefined;
|
|
933
|
+
this.groupMembersCursor = undefined;
|
|
385
934
|
const selected = conversationId === null
|
|
386
935
|
? undefined
|
|
387
936
|
: this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
388
937
|
if (selected !== undefined)
|
|
389
938
|
this.unreadAtOpen.set(selected.id, selected.unreadCount ?? 0);
|
|
390
|
-
this.publish({
|
|
939
|
+
this.publish({
|
|
940
|
+
...this.view,
|
|
941
|
+
selectedConversationId: conversationId,
|
|
942
|
+
selectedGroup: null,
|
|
943
|
+
groupMembers: [],
|
|
944
|
+
groupMembersHasMore: false,
|
|
945
|
+
messages: sameConversation ? this.view.messages : [],
|
|
946
|
+
historyHasMore: false,
|
|
947
|
+
localPending: conversationId !== null,
|
|
948
|
+
refreshing: false,
|
|
949
|
+
error: null,
|
|
950
|
+
});
|
|
951
|
+
if (selected?.kind === 'group')
|
|
952
|
+
void this.loadGroupState(selected, true);
|
|
391
953
|
if (conversationId === null)
|
|
392
954
|
return { ok: true, value: undefined };
|
|
393
955
|
const generation = this.generation;
|
|
394
|
-
const
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
&& selected?.kind === 'direct'
|
|
402
|
-
&& refreshed.value.did === selected.peerDid
|
|
403
|
-
&& refreshed.value.conversationId === selected.id) {
|
|
956
|
+
const localStartedAt = timingStart();
|
|
957
|
+
const local = await call(() => this.remote.getLocalHistory({ conversationId }));
|
|
958
|
+
recordTiming('conversation.select.local_timeline_ms', localStartedAt, local.ok);
|
|
959
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId)) {
|
|
960
|
+
return local.ok ? { ok: true, value: undefined } : local;
|
|
961
|
+
}
|
|
962
|
+
if (!local.ok) {
|
|
404
963
|
this.publish({
|
|
405
964
|
...this.view,
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const displayName = refreshed.value.displayName ?? conversation.displayName;
|
|
410
|
-
const peerHandle = refreshed.value.handle ?? conversation.peerHandle;
|
|
411
|
-
return {
|
|
412
|
-
...conversation,
|
|
413
|
-
title: displayName ?? peerHandle ?? conversation.title,
|
|
414
|
-
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
415
|
-
...(displayName === undefined ? {} : { displayName }),
|
|
416
|
-
};
|
|
417
|
-
}),
|
|
965
|
+
localPending: false,
|
|
966
|
+
refreshing: true,
|
|
967
|
+
error: local.error,
|
|
418
968
|
});
|
|
969
|
+
void this.reconcileSelectedConversation(conversationId, generation, selectionRevision);
|
|
970
|
+
void this.refreshSelectedDirectProfile(selected, generation, selectionRevision);
|
|
971
|
+
return local;
|
|
419
972
|
}
|
|
420
|
-
if (!
|
|
421
|
-
return
|
|
973
|
+
if (!pageBelongsToConversation(conversationId, local.value.items)) {
|
|
974
|
+
return this.failSelectedConversation(generation, selectionRevision, conversationId, 'AWiki 本地消息归属不一致,请重新打开会话。');
|
|
975
|
+
}
|
|
976
|
+
this.publish({
|
|
977
|
+
...this.view,
|
|
978
|
+
messages: mergeLatestMessages(this.view.messages, local.value.items),
|
|
979
|
+
localPending: false,
|
|
980
|
+
refreshing: true,
|
|
981
|
+
error: null,
|
|
982
|
+
});
|
|
983
|
+
recordTiming('conversation.select.first_paint_ms', selectStartedAt, true);
|
|
984
|
+
void this.reconcileSelectedConversation(conversationId, generation, selectionRevision);
|
|
985
|
+
void this.refreshSelectedDirectProfile(selected, generation, selectionRevision);
|
|
422
986
|
return { ok: true, value: undefined };
|
|
423
987
|
}
|
|
988
|
+
async loadGroupState(conversation, reset) {
|
|
989
|
+
const generation = this.generation;
|
|
990
|
+
const selectionRevision = this.selectionRevision;
|
|
991
|
+
const snapshot = await call(() => this.remote.getGroup({ groupDid: conversation.groupDid }), groupReadFailureMessage);
|
|
992
|
+
if (!snapshot.ok) {
|
|
993
|
+
if (this.currentSelection(generation, selectionRevision, conversation.id)) {
|
|
994
|
+
this.publish({ ...this.view, error: snapshot.error });
|
|
995
|
+
}
|
|
996
|
+
return snapshot;
|
|
997
|
+
}
|
|
998
|
+
const members = await call(() => this.remote.listGroupMembers({ groupDid: conversation.groupDid, limit: 50 }), groupReadFailureMessage);
|
|
999
|
+
if (!members.ok) {
|
|
1000
|
+
if (this.currentSelection(generation, selectionRevision, conversation.id)) {
|
|
1001
|
+
this.publish({ ...this.view, error: members.error });
|
|
1002
|
+
}
|
|
1003
|
+
return members;
|
|
1004
|
+
}
|
|
1005
|
+
if (!this.currentSelection(generation, selectionRevision, conversation.id))
|
|
1006
|
+
return snapshot;
|
|
1007
|
+
if (members.value.pageGroup !== undefined && members.value.pageGroup !== conversation.groupDid) {
|
|
1008
|
+
return this.fail('群成员列表归属不一致,请刷新后重试');
|
|
1009
|
+
}
|
|
1010
|
+
this.groupMembersCursor = members.value.nextCursor;
|
|
1011
|
+
this.publish({
|
|
1012
|
+
...this.view,
|
|
1013
|
+
selectedGroup: snapshot.value,
|
|
1014
|
+
groupMembers: reset
|
|
1015
|
+
? members.value.items
|
|
1016
|
+
: appendUnique(this.view.groupMembers, members.value.items, groupMemberKey),
|
|
1017
|
+
groupMembersHasMore: members.value.hasMore && members.value.nextCursor !== undefined,
|
|
1018
|
+
});
|
|
1019
|
+
return snapshot;
|
|
1020
|
+
}
|
|
1021
|
+
async reloadSelectedGroupAfterMutation(expectedGroupDid) {
|
|
1022
|
+
const conversation = this.selectedConversation();
|
|
1023
|
+
if (conversation?.kind !== 'group' || conversation.groupDid !== expectedGroupDid) {
|
|
1024
|
+
return { ok: true, value: undefined };
|
|
1025
|
+
}
|
|
1026
|
+
const generation = this.generation;
|
|
1027
|
+
const group = await this.loadGroupState(conversation, true);
|
|
1028
|
+
if (!group.ok)
|
|
1029
|
+
return group;
|
|
1030
|
+
if (!this.current(generation))
|
|
1031
|
+
return { ok: true, value: undefined };
|
|
1032
|
+
await this.refreshConversations(generation, true);
|
|
1033
|
+
return { ok: true, value: undefined };
|
|
1034
|
+
}
|
|
1035
|
+
async reconcileSelectedConversation(conversationId, generation, selectionRevision) {
|
|
1036
|
+
const selected = this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
1037
|
+
const remoteStartedAt = timingStart();
|
|
1038
|
+
const remote = await this.readRemoteHistoryWithGroupReadiness(selected, conversationId, generation, selectionRevision);
|
|
1039
|
+
recordTiming('conversation.select.remote_history_ms', remoteStartedAt, remote.ok);
|
|
1040
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
1041
|
+
return;
|
|
1042
|
+
if (!remote.ok) {
|
|
1043
|
+
this.publish({
|
|
1044
|
+
...this.view,
|
|
1045
|
+
refreshing: false,
|
|
1046
|
+
error: refreshFailureMessage(this.view.messages, remote.error),
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (!pageBelongsToConversation(conversationId, remote.value.items)) {
|
|
1051
|
+
this.failSelectedConversation(generation, selectionRevision, conversationId, 'AWiki 远端消息归属不一致,请重新打开会话。');
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
this.historyCursor = remote.value.nextCursor;
|
|
1055
|
+
const committed = await call(() => this.remote.getLocalHistory({ conversationId }));
|
|
1056
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
1057
|
+
return;
|
|
1058
|
+
if (!committed.ok) {
|
|
1059
|
+
this.publish({
|
|
1060
|
+
...this.view,
|
|
1061
|
+
refreshing: false,
|
|
1062
|
+
error: refreshFailureMessage(this.view.messages, committed.error),
|
|
1063
|
+
});
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (!pageBelongsToConversation(conversationId, committed.value.items)) {
|
|
1067
|
+
this.failSelectedConversation(generation, selectionRevision, conversationId, 'AWiki 本地消息归属不一致,请重新打开会话。');
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const existingIds = new Set(this.view.messages.map(message => message.id));
|
|
1071
|
+
const incoming = committed.value.items.filter(message => !existingIds.has(message.id));
|
|
1072
|
+
const existingError = this.view.error;
|
|
1073
|
+
this.publish({
|
|
1074
|
+
...this.view,
|
|
1075
|
+
messages: mergeLatestMessages(this.view.messages, committed.value.items),
|
|
1076
|
+
historyHasMore: remote.value.hasMore && remote.value.nextCursor !== undefined,
|
|
1077
|
+
refreshing: false,
|
|
1078
|
+
error: existingError?.startsWith('群聊已创建,但以下成员未加入:') ? existingError : null,
|
|
1079
|
+
summaries: this.staleSummaries(conversationId, incoming),
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
async readRemoteHistoryWithGroupReadiness(conversation, conversationId, generation, selectionRevision) {
|
|
1083
|
+
if (conversation?.kind !== 'group') {
|
|
1084
|
+
return call(() => this.remote.getHistory({ conversationId }));
|
|
1085
|
+
}
|
|
1086
|
+
for (const retryDelay of GROUP_HISTORY_RETRY_DELAYS_MS) {
|
|
1087
|
+
const remote = await call(() => this.remote.getHistory({ conversationId }), groupReadFailureMessage);
|
|
1088
|
+
if (remote.ok || !this.currentSelection(generation, selectionRevision, conversationId))
|
|
1089
|
+
return remote;
|
|
1090
|
+
await delay(retryDelay);
|
|
1091
|
+
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
1092
|
+
return remote;
|
|
1093
|
+
}
|
|
1094
|
+
return call(() => this.remote.getHistory({ conversationId }), groupReadFailureMessage);
|
|
1095
|
+
}
|
|
1096
|
+
async refreshSelectedDirectProfile(selected, generation, selectionRevision) {
|
|
1097
|
+
if (selected?.kind !== 'direct')
|
|
1098
|
+
return;
|
|
1099
|
+
const refreshed = await call(() => this.remote.resolvePeer({ peer: selected.peerDid }));
|
|
1100
|
+
if (!refreshed.ok
|
|
1101
|
+
|| !this.currentSelection(generation, selectionRevision, selected.id)
|
|
1102
|
+
|| refreshed.value.did !== selected.peerDid
|
|
1103
|
+
|| refreshed.value.conversationId !== selected.id)
|
|
1104
|
+
return;
|
|
1105
|
+
this.publish({
|
|
1106
|
+
...this.view,
|
|
1107
|
+
conversations: this.view.conversations.map((conversation) => {
|
|
1108
|
+
if (conversation.id !== selected.id || conversation.kind !== 'direct')
|
|
1109
|
+
return conversation;
|
|
1110
|
+
const displayName = refreshed.value.displayName ?? conversation.displayName;
|
|
1111
|
+
const peerHandle = refreshed.value.handle ?? conversation.peerHandle;
|
|
1112
|
+
this.directProfiles.set(conversation.peerDid, {
|
|
1113
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
1114
|
+
...(displayName === undefined ? {} : { displayName, title: displayName }),
|
|
1115
|
+
});
|
|
1116
|
+
return this.cacheConversation({
|
|
1117
|
+
...conversation,
|
|
1118
|
+
title: displayName ?? peerHandle ?? conversation.title,
|
|
1119
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
1120
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
1121
|
+
});
|
|
1122
|
+
}),
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
failSelectedConversation(generation, selectionRevision, conversationId, error) {
|
|
1126
|
+
if (this.currentSelection(generation, selectionRevision, conversationId)) {
|
|
1127
|
+
this.publish({
|
|
1128
|
+
...this.view,
|
|
1129
|
+
localPending: false,
|
|
1130
|
+
refreshing: false,
|
|
1131
|
+
error,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
return { ok: false, error };
|
|
1135
|
+
}
|
|
424
1136
|
/**
|
|
425
1137
|
* Mark the selected conversation read after the UI proves its newest message is visible.
|
|
426
1138
|
* Repeated scroll and layout notifications share one Host request, while a failed
|
|
@@ -518,16 +1230,20 @@ export class AwikiController {
|
|
|
518
1230
|
/**
|
|
519
1231
|
* Send one text message to the selected direct or group conversation.
|
|
520
1232
|
* @param text - non-empty text prepared by the composer.
|
|
1233
|
+
* @param clientMessageId - optional logical identity shared with the optimistic row.
|
|
521
1234
|
* @returns successful delivery or one display-safe failure.
|
|
522
1235
|
*/
|
|
523
|
-
async sendText(text) {
|
|
1236
|
+
async sendText(text, clientMessageId, mentions) {
|
|
524
1237
|
const conversation = this.selectedConversation();
|
|
525
1238
|
if (conversation === undefined)
|
|
526
1239
|
return this.fail('请先选择会话');
|
|
527
1240
|
const conversationId = conversation.id;
|
|
528
1241
|
const generation = this.generation;
|
|
529
1242
|
const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
|
|
530
|
-
target: targetOf(conversation),
|
|
1243
|
+
target: targetOf(conversation),
|
|
1244
|
+
text,
|
|
1245
|
+
idempotencyKey: clientMessageId ?? crypto.randomUUID(),
|
|
1246
|
+
...mentions === undefined || mentions.length === 0 ? {} : { mentions },
|
|
531
1247
|
})));
|
|
532
1248
|
if (!result.ok)
|
|
533
1249
|
return result;
|
|
@@ -554,7 +1270,7 @@ export class AwikiController {
|
|
|
554
1270
|
mimeType: file.mimeType,
|
|
555
1271
|
bytesBase64: file.bytesBase64,
|
|
556
1272
|
...(file.caption === undefined ? {} : { caption: file.caption }),
|
|
557
|
-
idempotencyKey: crypto.randomUUID(),
|
|
1273
|
+
idempotencyKey: file.clientMessageId ?? crypto.randomUUID(),
|
|
558
1274
|
};
|
|
559
1275
|
const result = await this.withPending('发送附件', () => call(() => this.remote.sendAttachment(request)));
|
|
560
1276
|
if (!result.ok)
|
|
@@ -574,9 +1290,34 @@ export class AwikiController {
|
|
|
574
1290
|
async downloadAttachment(messageId, attachmentId) {
|
|
575
1291
|
if (this.disposed)
|
|
576
1292
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
1293
|
+
const cacheKey = `${String(messageId)}\u0000${String(attachmentId)}`;
|
|
1294
|
+
const cached = this.imageAttachments.get(cacheKey);
|
|
1295
|
+
if (cached !== undefined) {
|
|
1296
|
+
this.imageAttachments.delete(cacheKey);
|
|
1297
|
+
this.imageAttachments.set(cacheKey, cached);
|
|
1298
|
+
return { ok: true, value: cached };
|
|
1299
|
+
}
|
|
577
1300
|
const generation = this.generation;
|
|
1301
|
+
const ownerDid = this.presentationCacheOwnerDid;
|
|
1302
|
+
if (ownerDid !== null) {
|
|
1303
|
+
const persisted = await this.persistentImageCache.read(ownerDid, messageId, attachmentId).catch(() => undefined);
|
|
1304
|
+
if (!this.current(generation))
|
|
1305
|
+
return { ok: false, error: 'AWiki 已关闭' };
|
|
1306
|
+
if (persisted !== undefined) {
|
|
1307
|
+
this.cacheImageAttachment(cacheKey, persisted);
|
|
1308
|
+
return { ok: true, value: persisted };
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
578
1311
|
const result = await call(() => this.remote.downloadAttachment({ attachmentId, messageId }));
|
|
579
|
-
|
|
1312
|
+
if (!this.current(generation))
|
|
1313
|
+
return { ok: false, error: 'AWiki 已关闭' };
|
|
1314
|
+
if (result.ok && result.value.attachment.mimeType.startsWith('image/')) {
|
|
1315
|
+
this.cacheImageAttachment(cacheKey, result.value);
|
|
1316
|
+
if (ownerDid !== null) {
|
|
1317
|
+
void this.persistentImageCache.write(ownerDid, messageId, result.value).catch(() => undefined);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
return result;
|
|
580
1321
|
}
|
|
581
1322
|
/** Clear Host-owned local data and immediately remove every cached browser projection. */
|
|
582
1323
|
async clearLocalData(request) {
|
|
@@ -585,12 +1326,15 @@ export class AwikiController {
|
|
|
585
1326
|
const result = await call(() => this.remote.clearLocalData(request));
|
|
586
1327
|
if (!result.ok)
|
|
587
1328
|
return result;
|
|
1329
|
+
await this.persistentImageCache.clear().catch(() => undefined);
|
|
588
1330
|
this.close();
|
|
589
1331
|
this.config = null;
|
|
590
1332
|
this.conversationsCursor = undefined;
|
|
591
1333
|
this.historyCursor = undefined;
|
|
592
1334
|
this.unreadAtOpen.clear();
|
|
593
1335
|
this.summaryBaselines.clear();
|
|
1336
|
+
this.clearPresentationCache();
|
|
1337
|
+
storeRecoveryOperation(null);
|
|
594
1338
|
this.publish({ ...INITIAL_VIEW, status: 'ready' });
|
|
595
1339
|
return result;
|
|
596
1340
|
}
|
|
@@ -600,16 +1344,19 @@ export class AwikiController {
|
|
|
600
1344
|
this.close();
|
|
601
1345
|
this.listeners.clear();
|
|
602
1346
|
}
|
|
603
|
-
async refreshConversations(generation) {
|
|
1347
|
+
async refreshConversations(generation, background = false) {
|
|
604
1348
|
const result = await call(() => this.remote.listConversations({}));
|
|
605
1349
|
if (!this.current(generation))
|
|
606
1350
|
return { ok: true, value: undefined };
|
|
607
1351
|
if (!result.ok)
|
|
608
|
-
return this.fail(result.error);
|
|
1352
|
+
return background ? result : this.fail(result.error);
|
|
609
1353
|
const firstPage = this.view.conversations.length === 0;
|
|
610
1354
|
if (firstPage)
|
|
611
1355
|
this.conversationsCursor = result.value.nextCursor;
|
|
612
|
-
const refreshed = result.value.items.map(
|
|
1356
|
+
const refreshed = result.value.items.map((incoming) => {
|
|
1357
|
+
const current = this.view.conversations.find(value => value.id === incoming.id);
|
|
1358
|
+
return this.cacheConversation(incoming, current);
|
|
1359
|
+
});
|
|
613
1360
|
this.publish({
|
|
614
1361
|
...this.view,
|
|
615
1362
|
conversations: firstPage
|
|
@@ -618,10 +1365,40 @@ export class AwikiController {
|
|
|
618
1365
|
conversationsHasMore: firstPage
|
|
619
1366
|
? result.value.hasMore && result.value.nextCursor !== undefined
|
|
620
1367
|
: this.view.conversationsHasMore,
|
|
621
|
-
error: null,
|
|
1368
|
+
error: background ? this.view.error : null,
|
|
622
1369
|
});
|
|
623
1370
|
return { ok: true, value: undefined };
|
|
624
1371
|
}
|
|
1372
|
+
/** Hydrate account-projected groups before asking Core to resume their durable rebind jobs. */
|
|
1373
|
+
async refreshConversationsAndRecoverGroups(generation, background = false) {
|
|
1374
|
+
const listed = await this.refreshConversations(generation, background);
|
|
1375
|
+
if (!listed.ok || !this.current(generation))
|
|
1376
|
+
return listed;
|
|
1377
|
+
await this.resumeGroupRebindRecovery(generation);
|
|
1378
|
+
return listed;
|
|
1379
|
+
}
|
|
1380
|
+
resumeGroupRebindRecovery(generation) {
|
|
1381
|
+
const active = this.groupRecoveryOperation;
|
|
1382
|
+
if (active !== undefined && active.generation === generation)
|
|
1383
|
+
return active.operation;
|
|
1384
|
+
let operation;
|
|
1385
|
+
operation = call(() => this.remote.resumeGroupRebindRecovery(), groupRecoveryFailureMessage).then((result) => {
|
|
1386
|
+
if (!this.current(generation))
|
|
1387
|
+
return result;
|
|
1388
|
+
this.publish({
|
|
1389
|
+
...this.view,
|
|
1390
|
+
groupRecovery: result.ok
|
|
1391
|
+
? groupRecoveryView(result.value)
|
|
1392
|
+
: { status: 'unavailable', pending: 0, blocked: 0 },
|
|
1393
|
+
});
|
|
1394
|
+
return result;
|
|
1395
|
+
}).finally(() => {
|
|
1396
|
+
if (this.groupRecoveryOperation?.operation === operation)
|
|
1397
|
+
this.groupRecoveryOperation = undefined;
|
|
1398
|
+
});
|
|
1399
|
+
this.groupRecoveryOperation = { generation, operation };
|
|
1400
|
+
return operation;
|
|
1401
|
+
}
|
|
625
1402
|
async loadHistory(older) {
|
|
626
1403
|
const conversationId = this.view.selectedConversationId;
|
|
627
1404
|
if (conversationId === null)
|
|
@@ -631,17 +1408,21 @@ export class AwikiController {
|
|
|
631
1408
|
conversationId,
|
|
632
1409
|
...(older && this.historyCursor !== undefined ? { cursor: this.historyCursor } : {}),
|
|
633
1410
|
};
|
|
634
|
-
const
|
|
1411
|
+
const selected = this.view.conversations.find(value => value.id === conversationId);
|
|
1412
|
+
const result = await this.withPending(older ? '加载更早消息' : '加载消息', () => call(() => this.remote.getHistory(request), selected?.kind === 'group' ? groupReadFailureMessage : undefined));
|
|
635
1413
|
if (!result.ok)
|
|
636
1414
|
return result;
|
|
637
1415
|
if (!this.current(generation))
|
|
638
1416
|
return { ok: true, value: undefined };
|
|
639
1417
|
if (this.view.selectedConversationId !== conversationId)
|
|
640
1418
|
return { ok: true, value: undefined };
|
|
1419
|
+
if (!pageBelongsToConversation(conversationId, result.value.items)) {
|
|
1420
|
+
return this.fail('AWiki 远端消息归属不一致,请重新打开会话。');
|
|
1421
|
+
}
|
|
641
1422
|
this.historyCursor = result.value.nextCursor;
|
|
642
1423
|
const messages = older
|
|
643
|
-
?
|
|
644
|
-
: result.value.items;
|
|
1424
|
+
? mergeOlderMessages(this.view.messages, result.value.items)
|
|
1425
|
+
: mergeLatestMessages(this.view.messages, result.value.items);
|
|
645
1426
|
this.publish({
|
|
646
1427
|
...this.view,
|
|
647
1428
|
// SDK pages are chronological. A continuation page contains older
|
|
@@ -657,16 +1438,26 @@ export class AwikiController {
|
|
|
657
1438
|
return;
|
|
658
1439
|
this.polling = true;
|
|
659
1440
|
try {
|
|
660
|
-
|
|
1441
|
+
const knownGroups = new Set(this.view.conversations
|
|
1442
|
+
.filter(conversation => conversation.kind === 'group')
|
|
1443
|
+
.map(conversation => conversation.groupDid));
|
|
1444
|
+
const listed = await this.refreshConversations(generation, true);
|
|
1445
|
+
if (listed.ok && this.view.conversations.some(conversation => conversation.kind === 'group' && !knownGroups.has(conversation.groupDid))) {
|
|
1446
|
+
await this.resumeGroupRebindRecovery(generation);
|
|
1447
|
+
}
|
|
661
1448
|
const selected = this.view.selectedConversationId;
|
|
662
1449
|
if (selected === null || !this.current(generation))
|
|
663
1450
|
return;
|
|
664
1451
|
const result = await call(() => this.remote.getHistory({ conversationId: selected }));
|
|
665
1452
|
if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
|
|
666
1453
|
return;
|
|
1454
|
+
if (!pageBelongsToConversation(selected, result.value.items)) {
|
|
1455
|
+
this.publish({ ...this.view, error: 'AWiki 远端消息归属不一致,请重新打开会话。' });
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
667
1458
|
const existingIds = new Set(this.view.messages.map(message => message.id));
|
|
668
1459
|
const incoming = result.value.items.filter(message => !existingIds.has(message.id));
|
|
669
|
-
const messages =
|
|
1460
|
+
const messages = mergeLatestMessages(this.view.messages, result.value.items);
|
|
670
1461
|
const added = messages.length - this.view.messages.length;
|
|
671
1462
|
if (added > 0 && (this.unreadAtOpen.get(selected) ?? 0) > 0) {
|
|
672
1463
|
this.unreadAtOpen.set(selected, (this.unreadAtOpen.get(selected) ?? 0) + added);
|
|
@@ -693,8 +1484,10 @@ export class AwikiController {
|
|
|
693
1484
|
return result;
|
|
694
1485
|
}
|
|
695
1486
|
appendMessage(message) {
|
|
1487
|
+
if (this.view.selectedConversationId !== message.conversationId)
|
|
1488
|
+
return;
|
|
696
1489
|
const isNew = !this.view.messages.some(current => current.id === message.id);
|
|
697
|
-
const messages =
|
|
1490
|
+
const messages = appendMessageById(this.view.messages, message);
|
|
698
1491
|
if ((this.unreadAtOpen.get(message.conversationId) ?? 0) > 0 && messages.length > this.view.messages.length) {
|
|
699
1492
|
this.unreadAtOpen.set(message.conversationId, (this.unreadAtOpen.get(message.conversationId) ?? 0) + 1);
|
|
700
1493
|
}
|
|
@@ -738,6 +1531,104 @@ export class AwikiController {
|
|
|
738
1531
|
const selected = this.view.selectedConversationId;
|
|
739
1532
|
return selected === null ? undefined : this.view.conversations.find(value => value.id === selected);
|
|
740
1533
|
}
|
|
1534
|
+
/** Keep presentation-only cache entries isolated to one authenticated identity. */
|
|
1535
|
+
activatePresentationCache(identity) {
|
|
1536
|
+
const ownerDid = identity?.did ?? null;
|
|
1537
|
+
if (ownerDid === this.presentationCacheOwnerDid)
|
|
1538
|
+
return;
|
|
1539
|
+
this.directProfiles.clear();
|
|
1540
|
+
this.groupTitles.clear();
|
|
1541
|
+
this.clearImageAttachments();
|
|
1542
|
+
this.presentationCacheOwnerDid = ownerDid;
|
|
1543
|
+
}
|
|
1544
|
+
/** Drop every browser projection without touching the Core-owned SQLite cache. */
|
|
1545
|
+
clearPresentationCache() {
|
|
1546
|
+
this.directProfiles.clear();
|
|
1547
|
+
this.groupTitles.clear();
|
|
1548
|
+
this.clearImageAttachments();
|
|
1549
|
+
this.presentationCacheOwnerDid = null;
|
|
1550
|
+
}
|
|
1551
|
+
/** Retain recently used verified image bytes without exposing them in AwikiView. */
|
|
1552
|
+
cacheImageAttachment(key, value) {
|
|
1553
|
+
if (value.attachment.size > BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES)
|
|
1554
|
+
return;
|
|
1555
|
+
const previous = this.imageAttachments.get(key);
|
|
1556
|
+
if (previous !== undefined)
|
|
1557
|
+
this.imageAttachmentCacheBytes -= previous.attachment.size;
|
|
1558
|
+
this.imageAttachments.delete(key);
|
|
1559
|
+
this.imageAttachments.set(key, Object.freeze({
|
|
1560
|
+
attachment: Object.freeze({ ...value.attachment }),
|
|
1561
|
+
bytesBase64: value.bytesBase64,
|
|
1562
|
+
}));
|
|
1563
|
+
this.imageAttachmentCacheBytes += value.attachment.size;
|
|
1564
|
+
while (this.imageAttachmentCacheBytes > BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES) {
|
|
1565
|
+
const oldestKey = this.imageAttachments.keys().next().value;
|
|
1566
|
+
if (oldestKey === undefined)
|
|
1567
|
+
break;
|
|
1568
|
+
const oldest = this.imageAttachments.get(oldestKey);
|
|
1569
|
+
this.imageAttachments.delete(oldestKey);
|
|
1570
|
+
if (oldest !== undefined)
|
|
1571
|
+
this.imageAttachmentCacheBytes -= oldest.attachment.size;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
clearImageAttachments() {
|
|
1575
|
+
this.imageAttachments.clear();
|
|
1576
|
+
this.imageAttachmentCacheBytes = 0;
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Reconcile direct identity and group title projections with their last trustworthy values.
|
|
1580
|
+
* Core remains authoritative; this browser cache only prevents sparse refreshes from
|
|
1581
|
+
* replacing already resolved presentation data with protocol identifiers.
|
|
1582
|
+
*/
|
|
1583
|
+
cacheConversation(incoming, current) {
|
|
1584
|
+
if (incoming.kind === 'direct') {
|
|
1585
|
+
const active = current?.kind === 'direct' && current.peerDid === incoming.peerDid ? current : undefined;
|
|
1586
|
+
const cached = this.directProfiles.get(incoming.peerDid);
|
|
1587
|
+
const incomingDisplayName = incoming.displayName?.trim();
|
|
1588
|
+
const displayName = active?.displayName
|
|
1589
|
+
?? cached?.displayName
|
|
1590
|
+
?? (incomingDisplayName === undefined || incomingDisplayName === '' ? undefined : incomingDisplayName);
|
|
1591
|
+
const peerHandle = active?.peerHandle ?? cached?.peerHandle ?? incoming.peerHandle;
|
|
1592
|
+
const title = displayName
|
|
1593
|
+
?? (active !== undefined && hasDisplayableDirectTitle(active) ? active.title : undefined)
|
|
1594
|
+
?? cached?.title
|
|
1595
|
+
?? (hasDisplayableDirectTitle(incoming) ? incoming.title : undefined)
|
|
1596
|
+
?? peerHandle
|
|
1597
|
+
?? incoming.title;
|
|
1598
|
+
if (displayName !== undefined || peerHandle !== undefined || hasDisplayableDirectTitle(incoming)) {
|
|
1599
|
+
this.directProfiles.set(incoming.peerDid, {
|
|
1600
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
1601
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
1602
|
+
...hasDisplayableDirectTitle({ ...incoming, title }) ? { title } : {},
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
return {
|
|
1606
|
+
...incoming,
|
|
1607
|
+
title,
|
|
1608
|
+
...(peerHandle === undefined ? {} : { peerHandle }),
|
|
1609
|
+
...(displayName === undefined ? {} : { displayName }),
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
return this.cacheGroupTitle(incoming, current);
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Reconcile one group roster row with the last trustworthy local presentation.
|
|
1616
|
+
* A real remote/Core title may update the cache; a temporary Group DID fallback may not.
|
|
1617
|
+
*/
|
|
1618
|
+
cacheGroupTitle(incoming, current) {
|
|
1619
|
+
if (incoming.kind !== 'group')
|
|
1620
|
+
return incoming;
|
|
1621
|
+
if (hasDisplayableGroupTitle(incoming)) {
|
|
1622
|
+
this.groupTitles.set(incoming.groupDid, incoming.title);
|
|
1623
|
+
return incoming;
|
|
1624
|
+
}
|
|
1625
|
+
if (current?.kind === 'group' && current.groupDid === incoming.groupDid && hasDisplayableGroupTitle(current)) {
|
|
1626
|
+
this.groupTitles.set(incoming.groupDid, current.title);
|
|
1627
|
+
return { ...incoming, title: current.title };
|
|
1628
|
+
}
|
|
1629
|
+
const cached = this.groupTitles.get(incoming.groupDid);
|
|
1630
|
+
return cached === undefined ? incoming : { ...incoming, title: cached };
|
|
1631
|
+
}
|
|
741
1632
|
fail(error) {
|
|
742
1633
|
this.publish({ ...this.view, status: this.view.status === 'loading' ? 'error' : this.view.status, pending: null, error });
|
|
743
1634
|
return { ok: false, error };
|
|
@@ -745,6 +1636,11 @@ export class AwikiController {
|
|
|
745
1636
|
current(generation) {
|
|
746
1637
|
return !this.disposed && generation === this.generation;
|
|
747
1638
|
}
|
|
1639
|
+
currentSelection(generation, selectionRevision, conversationId) {
|
|
1640
|
+
return this.current(generation)
|
|
1641
|
+
&& selectionRevision === this.selectionRevision
|
|
1642
|
+
&& this.view.selectedConversationId === conversationId;
|
|
1643
|
+
}
|
|
748
1644
|
publish(view) {
|
|
749
1645
|
/* v8 ignore next -- every asynchronous and public mutation path checks disposal before publishing. */
|
|
750
1646
|
if (this.disposed)
|