@awiki/dsh-plugin 0.3.0-rc.1 → 0.3.0

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 (91) hide show
  1. package/README.md +17 -16
  2. package/README.zh.md +15 -11
  3. package/THIRD_PARTY_NOTICES.md +0 -7
  4. package/lib/client.js +5572 -8534
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +373 -36
  7. package/lib/provider.js +1 -1
  8. package/lib/{sdk-adapter-DLhLrLI6.mjs → sdk-adapter-BIbsJmRj.mjs} +28 -4
  9. package/lib/typert.host.js +221 -81
  10. package/lib/typert.remote-client.d.ts +5 -1
  11. package/lib/typert.remote-client.d.ts.map +1 -1
  12. package/lib/typert.remote-client.js +221 -81
  13. package/lib/types/client/AwikiGroupAccessNotice.d.ts +12 -0
  14. package/lib/types/client/AwikiGroupAccessNotice.d.ts.map +1 -0
  15. package/lib/types/client/AwikiGroupAccessNotice.js +31 -0
  16. package/lib/types/client/AwikiGroupAccessNotice.js.map +1 -0
  17. package/lib/types/client/AwikiGroupDetails.d.ts +8 -1
  18. package/lib/types/client/AwikiGroupDetails.d.ts.map +1 -1
  19. package/lib/types/client/AwikiGroupDetails.js +40 -4
  20. package/lib/types/client/AwikiGroupDetails.js.map +1 -1
  21. package/lib/types/client/AwikiIdentityAccess.d.ts +2 -1
  22. package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
  23. package/lib/types/client/AwikiIdentityAccess.js +10 -2
  24. package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
  25. package/lib/types/client/AwikiMail.d.ts.map +1 -1
  26. package/lib/types/client/AwikiMail.js +5 -1
  27. package/lib/types/client/AwikiMail.js.map +1 -1
  28. package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
  29. package/lib/types/client/AwikiOverlay.js +72 -11
  30. package/lib/types/client/AwikiOverlay.js.map +1 -1
  31. package/lib/types/client/AwikiRecoveryForm.d.ts +3 -0
  32. package/lib/types/client/AwikiRecoveryForm.d.ts.map +1 -1
  33. package/lib/types/client/AwikiRecoveryForm.js +10 -6
  34. package/lib/types/client/AwikiRecoveryForm.js.map +1 -1
  35. package/lib/types/client/AwikiSettingsSection.d.ts +4 -18
  36. package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
  37. package/lib/types/client/AwikiSettingsSection.js +7 -271
  38. package/lib/types/client/AwikiSettingsSection.js.map +1 -1
  39. package/lib/types/client/awiki-client-bridge.d.ts +18 -0
  40. package/lib/types/client/awiki-client-bridge.d.ts.map +1 -0
  41. package/lib/types/client/awiki-client-bridge.js +18 -0
  42. package/lib/types/client/awiki-client-bridge.js.map +1 -0
  43. package/lib/types/client/controller.d.ts +38 -2
  44. package/lib/types/client/controller.d.ts.map +1 -1
  45. package/lib/types/client/controller.js +473 -47
  46. package/lib/types/client/controller.js.map +1 -1
  47. package/lib/types/client/index.d.ts +3 -3
  48. package/lib/types/client/index.d.ts.map +1 -1
  49. package/lib/types/client/index.js +7 -46
  50. package/lib/types/client/index.js.map +1 -1
  51. package/lib/types/client/settings-locales.d.ts +2 -5
  52. package/lib/types/client/settings-locales.d.ts.map +1 -1
  53. package/lib/types/client/settings-locales.js +3 -183
  54. package/lib/types/client/settings-locales.js.map +1 -1
  55. package/lib/types/client/slots.d.ts +6 -0
  56. package/lib/types/client/slots.d.ts.map +1 -1
  57. package/lib/types/conversation-preferences.d.ts +20 -0
  58. package/lib/types/conversation-preferences.d.ts.map +1 -0
  59. package/lib/types/conversation-preferences.js +294 -0
  60. package/lib/types/conversation-preferences.js.map +1 -0
  61. package/lib/types/index.d.ts +6 -1
  62. package/lib/types/index.d.ts.map +1 -1
  63. package/lib/types/index.js +39 -0
  64. package/lib/types/index.js.map +1 -1
  65. package/lib/types/sdk-adapter.d.ts.map +1 -1
  66. package/lib/types/sdk-adapter.js +35 -4
  67. package/lib/types/sdk-adapter.js.map +1 -1
  68. package/lib/types/types.d.ts +32 -1
  69. package/lib/types/types.d.ts.map +1 -1
  70. package/lib/types/types.js.map +1 -1
  71. package/package.json +57 -55
  72. package/lib/types/client/AwikiOnboarding.d.ts +0 -22
  73. package/lib/types/client/AwikiOnboarding.d.ts.map +0 -1
  74. package/lib/types/client/AwikiOnboarding.js +0 -104
  75. package/lib/types/client/AwikiOnboarding.js.map +0 -1
  76. package/lib/types/client/RechargeComingSoonDialog.d.ts +0 -11
  77. package/lib/types/client/RechargeComingSoonDialog.d.ts.map +0 -1
  78. package/lib/types/client/RechargeComingSoonDialog.js +0 -8
  79. package/lib/types/client/RechargeComingSoonDialog.js.map +0 -1
  80. package/lib/types/client/model-availability-controller.d.ts +0 -27
  81. package/lib/types/client/model-availability-controller.d.ts.map +0 -1
  82. package/lib/types/client/model-availability-controller.js +0 -111
  83. package/lib/types/client/model-availability-controller.js.map +0 -1
  84. package/lib/types/client/model-proxy-controller.d.ts +0 -45
  85. package/lib/types/client/model-proxy-controller.d.ts.map +0 -1
  86. package/lib/types/client/model-proxy-controller.js +0 -306
  87. package/lib/types/client/model-proxy-controller.js.map +0 -1
  88. package/lib/types/client/recharge-availability.d.ts +0 -6
  89. package/lib/types/client/recharge-availability.d.ts.map +0 -1
  90. package/lib/types/client/recharge-availability.js +0 -6
  91. package/lib/types/client/recharge-availability.js.map +0 -1
@@ -44,6 +44,43 @@ 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
+ }
47
84
  function identityAccessInspectionFailureMessage(failure) {
48
85
  switch (failure.code) {
49
86
  case 'invalid-request':
@@ -62,9 +99,11 @@ const INITIAL_VIEW = Object.freeze({
62
99
  identity: null,
63
100
  profile: null,
64
101
  conversations: Object.freeze([]),
102
+ hiddenConversations: Object.freeze([]),
65
103
  conversationsHasMore: false,
66
104
  selectedConversationId: null,
67
105
  selectedGroup: null,
106
+ groupAccess: null,
68
107
  groupMembers: Object.freeze([]),
69
108
  groupMembersHasMore: false,
70
109
  groupRecovery: null,
@@ -142,13 +181,66 @@ function groupRecoveryFailureMessage(failure) {
142
181
  ? '无法连接 AWiki 服务,暂时不能恢复旧群聊身份。'
143
182
  : '暂时不能检查旧群聊身份,请稍后重试。';
144
183
  }
145
- function groupRecoveryView(summary) {
146
- if (summary.blocked > 0) {
147
- return { status: 'blocked', pending: summary.pending, blocked: summary.blocked };
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' };
148
243
  }
149
- return summary.pending > 0
150
- ? { status: 'pending', pending: summary.pending, blocked: 0 }
151
- : null;
152
244
  }
153
245
  /** Flatten the carrier and business result once for every controller caller. */
154
246
  async function call(operation, failureMessage = failure => `${failure.code}:${failure.message}`, carrierFailureMessage = message => `连接 AWiki Host 失败:${message}`) {
@@ -168,6 +260,28 @@ async function call(operation, failureMessage = failure => `${failure.code}:${
168
260
  };
169
261
  }
170
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
+ }
171
285
  function recoveryCarrierFailureMessage(message) {
172
286
  return message.includes('business result failed boundary validation')
173
287
  ? '恢复信息已验证,但暂时无法读取恢复状态。请稍后重试。'
@@ -354,6 +468,13 @@ export class AwikiController {
354
468
  directProfiles = new Map();
355
469
  /** Last trustworthy group title for the active identity, keyed by canonical Group DID. */
356
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;
357
478
  /** Verified image payloads retained outside observable state for instant remounts. */
358
479
  imageAttachments = new Map();
359
480
  imageAttachmentCacheBytes = 0;
@@ -405,6 +526,7 @@ export class AwikiController {
405
526
  recoveryOperationId: storedRecoveryOperation(),
406
527
  });
407
528
  if (identity !== null) {
529
+ await this.loadConversationPreferences(generation);
408
530
  const profile = await call(() => this.remote.getProfile());
409
531
  if (this.current(generation) && profile.ok)
410
532
  this.publish({ ...this.view, profile: profile.value });
@@ -556,7 +678,7 @@ export class AwikiController {
556
678
  }
557
679
  /** Request a recovery OTP and persist only its secret-free operation id in the browser. */
558
680
  async sendRecoveryOtp(request) {
559
- const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage));
681
+ const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false });
560
682
  if (!result.ok)
561
683
  return result;
562
684
  storeRecoveryOperation(result.value.operationId);
@@ -568,7 +690,7 @@ export class AwikiController {
568
690
  const operationId = this.view.recoveryOperationId;
569
691
  if (operationId === null)
570
692
  return this.fail('请先获取恢复验证码');
571
- const result = await this.withPending('验证恢复信息', () => call(() => this.remote.prepareRecovery({ ...request, operationId }), registrationFailureMessage, recoveryCarrierFailureMessage));
693
+ const result = await this.withPending('验证恢复信息', () => call(() => this.remote.prepareRecovery({ ...request, operationId }), recoveryPreparationFailureMessage, recoveryCarrierFailureMessage), { publishFailure: false });
572
694
  if (result.ok)
573
695
  this.publish({ ...this.view, recoveryProgress: result.value });
574
696
  return result;
@@ -580,7 +702,8 @@ export class AwikiController {
580
702
  return this.fail('没有可继续的身份恢复操作');
581
703
  if (this.view.recoveryProgress?.phase !== 'ready_to_commit')
582
704
  return this.fail('请先完成恢复信息验证');
583
- const result = await this.withPending('恢复身份', () => call(() => this.remote.activateRecovery({ operationId }), registrationFailureMessage, recoveryCarrierFailureMessage));
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 });
584
707
  if (!result.ok)
585
708
  return result;
586
709
  this.publish({ ...this.view, recoveryProgress: result.value });
@@ -596,7 +719,7 @@ export class AwikiController {
596
719
  const operationId = this.view.recoveryOperationId;
597
720
  if (operationId === null)
598
721
  return this.fail('没有可查询的身份恢复操作');
599
- const result = await this.withPending('刷新恢复状态', () => call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage));
722
+ const result = await this.withPending('刷新恢复状态', () => call(() => this.remote.getRecoveryStatus({ operationId }), undefined, recoveryCarrierFailureMessage), { publishFailure: false });
600
723
  if (!result.ok)
601
724
  return result;
602
725
  this.publish({ ...this.view, recoveryProgress: result.value });
@@ -616,7 +739,7 @@ export class AwikiController {
616
739
  if (!progress.retryable && !['remote_outcome_unknown', 'remote_committed', 'identity_transition_pending'].includes(progress.phase)) {
617
740
  return this.fail('当前恢复状态不能重试');
618
741
  }
619
- const result = await this.withPending('继续恢复身份', () => call(() => this.remote.resumeRecovery({ operationId }), registrationFailureMessage, recoveryCarrierFailureMessage));
742
+ const result = await this.withPending('继续恢复身份', () => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage), { publishFailure: false });
620
743
  if (!result.ok)
621
744
  return result;
622
745
  this.publish({ ...this.view, recoveryProgress: result.value });
@@ -636,7 +759,7 @@ export class AwikiController {
636
759
  if (phase !== undefined && phase !== 'awaiting_factor' && phase !== 'ready_to_commit') {
637
760
  return this.fail('当前恢复状态不能取消');
638
761
  }
639
- const result = await this.withPending('取消身份恢复', () => call(() => this.remote.discardRecovery({ operationId })));
762
+ const result = await this.withPending('取消身份恢复', () => call(() => this.remote.discardRecovery({ operationId })), { publishFailure: false });
640
763
  if (!result.ok)
641
764
  return result;
642
765
  storeRecoveryOperation(null);
@@ -669,16 +792,20 @@ export class AwikiController {
669
792
  */
670
793
  async loadMoreConversations() {
671
794
  const generation = this.generation;
672
- 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 }));
673
796
  if (!result.ok)
674
797
  return result;
675
798
  if (!this.current(generation))
676
799
  return { ok: true, value: undefined };
677
800
  this.conversationsCursor = result.value.nextCursor;
678
- const conversations = result.value.items.map(incoming => this.cacheConversation(incoming, this.view.conversations.find(current => current.id === incoming.id)));
801
+ const conversations = await this.reconcileConversationPage(result.value.items, generation);
802
+ if (!this.current(generation))
803
+ return { ok: true, value: undefined };
679
804
  this.publish({
680
805
  ...this.view,
681
- 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(),
682
809
  conversationsHasMore: result.value.hasMore && result.value.nextCursor !== undefined,
683
810
  });
684
811
  return { ok: true, value: undefined };
@@ -693,6 +820,80 @@ export class AwikiController {
693
820
  return this.resumeGroupRebindRecovery(generation);
694
821
  });
695
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
+ }
696
897
  /**
697
898
  * Look up a Handle or DID, then open the matching direct conversation.
698
899
  * @param handle - peer Handle or DID typed by the user.
@@ -709,9 +910,15 @@ export class AwikiController {
709
910
  return this.fail('请先注册 AWiki 身份');
710
911
  if (sameIdentity(identity, peer))
711
912
  return this.fail('不能向自己发起私聊');
712
- const existing = findDirect(this.view.conversations, peer);
713
- 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
+ }
714
920
  return this.selectConversation(existing.id);
921
+ }
715
922
  const generation = this.generation;
716
923
  const resolved = await this.withPending('查找用户', () => call(() => this.remote.resolvePeer({ peer })));
717
924
  if (!resolved.ok) {
@@ -805,7 +1012,8 @@ export class AwikiController {
805
1012
  await this.refreshConversations(generation);
806
1013
  if (!this.current(generation))
807
1014
  return result;
808
- const conversation = this.view.conversations.find(value => value.kind === 'group' && value.groupDid === groupDid)
1015
+ const conversation = [...this.view.conversations, ...this.view.hiddenConversations]
1016
+ .find(value => value.kind === 'group' && value.groupDid === groupDid)
809
1017
  ?? this.cacheConversation({
810
1018
  kind: 'group',
811
1019
  id: result.value.conversationId,
@@ -813,10 +1021,17 @@ export class AwikiController {
813
1021
  title: result.value.title,
814
1022
  unreadCount: 0,
815
1023
  });
816
- this.publish({
817
- ...this.view,
818
- conversations: appendUnique([conversation], this.view.conversations, value => value.id),
819
- });
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
+ }
820
1035
  await this.selectConversation(conversation.id);
821
1036
  return result;
822
1037
  }
@@ -825,10 +1040,20 @@ export class AwikiController {
825
1040
  const conversation = this.selectedConversation();
826
1041
  if (conversation?.kind !== 'group')
827
1042
  return this.fail('请先打开一个群聊');
828
- return this.withPending('刷新群成员', async () => {
829
- const refreshed = await this.loadGroupState(conversation, true);
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);
830
1052
  return refreshed.ok ? { ok: true, value: undefined } : refreshed;
831
- });
1053
+ })();
1054
+ if (this.current(generation))
1055
+ this.publish({ ...this.view, pending: null, error: null });
1056
+ return result;
832
1057
  }
833
1058
  /** Load the next authoritative member page using Core's opaque cursor. */
834
1059
  async loadMoreGroupMembers() {
@@ -911,6 +1136,7 @@ export class AwikiController {
911
1136
  ...this.view,
912
1137
  selectedConversationId: null,
913
1138
  selectedGroup: null,
1139
+ groupAccess: null,
914
1140
  groupMembers: [],
915
1141
  groupMembersHasMore: false,
916
1142
  messages: [],
@@ -940,6 +1166,9 @@ export class AwikiController {
940
1166
  ...this.view,
941
1167
  selectedConversationId: conversationId,
942
1168
  selectedGroup: null,
1169
+ groupAccess: selected?.kind === 'group'
1170
+ ? { groupDid: selected.groupDid, status: 'loading' }
1171
+ : null,
943
1172
  groupMembers: [],
944
1173
  groupMembersHasMore: false,
945
1174
  messages: sameConversation ? this.view.messages : [],
@@ -985,20 +1214,39 @@ export class AwikiController {
985
1214
  void this.refreshSelectedDirectProfile(selected, generation, selectionRevision);
986
1215
  return { ok: true, value: undefined };
987
1216
  }
988
- async loadGroupState(conversation, reset) {
1217
+ async loadGroupState(conversation, reset, preserveAvailableOnNetworkFailure = false) {
989
1218
  const generation = this.generation;
990
1219
  const selectionRevision = this.selectionRevision;
991
- const snapshot = await call(() => this.remote.getGroup({ groupDid: conversation.groupDid }), groupReadFailureMessage);
1220
+ const snapshot = await callGroupRead(() => this.remote.getGroup({ groupDid: conversation.groupDid }));
992
1221
  if (!snapshot.ok) {
993
- if (this.currentSelection(generation, selectionRevision, conversation.id)) {
994
- this.publish({ ...this.view, error: snapshot.error });
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);
995
1228
  }
996
1229
  return snapshot;
997
1230
  }
998
- const members = await call(() => this.remote.listGroupMembers({ groupDid: conversation.groupDid, limit: 50 }), groupReadFailureMessage);
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 }));
999
1243
  if (!members.ok) {
1000
- if (this.currentSelection(generation, selectionRevision, conversation.id)) {
1001
- this.publish({ ...this.view, error: members.error });
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);
1002
1250
  }
1003
1251
  return members;
1004
1252
  }
@@ -1011,6 +1259,7 @@ export class AwikiController {
1011
1259
  this.publish({
1012
1260
  ...this.view,
1013
1261
  selectedGroup: snapshot.value,
1262
+ groupAccess: { groupDid: conversation.groupDid, status: 'available' },
1014
1263
  groupMembers: reset
1015
1264
  ? members.value.items
1016
1265
  : appendUnique(this.view.groupMembers, members.value.items, groupMemberKey),
@@ -1040,10 +1289,12 @@ export class AwikiController {
1040
1289
  if (!this.currentSelection(generation, selectionRevision, conversationId))
1041
1290
  return;
1042
1291
  if (!remote.ok) {
1292
+ if (selected?.kind === 'group')
1293
+ this.publishGroupAccessFailure(selected, remote.reason);
1043
1294
  this.publish({
1044
1295
  ...this.view,
1045
1296
  refreshing: false,
1046
- error: refreshFailureMessage(this.view.messages, remote.error),
1297
+ error: selected?.kind === 'group' ? null : refreshFailureMessage(this.view.messages, remote.error),
1047
1298
  });
1048
1299
  return;
1049
1300
  }
@@ -1081,17 +1332,22 @@ export class AwikiController {
1081
1332
  }
1082
1333
  async readRemoteHistoryWithGroupReadiness(conversation, conversationId, generation, selectionRevision) {
1083
1334
  if (conversation?.kind !== 'group') {
1084
- 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' };
1085
1339
  }
1086
1340
  for (const retryDelay of GROUP_HISTORY_RETRY_DELAYS_MS) {
1087
- const remote = await call(() => this.remote.getHistory({ conversationId }), groupReadFailureMessage);
1341
+ const remote = await callGroupRead(() => this.remote.getHistory({ conversationId }));
1088
1342
  if (remote.ok || !this.currentSelection(generation, selectionRevision, conversationId))
1089
1343
  return remote;
1344
+ if (remote.reason !== 'network-error')
1345
+ return remote;
1090
1346
  await delay(retryDelay);
1091
1347
  if (!this.currentSelection(generation, selectionRevision, conversationId))
1092
1348
  return remote;
1093
1349
  }
1094
- return call(() => this.remote.getHistory({ conversationId }), groupReadFailureMessage);
1350
+ return callGroupRead(() => this.remote.getHistory({ conversationId }));
1095
1351
  }
1096
1352
  async refreshSelectedDirectProfile(selected, generation, selectionRevision) {
1097
1353
  if (selected?.kind !== 'direct')
@@ -1145,6 +1401,9 @@ export class AwikiController {
1145
1401
  if (conversation === undefined || (conversation.unreadCount ?? 0) <= 0) {
1146
1402
  return { ok: true, value: undefined };
1147
1403
  }
1404
+ if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
1405
+ return { ok: true, value: undefined };
1406
+ }
1148
1407
  const existing = this.markReadInFlight.get(conversation.id);
1149
1408
  if (existing !== undefined)
1150
1409
  return existing;
@@ -1237,6 +1496,9 @@ export class AwikiController {
1237
1496
  const conversation = this.selectedConversation();
1238
1497
  if (conversation === undefined)
1239
1498
  return this.fail('请先选择会话');
1499
+ if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
1500
+ return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
1501
+ }
1240
1502
  const conversationId = conversation.id;
1241
1503
  const generation = this.generation;
1242
1504
  const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
@@ -1262,6 +1524,9 @@ export class AwikiController {
1262
1524
  const conversation = this.selectedConversation();
1263
1525
  if (conversation === undefined)
1264
1526
  return this.fail('请先选择会话');
1527
+ if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
1528
+ return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
1529
+ }
1265
1530
  const conversationId = conversation.id;
1266
1531
  const generation = this.generation;
1267
1532
  const request = {
@@ -1344,8 +1609,74 @@ export class AwikiController {
1344
1609
  this.close();
1345
1610
  this.listeners.clear();
1346
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
+ }
1347
1678
  async refreshConversations(generation, background = false) {
1348
- const result = await call(() => this.remote.listConversations({}));
1679
+ const result = await this.listConversationPage({});
1349
1680
  if (!this.current(generation))
1350
1681
  return { ok: true, value: undefined };
1351
1682
  if (!result.ok)
@@ -1353,15 +1684,16 @@ export class AwikiController {
1353
1684
  const firstPage = this.view.conversations.length === 0;
1354
1685
  if (firstPage)
1355
1686
  this.conversationsCursor = result.value.nextCursor;
1356
- const refreshed = result.value.items.map((incoming) => {
1357
- const current = this.view.conversations.find(value => value.id === incoming.id);
1358
- return this.cacheConversation(incoming, current);
1359
- });
1687
+ const refreshed = await this.reconcileConversationPage(result.value.items, generation);
1688
+ if (!this.current(generation))
1689
+ return { ok: true, value: undefined };
1360
1690
  this.publish({
1361
1691
  ...this.view,
1362
1692
  conversations: firstPage
1363
- ? refreshed
1364
- : 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(),
1365
1697
  conversationsHasMore: firstPage
1366
1698
  ? result.value.hasMore && result.value.nextCursor !== undefined
1367
1699
  : this.view.conversationsHasMore,
@@ -1369,6 +1701,51 @@ export class AwikiController {
1369
1701
  });
1370
1702
  return { ok: true, value: undefined };
1371
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
+ }
1372
1749
  /** Hydrate account-projected groups before asking Core to resume their durable rebind jobs. */
1373
1750
  async refreshConversationsAndRecoverGroups(generation, background = false) {
1374
1751
  const listed = await this.refreshConversations(generation, background);
@@ -1385,11 +1762,24 @@ export class AwikiController {
1385
1762
  operation = call(() => this.remote.resumeGroupRebindRecovery(), groupRecoveryFailureMessage).then((result) => {
1386
1763
  if (!this.current(generation))
1387
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
+ }
1388
1776
  this.publish({
1389
1777
  ...this.view,
1390
1778
  groupRecovery: result.ok
1391
- ? groupRecoveryView(result.value)
1392
- : { status: 'unavailable', pending: 0, blocked: 0 },
1779
+ ? this.currentGroupRecoveryView()
1780
+ : this.dismissedGroupRecoverySignature === 'unavailable'
1781
+ ? null
1782
+ : { status: 'unavailable', pending: 0, blocked: 0 },
1393
1783
  });
1394
1784
  return result;
1395
1785
  }).finally(() => {
@@ -1403,6 +1793,10 @@ export class AwikiController {
1403
1793
  const conversationId = this.view.selectedConversationId;
1404
1794
  if (conversationId === null)
1405
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
+ }
1406
1800
  const generation = this.generation;
1407
1801
  const request = {
1408
1802
  conversationId,
@@ -1448,6 +1842,9 @@ export class AwikiController {
1448
1842
  const selected = this.view.selectedConversationId;
1449
1843
  if (selected === null || !this.current(generation))
1450
1844
  return;
1845
+ const selectedConversation = this.selectedConversation();
1846
+ if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available')
1847
+ return;
1451
1848
  const result = await call(() => this.remote.getHistory({ conversationId: selected }));
1452
1849
  if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
1453
1850
  return;
@@ -1472,7 +1869,7 @@ export class AwikiController {
1472
1869
  this.polling = false;
1473
1870
  }
1474
1871
  }
1475
- async withPending(label, operation) {
1872
+ async withPending(label, operation, options = {}) {
1476
1873
  if (this.disposed)
1477
1874
  return { ok: false, error: 'AWiki 插件已卸载' };
1478
1875
  const generation = this.generation;
@@ -1480,7 +1877,11 @@ export class AwikiController {
1480
1877
  const result = await operation();
1481
1878
  if (!this.current(generation))
1482
1879
  return result;
1483
- 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
+ });
1484
1885
  return result;
1485
1886
  }
1486
1887
  appendMessage(message) {
@@ -1531,6 +1932,21 @@ export class AwikiController {
1531
1932
  const selected = this.view.selectedConversationId;
1532
1933
  return selected === null ? undefined : this.view.conversations.find(value => value.id === selected);
1533
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
+ }
1534
1950
  /** Keep presentation-only cache entries isolated to one authenticated identity. */
1535
1951
  activatePresentationCache(identity) {
1536
1952
  const ownerDid = identity?.did ?? null;
@@ -1538,6 +1954,11 @@ export class AwikiController {
1538
1954
  return;
1539
1955
  this.directProfiles.clear();
1540
1956
  this.groupTitles.clear();
1957
+ this.groupRecoveryItems.clear();
1958
+ this.hiddenConversationPreferences.clear();
1959
+ this.dismissedGroupRecoverySignature = undefined;
1960
+ this.lastGroupRecoverySignature = undefined;
1961
+ this.lastGroupRecoverySummary = undefined;
1541
1962
  this.clearImageAttachments();
1542
1963
  this.presentationCacheOwnerDid = ownerDid;
1543
1964
  }
@@ -1545,6 +1966,11 @@ export class AwikiController {
1545
1966
  clearPresentationCache() {
1546
1967
  this.directProfiles.clear();
1547
1968
  this.groupTitles.clear();
1969
+ this.groupRecoveryItems.clear();
1970
+ this.hiddenConversationPreferences.clear();
1971
+ this.dismissedGroupRecoverySignature = undefined;
1972
+ this.lastGroupRecoverySignature = undefined;
1973
+ this.lastGroupRecoverySummary = undefined;
1548
1974
  this.clearImageAttachments();
1549
1975
  this.presentationCacheOwnerDid = null;
1550
1976
  }