@awiki/dsh-plugin 0.2.5 → 0.3.0-rc.2
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 +69 -28
- package/README.zh.md +60 -21
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/cordis.patch.yml +0 -8
- package/lib/client.js +6755 -2022
- package/lib/client.js.map +1 -1
- package/lib/index.js +1321 -72
- package/lib/model-proxy-contract.js +8 -1
- package/lib/provider.js +3 -1
- package/lib/{sdk-adapter-DKgkyApB.mjs → sdk-adapter-BIbsJmRj.mjs} +245 -4
- package/lib/typert.host.js +944 -57
- package/lib/typert.remote-client.d.ts +37 -1
- package/lib/typert.remote-client.d.ts.map +1 -1
- package/lib/typert.remote-client.js +944 -57
- package/lib/types/client/AwikiGroupAccessNotice.d.ts +12 -0
- package/lib/types/client/AwikiGroupAccessNotice.d.ts.map +1 -0
- package/lib/types/client/AwikiGroupAccessNotice.js +31 -0
- package/lib/types/client/AwikiGroupAccessNotice.js.map +1 -0
- package/lib/types/client/AwikiGroupDetails.d.ts +23 -0
- package/lib/types/client/AwikiGroupDetails.d.ts.map +1 -0
- package/lib/types/client/AwikiGroupDetails.js +144 -0
- package/lib/types/client/AwikiGroupDetails.js.map +1 -0
- package/lib/types/client/AwikiIdentityAccess.d.ts +22 -0
- package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -0
- package/lib/types/client/AwikiIdentityAccess.js +156 -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 +2 -1
- package/lib/types/client/AwikiMail.d.ts.map +1 -1
- package/lib/types/client/AwikiMail.js +157 -33
- package/lib/types/client/AwikiMail.js.map +1 -1
- package/lib/types/client/AwikiOnboarding.d.ts.map +1 -1
- package/lib/types/client/AwikiOnboarding.js +29 -9
- package/lib/types/client/AwikiOnboarding.js.map +1 -1
- package/lib/types/client/AwikiOverlay.d.ts +0 -5
- package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
- package/lib/types/client/AwikiOverlay.js +202 -129
- 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 +27 -0
- package/lib/types/client/AwikiRecoveryForm.d.ts.map +1 -0
- package/lib/types/client/AwikiRecoveryForm.js +152 -0
- package/lib/types/client/AwikiRecoveryForm.js.map +1 -0
- package/lib/types/client/AwikiSettingsSection.d.ts +0 -2
- package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
- package/lib/types/client/AwikiSettingsSection.js +18 -16
- 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/controller.d.ts +131 -3
- package/lib/types/client/controller.d.ts.map +1 -1
- package/lib/types/client/controller.js +908 -29
- package/lib/types/client/controller.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +24 -4
- 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-proxy-controller.d.ts +4 -0
- package/lib/types/client/model-proxy-controller.d.ts.map +1 -1
- package/lib/types/client/model-proxy-controller.js +68 -3
- package/lib/types/client/model-proxy-controller.js.map +1 -1
- package/lib/types/client/recharge-availability.d.ts +1 -1
- package/lib/types/client/recharge-availability.d.ts.map +1 -1
- package/lib/types/client/recharge-availability.js +1 -1
- package/lib/types/client/recharge-availability.js.map +1 -1
- 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 +14 -8
- package/lib/types/client/settings-locales.js.map +1 -1
- package/lib/types/client/slots.d.ts +27 -2
- package/lib/types/client/slots.d.ts.map +1 -1
- package/lib/types/conversation-preferences.d.ts +20 -0
- package/lib/types/conversation-preferences.d.ts.map +1 -0
- package/lib/types/conversation-preferences.js +294 -0
- package/lib/types/conversation-preferences.js.map +1 -0
- package/lib/types/index.d.ts +45 -6
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +562 -23
- package/lib/types/index.js.map +1 -1
- package/lib/types/model-proxy-contract.d.ts +6 -0
- package/lib/types/model-proxy-contract.d.ts.map +1 -1
- package/lib/types/model-proxy-contract.js +6 -0
- package/lib/types/model-proxy-contract.js.map +1 -1
- package/lib/types/provider-api.d.ts +29 -1
- package/lib/types/provider-api.d.ts.map +1 -1
- package/lib/types/provider.d.ts.map +1 -1
- package/lib/types/provider.js +2 -0
- package/lib/types/provider.js.map +1 -1
- package/lib/types/sdk-adapter.d.ts +18 -1
- package/lib/types/sdk-adapter.d.ts.map +1 -1
- package/lib/types/sdk-adapter.js +277 -4
- 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/types.d.ts +183 -2
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js.map +1 -1
- package/package.json +15 -26
- package/lib/model-proxy.js +0 -383
- package/lib/types/client/model-account-settings-availability.d.ts +0 -4
- package/lib/types/client/model-account-settings-availability.d.ts.map +0 -1
- package/lib/types/client/model-account-settings-availability.js +0 -4
- package/lib/types/client/model-account-settings-availability.js.map +0 -1
- package/lib/types/model-proxy.d.ts +0 -14
- package/lib/types/model-proxy.d.ts.map +0 -1
- package/lib/types/model-proxy.js +0 -371
- package/lib/types/model-proxy.js.map +0 -1
|
@@ -12,7 +12,7 @@ function registrationFailureMessage(failure) {
|
|
|
12
12
|
case 'challenge-expired':
|
|
13
13
|
return '验证码状态已失效,请重新获取验证码后再注册。';
|
|
14
14
|
case 'handle-unavailable':
|
|
15
|
-
return '该 Handle
|
|
15
|
+
return '该 Handle 刚刚已被注册。请返回身份入口,再按恢复流程重新获取验证码。';
|
|
16
16
|
case 'conflict':
|
|
17
17
|
return '注册冲突:服务端可能已收到上次注册请求,或该手机号 / Handle 已绑定其他身份。请保留当前页面并再次提交;若仍失败,请勿清除本机身份数据,联系管理员并提供失败时间。';
|
|
18
18
|
case 'rate-limited':
|
|
@@ -44,13 +44,69 @@ function registrationOtpFailureMessage(failure) {
|
|
|
44
44
|
return registrationFailureMessage(failure);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
function recoveryPreparationFailureMessage(failure) {
|
|
48
|
+
switch (failure.code) {
|
|
49
|
+
case 'invalid-request':
|
|
50
|
+
return '恢复信息不匹配,请检查验证码后重试。';
|
|
51
|
+
case 'invalid-otp':
|
|
52
|
+
return '验证码不正确,请检查后重试。';
|
|
53
|
+
case 'challenge-expired':
|
|
54
|
+
return '验证码状态已失效,请重新获取恢复验证码。';
|
|
55
|
+
case 'rate-limited':
|
|
56
|
+
return '恢复验证过于频繁,请稍后重试。';
|
|
57
|
+
case 'network':
|
|
58
|
+
return '无法连接 AWiki 服务,请检查网络后重试。';
|
|
59
|
+
case 'remote':
|
|
60
|
+
return 'AWiki 服务暂时无法验证恢复信息,请稍后重试。';
|
|
61
|
+
default:
|
|
62
|
+
return `${failure.code}:${failure.message}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function recoveryContinuationFailureMessage(failure, phase) {
|
|
66
|
+
const remoteCommitted = phase === 'remote_committed' || phase === 'identity_transition_pending';
|
|
67
|
+
if (remoteCommitted && ['invalid-request', 'conflict', 'forbidden', 'remote'].includes(failure.code)) {
|
|
68
|
+
return '身份已在服务端恢复,但本机切换尚未完成。请保留当前恢复操作,并继续完成本机切换;不要重新获取验证码或创建新身份。';
|
|
69
|
+
}
|
|
70
|
+
switch (failure.code) {
|
|
71
|
+
case 'network':
|
|
72
|
+
return remoteCommitted
|
|
73
|
+
? '身份已在服务端恢复,但当前设备暂时无法完成本机切换。请检查网络后继续完成本机切换。'
|
|
74
|
+
: '恢复请求结果尚未确认。请保留当前恢复操作并重新检查状态,不要重复提交。';
|
|
75
|
+
case 'invalid-request':
|
|
76
|
+
case 'conflict':
|
|
77
|
+
return '身份恢复暂未完成。请保留当前恢复操作并重新检查状态,不要重新获取验证码或创建新身份。';
|
|
78
|
+
case 'rate-limited':
|
|
79
|
+
return '恢复状态检查过于频繁,请稍后继续。';
|
|
80
|
+
default:
|
|
81
|
+
return `${failure.code}:${failure.message}`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function identityAccessInspectionFailureMessage(failure) {
|
|
85
|
+
switch (failure.code) {
|
|
86
|
+
case 'invalid-request':
|
|
87
|
+
return 'Handle 格式不正确,请检查后重试。';
|
|
88
|
+
case 'network':
|
|
89
|
+
return '无法连接 AWiki 服务,暂时不能确认该 Handle 是否已经存在。';
|
|
90
|
+
case 'remote':
|
|
91
|
+
return 'AWiki 服务暂时无法确认该 Handle 的状态,请稍后重试。';
|
|
92
|
+
default:
|
|
93
|
+
return `${failure.code}:${failure.message}`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
47
96
|
const INITIAL_VIEW = Object.freeze({
|
|
48
97
|
status: 'cold',
|
|
49
98
|
sessionStatus: 'unregistered',
|
|
50
99
|
identity: null,
|
|
100
|
+
profile: null,
|
|
51
101
|
conversations: Object.freeze([]),
|
|
102
|
+
hiddenConversations: Object.freeze([]),
|
|
52
103
|
conversationsHasMore: false,
|
|
53
104
|
selectedConversationId: null,
|
|
105
|
+
selectedGroup: null,
|
|
106
|
+
groupAccess: null,
|
|
107
|
+
groupMembers: Object.freeze([]),
|
|
108
|
+
groupMembersHasMore: false,
|
|
109
|
+
groupRecovery: null,
|
|
54
110
|
messages: Object.freeze([]),
|
|
55
111
|
historyHasMore: false,
|
|
56
112
|
localPending: false,
|
|
@@ -59,6 +115,8 @@ const INITIAL_VIEW = Object.freeze({
|
|
|
59
115
|
error: null,
|
|
60
116
|
attachmentMaxBytes: 0,
|
|
61
117
|
summaries: Object.freeze({}),
|
|
118
|
+
recoveryOperationId: null,
|
|
119
|
+
recoveryProgress: null,
|
|
62
120
|
});
|
|
63
121
|
/** Turn a closed Host summary failure into one actionable Chinese message. */
|
|
64
122
|
function summaryFailureMessage(failure) {
|
|
@@ -85,12 +143,111 @@ function mailFailureMessage(failure) {
|
|
|
85
143
|
default: return 'AWiki 邮件服务暂时不可用,请稍后重试。';
|
|
86
144
|
}
|
|
87
145
|
}
|
|
146
|
+
/** Turn a rejected group create into a concrete next step instead of exposing Host error text. */
|
|
147
|
+
function groupCreateFailureMessage(failure) {
|
|
148
|
+
switch (failure.code) {
|
|
149
|
+
case 'invalid-request': return '群聊名称或首批成员格式不正确,请检查后重试。';
|
|
150
|
+
case 'not-registered': return '请先注册 AWiki 身份后创建群聊。';
|
|
151
|
+
case 'signed-out': return '当前 AWiki 身份已退出,请重新进入后创建群聊。';
|
|
152
|
+
case 'forbidden': return '当前 AWiki 身份没有创建群聊的权限。';
|
|
153
|
+
case 'rate-limited': return '创建群聊过于频繁,请稍后重试。';
|
|
154
|
+
case 'network': return '无法连接 AWiki 服务,请检查网络后重试。';
|
|
155
|
+
case 'conflict': return '群聊创建状态发生冲突,请刷新群聊列表后确认是否已经创建。';
|
|
156
|
+
case 'remote': return 'AWiki 服务暂时无法创建群聊,请稍后重试。';
|
|
157
|
+
default: return `${failure.code}:${failure.message}`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** Keep group authorization failures actionable without exposing raw service text. */
|
|
161
|
+
function groupReadFailureMessage(failure) {
|
|
162
|
+
switch (failure.code) {
|
|
163
|
+
case 'group-membership-required':
|
|
164
|
+
return '当前身份暂时无法访问这个群聊。若刚完成身份恢复,请返回会话列表重试群聊身份恢复;若持续出现,可能已不再是群成员。';
|
|
165
|
+
case 'group-identity-stale':
|
|
166
|
+
return '群聊身份正在恢复,请稍候后重试。';
|
|
167
|
+
case 'not-found':
|
|
168
|
+
return '这个群聊不存在,或当前身份已经无法访问。';
|
|
169
|
+
case 'forbidden':
|
|
170
|
+
return '当前身份没有访问这个群聊的权限。';
|
|
171
|
+
case 'network':
|
|
172
|
+
return '无法连接 AWiki 群聊服务,请检查网络后重试。';
|
|
173
|
+
case 'remote':
|
|
174
|
+
return 'AWiki 暂时无法读取这个群聊,请稍后重试。';
|
|
175
|
+
default:
|
|
176
|
+
return `${failure.code}:${failure.message}`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function groupRecoveryFailureMessage(failure) {
|
|
180
|
+
return failure.code === 'network'
|
|
181
|
+
? '无法连接 AWiki 服务,暂时不能恢复旧群聊身份。'
|
|
182
|
+
: '暂时不能检查旧群聊身份,请稍后重试。';
|
|
183
|
+
}
|
|
184
|
+
function conversationPreferenceFailureMessage(_failure) {
|
|
185
|
+
return '无法保存本机会话设置,请稍后重试。';
|
|
186
|
+
}
|
|
187
|
+
function recoveryFingerprint(parts) {
|
|
188
|
+
let hash = 0x811c9dc5;
|
|
189
|
+
for (const part of parts) {
|
|
190
|
+
for (let index = 0; index < part.length; index += 1) {
|
|
191
|
+
hash ^= part.charCodeAt(index);
|
|
192
|
+
hash = Math.imul(hash, 0x01000193);
|
|
193
|
+
}
|
|
194
|
+
hash ^= 0;
|
|
195
|
+
hash = Math.imul(hash, 0x01000193);
|
|
196
|
+
}
|
|
197
|
+
return (hash >>> 0).toString(16).padStart(8, '0');
|
|
198
|
+
}
|
|
199
|
+
function groupRecoveryProjection(summary, hiddenGroupDids) {
|
|
200
|
+
const hiddenItems = summary.items.filter(item => hiddenGroupDids.has(item.groupDid));
|
|
201
|
+
const hiddenPending = hiddenItems.filter(item => item.status === 'pending').length;
|
|
202
|
+
const hiddenBlocked = hiddenItems.filter(item => item.status === 'blocked').length;
|
|
203
|
+
const pending = Math.max(0, summary.pending - hiddenPending);
|
|
204
|
+
const blocked = Math.max(0, summary.blocked - hiddenBlocked);
|
|
205
|
+
const visibleItems = summary.items
|
|
206
|
+
.filter(item => !hiddenGroupDids.has(item.groupDid))
|
|
207
|
+
.map(item => `${item.status}:${item.groupDid}`)
|
|
208
|
+
.sort();
|
|
209
|
+
const signature = `v1:${pending}:${blocked}:${recoveryFingerprint(visibleItems)}`;
|
|
210
|
+
if (blocked > 0)
|
|
211
|
+
return { view: { status: 'blocked', pending, blocked }, signature };
|
|
212
|
+
return {
|
|
213
|
+
view: pending > 0 ? { status: 'pending', pending, blocked: 0 } : null,
|
|
214
|
+
signature,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function groupReadFailureReason(failure) {
|
|
218
|
+
switch (failure.code) {
|
|
219
|
+
case 'group-identity-stale': return 'recovering';
|
|
220
|
+
case 'group-membership-required':
|
|
221
|
+
case 'not-found':
|
|
222
|
+
case 'forbidden': return 'not-member';
|
|
223
|
+
default: return 'network-error';
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function callGroupRead(operation) {
|
|
227
|
+
try {
|
|
228
|
+
const carried = await operation();
|
|
229
|
+
if (!carried.ok) {
|
|
230
|
+
return { ok: false, error: '暂时无法连接 AWiki 群聊服务,请稍后重新检查。', reason: 'network-error' };
|
|
231
|
+
}
|
|
232
|
+
if (!carried.value.ok) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
error: groupReadFailureMessage(carried.value.error),
|
|
236
|
+
reason: groupReadFailureReason(carried.value.error),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, value: carried.value.value };
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return { ok: false, error: '暂时无法连接 AWiki 群聊服务,请稍后重新检查。', reason: 'network-error' };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
88
245
|
/** Flatten the carrier and business result once for every controller caller. */
|
|
89
|
-
async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`) {
|
|
246
|
+
async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`, carrierFailureMessage = message => `连接 AWiki Host 失败:${message}`) {
|
|
90
247
|
try {
|
|
91
248
|
const carried = await operation();
|
|
92
249
|
if (!carried.ok)
|
|
93
|
-
return { ok: false, error:
|
|
250
|
+
return { ok: false, error: carrierFailureMessage(carried.error.message) };
|
|
94
251
|
if (!carried.value.ok) {
|
|
95
252
|
return { ok: false, error: failureMessage(carried.value.error) };
|
|
96
253
|
}
|
|
@@ -103,6 +260,33 @@ async function call(operation, failureMessage = failure => `${failure.code}:${
|
|
|
103
260
|
};
|
|
104
261
|
}
|
|
105
262
|
}
|
|
263
|
+
/** Preserve only the stable business code for controller-level state transitions. */
|
|
264
|
+
async function callWithFailureCode(operation) {
|
|
265
|
+
try {
|
|
266
|
+
const carried = await operation();
|
|
267
|
+
if (!carried.ok)
|
|
268
|
+
return { ok: false, error: `连接 AWiki Host 失败:${carried.error.message}` };
|
|
269
|
+
if (!carried.value.ok) {
|
|
270
|
+
return {
|
|
271
|
+
ok: false,
|
|
272
|
+
error: `${carried.value.error.code}:${carried.value.error.message}`,
|
|
273
|
+
failureCode: carried.value.error.code,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, value: carried.value.value };
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
return {
|
|
280
|
+
ok: false,
|
|
281
|
+
error: error instanceof Error ? `AWiki 调用失败:${error.message}` : 'AWiki 调用失败',
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function recoveryCarrierFailureMessage(message) {
|
|
286
|
+
return message.includes('business result failed boundary validation')
|
|
287
|
+
? '恢复信息已验证,但暂时无法读取恢复状态。请稍后重试。'
|
|
288
|
+
: `连接 AWiki Host 失败:${message}`;
|
|
289
|
+
}
|
|
106
290
|
/** Append unique values while retaining existing references. */
|
|
107
291
|
function appendUnique(current, incoming, id) {
|
|
108
292
|
const seen = new Set(current.map(id));
|
|
@@ -200,6 +384,26 @@ function sameIdentity(identity, peer) {
|
|
|
200
384
|
const GROUP_HISTORY_RETRY_DELAYS_MS = [250, 750, 1_500, 2_500];
|
|
201
385
|
/** Runtime-only decoded-byte budget that prevents repeat Host calls while browsing. */
|
|
202
386
|
const BROWSER_IMAGE_ATTACHMENT_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
387
|
+
const RECOVERY_OPERATION_STORAGE_KEY = 'awiki.handle-recovery.operation.v1';
|
|
388
|
+
function storedRecoveryOperation() {
|
|
389
|
+
try {
|
|
390
|
+
return globalThis.localStorage?.getItem(RECOVERY_OPERATION_STORAGE_KEY) ?? null;
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function storeRecoveryOperation(operationId) {
|
|
397
|
+
try {
|
|
398
|
+
if (operationId === null)
|
|
399
|
+
globalThis.localStorage?.removeItem(RECOVERY_OPERATION_STORAGE_KEY);
|
|
400
|
+
else
|
|
401
|
+
globalThis.localStorage?.setItem(RECOVERY_OPERATION_STORAGE_KEY, operationId);
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// Recovery remains durable in Core even when browser storage is unavailable.
|
|
405
|
+
}
|
|
406
|
+
}
|
|
203
407
|
/** Wait between group-history readiness probes without retaining controller state. */
|
|
204
408
|
function delay(milliseconds) {
|
|
205
409
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
@@ -218,6 +422,9 @@ function findDirect(conversations, peer) {
|
|
|
218
422
|
const key = peer.toLowerCase();
|
|
219
423
|
return conversations.find((conversation) => (conversation.kind === 'direct' && directPeerKeys(conversation).includes(key)));
|
|
220
424
|
}
|
|
425
|
+
function groupMemberKey(member) {
|
|
426
|
+
return member.membershipId ?? member.did ?? member.credentialDid ?? member.handle ?? `${member.role ?? ''}:${member.joinedAt ?? ''}`;
|
|
427
|
+
}
|
|
221
428
|
/** True when a group title contains presentation data instead of a protocol fallback. */
|
|
222
429
|
function hasDisplayableGroupTitle(conversation) {
|
|
223
430
|
if (conversation.kind !== 'group')
|
|
@@ -248,6 +455,7 @@ export class AwikiController {
|
|
|
248
455
|
config = null;
|
|
249
456
|
conversationsCursor;
|
|
250
457
|
historyCursor;
|
|
458
|
+
groupMembersCursor;
|
|
251
459
|
timer;
|
|
252
460
|
generation = 0;
|
|
253
461
|
selectionRevision = 0;
|
|
@@ -260,10 +468,18 @@ export class AwikiController {
|
|
|
260
468
|
directProfiles = new Map();
|
|
261
469
|
/** Last trustworthy group title for the active identity, keyed by canonical Group DID. */
|
|
262
470
|
groupTitles = new Map();
|
|
471
|
+
/** Current secret-free Core journal state, keyed by canonical Group DID. */
|
|
472
|
+
groupRecoveryItems = new Map();
|
|
473
|
+
/** Identity-scoped product overlays. Core conversations and history remain untouched. */
|
|
474
|
+
hiddenConversationPreferences = new Map();
|
|
475
|
+
dismissedGroupRecoverySignature;
|
|
476
|
+
lastGroupRecoverySignature;
|
|
477
|
+
lastGroupRecoverySummary;
|
|
263
478
|
/** Verified image payloads retained outside observable state for instant remounts. */
|
|
264
479
|
imageAttachments = new Map();
|
|
265
480
|
imageAttachmentCacheBytes = 0;
|
|
266
481
|
presentationCacheOwnerDid = null;
|
|
482
|
+
groupRecoveryOperation;
|
|
267
483
|
/**
|
|
268
484
|
* @param remote - generated Host Remote namespace.
|
|
269
485
|
* @param persistentImageCache - browser-origin verified preview cache.
|
|
@@ -307,7 +523,27 @@ export class AwikiController {
|
|
|
307
523
|
identity: session.value.status === 'active' ? session.value.identity : null,
|
|
308
524
|
error: null,
|
|
309
525
|
attachmentMaxBytes: config.value.attachmentMaxBytes,
|
|
526
|
+
recoveryOperationId: storedRecoveryOperation(),
|
|
310
527
|
});
|
|
528
|
+
if (identity !== null) {
|
|
529
|
+
await this.loadConversationPreferences(generation);
|
|
530
|
+
const profile = await call(() => this.remote.getProfile());
|
|
531
|
+
if (this.current(generation) && profile.ok)
|
|
532
|
+
this.publish({ ...this.view, profile: profile.value });
|
|
533
|
+
}
|
|
534
|
+
else {
|
|
535
|
+
const operationId = storedRecoveryOperation();
|
|
536
|
+
if (operationId !== null) {
|
|
537
|
+
const recovery = await call(() => this.remote.getRecoveryStatus({ operationId }));
|
|
538
|
+
if (this.current(generation) && recovery.ok) {
|
|
539
|
+
this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: recovery.value });
|
|
540
|
+
if (recovery.value.phase === 'applied') {
|
|
541
|
+
storeRecoveryOperation(null);
|
|
542
|
+
return this.loadSession();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
311
547
|
return { ok: true, value: undefined };
|
|
312
548
|
}
|
|
313
549
|
/**
|
|
@@ -320,7 +556,7 @@ export class AwikiController {
|
|
|
320
556
|
return loaded;
|
|
321
557
|
const generation = this.generation;
|
|
322
558
|
if (this.view.identity !== null) {
|
|
323
|
-
const listed = await this.
|
|
559
|
+
const listed = await this.refreshConversationsAndRecoverGroups(generation);
|
|
324
560
|
if (!listed.ok)
|
|
325
561
|
return listed;
|
|
326
562
|
}
|
|
@@ -370,6 +606,7 @@ export class AwikiController {
|
|
|
370
606
|
this.timer = undefined;
|
|
371
607
|
this.polling = false;
|
|
372
608
|
this.markReadInFlight.clear();
|
|
609
|
+
this.groupMembersCursor = undefined;
|
|
373
610
|
}
|
|
374
611
|
/**
|
|
375
612
|
* Request one phone verification challenge.
|
|
@@ -379,6 +616,10 @@ export class AwikiController {
|
|
|
379
616
|
async sendRegistrationOtp(request) {
|
|
380
617
|
return this.withPending('发送验证码', () => call(() => this.remote.sendRegistrationOtp(request), registrationOtpFailureMessage));
|
|
381
618
|
}
|
|
619
|
+
/** Classify one Handle before sending exactly one registration or recovery OTP. */
|
|
620
|
+
async inspectIdentityAccess(request) {
|
|
621
|
+
return this.withPending('检查身份', () => call(() => this.remote.inspectIdentityAccess(request), identityAccessInspectionFailureMessage));
|
|
622
|
+
}
|
|
382
623
|
/**
|
|
383
624
|
* Register the deployment identity and populate the initial conversation list.
|
|
384
625
|
* @param request - verified Handle, phone number, and one-time code.
|
|
@@ -393,7 +634,7 @@ export class AwikiController {
|
|
|
393
634
|
return result;
|
|
394
635
|
this.activatePresentationCache(result.value);
|
|
395
636
|
this.publish({ ...this.view, sessionStatus: 'active', identity: result.value, error: null });
|
|
396
|
-
await this.
|
|
637
|
+
await this.refreshConversationsAndRecoverGroups(generation);
|
|
397
638
|
return result;
|
|
398
639
|
}
|
|
399
640
|
/**
|
|
@@ -415,6 +656,116 @@ export class AwikiController {
|
|
|
415
656
|
this.publish({ ...this.view, identity: result.value, error: null });
|
|
416
657
|
return result;
|
|
417
658
|
}
|
|
659
|
+
/** Save all supported public profile fields and keep identity/profile projections aligned. */
|
|
660
|
+
async updateProfile(request) {
|
|
661
|
+
const displayName = request.displayName.trim();
|
|
662
|
+
const bio = request.bio.trim();
|
|
663
|
+
const tags = request.tags.map(tag => tag.trim()).filter(tag => tag !== '');
|
|
664
|
+
if (displayName === '' || Array.from(displayName).length > 50)
|
|
665
|
+
return this.fail('昵称需要填写且不能超过 50 个字符');
|
|
666
|
+
if (Array.from(bio).length > 100)
|
|
667
|
+
return this.fail('个人简介不能超过 100 个字符');
|
|
668
|
+
if (tags.length > 5 || new Set(tags.map(tag => tag.toLocaleLowerCase())).size !== tags.length) {
|
|
669
|
+
return this.fail('最多填写 5 个不重复的标签');
|
|
670
|
+
}
|
|
671
|
+
const generation = this.generation;
|
|
672
|
+
const result = await this.withPending('保存资料', () => call(() => this.remote.updateProfile({ displayName, bio, tags })));
|
|
673
|
+
if (!result.ok || !this.current(generation))
|
|
674
|
+
return result;
|
|
675
|
+
const identity = this.view.identity === null ? null : { ...this.view.identity, displayName: result.value.displayName };
|
|
676
|
+
this.publish({ ...this.view, identity, profile: result.value, error: null });
|
|
677
|
+
return result;
|
|
678
|
+
}
|
|
679
|
+
/** Request a recovery OTP and persist only its secret-free operation id in the browser. */
|
|
680
|
+
async sendRecoveryOtp(request) {
|
|
681
|
+
const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false });
|
|
682
|
+
if (!result.ok)
|
|
683
|
+
return result;
|
|
684
|
+
storeRecoveryOperation(result.value.operationId);
|
|
685
|
+
this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null });
|
|
686
|
+
return result;
|
|
687
|
+
}
|
|
688
|
+
/** Verify the recovery OTP without attempting the remote identity mutation yet. */
|
|
689
|
+
async prepareRecovery(request) {
|
|
690
|
+
const operationId = this.view.recoveryOperationId;
|
|
691
|
+
if (operationId === null)
|
|
692
|
+
return this.fail('请先获取恢复验证码');
|
|
693
|
+
const result = await this.withPending('验证恢复信息', () => call(() => this.remote.prepareRecovery({ ...request, operationId }), recoveryPreparationFailureMessage, recoveryCarrierFailureMessage), { publishFailure: false });
|
|
694
|
+
if (result.ok)
|
|
695
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
696
|
+
return result;
|
|
697
|
+
}
|
|
698
|
+
/** Commit the prepared operation once. Unknown outcomes remain available through status refresh. */
|
|
699
|
+
async activateRecovery() {
|
|
700
|
+
const operationId = this.view.recoveryOperationId;
|
|
701
|
+
if (operationId === null)
|
|
702
|
+
return this.fail('没有可继续的身份恢复操作');
|
|
703
|
+
if (this.view.recoveryProgress?.phase !== 'ready_to_commit')
|
|
704
|
+
return this.fail('请先完成恢复信息验证');
|
|
705
|
+
const phase = this.view.recoveryProgress.phase;
|
|
706
|
+
const result = await this.withPending('恢复身份', () => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, phase), recoveryCarrierFailureMessage), { publishFailure: false });
|
|
707
|
+
if (!result.ok)
|
|
708
|
+
return result;
|
|
709
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
710
|
+
if (result.value.phase === 'applied') {
|
|
711
|
+
storeRecoveryOperation(null);
|
|
712
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: result.value });
|
|
713
|
+
await this.open();
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
/** Refresh Core status without repeating activation. */
|
|
718
|
+
async refreshRecoveryStatus() {
|
|
719
|
+
const operationId = this.view.recoveryOperationId;
|
|
720
|
+
if (operationId === null)
|
|
721
|
+
return this.fail('没有可查询的身份恢复操作');
|
|
722
|
+
const result = await this.withPending('刷新恢复状态', () => call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage), { publishFailure: false });
|
|
723
|
+
if (!result.ok)
|
|
724
|
+
return result;
|
|
725
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
726
|
+
if (result.value.phase === 'applied') {
|
|
727
|
+
storeRecoveryOperation(null);
|
|
728
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: result.value });
|
|
729
|
+
await this.open();
|
|
730
|
+
}
|
|
731
|
+
return result;
|
|
732
|
+
}
|
|
733
|
+
/** Resume only a Core-declared retryable or uncertain phase. */
|
|
734
|
+
async resumeRecovery() {
|
|
735
|
+
const operationId = this.view.recoveryOperationId;
|
|
736
|
+
const progress = this.view.recoveryProgress;
|
|
737
|
+
if (operationId === null || progress === null)
|
|
738
|
+
return this.fail('请先刷新恢复状态');
|
|
739
|
+
if (!progress.retryable && !['remote_outcome_unknown', 'remote_committed', 'identity_transition_pending'].includes(progress.phase)) {
|
|
740
|
+
return this.fail('当前恢复状态不能重试');
|
|
741
|
+
}
|
|
742
|
+
const result = await this.withPending('继续恢复身份', () => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage), { publishFailure: false });
|
|
743
|
+
if (!result.ok)
|
|
744
|
+
return result;
|
|
745
|
+
this.publish({ ...this.view, recoveryProgress: result.value });
|
|
746
|
+
if (result.value.phase === 'applied') {
|
|
747
|
+
storeRecoveryOperation(null);
|
|
748
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: result.value });
|
|
749
|
+
await this.open();
|
|
750
|
+
}
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
/** Discard only a pre-attempt operation. */
|
|
754
|
+
async discardRecovery() {
|
|
755
|
+
const operationId = this.view.recoveryOperationId;
|
|
756
|
+
if (operationId === null)
|
|
757
|
+
return { ok: true, value: undefined };
|
|
758
|
+
const phase = this.view.recoveryProgress?.phase;
|
|
759
|
+
if (phase !== undefined && phase !== 'awaiting_factor' && phase !== 'ready_to_commit') {
|
|
760
|
+
return this.fail('当前恢复状态不能取消');
|
|
761
|
+
}
|
|
762
|
+
const result = await this.withPending('取消身份恢复', () => call(() => this.remote.discardRecovery({ operationId })), { publishFailure: false });
|
|
763
|
+
if (!result.ok)
|
|
764
|
+
return result;
|
|
765
|
+
storeRecoveryOperation(null);
|
|
766
|
+
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null });
|
|
767
|
+
return { ok: true, value: undefined };
|
|
768
|
+
}
|
|
418
769
|
/** Read the active deployment identity's public mailbox state. */
|
|
419
770
|
getMailAccount() {
|
|
420
771
|
return call(() => this.remote.getMailAccount(), mailFailureMessage);
|
|
@@ -441,20 +792,108 @@ export class AwikiController {
|
|
|
441
792
|
*/
|
|
442
793
|
async loadMoreConversations() {
|
|
443
794
|
const generation = this.generation;
|
|
444
|
-
const result = await this.withPending('加载更多会话', () =>
|
|
795
|
+
const result = await this.withPending('加载更多会话', () => this.listConversationPage(this.conversationsCursor === undefined ? {} : { cursor: this.conversationsCursor }));
|
|
445
796
|
if (!result.ok)
|
|
446
797
|
return result;
|
|
447
798
|
if (!this.current(generation))
|
|
448
799
|
return { ok: true, value: undefined };
|
|
449
800
|
this.conversationsCursor = result.value.nextCursor;
|
|
450
|
-
const conversations = result.value.items
|
|
801
|
+
const conversations = await this.reconcileConversationPage(result.value.items, generation);
|
|
802
|
+
if (!this.current(generation))
|
|
803
|
+
return { ok: true, value: undefined };
|
|
451
804
|
this.publish({
|
|
452
805
|
...this.view,
|
|
453
|
-
conversations: appendUnique(this.view.conversations, conversations, value => value.id),
|
|
806
|
+
conversations: appendUnique(this.view.conversations, conversations.visible, value => value.id),
|
|
807
|
+
hiddenConversations: conversations.hidden,
|
|
808
|
+
groupRecovery: this.currentGroupRecoveryView(),
|
|
454
809
|
conversationsHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
455
810
|
});
|
|
456
811
|
return { ok: true, value: undefined };
|
|
457
812
|
}
|
|
813
|
+
/** Synchronize account projections, then retry Core's durable group-rebind journal. */
|
|
814
|
+
async retryGroupRebindRecovery() {
|
|
815
|
+
const generation = this.generation;
|
|
816
|
+
return this.withPending('恢复群聊身份', async () => {
|
|
817
|
+
const listed = await this.refreshConversations(generation, true);
|
|
818
|
+
if (!listed.ok)
|
|
819
|
+
return { ok: false, error: listed.error };
|
|
820
|
+
return this.resumeGroupRebindRecovery(generation);
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
/** Dismiss only the current recovery-summary revision; Core recovery remains untouched. */
|
|
824
|
+
async dismissGroupRecoveryNotice() {
|
|
825
|
+
const signature = this.lastGroupRecoverySignature;
|
|
826
|
+
if (signature === undefined || this.view.groupRecovery === null)
|
|
827
|
+
return { ok: true, value: undefined };
|
|
828
|
+
const result = await call(() => this.remote.updateConversationPreference({ action: 'dismiss-group-recovery', signature }), conversationPreferenceFailureMessage);
|
|
829
|
+
if (!result.ok)
|
|
830
|
+
return this.fail(result.error);
|
|
831
|
+
this.applyConversationPreferences(result.value);
|
|
832
|
+
this.publish({ ...this.view, groupRecovery: null, error: null });
|
|
833
|
+
return { ok: true, value: undefined };
|
|
834
|
+
}
|
|
835
|
+
/** Hide one recent row locally without leaving a group or deleting history. */
|
|
836
|
+
async hideConversation(conversationId) {
|
|
837
|
+
const conversation = this.view.conversations.find(item => item.id === conversationId);
|
|
838
|
+
if (conversation === undefined)
|
|
839
|
+
return this.fail('该会话已不在当前列表中');
|
|
840
|
+
const generation = this.generation;
|
|
841
|
+
const result = await this.withPending('移除会话', () => call(() => this.remote.updateConversationPreference({ action: 'hide', conversation }), conversationPreferenceFailureMessage));
|
|
842
|
+
if (!result.ok)
|
|
843
|
+
return result;
|
|
844
|
+
if (!this.current(generation))
|
|
845
|
+
return { ok: true, value: undefined };
|
|
846
|
+
this.applyConversationPreferences(result.value);
|
|
847
|
+
const selected = this.view.selectedConversationId === conversationId;
|
|
848
|
+
if (selected) {
|
|
849
|
+
this.selectionRevision += 1;
|
|
850
|
+
this.historyCursor = undefined;
|
|
851
|
+
this.groupMembersCursor = undefined;
|
|
852
|
+
}
|
|
853
|
+
this.publish({
|
|
854
|
+
...this.view,
|
|
855
|
+
conversations: this.view.conversations.filter(item => item.id !== conversationId),
|
|
856
|
+
hiddenConversations: this.hiddenConversationsView(),
|
|
857
|
+
...(selected
|
|
858
|
+
? {
|
|
859
|
+
selectedConversationId: null,
|
|
860
|
+
selectedGroup: null,
|
|
861
|
+
groupAccess: null,
|
|
862
|
+
groupMembers: [],
|
|
863
|
+
groupMembersHasMore: false,
|
|
864
|
+
messages: [],
|
|
865
|
+
historyHasMore: false,
|
|
866
|
+
localPending: false,
|
|
867
|
+
refreshing: false,
|
|
868
|
+
}
|
|
869
|
+
: {}),
|
|
870
|
+
groupRecovery: this.currentGroupRecoveryView(),
|
|
871
|
+
error: null,
|
|
872
|
+
});
|
|
873
|
+
return { ok: true, value: undefined };
|
|
874
|
+
}
|
|
875
|
+
/** Restore one locally hidden row to the recent roster. */
|
|
876
|
+
async restoreConversation(conversationId) {
|
|
877
|
+
const hidden = this.hiddenConversationPreferences.get(conversationId);
|
|
878
|
+
if (hidden === undefined)
|
|
879
|
+
return { ok: true, value: undefined };
|
|
880
|
+
const generation = this.generation;
|
|
881
|
+
const result = await this.withPending('恢复会话', () => call(() => this.remote.updateConversationPreference({ action: 'restore', conversationId }), conversationPreferenceFailureMessage));
|
|
882
|
+
if (!result.ok)
|
|
883
|
+
return result;
|
|
884
|
+
if (!this.current(generation))
|
|
885
|
+
return { ok: true, value: undefined };
|
|
886
|
+
this.applyConversationPreferences(result.value);
|
|
887
|
+
const conversation = this.cacheConversation(hidden.conversation, this.view.conversations.find(item => item.id === conversationId));
|
|
888
|
+
this.publish({
|
|
889
|
+
...this.view,
|
|
890
|
+
conversations: appendUnique([conversation], this.view.conversations, item => item.id),
|
|
891
|
+
hiddenConversations: this.hiddenConversationsView(),
|
|
892
|
+
groupRecovery: this.currentGroupRecoveryView(),
|
|
893
|
+
error: null,
|
|
894
|
+
});
|
|
895
|
+
return { ok: true, value: undefined };
|
|
896
|
+
}
|
|
458
897
|
/**
|
|
459
898
|
* Look up a Handle or DID, then open the matching direct conversation.
|
|
460
899
|
* @param handle - peer Handle or DID typed by the user.
|
|
@@ -471,9 +910,15 @@ export class AwikiController {
|
|
|
471
910
|
return this.fail('请先注册 AWiki 身份');
|
|
472
911
|
if (sameIdentity(identity, peer))
|
|
473
912
|
return this.fail('不能向自己发起私聊');
|
|
474
|
-
const existing = findDirect(this.view.conversations, peer);
|
|
475
|
-
if (existing !== undefined)
|
|
913
|
+
const existing = findDirect([...this.view.conversations, ...this.view.hiddenConversations], peer);
|
|
914
|
+
if (existing !== undefined) {
|
|
915
|
+
if (this.hiddenConversationPreferences.has(existing.id)) {
|
|
916
|
+
const restored = await this.restoreConversation(existing.id);
|
|
917
|
+
if (!restored.ok)
|
|
918
|
+
return restored;
|
|
919
|
+
}
|
|
476
920
|
return this.selectConversation(existing.id);
|
|
921
|
+
}
|
|
477
922
|
const generation = this.generation;
|
|
478
923
|
const resolved = await this.withPending('查找用户', () => call(() => this.remote.resolvePeer({ peer })));
|
|
479
924
|
if (!resolved.ok) {
|
|
@@ -523,8 +968,6 @@ export class AwikiController {
|
|
|
523
968
|
if (Array.from(normalizedName).length > 100)
|
|
524
969
|
return this.fail('群聊名称不能超过 100 个字符');
|
|
525
970
|
const normalizedMembers = [...new Set(members.map(normalizeHandle).filter(member => member !== ''))];
|
|
526
|
-
if (normalizedMembers.length === 0)
|
|
527
|
-
return this.fail('请至少添加一位群成员');
|
|
528
971
|
if (normalizedMembers.length > 50)
|
|
529
972
|
return this.fail('首批群成员不能超过 50 位');
|
|
530
973
|
const identity = this.view.identity;
|
|
@@ -537,7 +980,7 @@ export class AwikiController {
|
|
|
537
980
|
const result = await this.withPending('创建群聊', () => call(() => this.remote.createGroup({
|
|
538
981
|
name: normalizedName,
|
|
539
982
|
members: normalizedMembers,
|
|
540
|
-
})));
|
|
983
|
+
}), groupCreateFailureMessage));
|
|
541
984
|
if (!result.ok || !this.current(generation))
|
|
542
985
|
return result;
|
|
543
986
|
const conversation = this.cacheConversation(result.value.conversation);
|
|
@@ -557,6 +1000,150 @@ export class AwikiController {
|
|
|
557
1000
|
this.publish({ ...this.view, error: warning });
|
|
558
1001
|
return result;
|
|
559
1002
|
}
|
|
1003
|
+
/** Join one open group by its canonical DID, then select the refreshed conversation. */
|
|
1004
|
+
async joinGroup(groupDidInput) {
|
|
1005
|
+
const groupDid = groupDidInput.trim();
|
|
1006
|
+
if (!groupDid.startsWith('did:'))
|
|
1007
|
+
return this.fail('请输入有效的群 DID');
|
|
1008
|
+
const generation = this.generation;
|
|
1009
|
+
const result = await this.withPending('加入群聊', () => call(() => this.remote.joinGroup({ groupDid })));
|
|
1010
|
+
if (!result.ok || !this.current(generation))
|
|
1011
|
+
return result;
|
|
1012
|
+
await this.refreshConversations(generation);
|
|
1013
|
+
if (!this.current(generation))
|
|
1014
|
+
return result;
|
|
1015
|
+
const conversation = [...this.view.conversations, ...this.view.hiddenConversations]
|
|
1016
|
+
.find(value => value.kind === 'group' && value.groupDid === groupDid)
|
|
1017
|
+
?? this.cacheConversation({
|
|
1018
|
+
kind: 'group',
|
|
1019
|
+
id: result.value.conversationId,
|
|
1020
|
+
groupDid,
|
|
1021
|
+
title: result.value.title,
|
|
1022
|
+
unreadCount: 0,
|
|
1023
|
+
});
|
|
1024
|
+
if (this.hiddenConversationPreferences.has(conversation.id)) {
|
|
1025
|
+
const restored = await this.restoreConversation(conversation.id);
|
|
1026
|
+
if (!restored.ok)
|
|
1027
|
+
return { ok: false, error: restored.error };
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
this.publish({
|
|
1031
|
+
...this.view,
|
|
1032
|
+
conversations: appendUnique([conversation], this.view.conversations, value => value.id),
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
await this.selectConversation(conversation.id);
|
|
1036
|
+
return result;
|
|
1037
|
+
}
|
|
1038
|
+
/** Refresh the selected group's authoritative snapshot and first member page. */
|
|
1039
|
+
async refreshSelectedGroup() {
|
|
1040
|
+
const conversation = this.selectedConversation();
|
|
1041
|
+
if (conversation?.kind !== 'group')
|
|
1042
|
+
return this.fail('请先打开一个群聊');
|
|
1043
|
+
if (this.disposed)
|
|
1044
|
+
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
1045
|
+
const generation = this.generation;
|
|
1046
|
+
this.publish({ ...this.view, pending: '刷新群成员', error: null });
|
|
1047
|
+
const result = await (async () => {
|
|
1048
|
+
if (this.view.groupAccess?.status !== 'available' && this.view.groupAccess?.status !== 'loading') {
|
|
1049
|
+
await this.resumeGroupRebindRecovery(this.generation);
|
|
1050
|
+
}
|
|
1051
|
+
const refreshed = await this.loadGroupState(conversation, true, true);
|
|
1052
|
+
return refreshed.ok ? { ok: true, value: undefined } : refreshed;
|
|
1053
|
+
})();
|
|
1054
|
+
if (this.current(generation))
|
|
1055
|
+
this.publish({ ...this.view, pending: null, error: null });
|
|
1056
|
+
return result;
|
|
1057
|
+
}
|
|
1058
|
+
/** Load the next authoritative member page using Core's opaque cursor. */
|
|
1059
|
+
async loadMoreGroupMembers() {
|
|
1060
|
+
const group = this.view.selectedGroup;
|
|
1061
|
+
if (group === null || this.groupMembersCursor === undefined)
|
|
1062
|
+
return { ok: true, value: undefined };
|
|
1063
|
+
const result = await this.withPending('加载更多群成员', () => call(() => this.remote.listGroupMembers({
|
|
1064
|
+
groupDid: group.groupDid,
|
|
1065
|
+
cursor: this.groupMembersCursor,
|
|
1066
|
+
limit: 50,
|
|
1067
|
+
})));
|
|
1068
|
+
if (!result.ok)
|
|
1069
|
+
return result;
|
|
1070
|
+
if (result.value.pageGroup !== undefined && result.value.pageGroup !== group.groupDid) {
|
|
1071
|
+
return this.fail('群成员分页归属不一致,请刷新后重试');
|
|
1072
|
+
}
|
|
1073
|
+
this.groupMembersCursor = result.value.nextCursor;
|
|
1074
|
+
this.publish({
|
|
1075
|
+
...this.view,
|
|
1076
|
+
groupMembers: appendUnique(this.view.groupMembers, result.value.items, groupMemberKey),
|
|
1077
|
+
groupMembersHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
|
|
1078
|
+
});
|
|
1079
|
+
return { ok: true, value: undefined };
|
|
1080
|
+
}
|
|
1081
|
+
/** Invite one ordinary member, then replace snapshot and roster with authoritative reads. */
|
|
1082
|
+
async addSelectedGroupMember(memberInput) {
|
|
1083
|
+
const group = this.view.selectedGroup;
|
|
1084
|
+
const member = normalizeHandle(memberInput);
|
|
1085
|
+
if (group === null)
|
|
1086
|
+
return this.fail('请先打开一个群聊');
|
|
1087
|
+
if (member === '')
|
|
1088
|
+
return this.fail('请输入成员 Handle 或 DID');
|
|
1089
|
+
if (this.view.identity !== null && (member === this.view.identity.did || sameIdentity(this.view.identity, member))) {
|
|
1090
|
+
return this.fail('群成员列表不需要包含自己');
|
|
1091
|
+
}
|
|
1092
|
+
return await this.withPending('邀请群成员', async () => {
|
|
1093
|
+
const invited = await call(() => this.remote.addGroupMember({
|
|
1094
|
+
groupDid: group.groupDid,
|
|
1095
|
+
member,
|
|
1096
|
+
}));
|
|
1097
|
+
if (!invited.ok)
|
|
1098
|
+
return invited;
|
|
1099
|
+
const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
1100
|
+
if (!refreshed.ok) {
|
|
1101
|
+
return {
|
|
1102
|
+
ok: false,
|
|
1103
|
+
error: `已提交对 ${member} 的邀请,但成员列表刷新失败。请点击刷新查看最新状态。`,
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
return invited;
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
/** Remove one authorized member, then refresh count, roles, and roster from Core. */
|
|
1110
|
+
async removeSelectedGroupMember(member) {
|
|
1111
|
+
const group = this.view.selectedGroup;
|
|
1112
|
+
const reference = member.did ?? member.handle;
|
|
1113
|
+
if (group === null || reference === undefined)
|
|
1114
|
+
return this.fail('该成员缺少可用的身份标识');
|
|
1115
|
+
const result = await this.withPending('移除群成员', () => call(() => this.remote.removeGroupMember({
|
|
1116
|
+
groupDid: group.groupDid,
|
|
1117
|
+
member: reference,
|
|
1118
|
+
})));
|
|
1119
|
+
if (result.ok)
|
|
1120
|
+
await this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
/** Leave the selected group; Core prevents the owner from leaving. */
|
|
1124
|
+
async leaveSelectedGroup() {
|
|
1125
|
+
const group = this.view.selectedGroup;
|
|
1126
|
+
if (group === null)
|
|
1127
|
+
return this.fail('请先打开一个群聊');
|
|
1128
|
+
const generation = this.generation;
|
|
1129
|
+
const result = await this.withPending('退出群聊', () => call(() => this.remote.leaveGroup({ groupDid: group.groupDid })));
|
|
1130
|
+
if (!result.ok)
|
|
1131
|
+
return result;
|
|
1132
|
+
if (!this.current(generation))
|
|
1133
|
+
return { ok: true, value: undefined };
|
|
1134
|
+
this.groupMembersCursor = undefined;
|
|
1135
|
+
this.publish({
|
|
1136
|
+
...this.view,
|
|
1137
|
+
selectedConversationId: null,
|
|
1138
|
+
selectedGroup: null,
|
|
1139
|
+
groupAccess: null,
|
|
1140
|
+
groupMembers: [],
|
|
1141
|
+
groupMembersHasMore: false,
|
|
1142
|
+
messages: [],
|
|
1143
|
+
});
|
|
1144
|
+
await this.refreshConversations(generation);
|
|
1145
|
+
return { ok: true, value: undefined };
|
|
1146
|
+
}
|
|
560
1147
|
/**
|
|
561
1148
|
* Select a conversation and load its newest history page.
|
|
562
1149
|
* @param conversationId - selected conversation, or `null` to return to the roster.
|
|
@@ -569,6 +1156,7 @@ export class AwikiController {
|
|
|
569
1156
|
const sameConversation = conversationId !== null && previousConversationId === conversationId;
|
|
570
1157
|
const selectionRevision = ++this.selectionRevision;
|
|
571
1158
|
this.historyCursor = undefined;
|
|
1159
|
+
this.groupMembersCursor = undefined;
|
|
572
1160
|
const selected = conversationId === null
|
|
573
1161
|
? undefined
|
|
574
1162
|
: this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
@@ -577,12 +1165,20 @@ export class AwikiController {
|
|
|
577
1165
|
this.publish({
|
|
578
1166
|
...this.view,
|
|
579
1167
|
selectedConversationId: conversationId,
|
|
1168
|
+
selectedGroup: null,
|
|
1169
|
+
groupAccess: selected?.kind === 'group'
|
|
1170
|
+
? { groupDid: selected.groupDid, status: 'loading' }
|
|
1171
|
+
: null,
|
|
1172
|
+
groupMembers: [],
|
|
1173
|
+
groupMembersHasMore: false,
|
|
580
1174
|
messages: sameConversation ? this.view.messages : [],
|
|
581
1175
|
historyHasMore: false,
|
|
582
1176
|
localPending: conversationId !== null,
|
|
583
1177
|
refreshing: false,
|
|
584
1178
|
error: null,
|
|
585
1179
|
});
|
|
1180
|
+
if (selected?.kind === 'group')
|
|
1181
|
+
void this.loadGroupState(selected, true);
|
|
586
1182
|
if (conversationId === null)
|
|
587
1183
|
return { ok: true, value: undefined };
|
|
588
1184
|
const generation = this.generation;
|
|
@@ -618,6 +1214,73 @@ export class AwikiController {
|
|
|
618
1214
|
void this.refreshSelectedDirectProfile(selected, generation, selectionRevision);
|
|
619
1215
|
return { ok: true, value: undefined };
|
|
620
1216
|
}
|
|
1217
|
+
async loadGroupState(conversation, reset, preserveAvailableOnNetworkFailure = false) {
|
|
1218
|
+
const generation = this.generation;
|
|
1219
|
+
const selectionRevision = this.selectionRevision;
|
|
1220
|
+
const snapshot = await callGroupRead(() => this.remote.getGroup({ groupDid: conversation.groupDid }));
|
|
1221
|
+
if (!snapshot.ok) {
|
|
1222
|
+
const retainAvailable = preserveAvailableOnNetworkFailure
|
|
1223
|
+
&& snapshot.reason === 'network-error'
|
|
1224
|
+
&& this.view.groupAccess?.groupDid === conversation.groupDid
|
|
1225
|
+
&& this.view.groupAccess.status === 'available';
|
|
1226
|
+
if (!retainAvailable && this.currentSelection(generation, selectionRevision, conversation.id)) {
|
|
1227
|
+
this.publishGroupAccessFailure(conversation, snapshot.reason);
|
|
1228
|
+
}
|
|
1229
|
+
return snapshot;
|
|
1230
|
+
}
|
|
1231
|
+
if (this.currentSelection(generation, selectionRevision, conversation.id)) {
|
|
1232
|
+
const access = this.view.groupAccess;
|
|
1233
|
+
this.publish({
|
|
1234
|
+
...this.view,
|
|
1235
|
+
selectedGroup: snapshot.value,
|
|
1236
|
+
groupAccess: access?.groupDid === conversation.groupDid && access.status === 'available'
|
|
1237
|
+
? access
|
|
1238
|
+
: { groupDid: conversation.groupDid, status: 'loading' },
|
|
1239
|
+
error: null,
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const members = await callGroupRead(() => this.remote.listGroupMembers({ groupDid: conversation.groupDid, limit: 50 }));
|
|
1243
|
+
if (!members.ok) {
|
|
1244
|
+
const retainAvailable = preserveAvailableOnNetworkFailure
|
|
1245
|
+
&& members.reason === 'network-error'
|
|
1246
|
+
&& this.view.groupAccess?.groupDid === conversation.groupDid
|
|
1247
|
+
&& this.view.groupAccess.status === 'available';
|
|
1248
|
+
if (!retainAvailable && this.currentSelection(generation, selectionRevision, conversation.id)) {
|
|
1249
|
+
this.publishGroupAccessFailure(conversation, members.reason);
|
|
1250
|
+
}
|
|
1251
|
+
return members;
|
|
1252
|
+
}
|
|
1253
|
+
if (!this.currentSelection(generation, selectionRevision, conversation.id))
|
|
1254
|
+
return snapshot;
|
|
1255
|
+
if (members.value.pageGroup !== undefined && members.value.pageGroup !== conversation.groupDid) {
|
|
1256
|
+
return this.fail('群成员列表归属不一致,请刷新后重试');
|
|
1257
|
+
}
|
|
1258
|
+
this.groupMembersCursor = members.value.nextCursor;
|
|
1259
|
+
this.publish({
|
|
1260
|
+
...this.view,
|
|
1261
|
+
selectedGroup: snapshot.value,
|
|
1262
|
+
groupAccess: { groupDid: conversation.groupDid, status: 'available' },
|
|
1263
|
+
groupMembers: reset
|
|
1264
|
+
? members.value.items
|
|
1265
|
+
: appendUnique(this.view.groupMembers, members.value.items, groupMemberKey),
|
|
1266
|
+
groupMembersHasMore: members.value.hasMore && members.value.nextCursor !== undefined,
|
|
1267
|
+
});
|
|
1268
|
+
return snapshot;
|
|
1269
|
+
}
|
|
1270
|
+
async reloadSelectedGroupAfterMutation(expectedGroupDid) {
|
|
1271
|
+
const conversation = this.selectedConversation();
|
|
1272
|
+
if (conversation?.kind !== 'group' || conversation.groupDid !== expectedGroupDid) {
|
|
1273
|
+
return { ok: true, value: undefined };
|
|
1274
|
+
}
|
|
1275
|
+
const generation = this.generation;
|
|
1276
|
+
const group = await this.loadGroupState(conversation, true);
|
|
1277
|
+
if (!group.ok)
|
|
1278
|
+
return group;
|
|
1279
|
+
if (!this.current(generation))
|
|
1280
|
+
return { ok: true, value: undefined };
|
|
1281
|
+
await this.refreshConversations(generation, true);
|
|
1282
|
+
return { ok: true, value: undefined };
|
|
1283
|
+
}
|
|
621
1284
|
async reconcileSelectedConversation(conversationId, generation, selectionRevision) {
|
|
622
1285
|
const selected = this.view.conversations.find(conversation => conversation.id === conversationId);
|
|
623
1286
|
const remoteStartedAt = timingStart();
|
|
@@ -626,10 +1289,12 @@ export class AwikiController {
|
|
|
626
1289
|
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
627
1290
|
return;
|
|
628
1291
|
if (!remote.ok) {
|
|
1292
|
+
if (selected?.kind === 'group')
|
|
1293
|
+
this.publishGroupAccessFailure(selected, remote.reason);
|
|
629
1294
|
this.publish({
|
|
630
1295
|
...this.view,
|
|
631
1296
|
refreshing: false,
|
|
632
|
-
error: refreshFailureMessage(this.view.messages, remote.error),
|
|
1297
|
+
error: selected?.kind === 'group' ? null : refreshFailureMessage(this.view.messages, remote.error),
|
|
633
1298
|
});
|
|
634
1299
|
return;
|
|
635
1300
|
}
|
|
@@ -667,17 +1332,22 @@ export class AwikiController {
|
|
|
667
1332
|
}
|
|
668
1333
|
async readRemoteHistoryWithGroupReadiness(conversation, conversationId, generation, selectionRevision) {
|
|
669
1334
|
if (conversation?.kind !== 'group') {
|
|
670
|
-
|
|
1335
|
+
const result = await call(() => this.remote.getHistory({ conversationId }));
|
|
1336
|
+
return result.ok
|
|
1337
|
+
? result
|
|
1338
|
+
: { ...result, reason: 'network-error' };
|
|
671
1339
|
}
|
|
672
1340
|
for (const retryDelay of GROUP_HISTORY_RETRY_DELAYS_MS) {
|
|
673
|
-
const remote = await
|
|
1341
|
+
const remote = await callGroupRead(() => this.remote.getHistory({ conversationId }));
|
|
674
1342
|
if (remote.ok || !this.currentSelection(generation, selectionRevision, conversationId))
|
|
675
1343
|
return remote;
|
|
1344
|
+
if (remote.reason !== 'network-error')
|
|
1345
|
+
return remote;
|
|
676
1346
|
await delay(retryDelay);
|
|
677
1347
|
if (!this.currentSelection(generation, selectionRevision, conversationId))
|
|
678
1348
|
return remote;
|
|
679
1349
|
}
|
|
680
|
-
return
|
|
1350
|
+
return callGroupRead(() => this.remote.getHistory({ conversationId }));
|
|
681
1351
|
}
|
|
682
1352
|
async refreshSelectedDirectProfile(selected, generation, selectionRevision) {
|
|
683
1353
|
if (selected?.kind !== 'direct')
|
|
@@ -731,6 +1401,9 @@ export class AwikiController {
|
|
|
731
1401
|
if (conversation === undefined || (conversation.unreadCount ?? 0) <= 0) {
|
|
732
1402
|
return { ok: true, value: undefined };
|
|
733
1403
|
}
|
|
1404
|
+
if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
1405
|
+
return { ok: true, value: undefined };
|
|
1406
|
+
}
|
|
734
1407
|
const existing = this.markReadInFlight.get(conversation.id);
|
|
735
1408
|
if (existing !== undefined)
|
|
736
1409
|
return existing;
|
|
@@ -819,16 +1492,20 @@ export class AwikiController {
|
|
|
819
1492
|
* @param clientMessageId - optional logical identity shared with the optimistic row.
|
|
820
1493
|
* @returns successful delivery or one display-safe failure.
|
|
821
1494
|
*/
|
|
822
|
-
async sendText(text, clientMessageId) {
|
|
1495
|
+
async sendText(text, clientMessageId, mentions) {
|
|
823
1496
|
const conversation = this.selectedConversation();
|
|
824
1497
|
if (conversation === undefined)
|
|
825
1498
|
return this.fail('请先选择会话');
|
|
1499
|
+
if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
1500
|
+
return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
|
|
1501
|
+
}
|
|
826
1502
|
const conversationId = conversation.id;
|
|
827
1503
|
const generation = this.generation;
|
|
828
1504
|
const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
|
|
829
1505
|
target: targetOf(conversation),
|
|
830
1506
|
text,
|
|
831
1507
|
idempotencyKey: clientMessageId ?? crypto.randomUUID(),
|
|
1508
|
+
...mentions === undefined || mentions.length === 0 ? {} : { mentions },
|
|
832
1509
|
})));
|
|
833
1510
|
if (!result.ok)
|
|
834
1511
|
return result;
|
|
@@ -847,6 +1524,9 @@ export class AwikiController {
|
|
|
847
1524
|
const conversation = this.selectedConversation();
|
|
848
1525
|
if (conversation === undefined)
|
|
849
1526
|
return this.fail('请先选择会话');
|
|
1527
|
+
if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
1528
|
+
return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
|
|
1529
|
+
}
|
|
850
1530
|
const conversationId = conversation.id;
|
|
851
1531
|
const generation = this.generation;
|
|
852
1532
|
const request = {
|
|
@@ -919,6 +1599,7 @@ export class AwikiController {
|
|
|
919
1599
|
this.unreadAtOpen.clear();
|
|
920
1600
|
this.summaryBaselines.clear();
|
|
921
1601
|
this.clearPresentationCache();
|
|
1602
|
+
storeRecoveryOperation(null);
|
|
922
1603
|
this.publish({ ...INITIAL_VIEW, status: 'ready' });
|
|
923
1604
|
return result;
|
|
924
1605
|
}
|
|
@@ -928,8 +1609,74 @@ export class AwikiController {
|
|
|
928
1609
|
this.close();
|
|
929
1610
|
this.listeners.clear();
|
|
930
1611
|
}
|
|
1612
|
+
async loadConversationPreferences(generation) {
|
|
1613
|
+
const result = await call(() => this.remote.getConversationPreferences(), conversationPreferenceFailureMessage);
|
|
1614
|
+
if (!result.ok || !this.current(generation))
|
|
1615
|
+
return;
|
|
1616
|
+
this.applyConversationPreferences(result.value);
|
|
1617
|
+
this.publish({ ...this.view, hiddenConversations: this.hiddenConversationsView() });
|
|
1618
|
+
}
|
|
1619
|
+
applyConversationPreferences(preferences) {
|
|
1620
|
+
this.hiddenConversationPreferences.clear();
|
|
1621
|
+
for (const hidden of preferences.hiddenConversations) {
|
|
1622
|
+
this.hiddenConversationPreferences.set(hidden.conversation.id, {
|
|
1623
|
+
conversation: { ...hidden.conversation },
|
|
1624
|
+
hiddenAt: hidden.hiddenAt,
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
this.dismissedGroupRecoverySignature = preferences.dismissedGroupRecoverySignature;
|
|
1628
|
+
}
|
|
1629
|
+
hiddenConversationsView() {
|
|
1630
|
+
return [...this.hiddenConversationPreferences.values()]
|
|
1631
|
+
.sort((left, right) => right.hiddenAt - left.hiddenAt)
|
|
1632
|
+
.map(item => item.conversation);
|
|
1633
|
+
}
|
|
1634
|
+
currentGroupRecoveryView() {
|
|
1635
|
+
const summary = this.lastGroupRecoverySummary;
|
|
1636
|
+
if (summary === undefined)
|
|
1637
|
+
return this.view.groupRecovery;
|
|
1638
|
+
const hiddenGroupDids = new Set([...this.hiddenConversationPreferences.values()]
|
|
1639
|
+
.map(item => item.conversation)
|
|
1640
|
+
.filter((conversation) => conversation.kind === 'group')
|
|
1641
|
+
.map(conversation => conversation.groupDid));
|
|
1642
|
+
const projection = groupRecoveryProjection(summary, hiddenGroupDids);
|
|
1643
|
+
this.lastGroupRecoverySignature = projection.signature;
|
|
1644
|
+
return projection.signature === this.dismissedGroupRecoverySignature ? null : projection.view;
|
|
1645
|
+
}
|
|
1646
|
+
async reconcileConversationPage(incoming, generation) {
|
|
1647
|
+
const current = [...this.view.conversations, ...this.view.hiddenConversations];
|
|
1648
|
+
const conversations = incoming.map(item => this.cacheConversation(item, current.find(candidate => candidate.id === item.id)));
|
|
1649
|
+
for (const conversation of conversations) {
|
|
1650
|
+
const hidden = this.hiddenConversationPreferences.get(conversation.id);
|
|
1651
|
+
if (hidden === undefined)
|
|
1652
|
+
continue;
|
|
1653
|
+
const previousActivity = hidden.conversation.lastMessageAt ?? 0;
|
|
1654
|
+
const currentActivity = conversation.lastMessageAt ?? 0;
|
|
1655
|
+
if (currentActivity <= previousActivity)
|
|
1656
|
+
continue;
|
|
1657
|
+
const restored = await call(() => this.remote.updateConversationPreference({
|
|
1658
|
+
action: 'restore',
|
|
1659
|
+
conversationId: conversation.id,
|
|
1660
|
+
}), conversationPreferenceFailureMessage);
|
|
1661
|
+
if (!this.current(generation))
|
|
1662
|
+
return { visible: [], hidden: this.hiddenConversationsView() };
|
|
1663
|
+
if (restored.ok)
|
|
1664
|
+
this.applyConversationPreferences(restored.value);
|
|
1665
|
+
}
|
|
1666
|
+
const visible = [];
|
|
1667
|
+
for (const conversation of conversations) {
|
|
1668
|
+
const hidden = this.hiddenConversationPreferences.get(conversation.id);
|
|
1669
|
+
if (hidden === undefined) {
|
|
1670
|
+
visible.push(conversation);
|
|
1671
|
+
}
|
|
1672
|
+
else {
|
|
1673
|
+
this.hiddenConversationPreferences.set(conversation.id, { ...hidden, conversation });
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
return { visible, hidden: this.hiddenConversationsView() };
|
|
1677
|
+
}
|
|
931
1678
|
async refreshConversations(generation, background = false) {
|
|
932
|
-
const result = await
|
|
1679
|
+
const result = await this.listConversationPage({});
|
|
933
1680
|
if (!this.current(generation))
|
|
934
1681
|
return { ok: true, value: undefined };
|
|
935
1682
|
if (!result.ok)
|
|
@@ -937,15 +1684,16 @@ export class AwikiController {
|
|
|
937
1684
|
const firstPage = this.view.conversations.length === 0;
|
|
938
1685
|
if (firstPage)
|
|
939
1686
|
this.conversationsCursor = result.value.nextCursor;
|
|
940
|
-
const refreshed = result.value.items
|
|
941
|
-
|
|
942
|
-
return
|
|
943
|
-
});
|
|
1687
|
+
const refreshed = await this.reconcileConversationPage(result.value.items, generation);
|
|
1688
|
+
if (!this.current(generation))
|
|
1689
|
+
return { ok: true, value: undefined };
|
|
944
1690
|
this.publish({
|
|
945
1691
|
...this.view,
|
|
946
1692
|
conversations: firstPage
|
|
947
|
-
? refreshed
|
|
948
|
-
: appendUnique(refreshed, this.view.conversations, value => value.id),
|
|
1693
|
+
? refreshed.visible
|
|
1694
|
+
: appendUnique(refreshed.visible, this.view.conversations, value => value.id),
|
|
1695
|
+
hiddenConversations: refreshed.hidden,
|
|
1696
|
+
groupRecovery: this.currentGroupRecoveryView(),
|
|
949
1697
|
conversationsHasMore: firstPage
|
|
950
1698
|
? result.value.hasMore && result.value.nextCursor !== undefined
|
|
951
1699
|
: this.view.conversationsHasMore,
|
|
@@ -953,16 +1701,109 @@ export class AwikiController {
|
|
|
953
1701
|
});
|
|
954
1702
|
return { ok: true, value: undefined };
|
|
955
1703
|
}
|
|
1704
|
+
/** List the active identity's own conversations and detect a revoked local credential. */
|
|
1705
|
+
async listConversationPage(request) {
|
|
1706
|
+
const result = await callWithFailureCode(() => this.remote.listConversations(request));
|
|
1707
|
+
if (!result.ok && (result.failureCode === 'identity-recovery-required' || result.failureCode === 'forbidden')) {
|
|
1708
|
+
this.enterIdentityRecoveryRequired();
|
|
1709
|
+
return { ok: false, error: '当前设备的 AWiki 身份凭证已失效,请重新恢复身份。' };
|
|
1710
|
+
}
|
|
1711
|
+
return result.ok ? result : { ok: false, error: result.error };
|
|
1712
|
+
}
|
|
1713
|
+
/** Replace only visible browser projections; Core identity and SQLite state remain untouched. */
|
|
1714
|
+
enterIdentityRecoveryRequired() {
|
|
1715
|
+
if (this.view.identity === null)
|
|
1716
|
+
return;
|
|
1717
|
+
this.close();
|
|
1718
|
+
this.conversationsCursor = undefined;
|
|
1719
|
+
this.historyCursor = undefined;
|
|
1720
|
+
this.groupMembersCursor = undefined;
|
|
1721
|
+
this.unreadAtOpen.clear();
|
|
1722
|
+
this.summaryBaselines.clear();
|
|
1723
|
+
this.groupRecoveryItems.clear();
|
|
1724
|
+
this.lastGroupRecoverySignature = undefined;
|
|
1725
|
+
this.lastGroupRecoverySummary = undefined;
|
|
1726
|
+
this.clearImageAttachments();
|
|
1727
|
+
this.publish({
|
|
1728
|
+
...this.view,
|
|
1729
|
+
status: 'ready',
|
|
1730
|
+
sessionStatus: 'recovery-required',
|
|
1731
|
+
conversations: Object.freeze([]),
|
|
1732
|
+
hiddenConversations: Object.freeze([]),
|
|
1733
|
+
conversationsHasMore: false,
|
|
1734
|
+
selectedConversationId: null,
|
|
1735
|
+
selectedGroup: null,
|
|
1736
|
+
groupAccess: null,
|
|
1737
|
+
groupMembers: Object.freeze([]),
|
|
1738
|
+
groupMembersHasMore: false,
|
|
1739
|
+
groupRecovery: null,
|
|
1740
|
+
messages: Object.freeze([]),
|
|
1741
|
+
historyHasMore: false,
|
|
1742
|
+
localPending: false,
|
|
1743
|
+
refreshing: false,
|
|
1744
|
+
pending: null,
|
|
1745
|
+
error: null,
|
|
1746
|
+
summaries: Object.freeze({}),
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
/** Hydrate account-projected groups before asking Core to resume their durable rebind jobs. */
|
|
1750
|
+
async refreshConversationsAndRecoverGroups(generation, background = false) {
|
|
1751
|
+
const listed = await this.refreshConversations(generation, background);
|
|
1752
|
+
if (!listed.ok || !this.current(generation))
|
|
1753
|
+
return listed;
|
|
1754
|
+
await this.resumeGroupRebindRecovery(generation);
|
|
1755
|
+
return listed;
|
|
1756
|
+
}
|
|
1757
|
+
resumeGroupRebindRecovery(generation) {
|
|
1758
|
+
const active = this.groupRecoveryOperation;
|
|
1759
|
+
if (active !== undefined && active.generation === generation)
|
|
1760
|
+
return active.operation;
|
|
1761
|
+
let operation;
|
|
1762
|
+
operation = call(() => this.remote.resumeGroupRebindRecovery(), groupRecoveryFailureMessage).then((result) => {
|
|
1763
|
+
if (!this.current(generation))
|
|
1764
|
+
return result;
|
|
1765
|
+
if (result.ok) {
|
|
1766
|
+
this.lastGroupRecoverySummary = result.value;
|
|
1767
|
+
this.groupRecoveryItems.clear();
|
|
1768
|
+
for (const item of result.value.items) {
|
|
1769
|
+
this.groupRecoveryItems.set(item.groupDid, item.status);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
else {
|
|
1773
|
+
this.lastGroupRecoverySummary = undefined;
|
|
1774
|
+
this.lastGroupRecoverySignature = 'unavailable';
|
|
1775
|
+
}
|
|
1776
|
+
this.publish({
|
|
1777
|
+
...this.view,
|
|
1778
|
+
groupRecovery: result.ok
|
|
1779
|
+
? this.currentGroupRecoveryView()
|
|
1780
|
+
: this.dismissedGroupRecoverySignature === 'unavailable'
|
|
1781
|
+
? null
|
|
1782
|
+
: { status: 'unavailable', pending: 0, blocked: 0 },
|
|
1783
|
+
});
|
|
1784
|
+
return result;
|
|
1785
|
+
}).finally(() => {
|
|
1786
|
+
if (this.groupRecoveryOperation?.operation === operation)
|
|
1787
|
+
this.groupRecoveryOperation = undefined;
|
|
1788
|
+
});
|
|
1789
|
+
this.groupRecoveryOperation = { generation, operation };
|
|
1790
|
+
return operation;
|
|
1791
|
+
}
|
|
956
1792
|
async loadHistory(older) {
|
|
957
1793
|
const conversationId = this.view.selectedConversationId;
|
|
958
1794
|
if (conversationId === null)
|
|
959
1795
|
return this.fail('请先选择会话');
|
|
1796
|
+
const selectedConversation = this.selectedConversation();
|
|
1797
|
+
if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
1798
|
+
return { ok: false, error: '当前群聊仅可查看本机已有记录,请先重新检查群成员状态。' };
|
|
1799
|
+
}
|
|
960
1800
|
const generation = this.generation;
|
|
961
1801
|
const request = {
|
|
962
1802
|
conversationId,
|
|
963
1803
|
...(older && this.historyCursor !== undefined ? { cursor: this.historyCursor } : {}),
|
|
964
1804
|
};
|
|
965
|
-
const
|
|
1805
|
+
const selected = this.view.conversations.find(value => value.id === conversationId);
|
|
1806
|
+
const result = await this.withPending(older ? '加载更早消息' : '加载消息', () => call(() => this.remote.getHistory(request), selected?.kind === 'group' ? groupReadFailureMessage : undefined));
|
|
966
1807
|
if (!result.ok)
|
|
967
1808
|
return result;
|
|
968
1809
|
if (!this.current(generation))
|
|
@@ -991,10 +1832,19 @@ export class AwikiController {
|
|
|
991
1832
|
return;
|
|
992
1833
|
this.polling = true;
|
|
993
1834
|
try {
|
|
994
|
-
|
|
1835
|
+
const knownGroups = new Set(this.view.conversations
|
|
1836
|
+
.filter(conversation => conversation.kind === 'group')
|
|
1837
|
+
.map(conversation => conversation.groupDid));
|
|
1838
|
+
const listed = await this.refreshConversations(generation, true);
|
|
1839
|
+
if (listed.ok && this.view.conversations.some(conversation => conversation.kind === 'group' && !knownGroups.has(conversation.groupDid))) {
|
|
1840
|
+
await this.resumeGroupRebindRecovery(generation);
|
|
1841
|
+
}
|
|
995
1842
|
const selected = this.view.selectedConversationId;
|
|
996
1843
|
if (selected === null || !this.current(generation))
|
|
997
1844
|
return;
|
|
1845
|
+
const selectedConversation = this.selectedConversation();
|
|
1846
|
+
if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available')
|
|
1847
|
+
return;
|
|
998
1848
|
const result = await call(() => this.remote.getHistory({ conversationId: selected }));
|
|
999
1849
|
if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
|
|
1000
1850
|
return;
|
|
@@ -1019,7 +1869,7 @@ export class AwikiController {
|
|
|
1019
1869
|
this.polling = false;
|
|
1020
1870
|
}
|
|
1021
1871
|
}
|
|
1022
|
-
async withPending(label, operation) {
|
|
1872
|
+
async withPending(label, operation, options = {}) {
|
|
1023
1873
|
if (this.disposed)
|
|
1024
1874
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
1025
1875
|
const generation = this.generation;
|
|
@@ -1027,7 +1877,11 @@ export class AwikiController {
|
|
|
1027
1877
|
const result = await operation();
|
|
1028
1878
|
if (!this.current(generation))
|
|
1029
1879
|
return result;
|
|
1030
|
-
this.publish({
|
|
1880
|
+
this.publish({
|
|
1881
|
+
...this.view,
|
|
1882
|
+
pending: null,
|
|
1883
|
+
error: result.ok || options.publishFailure === false ? null : result.error,
|
|
1884
|
+
});
|
|
1031
1885
|
return result;
|
|
1032
1886
|
}
|
|
1033
1887
|
appendMessage(message) {
|
|
@@ -1078,6 +1932,21 @@ export class AwikiController {
|
|
|
1078
1932
|
const selected = this.view.selectedConversationId;
|
|
1079
1933
|
return selected === null ? undefined : this.view.conversations.find(value => value.id === selected);
|
|
1080
1934
|
}
|
|
1935
|
+
publishGroupAccessFailure(conversation, reason) {
|
|
1936
|
+
const journal = this.groupRecoveryItems.get(conversation.groupDid);
|
|
1937
|
+
const status = reason === 'not-member'
|
|
1938
|
+
? journal === 'blocked'
|
|
1939
|
+
? 'blocked'
|
|
1940
|
+
: journal === 'pending'
|
|
1941
|
+
? 'recovering'
|
|
1942
|
+
: 'not-member'
|
|
1943
|
+
: reason;
|
|
1944
|
+
this.publish({
|
|
1945
|
+
...this.view,
|
|
1946
|
+
groupAccess: { groupDid: conversation.groupDid, status },
|
|
1947
|
+
error: null,
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1081
1950
|
/** Keep presentation-only cache entries isolated to one authenticated identity. */
|
|
1082
1951
|
activatePresentationCache(identity) {
|
|
1083
1952
|
const ownerDid = identity?.did ?? null;
|
|
@@ -1085,6 +1954,11 @@ export class AwikiController {
|
|
|
1085
1954
|
return;
|
|
1086
1955
|
this.directProfiles.clear();
|
|
1087
1956
|
this.groupTitles.clear();
|
|
1957
|
+
this.groupRecoveryItems.clear();
|
|
1958
|
+
this.hiddenConversationPreferences.clear();
|
|
1959
|
+
this.dismissedGroupRecoverySignature = undefined;
|
|
1960
|
+
this.lastGroupRecoverySignature = undefined;
|
|
1961
|
+
this.lastGroupRecoverySummary = undefined;
|
|
1088
1962
|
this.clearImageAttachments();
|
|
1089
1963
|
this.presentationCacheOwnerDid = ownerDid;
|
|
1090
1964
|
}
|
|
@@ -1092,6 +1966,11 @@ export class AwikiController {
|
|
|
1092
1966
|
clearPresentationCache() {
|
|
1093
1967
|
this.directProfiles.clear();
|
|
1094
1968
|
this.groupTitles.clear();
|
|
1969
|
+
this.groupRecoveryItems.clear();
|
|
1970
|
+
this.hiddenConversationPreferences.clear();
|
|
1971
|
+
this.dismissedGroupRecoverySignature = undefined;
|
|
1972
|
+
this.lastGroupRecoverySignature = undefined;
|
|
1973
|
+
this.lastGroupRecoverySummary = undefined;
|
|
1095
1974
|
this.clearImageAttachments();
|
|
1096
1975
|
this.presentationCacheOwnerDid = null;
|
|
1097
1976
|
}
|