@awiki/dsh-plugin 0.3.17 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/README.zh.md +11 -0
- package/THIRD_PARTY_NOTICES.md +6 -0
- package/lib/client.js +4012 -671
- package/lib/client.js.map +1 -1
- package/lib/index.js +603 -61
- package/lib/{sdk-adapter-DGcTiEZz.mjs → package-version.generated-nZcAVnm8.mjs} +103 -10
- package/lib/provider.js +5 -3
- package/lib/typert.host.js +553 -133
- package/lib/typert.remote-client.d.ts +11 -3
- package/lib/typert.remote-client.d.ts.map +1 -1
- package/lib/typert.remote-client.js +553 -133
- package/lib/types/backend-capabilities.d.ts +12 -0
- package/lib/types/backend-capabilities.d.ts.map +1 -0
- package/lib/types/backend-capabilities.js +48 -0
- package/lib/types/backend-capabilities.js.map +1 -0
- package/lib/types/client/AwikiAvatar.d.ts +40 -0
- package/lib/types/client/AwikiAvatar.d.ts.map +1 -0
- package/lib/types/client/AwikiAvatar.js +201 -0
- package/lib/types/client/AwikiAvatar.js.map +1 -0
- package/lib/types/client/AwikiAvatarEditor.d.ts +11 -0
- package/lib/types/client/AwikiAvatarEditor.d.ts.map +1 -0
- package/lib/types/client/AwikiAvatarEditor.js +113 -0
- package/lib/types/client/AwikiAvatarEditor.js.map +1 -0
- package/lib/types/client/AwikiAvatarPreview.d.ts +8 -0
- package/lib/types/client/AwikiAvatarPreview.d.ts.map +1 -0
- package/lib/types/client/AwikiAvatarPreview.js +68 -0
- package/lib/types/client/AwikiAvatarPreview.js.map +1 -0
- package/lib/types/client/AwikiGroupDetails.js +2 -2
- package/lib/types/client/AwikiGroupDetails.js.map +1 -1
- package/lib/types/client/AwikiIdentityAccess.d.ts +1 -0
- package/lib/types/client/AwikiIdentityAccess.d.ts.map +1 -1
- package/lib/types/client/AwikiIdentityAccess.js +21 -4
- package/lib/types/client/AwikiIdentityAccess.js.map +1 -1
- package/lib/types/client/AwikiOverlay.d.ts.map +1 -1
- package/lib/types/client/AwikiOverlay.js +97 -29
- package/lib/types/client/AwikiOverlay.js.map +1 -1
- package/lib/types/client/AwikiProfileCard.d.ts +1 -1
- package/lib/types/client/AwikiProfileCard.d.ts.map +1 -1
- package/lib/types/client/AwikiProfileCard.js +6 -2
- package/lib/types/client/AwikiProfileCard.js.map +1 -1
- package/lib/types/client/AwikiSettingsSection.d.ts +1 -0
- package/lib/types/client/AwikiSettingsSection.d.ts.map +1 -1
- package/lib/types/client/AwikiSettingsSection.js +5 -4
- package/lib/types/client/AwikiSettingsSection.js.map +1 -1
- package/lib/types/client/avatar-cache.d.ts +24 -0
- package/lib/types/client/avatar-cache.d.ts.map +1 -0
- package/lib/types/client/avatar-cache.js +336 -0
- package/lib/types/client/avatar-cache.js.map +1 -0
- package/lib/types/client/avatar-dialog.d.ts +3 -0
- package/lib/types/client/avatar-dialog.d.ts.map +1 -0
- package/lib/types/client/avatar-dialog.js +18 -0
- package/lib/types/client/avatar-dialog.js.map +1 -0
- package/lib/types/client/avatar-image.d.ts +26 -0
- package/lib/types/client/avatar-image.d.ts.map +1 -0
- package/lib/types/client/avatar-image.js +193 -0
- package/lib/types/client/avatar-image.js.map +1 -0
- package/lib/types/client/controller.d.ts +37 -4
- package/lib/types/client/controller.d.ts.map +1 -1
- package/lib/types/client/controller.js +306 -59
- package/lib/types/client/controller.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +11 -2
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/settings-locales.d.ts +1 -1
- package/lib/types/client/settings-locales.d.ts.map +1 -1
- package/lib/types/client/settings-locales.js +26 -2
- package/lib/types/client/settings-locales.js.map +1 -1
- package/lib/types/client/slots.d.ts +12 -3
- package/lib/types/client/slots.d.ts.map +1 -1
- package/lib/types/conversation-preferences.d.ts.map +1 -1
- package/lib/types/conversation-preferences.js +27 -0
- package/lib/types/conversation-preferences.js.map +1 -1
- package/lib/types/index.d.ts +26 -3
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +321 -33
- package/lib/types/index.js.map +1 -1
- package/lib/types/listener.d.ts.map +1 -1
- package/lib/types/listener.js +9 -8
- package/lib/types/listener.js.map +1 -1
- package/lib/types/package-version.generated.d.ts +2 -2
- package/lib/types/package-version.generated.js +2 -2
- package/lib/types/provider-api.d.ts +28 -3
- package/lib/types/provider-api.d.ts.map +1 -1
- package/lib/types/provider.d.ts.map +1 -1
- package/lib/types/provider.js +5 -2
- package/lib/types/provider.js.map +1 -1
- package/lib/types/sdk-adapter.d.ts +10 -3
- package/lib/types/sdk-adapter.d.ts.map +1 -1
- package/lib/types/sdk-adapter.js +78 -7
- package/lib/types/sdk-adapter.js.map +1 -1
- package/lib/types/tenant-capability-cache.d.ts +15 -0
- package/lib/types/tenant-capability-cache.d.ts.map +1 -0
- package/lib/types/tenant-capability-cache.js +45 -0
- package/lib/types/tenant-capability-cache.js.map +1 -0
- package/lib/types/tenant-registry.d.ts +2 -0
- package/lib/types/tenant-registry.d.ts.map +1 -1
- package/lib/types/tenant-registry.js +83 -16
- package/lib/types/tenant-registry.js.map +1 -1
- package/lib/types/types.d.ts +70 -7
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js.map +1 -1
- package/lib/types/update-policy.d.ts +2 -2
- package/lib/types/update-policy.d.ts.map +1 -1
- package/lib/types/update-policy.js +10 -1
- package/lib/types/update-policy.js.map +1 -1
- package/package.json +33 -34
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { avatarCache } from "./avatar-cache.js";
|
|
1
2
|
import { AwikiDraftStore } from "./drafts.js";
|
|
2
3
|
import { SHORT_HANDLE_INVITE_MESSAGE } from "../registration-policy.js";
|
|
3
4
|
import { IndexedDbAwikiBrowserImageCache, } from "./image-cache.js";
|
|
@@ -12,6 +13,8 @@ function genericFailureMessage(failure) {
|
|
|
12
13
|
return '无法连接 AWiki 服务,请检查网络后重试。';
|
|
13
14
|
case 'rate-limited':
|
|
14
15
|
return 'AWiki 请求过于频繁,请稍后重试。';
|
|
16
|
+
case 'group-member-encryption-not-ready':
|
|
17
|
+
return '尚未加入:对方尚未完成加密准备,请对方打开客户端后重试。';
|
|
15
18
|
case 'not-registered':
|
|
16
19
|
return '请先注册 AWiki 身份后重试。';
|
|
17
20
|
case 'signed-out':
|
|
@@ -337,13 +340,17 @@ function canonicalMessagePage(incoming) {
|
|
|
337
340
|
}
|
|
338
341
|
/** Replace the newest loaded window while preserving older messages before it. */
|
|
339
342
|
function mergeLatestMessages(current, incoming) {
|
|
340
|
-
const
|
|
343
|
+
const previous = new Map(current.map(message => [message.id, message]));
|
|
344
|
+
const page = canonicalMessagePage(incoming).map(message => previous.get(message.id)?.encryptionRequired === true
|
|
345
|
+
? { ...message, encryptionRequired: true } : message);
|
|
341
346
|
const incomingIds = new Set(page.map(message => message.id));
|
|
342
347
|
return [...current.filter(message => !incomingIds.has(message.id)), ...page];
|
|
343
348
|
}
|
|
344
349
|
/** Prepend one chronological continuation page while exact-updating any overlap. */
|
|
345
350
|
function mergeOlderMessages(current, incoming) {
|
|
346
|
-
const
|
|
351
|
+
const previous = new Map(current.map(message => [message.id, message]));
|
|
352
|
+
const page = canonicalMessagePage(incoming).map(message => previous.get(message.id)?.encryptionRequired === true
|
|
353
|
+
? { ...message, encryptionRequired: true } : message);
|
|
347
354
|
const incomingIds = new Set(page.map(message => message.id));
|
|
348
355
|
return [...page, ...current.filter(message => !incomingIds.has(message.id))];
|
|
349
356
|
}
|
|
@@ -352,7 +359,7 @@ function appendMessageById(current, incoming) {
|
|
|
352
359
|
const index = current.findIndex(message => message.id === incoming.id);
|
|
353
360
|
if (index < 0)
|
|
354
361
|
return [...current, incoming];
|
|
355
|
-
return current.map((message, currentIndex) => currentIndex === index ? incoming : message);
|
|
362
|
+
return current.map((message, currentIndex) => currentIndex === index ? { ...incoming, ...(message.encryptionRequired === true ? { encryptionRequired: true } : {}) } : message);
|
|
356
363
|
}
|
|
357
364
|
/** Explain a background failure without implying that visible local messages were lost. */
|
|
358
365
|
function refreshFailureMessage(messages, error) {
|
|
@@ -524,6 +531,7 @@ export class AwikiController {
|
|
|
524
531
|
remote;
|
|
525
532
|
persistentImageCache;
|
|
526
533
|
drafts = new AwikiDraftStore();
|
|
534
|
+
retiredMessageIds = new Set();
|
|
527
535
|
view = INITIAL_VIEW;
|
|
528
536
|
listeners = new Set();
|
|
529
537
|
/** Runtime-only selected operations survive tenant round trips without reopening stale browser hints after reload. */
|
|
@@ -532,6 +540,7 @@ export class AwikiController {
|
|
|
532
540
|
conversationsCursor;
|
|
533
541
|
historyCursor;
|
|
534
542
|
groupMembersCursor;
|
|
543
|
+
lastCapabilityRefresh = 0;
|
|
535
544
|
timer;
|
|
536
545
|
opening;
|
|
537
546
|
drawerOpen = false;
|
|
@@ -539,8 +548,12 @@ export class AwikiController {
|
|
|
539
548
|
tenantTransition;
|
|
540
549
|
generation = 0;
|
|
541
550
|
selectionRevision = 0;
|
|
551
|
+
groupReadRevision = 0;
|
|
542
552
|
disposed = false;
|
|
543
553
|
polling = false;
|
|
554
|
+
lastAvatarProfileCheck = 0;
|
|
555
|
+
avatarProfileRefreshGeneration = -1;
|
|
556
|
+
avatarProfileRefresh;
|
|
544
557
|
markReadInFlight = new Map();
|
|
545
558
|
unreadAtOpen = new Map();
|
|
546
559
|
summaryBaselines = new Map();
|
|
@@ -583,7 +596,7 @@ export class AwikiController {
|
|
|
583
596
|
if (!preserveView)
|
|
584
597
|
this.summaryBaselines.clear();
|
|
585
598
|
const previous = this.view;
|
|
586
|
-
const previousTenant = this.config?.tenantId;
|
|
599
|
+
const previousTenant = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
|
|
587
600
|
const generation = this.generation;
|
|
588
601
|
if (!preserveView)
|
|
589
602
|
this.publish({ ...INITIAL_VIEW, status: 'loading' });
|
|
@@ -600,19 +613,21 @@ export class AwikiController {
|
|
|
600
613
|
if (!config.ok)
|
|
601
614
|
return failed(config.error);
|
|
602
615
|
this.config = config.value;
|
|
616
|
+
this.lastCapabilityRefresh = Date.now();
|
|
603
617
|
const session = await call(() => this.remote.getSession());
|
|
604
618
|
if (!this.current(generation))
|
|
605
619
|
return { ok: true, value: undefined };
|
|
606
620
|
if (!session.ok)
|
|
607
621
|
return failed(session.error);
|
|
608
622
|
const identity = session.value.status === 'active' ? session.value.identity : null;
|
|
609
|
-
const sameOwner = preserveView && previousTenant === config.value.tenantId
|
|
623
|
+
const sameOwner = preserveView && previousTenant === (config.value.tenantStorageScopeId ?? config.value.tenantId)
|
|
610
624
|
&& previous.sessionStatus === session.value.status && previous.identity?.did === identity?.did;
|
|
611
|
-
this.drafts.setScope(config.value.tenantId ?? 'default', identity?.did ?? session.value.status);
|
|
625
|
+
this.drafts.setScope((config.value.tenantStorageScopeId ?? config.value.tenantId) ?? 'default', identity?.did ?? session.value.status);
|
|
626
|
+
this.restoreGroupCreationDraft();
|
|
612
627
|
this.activatePresentationCache(identity);
|
|
613
|
-
const selectedRecovery = previousTenant === config.value.tenantId
|
|
628
|
+
const selectedRecovery = previousTenant === (config.value.tenantStorageScopeId ?? config.value.tenantId)
|
|
614
629
|
? previous.recoveryOperationId
|
|
615
|
-
: this.recoveryOperationsByTenant.get(recoveryTenantKey(config.value.tenantId)) ?? null;
|
|
630
|
+
: this.recoveryOperationsByTenant.get(recoveryTenantKey((config.value.tenantStorageScopeId ?? config.value.tenantId))) ?? null;
|
|
616
631
|
this.publish({
|
|
617
632
|
...(sameOwner ? this.view : INITIAL_VIEW),
|
|
618
633
|
status: 'ready',
|
|
@@ -620,9 +635,11 @@ export class AwikiController {
|
|
|
620
635
|
identity: session.value.status === 'active' ? session.value.identity : null,
|
|
621
636
|
error: null,
|
|
622
637
|
attachmentMaxBytes: config.value.attachmentMaxBytes,
|
|
638
|
+
serverFeatures: config.value.serverFeatures,
|
|
639
|
+
serverFeatureDiagnostics: config.value.serverFeatureDiagnostics,
|
|
623
640
|
handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled,
|
|
624
641
|
recoveryOperationId: selectedRecovery,
|
|
625
|
-
recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, config.value.tenantId),
|
|
642
|
+
recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selectedRecovery, (config.value.tenantStorageScopeId ?? config.value.tenantId)),
|
|
626
643
|
});
|
|
627
644
|
await this.refreshIdentityAccess();
|
|
628
645
|
if (!this.current(generation))
|
|
@@ -634,7 +651,7 @@ export class AwikiController {
|
|
|
634
651
|
await this.loadConversationPreferences(generation);
|
|
635
652
|
const profile = await call(() => this.remote.getProfile());
|
|
636
653
|
if (this.current(generation) && profile.ok)
|
|
637
|
-
this.
|
|
654
|
+
this.applyProfile(profile.value);
|
|
638
655
|
}
|
|
639
656
|
return { ok: true, value: undefined };
|
|
640
657
|
}
|
|
@@ -698,6 +715,8 @@ export class AwikiController {
|
|
|
698
715
|
status: 'ready',
|
|
699
716
|
sessionStatus: 'signed-out',
|
|
700
717
|
attachmentMaxBytes: this.config?.attachmentMaxBytes ?? 0,
|
|
718
|
+
serverFeatures: this.config?.serverFeatures,
|
|
719
|
+
serverFeatureDiagnostics: this.config?.serverFeatureDiagnostics,
|
|
701
720
|
handleRecoveryPhoneEnabled: this.config?.handleRecoveryPhoneEnabled ?? false,
|
|
702
721
|
});
|
|
703
722
|
await this.loadSession();
|
|
@@ -806,17 +825,17 @@ export class AwikiController {
|
|
|
806
825
|
}
|
|
807
826
|
const candidates = result.value.recoveries;
|
|
808
827
|
const selected = this.view.recoveryOperationId;
|
|
809
|
-
this.publish({ ...this.view, identityAccess: result.value, recoveryOperationId: selected,
|
|
828
|
+
this.publish({ ...this.view, identityAccess: this.projectRegistrationAccess(result.value), recoveryOperationId: selected,
|
|
810
829
|
recoveryProgress: selected === this.view.recoveryOperationId ? this.view.recoveryProgress : null,
|
|
811
|
-
recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, this.config?.tenantId) });
|
|
830
|
+
recoveryOtpRetryAt: storedRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)) });
|
|
812
831
|
if (selected !== null) {
|
|
813
832
|
const status = await callWithFailureCode(() => this.remote.getRecoveryStatus({ operationId: selected }));
|
|
814
833
|
if (!this.recoveryCurrent(generation, revision))
|
|
815
834
|
return this.recoveryLeft();
|
|
816
835
|
if (!status.ok) {
|
|
817
836
|
if (status.failureCode === 'not-found' && candidates.length === 0) {
|
|
818
|
-
clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
|
|
819
|
-
this.rememberRecoveryOperation(null, this.config?.tenantId);
|
|
837
|
+
clearRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
838
|
+
this.rememberRecoveryOperation(null, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
820
839
|
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, recoveryOtpRetryAt: null });
|
|
821
840
|
}
|
|
822
841
|
else {
|
|
@@ -827,10 +846,10 @@ export class AwikiController {
|
|
|
827
846
|
else {
|
|
828
847
|
const complete = status.value.phase === 'applied';
|
|
829
848
|
if (complete)
|
|
830
|
-
clearRecoveryOtpRetryAt(selected, this.config?.tenantId);
|
|
849
|
+
clearRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
831
850
|
this.publish({ ...this.view, recoveryProgress: status.value,
|
|
832
|
-
recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, this.config?.tenantId),
|
|
833
|
-
identityAccess: result.value });
|
|
851
|
+
recoveryOtpRetryAt: complete ? null : storedRecoveryOtpRetryAt(selected, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)),
|
|
852
|
+
identityAccess: this.projectRegistrationAccess(result.value) });
|
|
834
853
|
}
|
|
835
854
|
}
|
|
836
855
|
this.publish({ ...this.view, accessLoading: false, accessError: null });
|
|
@@ -844,9 +863,9 @@ export class AwikiController {
|
|
|
844
863
|
this.leaveRecovery();
|
|
845
864
|
else
|
|
846
865
|
this.recoveryRevision++;
|
|
847
|
-
this.rememberRecoveryOperation(operationId, this.config?.tenantId);
|
|
866
|
+
this.rememberRecoveryOperation(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
848
867
|
this.publish({ ...this.view, recoveryOperationId: operationId, recoveryProgress: null,
|
|
849
|
-
recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, this.config?.tenantId) });
|
|
868
|
+
recoveryOtpRetryAt: storedRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId)) });
|
|
850
869
|
const result = await this.refreshRecoveryStatus();
|
|
851
870
|
return result.ok ? { ok: true, value: undefined } : result;
|
|
852
871
|
}
|
|
@@ -877,7 +896,7 @@ export class AwikiController {
|
|
|
877
896
|
this.pendingRequest = undefined;
|
|
878
897
|
this.pendingRecovery = false;
|
|
879
898
|
this.drafts.clearFlow();
|
|
880
|
-
this.rememberRecoveryOperation(null, this.config?.tenantId);
|
|
899
|
+
this.rememberRecoveryOperation(null, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
881
900
|
this.publish({ ...this.view, recoveryOperationId: null, recoveryProgress: null, accessError: null,
|
|
882
901
|
accessLoading: false, pending: wasPending ? null : this.view.pending, error: null });
|
|
883
902
|
}
|
|
@@ -927,9 +946,15 @@ export class AwikiController {
|
|
|
927
946
|
}
|
|
928
947
|
if (result.value.status === 'registered') {
|
|
929
948
|
this.drafts.clearScope();
|
|
930
|
-
this.drafts.setScope(this.config?.tenantId ?? 'default', result.value.identity.did);
|
|
949
|
+
this.drafts.setScope((this.config?.tenantStorageScopeId ?? this.config?.tenantId) ?? 'default', result.value.identity.did);
|
|
931
950
|
this.activatePresentationCache(result.value.identity);
|
|
932
951
|
this.publish({ ...this.view, sessionStatus: 'active', identity: result.value.identity, error: null });
|
|
952
|
+
await this.refreshServerFeatures(generation);
|
|
953
|
+
if (!this.current(generation))
|
|
954
|
+
return result;
|
|
955
|
+
await this.loadConversationPreferences(generation);
|
|
956
|
+
if (!this.current(generation))
|
|
957
|
+
return result;
|
|
933
958
|
await this.refreshConversations(generation);
|
|
934
959
|
}
|
|
935
960
|
return result;
|
|
@@ -970,7 +995,7 @@ export class AwikiController {
|
|
|
970
995
|
/** Cancel one exact Join choice and send Recovery OTP only while its original browser scope remains current. */
|
|
971
996
|
async beginRecoveryFromDeviceJoin(request) {
|
|
972
997
|
const generation = this.generation;
|
|
973
|
-
const tenantId = this.config?.tenantId;
|
|
998
|
+
const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
|
|
974
999
|
const flowScope = this.drafts.getScope('recovery:retryDeadline');
|
|
975
1000
|
const flowEpoch = this.drafts.epoch(flowScope);
|
|
976
1001
|
const changed = () => ({
|
|
@@ -978,7 +1003,7 @@ export class AwikiController {
|
|
|
978
1003
|
error: '恢复能力或身份环境已改变,请重新检查后再选择。',
|
|
979
1004
|
});
|
|
980
1005
|
const scopeCurrent = () => this.current(generation)
|
|
981
|
-
&& this.config?.tenantId === tenantId
|
|
1006
|
+
&& (this.config?.tenantStorageScopeId ?? this.config?.tenantId) === tenantId
|
|
982
1007
|
&& this.drafts.getScope('recovery:retryDeadline') === flowScope
|
|
983
1008
|
&& this.drafts.epoch(flowScope) === flowEpoch;
|
|
984
1009
|
if (!scopeCurrent())
|
|
@@ -988,7 +1013,7 @@ export class AwikiController {
|
|
|
988
1013
|
return changed();
|
|
989
1014
|
if (!latest.ok)
|
|
990
1015
|
return { ok: false, error: '暂时无法确认服务器的恢复能力,请稍后重试。' };
|
|
991
|
-
if (latest.value.tenantId !== tenantId)
|
|
1016
|
+
if ((latest.value.tenantStorageScopeId ?? latest.value.tenantId) !== tenantId)
|
|
992
1017
|
return changed();
|
|
993
1018
|
this.config = latest.value;
|
|
994
1019
|
this.publish({ ...this.view, handleRecoveryPhoneEnabled: latest.value.handleRecoveryPhoneEnabled });
|
|
@@ -1018,7 +1043,7 @@ export class AwikiController {
|
|
|
1018
1043
|
if (result.value === null)
|
|
1019
1044
|
return result;
|
|
1020
1045
|
this.recordRecoveryOtp(result.value, tenantId);
|
|
1021
|
-
const sameFlow = this.config?.tenantId === tenantId
|
|
1046
|
+
const sameFlow = (this.config?.tenantStorageScopeId ?? this.config?.tenantId) === tenantId
|
|
1022
1047
|
&& this.drafts.getScope('recovery:retryDeadline') === flowScope
|
|
1023
1048
|
&& this.drafts.epoch(flowScope) === flowEpoch;
|
|
1024
1049
|
if (!sameFlow)
|
|
@@ -1090,6 +1115,65 @@ export class AwikiController {
|
|
|
1090
1115
|
this.publish({ ...this.view, identity: result.value, error: null });
|
|
1091
1116
|
return result;
|
|
1092
1117
|
}
|
|
1118
|
+
applyProfile(profile, updateIdentity = false) {
|
|
1119
|
+
if (profile.did !== this.view.identity?.did)
|
|
1120
|
+
return;
|
|
1121
|
+
const current = this.view.profile?.profileVersion;
|
|
1122
|
+
const incoming = profile.profileVersion;
|
|
1123
|
+
if (current !== undefined && incoming !== undefined && /^(0|[1-9][0-9]*)$/u.test(current) && /^(0|[1-9][0-9]*)$/u.test(incoming) && BigInt(incoming) < BigInt(current))
|
|
1124
|
+
return;
|
|
1125
|
+
const identity = updateIdentity ? { ...this.view.identity, displayName: profile.displayName } : this.view.identity;
|
|
1126
|
+
this.publish({ ...this.view, identity, profile, error: null });
|
|
1127
|
+
}
|
|
1128
|
+
refreshAvatarProfile() {
|
|
1129
|
+
if (this.avatarProfileRefresh !== undefined && this.avatarProfileRefreshGeneration === this.generation)
|
|
1130
|
+
return this.avatarProfileRefresh;
|
|
1131
|
+
const generation = this.generation;
|
|
1132
|
+
this.lastAvatarProfileCheck = Date.now();
|
|
1133
|
+
const operation = (async () => {
|
|
1134
|
+
const result = await call(() => this.remote.getProfile());
|
|
1135
|
+
if (!this.current(generation))
|
|
1136
|
+
return { ok: false, error: '账号已切换,请重新打开头像设置' };
|
|
1137
|
+
if (!result.ok)
|
|
1138
|
+
return { ok: false, error: result.error };
|
|
1139
|
+
this.applyProfile(result.value);
|
|
1140
|
+
return { ok: true, value: this.view.profile ?? result.value };
|
|
1141
|
+
})();
|
|
1142
|
+
this.avatarProfileRefresh = operation;
|
|
1143
|
+
this.avatarProfileRefreshGeneration = generation;
|
|
1144
|
+
void operation.finally(() => { if (this.avatarProfileRefresh === operation)
|
|
1145
|
+
this.avatarProfileRefresh = undefined; });
|
|
1146
|
+
return operation;
|
|
1147
|
+
}
|
|
1148
|
+
async setAvatar(request) {
|
|
1149
|
+
return this.mutateAvatar(() => this.remote.setAvatar(request));
|
|
1150
|
+
}
|
|
1151
|
+
async clearAvatar(request) {
|
|
1152
|
+
return this.mutateAvatar(() => this.remote.clearAvatar(request));
|
|
1153
|
+
}
|
|
1154
|
+
async mutateAvatar(operation) {
|
|
1155
|
+
if (this.view.profile?.avatarUploadEnabled !== true)
|
|
1156
|
+
return { ok: false, error: '当前账号或服务暂不支持修改头像' };
|
|
1157
|
+
const generation = this.generation;
|
|
1158
|
+
const result = await this.withPending('保存头像', () => call(operation), { publishFailure: false });
|
|
1159
|
+
if (!this.current(generation))
|
|
1160
|
+
return { ok: false, error: '账号已切换,请重新打开头像设置' };
|
|
1161
|
+
if (result.ok)
|
|
1162
|
+
this.applyProfile(result.value);
|
|
1163
|
+
else
|
|
1164
|
+
await this.refreshAvatarProfile();
|
|
1165
|
+
return result;
|
|
1166
|
+
}
|
|
1167
|
+
async avatarDisplayProfiles(peers) {
|
|
1168
|
+
const generation = this.generation;
|
|
1169
|
+
const result = await call(() => this.remote.getDisplayProfiles(peers.slice(0, 100), true));
|
|
1170
|
+
return this.current(generation) && result.ok ? result.value : [];
|
|
1171
|
+
}
|
|
1172
|
+
async avatarGroup(groupDid) {
|
|
1173
|
+
const generation = this.generation;
|
|
1174
|
+
const result = await call(() => this.remote.getGroup({ groupDid }));
|
|
1175
|
+
return this.current(generation) && result.ok ? result.value : null;
|
|
1176
|
+
}
|
|
1093
1177
|
/** Save all supported public profile fields and keep identity/profile projections aligned. */
|
|
1094
1178
|
async updateProfile(request) {
|
|
1095
1179
|
const displayName = request.displayName.trim();
|
|
@@ -1106,15 +1190,14 @@ export class AwikiController {
|
|
|
1106
1190
|
const result = await this.withPending('保存资料', () => call(() => this.remote.updateProfile({ displayName, bio, tags })));
|
|
1107
1191
|
if (!result.ok || !this.current(generation))
|
|
1108
1192
|
return result;
|
|
1109
|
-
|
|
1110
|
-
this.publish({ ...this.view, identity, profile: result.value, error: null });
|
|
1193
|
+
this.applyProfile(result.value, true);
|
|
1111
1194
|
return result;
|
|
1112
1195
|
}
|
|
1113
1196
|
/** Request a dedicated OTP only within the selected account flow. */
|
|
1114
1197
|
async sendRecoveryOtp(request) {
|
|
1115
1198
|
const generation = this.generation;
|
|
1116
1199
|
const revision = this.recoveryRevision;
|
|
1117
|
-
const tenantId = this.config?.tenantId;
|
|
1200
|
+
const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
|
|
1118
1201
|
const flowScope = this.drafts.getScope('recovery:retryDeadline');
|
|
1119
1202
|
const result = await this.withPending('发送恢复验证码', () => call(() => this.remote.sendRecoveryOtp(request), registrationOtpFailureMessage), { publishFailure: false, recovery: true });
|
|
1120
1203
|
if (!result.ok) {
|
|
@@ -1125,7 +1208,7 @@ export class AwikiController {
|
|
|
1125
1208
|
}
|
|
1126
1209
|
this.recordRecoveryOtp(result.value, tenantId);
|
|
1127
1210
|
if (!this.recoveryCurrent(generation, revision)
|
|
1128
|
-
|| this.config?.tenantId !== tenantId
|
|
1211
|
+
|| (this.config?.tenantStorageScopeId ?? this.config?.tenantId) !== tenantId
|
|
1129
1212
|
|| this.drafts.getScope('recovery:retryDeadline') !== flowScope)
|
|
1130
1213
|
return result;
|
|
1131
1214
|
this.publish({ ...this.view, recoveryOperationId: result.value.operationId, recoveryProgress: null,
|
|
@@ -1138,7 +1221,7 @@ export class AwikiController {
|
|
|
1138
1221
|
storeRecoveryOtpRetryAt(result.operationId, recoveryOtpRetryAt(result), tenantId);
|
|
1139
1222
|
}
|
|
1140
1223
|
/** Publish results only into the exact selected flow; Core work survives navigation. */
|
|
1141
|
-
async runRecovery(label, operation
|
|
1224
|
+
async runRecovery(label, operation) {
|
|
1142
1225
|
const generation = this.generation;
|
|
1143
1226
|
const revision = this.recoveryRevision;
|
|
1144
1227
|
const operationId = this.view.recoveryOperationId;
|
|
@@ -1158,9 +1241,7 @@ export class AwikiController {
|
|
|
1158
1241
|
}
|
|
1159
1242
|
this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
|
|
1160
1243
|
if (result.value.phase === 'applied')
|
|
1161
|
-
clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
|
|
1162
|
-
if (enterWhenApplied && result.value.phase === 'applied')
|
|
1163
|
-
return this.enterRecoveredSession();
|
|
1244
|
+
clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
1164
1245
|
return result;
|
|
1165
1246
|
}
|
|
1166
1247
|
async prepareRecovery(request) {
|
|
@@ -1172,7 +1253,7 @@ export class AwikiController {
|
|
|
1172
1253
|
const progress = this.view.recoveryProgress;
|
|
1173
1254
|
if (!progress?.allowedActions?.includes('activate'))
|
|
1174
1255
|
return { ok: false, error: '请先完成恢复信息验证' };
|
|
1175
|
-
return this.runRecovery('恢复身份', operationId => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage)
|
|
1256
|
+
return this.runRecovery('恢复身份', operationId => call(() => this.remote.activateRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage));
|
|
1176
1257
|
}
|
|
1177
1258
|
/** Read-only even for applied operations. Entering the identity is a separate action. */
|
|
1178
1259
|
async refreshRecoveryStatus() {
|
|
@@ -1193,14 +1274,14 @@ export class AwikiController {
|
|
|
1193
1274
|
}
|
|
1194
1275
|
this.publish({ ...this.view, recoveryProgress: result.value, accessError: null });
|
|
1195
1276
|
if (result.value.phase === 'applied')
|
|
1196
|
-
clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
|
|
1277
|
+
clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
1197
1278
|
return result;
|
|
1198
1279
|
}
|
|
1199
1280
|
async resumeRecovery() {
|
|
1200
1281
|
const progress = this.view.recoveryProgress;
|
|
1201
1282
|
if (!progress?.allowedActions?.includes('resume'))
|
|
1202
1283
|
return { ok: false, error: '当前恢复状态不能重试' };
|
|
1203
|
-
return this.runRecovery('继续恢复身份', operationId => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage)
|
|
1284
|
+
return this.runRecovery('继续恢复身份', operationId => call(() => this.remote.resumeRecovery({ operationId }), failure => recoveryContinuationFailureMessage(failure, progress.phase), recoveryCarrierFailureMessage));
|
|
1204
1285
|
}
|
|
1205
1286
|
async enterRecoveredSession() {
|
|
1206
1287
|
const generation = this.generation;
|
|
@@ -1214,7 +1295,7 @@ export class AwikiController {
|
|
|
1214
1295
|
return this.recoveryLeft();
|
|
1215
1296
|
if (!result.ok)
|
|
1216
1297
|
return result;
|
|
1217
|
-
clearRecoveryOtpRetryAt(progress.operationId, this.config?.tenantId);
|
|
1298
|
+
clearRecoveryOtpRetryAt(progress.operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
1218
1299
|
this.leaveRecovery();
|
|
1219
1300
|
const opened = await this.open();
|
|
1220
1301
|
return opened.ok ? result : opened;
|
|
@@ -1234,7 +1315,7 @@ export class AwikiController {
|
|
|
1234
1315
|
return this.recoveryLeft();
|
|
1235
1316
|
if (!result.ok)
|
|
1236
1317
|
return result;
|
|
1237
|
-
clearRecoveryOtpRetryAt(operationId, this.config?.tenantId);
|
|
1318
|
+
clearRecoveryOtpRetryAt(operationId, (this.config?.tenantStorageScopeId ?? this.config?.tenantId));
|
|
1238
1319
|
this.leaveRecovery();
|
|
1239
1320
|
void this.refreshIdentityAccess();
|
|
1240
1321
|
return { ok: true, value: undefined };
|
|
@@ -1434,7 +1515,7 @@ export class AwikiController {
|
|
|
1434
1515
|
* @param members - Handle or DID values entered by the user.
|
|
1435
1516
|
* @returns the created group and settled invitation outcomes.
|
|
1436
1517
|
*/
|
|
1437
|
-
async createGroup(name, members) {
|
|
1518
|
+
async createGroup(name, members, encrypted = false, draftOperationId) {
|
|
1438
1519
|
if (this.disposed)
|
|
1439
1520
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
1440
1521
|
const normalizedName = name.trim();
|
|
@@ -1452,29 +1533,80 @@ export class AwikiController {
|
|
|
1452
1533
|
return this.fail('群成员列表不需要包含自己');
|
|
1453
1534
|
}
|
|
1454
1535
|
const generation = this.generation;
|
|
1536
|
+
const resumeKey = this.groupCreationStorageKey();
|
|
1537
|
+
const operationId = draftOperationId ?? `group-${crypto.randomUUID()}`;
|
|
1538
|
+
const operationKey = `${resumeKey}:${operationId}`;
|
|
1539
|
+
try {
|
|
1540
|
+
const storage = globalThis.localStorage;
|
|
1541
|
+
storage?.setItem(operationKey, JSON.stringify({ name: normalizedName, members: normalizedMembers, encrypted, operationId }));
|
|
1542
|
+
const keys = JSON.parse(storage?.getItem(`${resumeKey}:index`) ?? '[]');
|
|
1543
|
+
storage?.setItem(`${resumeKey}:index`, JSON.stringify([...new Set([...keys, operationKey])]));
|
|
1544
|
+
}
|
|
1545
|
+
catch {
|
|
1546
|
+
return this.fail('无法保存建群恢复标识,请检查本机存储后重试');
|
|
1547
|
+
}
|
|
1455
1548
|
const result = await this.withPending('创建群聊', () => call(() => this.remote.createGroup({
|
|
1456
1549
|
name: normalizedName,
|
|
1457
1550
|
members: normalizedMembers,
|
|
1551
|
+
encrypted,
|
|
1552
|
+
operationId,
|
|
1458
1553
|
}), groupCreateFailureMessage));
|
|
1459
1554
|
if (!result.ok || !this.current(generation))
|
|
1460
1555
|
return result;
|
|
1556
|
+
try {
|
|
1557
|
+
globalThis.localStorage?.removeItem(operationKey);
|
|
1558
|
+
}
|
|
1559
|
+
catch { /* Preserve the resume ID when cleanup is unavailable. */ }
|
|
1560
|
+
this.drafts.write(this.drafts.getScope('compose:groupFrozen'), 'compose:groupFrozen', false, false, true);
|
|
1461
1561
|
const conversation = this.cacheConversation(result.value.conversation);
|
|
1462
1562
|
this.publish({
|
|
1463
1563
|
...this.view,
|
|
1464
1564
|
conversations: appendUnique([conversation], this.view.conversations, value => value.id),
|
|
1465
1565
|
error: null,
|
|
1466
1566
|
});
|
|
1467
|
-
|
|
1567
|
+
// Navigation belongs to the live dialog instance; a closed form cannot redirect it.
|
|
1568
|
+
const selected = draftOperationId === undefined ? await this.selectConversation(result.value.conversation.id) : { ok: true };
|
|
1468
1569
|
if (!this.current(generation))
|
|
1469
1570
|
return result;
|
|
1470
1571
|
const failed = result.value.failedMembers.map(item => item.member);
|
|
1471
1572
|
const warning = failed.length === 0
|
|
1472
1573
|
? selected.ok ? null : '群聊已创建,但暂时无法打开消息历史。'
|
|
1473
|
-
:
|
|
1574
|
+
: `群聊已创建,以下成员的邀请或加密准备未完成,请在群成员中重试:${failed.join('、')}`;
|
|
1474
1575
|
if (warning !== null)
|
|
1475
1576
|
this.publish({ ...this.view, error: warning });
|
|
1476
1577
|
return result;
|
|
1477
1578
|
}
|
|
1579
|
+
groupCreationStorageKey() {
|
|
1580
|
+
return `awiki.group-create.v2:${this.drafts.getScope('compose:groupName')}`;
|
|
1581
|
+
}
|
|
1582
|
+
restoreGroupCreationDraft() {
|
|
1583
|
+
const scope = this.drafts.getScope('compose:groupName');
|
|
1584
|
+
this.drafts.write(scope, 'compose:groupName', '', '', true);
|
|
1585
|
+
this.drafts.write(scope, 'compose:groupMembers', '', '', true);
|
|
1586
|
+
this.drafts.write(scope, 'compose:encryptedGroup', false, false, true);
|
|
1587
|
+
this.drafts.write(scope, 'compose:groupFrozen', false, false, true);
|
|
1588
|
+
// Retain the operation independently of the new form, including legacy records.
|
|
1589
|
+
const generation = this.generation;
|
|
1590
|
+
const prefix = this.groupCreationStorageKey();
|
|
1591
|
+
try {
|
|
1592
|
+
const storage = globalThis.localStorage;
|
|
1593
|
+
if (storage === undefined)
|
|
1594
|
+
return;
|
|
1595
|
+
const keys = [prefix, ...JSON.parse(storage.getItem(`${prefix}:index`) ?? '[]')];
|
|
1596
|
+
for (const key of keys.slice(0, 8)) {
|
|
1597
|
+
const value = JSON.parse(storage.getItem(key) ?? 'null');
|
|
1598
|
+
if (value === null || typeof value.operationId !== 'string' || typeof value.name !== 'string' || !Array.isArray(value.members))
|
|
1599
|
+
continue;
|
|
1600
|
+
void call(() => this.remote.createGroup(value)).then(result => {
|
|
1601
|
+
if (!result.ok || !this.current(generation))
|
|
1602
|
+
return;
|
|
1603
|
+
storage.removeItem(key);
|
|
1604
|
+
void this.refreshConversations(generation);
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
catch { /* Keep unreadable legacy records; they must not freeze a new form. */ }
|
|
1609
|
+
}
|
|
1478
1610
|
/** Join one open group by its canonical DID, then select the refreshed conversation. */
|
|
1479
1611
|
async joinGroup(groupDidInput) {
|
|
1480
1612
|
const groupDid = groupDidInput.trim();
|
|
@@ -1566,9 +1698,9 @@ export class AwikiController {
|
|
|
1566
1698
|
groupDid: group.groupDid,
|
|
1567
1699
|
member,
|
|
1568
1700
|
}));
|
|
1701
|
+
const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
1569
1702
|
if (!invited.ok)
|
|
1570
1703
|
return invited;
|
|
1571
|
-
const refreshed = await this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
1572
1704
|
if (!refreshed.ok) {
|
|
1573
1705
|
return {
|
|
1574
1706
|
ok: false,
|
|
@@ -1689,18 +1821,21 @@ export class AwikiController {
|
|
|
1689
1821
|
async loadGroupState(conversation, reset, preserveAvailableOnNetworkFailure = false) {
|
|
1690
1822
|
const generation = this.generation;
|
|
1691
1823
|
const selectionRevision = this.selectionRevision;
|
|
1824
|
+
const readRevision = ++this.groupReadRevision;
|
|
1825
|
+
const isCurrent = () => this.currentSelection(generation, selectionRevision, conversation.id)
|
|
1826
|
+
&& this.groupReadRevision === readRevision;
|
|
1692
1827
|
const snapshot = await callGroupRead(() => this.remote.getGroup({ groupDid: conversation.groupDid }));
|
|
1693
1828
|
if (!snapshot.ok) {
|
|
1694
1829
|
const retainAvailable = preserveAvailableOnNetworkFailure
|
|
1695
1830
|
&& snapshot.reason === 'network-error'
|
|
1696
1831
|
&& this.view.groupAccess?.groupDid === conversation.groupDid
|
|
1697
1832
|
&& this.view.groupAccess.status === 'available';
|
|
1698
|
-
if (!retainAvailable &&
|
|
1833
|
+
if (!retainAvailable && isCurrent()) {
|
|
1699
1834
|
this.publishGroupAccessFailure(conversation, snapshot.reason);
|
|
1700
1835
|
}
|
|
1701
1836
|
return snapshot;
|
|
1702
1837
|
}
|
|
1703
|
-
if (
|
|
1838
|
+
if (isCurrent()) {
|
|
1704
1839
|
const access = this.view.groupAccess;
|
|
1705
1840
|
this.publish({
|
|
1706
1841
|
...this.view,
|
|
@@ -1717,12 +1852,12 @@ export class AwikiController {
|
|
|
1717
1852
|
&& members.reason === 'network-error'
|
|
1718
1853
|
&& this.view.groupAccess?.groupDid === conversation.groupDid
|
|
1719
1854
|
&& this.view.groupAccess.status === 'available';
|
|
1720
|
-
if (!retainAvailable &&
|
|
1855
|
+
if (!retainAvailable && isCurrent()) {
|
|
1721
1856
|
this.publishGroupAccessFailure(conversation, members.reason);
|
|
1722
1857
|
}
|
|
1723
1858
|
return members;
|
|
1724
1859
|
}
|
|
1725
|
-
if (!
|
|
1860
|
+
if (!isCurrent())
|
|
1726
1861
|
return snapshot;
|
|
1727
1862
|
if (members.value.pageGroup !== undefined && members.value.pageGroup !== conversation.groupDid) {
|
|
1728
1863
|
return this.fail('群成员列表归属不一致,请刷新后重试');
|
|
@@ -1798,7 +1933,7 @@ export class AwikiController {
|
|
|
1798
1933
|
messages: mergeLatestMessages(this.view.messages, committed.value.items),
|
|
1799
1934
|
historyHasMore: remote.value.hasMore && remote.value.nextCursor !== undefined,
|
|
1800
1935
|
refreshing: false,
|
|
1801
|
-
error: existingError?.startsWith('
|
|
1936
|
+
error: existingError?.startsWith('群聊已创建,以下成员的邀请或加密准备未完成,请在群成员中重试:') ? existingError : null,
|
|
1802
1937
|
summaries: this.staleSummaries(conversationId, incoming),
|
|
1803
1938
|
});
|
|
1804
1939
|
}
|
|
@@ -1964,27 +2099,37 @@ export class AwikiController {
|
|
|
1964
2099
|
* @param clientMessageId - optional logical identity shared with the optimistic row.
|
|
1965
2100
|
* @returns successful delivery or one display-safe failure.
|
|
1966
2101
|
*/
|
|
1967
|
-
async sendText(text, clientMessageId, mentions) {
|
|
2102
|
+
async sendText(text, clientMessageId, mentions, security) {
|
|
1968
2103
|
const conversation = this.selectedConversation();
|
|
1969
2104
|
if (conversation === undefined)
|
|
1970
2105
|
return this.fail('请先选择会话');
|
|
2106
|
+
if (conversation.kind === 'direct' && this.view.messageSecurityReady !== true)
|
|
2107
|
+
return this.fail('消息模式尚未就绪,请刷新后重试');
|
|
2108
|
+
if (conversation.kind === 'group' && this.view.selectedGroup?.encrypted && !this.view.selectedGroup.canSendSecure)
|
|
2109
|
+
return this.fail('群加密尚未就绪,请等待群主完成准备或修复');
|
|
1971
2110
|
if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
1972
2111
|
return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
|
|
1973
2112
|
}
|
|
1974
2113
|
const conversationId = conversation.id;
|
|
1975
2114
|
const generation = this.generation;
|
|
1976
2115
|
const result = await this.withPending('发送消息', () => call(() => this.remote.sendText({
|
|
2116
|
+
security: security ?? 'plain',
|
|
1977
2117
|
target: targetOf(conversation),
|
|
1978
2118
|
text,
|
|
1979
2119
|
idempotencyKey: clientMessageId ?? crypto.randomUUID(),
|
|
1980
2120
|
...mentions === undefined || mentions.length === 0 ? {} : { mentions },
|
|
1981
2121
|
})));
|
|
1982
|
-
if (!result.ok)
|
|
2122
|
+
if (!result.ok) {
|
|
2123
|
+
if (conversation.kind === 'group' && this.current(generation) && this.view.selectedGroup?.groupDid === conversation.groupDid) {
|
|
2124
|
+
this.publish({ ...this.view, selectedGroup: { ...this.view.selectedGroup, canSendSecure: false } });
|
|
2125
|
+
await this.loadGroupState(conversation, true, true);
|
|
2126
|
+
}
|
|
1983
2127
|
return result;
|
|
2128
|
+
}
|
|
1984
2129
|
if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
|
|
1985
2130
|
return { ok: true, value: undefined };
|
|
1986
2131
|
}
|
|
1987
|
-
this.appendMessage(result.value);
|
|
2132
|
+
this.appendMessage(result.value, clientMessageId);
|
|
1988
2133
|
return { ok: true, value: undefined };
|
|
1989
2134
|
}
|
|
1990
2135
|
/**
|
|
@@ -1996,12 +2141,17 @@ export class AwikiController {
|
|
|
1996
2141
|
const conversation = this.selectedConversation();
|
|
1997
2142
|
if (conversation === undefined)
|
|
1998
2143
|
return this.fail('请先选择会话');
|
|
2144
|
+
if (conversation.kind === 'direct' && this.view.messageSecurityReady !== true)
|
|
2145
|
+
return this.fail('消息模式尚未就绪,请刷新后重试');
|
|
2146
|
+
if (conversation.kind === 'group' && this.view.selectedGroup?.encrypted && !this.view.selectedGroup.canSendSecure)
|
|
2147
|
+
return this.fail('群加密尚未就绪,请等待群主完成准备或修复');
|
|
1999
2148
|
if (conversation.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
2000
2149
|
return this.fail('当前身份尚未获得这个群聊的发送权限,请先重新检查群成员状态。');
|
|
2001
2150
|
}
|
|
2002
2151
|
const conversationId = conversation.id;
|
|
2003
2152
|
const generation = this.generation;
|
|
2004
2153
|
const request = {
|
|
2154
|
+
security: file.security ?? 'plain',
|
|
2005
2155
|
target: targetOf(conversation),
|
|
2006
2156
|
fileName: file.fileName,
|
|
2007
2157
|
mimeType: file.mimeType,
|
|
@@ -2010,12 +2160,17 @@ export class AwikiController {
|
|
|
2010
2160
|
idempotencyKey: file.clientMessageId ?? crypto.randomUUID(),
|
|
2011
2161
|
};
|
|
2012
2162
|
const result = await this.withPending('发送附件', () => call(() => this.remote.sendAttachment(request)));
|
|
2013
|
-
if (!result.ok)
|
|
2163
|
+
if (!result.ok) {
|
|
2164
|
+
if (conversation.kind === 'group' && this.current(generation) && this.view.selectedGroup?.groupDid === conversation.groupDid) {
|
|
2165
|
+
this.publish({ ...this.view, selectedGroup: { ...this.view.selectedGroup, canSendSecure: false } });
|
|
2166
|
+
await this.loadGroupState(conversation, true, true);
|
|
2167
|
+
}
|
|
2014
2168
|
return result;
|
|
2169
|
+
}
|
|
2015
2170
|
if (!this.current(generation) || this.view.selectedConversationId !== conversationId) {
|
|
2016
2171
|
return { ok: true, value: undefined };
|
|
2017
2172
|
}
|
|
2018
|
-
this.appendMessage(result.value);
|
|
2173
|
+
this.appendMessage(result.value, file.clientMessageId);
|
|
2019
2174
|
return { ok: true, value: undefined };
|
|
2020
2175
|
}
|
|
2021
2176
|
/**
|
|
@@ -2062,13 +2217,14 @@ export class AwikiController {
|
|
|
2062
2217
|
return { ok: false, error: 'AWiki 插件已卸载' };
|
|
2063
2218
|
const generation = this.generation;
|
|
2064
2219
|
const ownerDid = this.presentationCacheOwnerDid;
|
|
2065
|
-
const tenantId = this.config?.tenantId;
|
|
2220
|
+
const tenantId = (this.config?.tenantStorageScopeId ?? this.config?.tenantId);
|
|
2066
2221
|
const result = await call(() => this.remote.clearLocalData(request));
|
|
2067
2222
|
if (!result.ok)
|
|
2068
2223
|
return result;
|
|
2069
2224
|
const owners = [...(result.value.clearedIdentityDids ?? []), ...(ownerDid === null ? [] : [ownerDid])];
|
|
2070
2225
|
for (const owner of new Set(owners)) {
|
|
2071
2226
|
await this.persistentImageCache.clear(owner).catch(() => undefined);
|
|
2227
|
+
await avatarCache.clear(owner);
|
|
2072
2228
|
try {
|
|
2073
2229
|
clearMailBrowserCache(globalThis.localStorage, owner);
|
|
2074
2230
|
}
|
|
@@ -2088,6 +2244,8 @@ export class AwikiController {
|
|
|
2088
2244
|
this.clearPresentationCache();
|
|
2089
2245
|
this.publish({ ...INITIAL_VIEW, status: 'ready', accessLoading: false,
|
|
2090
2246
|
attachmentMaxBytes: this.config?.attachmentMaxBytes ?? INITIAL_VIEW.attachmentMaxBytes,
|
|
2247
|
+
serverFeatures: this.config?.serverFeatures,
|
|
2248
|
+
serverFeatureDiagnostics: this.config?.serverFeatureDiagnostics,
|
|
2091
2249
|
handleRecoveryPhoneEnabled: this.config?.handleRecoveryPhoneEnabled ?? false,
|
|
2092
2250
|
identityAccess: { choice: null, joining: false, recoveries: [] } });
|
|
2093
2251
|
return result;
|
|
@@ -2148,14 +2306,49 @@ export class AwikiController {
|
|
|
2148
2306
|
this.close();
|
|
2149
2307
|
this.listeners.clear();
|
|
2150
2308
|
}
|
|
2309
|
+
async retryMessageSecurity() {
|
|
2310
|
+
await this.loadConversationPreferences(this.generation);
|
|
2311
|
+
}
|
|
2151
2312
|
async loadConversationPreferences(generation) {
|
|
2313
|
+
this.publish({ ...this.view, messageSecurityReady: false, messageSecurityError: false });
|
|
2152
2314
|
const result = await call(() => this.remote.getConversationPreferences(), conversationPreferenceFailureMessage);
|
|
2153
|
-
if (!
|
|
2315
|
+
if (!this.current(generation))
|
|
2154
2316
|
return;
|
|
2317
|
+
if (!result.ok) {
|
|
2318
|
+
this.publish({ ...this.view, messageSecurityError: true });
|
|
2319
|
+
this.fail(result.error);
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2155
2322
|
this.applyConversationPreferences(result.value);
|
|
2156
2323
|
this.publish({ ...this.view, hiddenConversations: this.hiddenConversationsView() });
|
|
2157
2324
|
}
|
|
2325
|
+
async setMessageSecurity(conversationId, encrypted) {
|
|
2326
|
+
if (this.view.messageSecurityReady !== true)
|
|
2327
|
+
return this.fail('消息模式尚未就绪,请重试读取');
|
|
2328
|
+
const generation = this.generation;
|
|
2329
|
+
const previous = this.view.encryptedConversationIds ?? [];
|
|
2330
|
+
this.publish({ ...this.view, encryptedConversationIds: encrypted ? [...new Set([...previous, conversationId])] : previous.filter(id => id !== conversationId) });
|
|
2331
|
+
const result = await this.withPending('保存消息模式', () => call(() => this.remote.updateConversationPreference({ action: 'security', conversationId, encrypted })));
|
|
2332
|
+
if (!result.ok) {
|
|
2333
|
+
if (this.current(generation))
|
|
2334
|
+
this.publish({ ...this.view, encryptedConversationIds: previous });
|
|
2335
|
+
return result;
|
|
2336
|
+
}
|
|
2337
|
+
if (this.current(generation))
|
|
2338
|
+
this.applyConversationPreferences(result.value);
|
|
2339
|
+
return { ok: true, value: undefined };
|
|
2340
|
+
}
|
|
2341
|
+
async repairSelectedGroupSecurity() {
|
|
2342
|
+
const group = this.view.selectedGroup;
|
|
2343
|
+
if (group === null)
|
|
2344
|
+
return this.fail('请先选择群聊');
|
|
2345
|
+
const result = await this.withPending('修复群加密', () => call(() => this.remote.repairGroupSecurity({ groupDid: group.groupDid })));
|
|
2346
|
+
if (!result.ok)
|
|
2347
|
+
return result;
|
|
2348
|
+
return this.reloadSelectedGroupAfterMutation(group.groupDid);
|
|
2349
|
+
}
|
|
2158
2350
|
applyConversationPreferences(preferences) {
|
|
2351
|
+
this.publish({ ...this.view, messageSecurityReady: true, messageSecurityError: false, encryptedConversationIds: [...(preferences.encryptedConversationIds ?? [])] });
|
|
2159
2352
|
this.hiddenConversationPreferences.clear();
|
|
2160
2353
|
for (const hidden of preferences.hiddenConversations) {
|
|
2161
2354
|
this.hiddenConversationPreferences.set(hidden.conversation.id, {
|
|
@@ -2256,6 +2449,9 @@ export class AwikiController {
|
|
|
2256
2449
|
sessionStatus: status,
|
|
2257
2450
|
conversations: Object.freeze([]),
|
|
2258
2451
|
hiddenConversations: Object.freeze([]),
|
|
2452
|
+
messageSecurityReady: false,
|
|
2453
|
+
messageSecurityError: false,
|
|
2454
|
+
encryptedConversationIds: Object.freeze([]),
|
|
2259
2455
|
conversationsHasMore: false,
|
|
2260
2456
|
selectedConversationId: null,
|
|
2261
2457
|
selectedGroup: null,
|
|
@@ -2309,18 +2505,59 @@ export class AwikiController {
|
|
|
2309
2505
|
});
|
|
2310
2506
|
return { ok: true, value: undefined };
|
|
2311
2507
|
}
|
|
2508
|
+
async refreshBackendCapabilities() {
|
|
2509
|
+
await this.refreshServerFeatures(this.generation, true);
|
|
2510
|
+
}
|
|
2511
|
+
projectRegistrationAccess(access) {
|
|
2512
|
+
const support = this.config?.serverFeatures?.['registration.handle_only'];
|
|
2513
|
+
return access !== null && (support === 'supported' || support === 'unsupported')
|
|
2514
|
+
? { ...access, registrationHandleOnly: support === 'supported' }
|
|
2515
|
+
: access;
|
|
2516
|
+
}
|
|
2517
|
+
async refreshServerFeatures(generation, force = false) {
|
|
2518
|
+
this.lastCapabilityRefresh = Date.now();
|
|
2519
|
+
const config = await call(() => force ? this.remote.refreshBackendCapabilities() : this.remote.getConfig());
|
|
2520
|
+
if (!this.current(generation))
|
|
2521
|
+
return;
|
|
2522
|
+
if (config.ok && config.value.tenantId === this.config?.tenantId && config.value.tenantGeneration === this.config?.tenantGeneration) {
|
|
2523
|
+
this.config = config.value;
|
|
2524
|
+
this.publish({ ...this.view, attachmentMaxBytes: config.value.attachmentMaxBytes, identityAccess: this.projectRegistrationAccess(this.view.identityAccess), serverFeatures: config.value.serverFeatures, serverFeatureDiagnostics: config.value.serverFeatureDiagnostics, handleRecoveryPhoneEnabled: config.value.handleRecoveryPhoneEnabled });
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2312
2527
|
async poll(generation) {
|
|
2313
|
-
if (
|
|
2528
|
+
if (!this.current(generation))
|
|
2529
|
+
return;
|
|
2530
|
+
// Public discovery also completes while onboarding has no identity.
|
|
2531
|
+
// Its Host projection must never hold up local sessions or history.
|
|
2532
|
+
if (Date.now() - this.lastCapabilityRefresh >= 5_000)
|
|
2533
|
+
void this.refreshServerFeatures(generation);
|
|
2534
|
+
if (this.polling || this.view.identity === null)
|
|
2314
2535
|
return;
|
|
2315
2536
|
this.polling = true;
|
|
2537
|
+
if (globalThis.document?.visibilityState !== 'hidden' && Date.now() - this.lastAvatarProfileCheck >= 60_000)
|
|
2538
|
+
void this.refreshAvatarProfile();
|
|
2316
2539
|
try {
|
|
2317
2540
|
await this.refreshConversations(generation, true);
|
|
2318
2541
|
const selected = this.view.selectedConversationId;
|
|
2319
2542
|
if (selected === null || !this.current(generation))
|
|
2320
2543
|
return;
|
|
2321
2544
|
const selectedConversation = this.selectedConversation();
|
|
2322
|
-
if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available')
|
|
2323
|
-
|
|
2545
|
+
if (selectedConversation?.kind === 'group' && this.view.groupAccess?.status !== 'available') {
|
|
2546
|
+
const access = this.view.groupAccess?.status;
|
|
2547
|
+
if (access === 'blocked' || access === 'not-member')
|
|
2548
|
+
return;
|
|
2549
|
+
await this.loadGroupState(selectedConversation, false, true);
|
|
2550
|
+
if (!this.current(generation) || this.view.selectedConversationId !== selected
|
|
2551
|
+
|| this.getSnapshot().groupAccess?.status !== 'available')
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2554
|
+
if (selectedConversation?.kind === 'group' && this.view.selectedGroup?.encrypted) {
|
|
2555
|
+
const status = await callGroupRead(() => this.remote.getGroup({ groupDid: selectedConversation.groupDid }));
|
|
2556
|
+
if (!this.current(generation) || this.view.selectedConversationId !== selected)
|
|
2557
|
+
return;
|
|
2558
|
+
if (status.ok)
|
|
2559
|
+
this.publish({ ...this.view, selectedGroup: status.value });
|
|
2560
|
+
}
|
|
2324
2561
|
const result = await call(() => this.remote.getHistory({ conversationId: selected }));
|
|
2325
2562
|
if (!this.current(generation) || !result.ok || this.view.selectedConversationId !== selected)
|
|
2326
2563
|
return;
|
|
@@ -2402,9 +2639,14 @@ export class AwikiController {
|
|
|
2402
2639
|
}
|
|
2403
2640
|
}
|
|
2404
2641
|
}
|
|
2405
|
-
appendMessage(message) {
|
|
2642
|
+
appendMessage(message, logicalId) {
|
|
2406
2643
|
if (this.view.selectedConversationId !== message.conversationId)
|
|
2407
2644
|
return;
|
|
2645
|
+
// Core resolves a queued logical ID to the accepted group event ID. Retire
|
|
2646
|
+
// only that explicit alias, including history reads started before acceptance.
|
|
2647
|
+
if (logicalId !== undefined && logicalId !== message.id) {
|
|
2648
|
+
this.retiredMessageIds.add(`${message.conversationId}/${logicalId}`);
|
|
2649
|
+
}
|
|
2408
2650
|
const isNew = !this.view.messages.some(current => current.id === message.id);
|
|
2409
2651
|
const messages = appendMessageById(this.view.messages, message);
|
|
2410
2652
|
if ((this.unreadAtOpen.get(message.conversationId) ?? 0) > 0 && messages.length > this.view.messages.length) {
|
|
@@ -2463,6 +2705,7 @@ export class AwikiController {
|
|
|
2463
2705
|
const ownerDid = identity?.did ?? null;
|
|
2464
2706
|
if (ownerDid === this.presentationCacheOwnerDid)
|
|
2465
2707
|
return;
|
|
2708
|
+
this.retiredMessageIds.clear();
|
|
2466
2709
|
this.directProfiles.clear();
|
|
2467
2710
|
this.groupTitles.clear();
|
|
2468
2711
|
this.hiddenConversationPreferences.clear();
|
|
@@ -2471,6 +2714,7 @@ export class AwikiController {
|
|
|
2471
2714
|
}
|
|
2472
2715
|
/** Drop every browser projection without touching the Core-owned SQLite cache. */
|
|
2473
2716
|
clearPresentationCache() {
|
|
2717
|
+
this.retiredMessageIds.clear();
|
|
2474
2718
|
this.directProfiles.clear();
|
|
2475
2719
|
this.groupTitles.clear();
|
|
2476
2720
|
this.hiddenConversationPreferences.clear();
|
|
@@ -2574,7 +2818,10 @@ export class AwikiController {
|
|
|
2574
2818
|
/* v8 ignore next -- every asynchronous and public mutation path checks disposal before publishing. */
|
|
2575
2819
|
if (this.disposed)
|
|
2576
2820
|
return;
|
|
2577
|
-
|
|
2821
|
+
const hasRetired = view.messages.some(message => this.retiredMessageIds.has(`${message.conversationId}/${message.id}`));
|
|
2822
|
+
this.view = Object.freeze(hasRetired
|
|
2823
|
+
? { ...view, messages: view.messages.filter(message => !this.retiredMessageIds.has(`${message.conversationId}/${message.id}`)) }
|
|
2824
|
+
: view);
|
|
2578
2825
|
for (const listener of [...this.listeners])
|
|
2579
2826
|
listener();
|
|
2580
2827
|
}
|