@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.
Files changed (124) hide show
  1. package/README.md +69 -28
  2. package/README.zh.md +60 -21
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/cordis.patch.yml +0 -8
  5. package/lib/client.js +6755 -2022
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +1321 -72
  8. package/lib/model-proxy-contract.js +8 -1
  9. package/lib/provider.js +3 -1
  10. package/lib/{sdk-adapter-DKgkyApB.mjs → sdk-adapter-BIbsJmRj.mjs} +245 -4
  11. package/lib/typert.host.js +944 -57
  12. package/lib/typert.remote-client.d.ts +37 -1
  13. package/lib/typert.remote-client.d.ts.map +1 -1
  14. package/lib/typert.remote-client.js +944 -57
  15. package/lib/types/client/AwikiGroupAccessNotice.d.ts +12 -0
  16. package/lib/types/client/AwikiGroupAccessNotice.d.ts.map +1 -0
  17. package/lib/types/client/AwikiGroupAccessNotice.js +31 -0
  18. package/lib/types/client/AwikiGroupAccessNotice.js.map +1 -0
  19. package/lib/types/client/AwikiGroupDetails.d.ts +23 -0
  20. package/lib/types/client/AwikiGroupDetails.d.ts.map +1 -0
  21. package/lib/types/client/AwikiGroupDetails.js +144 -0
  22. package/lib/types/client/AwikiGroupDetails.js.map +1 -0
  23. package/lib/types/client/AwikiIdentityAccess.d.ts +22 -0
  24. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -0
  25. package/lib/types/client/AwikiIdentityAccess.js +156 -0
  26. package/lib/types/client/AwikiIdentityAccess.js.map +1 -0
  27. package/lib/types/client/AwikiIdentityPage.d.ts +11 -0
  28. package/lib/types/client/AwikiIdentityPage.d.ts.map +1 -0
  29. package/lib/types/client/AwikiIdentityPage.js +8 -0
  30. package/lib/types/client/AwikiIdentityPage.js.map +1 -0
  31. package/lib/types/client/AwikiMail.d.ts +2 -1
  32. package/lib/types/client/AwikiMail.d.ts.map +1 -1
  33. package/lib/types/client/AwikiMail.js +157 -33
  34. package/lib/types/client/AwikiMail.js.map +1 -1
  35. package/lib/types/client/AwikiOnboarding.d.ts.map +1 -1
  36. package/lib/types/client/AwikiOnboarding.js +29 -9
  37. package/lib/types/client/AwikiOnboarding.js.map +1 -1
  38. package/lib/types/client/AwikiOverlay.d.ts +0 -5
  39. package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
  40. package/lib/types/client/AwikiOverlay.js +202 -129
  41. package/lib/types/client/AwikiOverlay.js.map +1 -1
  42. package/lib/types/client/AwikiProfileCard.d.ts +9 -0
  43. package/lib/types/client/AwikiProfileCard.d.ts.map +1 -0
  44. package/lib/types/client/AwikiProfileCard.js +90 -0
  45. package/lib/types/client/AwikiProfileCard.js.map +1 -0
  46. package/lib/types/client/AwikiRecoveryForm.d.ts +27 -0
  47. package/lib/types/client/AwikiRecoveryForm.d.ts.map +1 -0
  48. package/lib/types/client/AwikiRecoveryForm.js +152 -0
  49. package/lib/types/client/AwikiRecoveryForm.js.map +1 -0
  50. package/lib/types/client/AwikiSettingsSection.d.ts +0 -2
  51. package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
  52. package/lib/types/client/AwikiSettingsSection.js +18 -16
  53. package/lib/types/client/AwikiSettingsSection.js.map +1 -1
  54. package/lib/types/client/MentionText.d.ts +7 -0
  55. package/lib/types/client/MentionText.d.ts.map +1 -0
  56. package/lib/types/client/MentionText.js +10 -0
  57. package/lib/types/client/MentionText.js.map +1 -0
  58. package/lib/types/client/controller.d.ts +131 -3
  59. package/lib/types/client/controller.d.ts.map +1 -1
  60. package/lib/types/client/controller.js +908 -29
  61. package/lib/types/client/controller.js.map +1 -1
  62. package/lib/types/client/index.d.ts.map +1 -1
  63. package/lib/types/client/index.js +24 -4
  64. package/lib/types/client/index.js.map +1 -1
  65. package/lib/types/client/mail-list-cache.d.ts +15 -0
  66. package/lib/types/client/mail-list-cache.d.ts.map +1 -0
  67. package/lib/types/client/mail-list-cache.js +212 -0
  68. package/lib/types/client/mail-list-cache.js.map +1 -0
  69. package/lib/types/client/mentions.d.ts +51 -0
  70. package/lib/types/client/mentions.d.ts.map +1 -0
  71. package/lib/types/client/mentions.js +165 -0
  72. package/lib/types/client/mentions.js.map +1 -0
  73. package/lib/types/client/model-proxy-controller.d.ts +4 -0
  74. package/lib/types/client/model-proxy-controller.d.ts.map +1 -1
  75. package/lib/types/client/model-proxy-controller.js +68 -3
  76. package/lib/types/client/model-proxy-controller.js.map +1 -1
  77. package/lib/types/client/recharge-availability.d.ts +1 -1
  78. package/lib/types/client/recharge-availability.d.ts.map +1 -1
  79. package/lib/types/client/recharge-availability.js +1 -1
  80. package/lib/types/client/recharge-availability.js.map +1 -1
  81. package/lib/types/client/settings-locales.d.ts +1 -1
  82. package/lib/types/client/settings-locales.d.ts.map +1 -1
  83. package/lib/types/client/settings-locales.js +14 -8
  84. package/lib/types/client/settings-locales.js.map +1 -1
  85. package/lib/types/client/slots.d.ts +27 -2
  86. package/lib/types/client/slots.d.ts.map +1 -1
  87. package/lib/types/conversation-preferences.d.ts +20 -0
  88. package/lib/types/conversation-preferences.d.ts.map +1 -0
  89. package/lib/types/conversation-preferences.js +294 -0
  90. package/lib/types/conversation-preferences.js.map +1 -0
  91. package/lib/types/index.d.ts +45 -6
  92. package/lib/types/index.d.ts.map +1 -1
  93. package/lib/types/index.js +562 -23
  94. package/lib/types/index.js.map +1 -1
  95. package/lib/types/model-proxy-contract.d.ts +6 -0
  96. package/lib/types/model-proxy-contract.d.ts.map +1 -1
  97. package/lib/types/model-proxy-contract.js +6 -0
  98. package/lib/types/model-proxy-contract.js.map +1 -1
  99. package/lib/types/provider-api.d.ts +29 -1
  100. package/lib/types/provider-api.d.ts.map +1 -1
  101. package/lib/types/provider.d.ts.map +1 -1
  102. package/lib/types/provider.js +2 -0
  103. package/lib/types/provider.js.map +1 -1
  104. package/lib/types/sdk-adapter.d.ts +18 -1
  105. package/lib/types/sdk-adapter.d.ts.map +1 -1
  106. package/lib/types/sdk-adapter.js +277 -4
  107. package/lib/types/sdk-adapter.js.map +1 -1
  108. package/lib/types/sent-mail-store.d.ts +22 -0
  109. package/lib/types/sent-mail-store.d.ts.map +1 -0
  110. package/lib/types/sent-mail-store.js +246 -0
  111. package/lib/types/sent-mail-store.js.map +1 -0
  112. package/lib/types/types.d.ts +183 -2
  113. package/lib/types/types.d.ts.map +1 -1
  114. package/lib/types/types.js.map +1 -1
  115. package/package.json +15 -26
  116. package/lib/model-proxy.js +0 -383
  117. package/lib/types/client/model-account-settings-availability.d.ts +0 -4
  118. package/lib/types/client/model-account-settings-availability.d.ts.map +0 -1
  119. package/lib/types/client/model-account-settings-availability.js +0 -4
  120. package/lib/types/client/model-account-settings-availability.js.map +0 -1
  121. package/lib/types/model-proxy.d.ts +0 -14
  122. package/lib/types/model-proxy.d.ts.map +0 -1
  123. package/lib/types/model-proxy.js +0 -371
  124. 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 已存在,无法重复注册。请更换一个未使用的 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: `连接 AWiki Host 失败:${carried.error.message}` };
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.refreshConversations(generation);
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.refreshConversations(generation);
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('加载更多会话', () => call(() => this.remote.listConversations(this.conversationsCursor === undefined ? {} : { cursor: this.conversationsCursor })));
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.map(incoming => this.cacheConversation(incoming, this.view.conversations.find(current => current.id === incoming.id)));
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
- return call(() => this.remote.getHistory({ conversationId }));
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 call(() => this.remote.getHistory({ conversationId }));
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 call(() => this.remote.getHistory({ conversationId }));
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 call(() => this.remote.listConversations({}));
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.map((incoming) => {
941
- const current = this.view.conversations.find(value => value.id === incoming.id);
942
- return this.cacheConversation(incoming, current);
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 result = await this.withPending(older ? '加载更早消息' : '加载消息', () => call(() => this.remote.getHistory(request)));
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
- await this.refreshConversations(generation, true);
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({ ...this.view, pending: null, error: result.ok ? null : result.error });
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
  }