From 54894072414b8919fc43eb34c71a6cf2a4484707 Mon Sep 17 00:00:00 2001 From: JustMe Date: Mon, 19 Oct 2026 13:38:51 +0800 Subject: [PATCH] refactor(web): restructure into api/domain/services/worker; capabilities, file domains, video covers - dissolve core-bridge into api (wire contract), domain (models), services (what the UI calls) and worker (everything that runs in the Web Worker, grouped by account, crypto, space, files, shares, runtime); regroup features, lib and store by responsibility and delete unused components - the UI only reads backend capabilities (domain/capabilities); one tokenRole picks the token, hasCode/isRetryable handle errors; pre-checks that decided permissions on the client are gone; tests fail if those rules are reimplemented - files go straight to the file domain (create, upload, download, delete, commit, cancel); the host sees one registration per file; creating a channel allocates a file domain and works even when the file service is down - file panel shows which channel the files belong to and its space usage; unlimited files preview in chat; videos get a stored cover; the file service status is shown - fix: console flooded by Retry-After reads on every upload frame --- web/src/{core-bridge => api}/contract.test.ts | 0 web/src/{core-bridge => api}/contract.ts | 0 web/src/{core-bridge => api}/errors.ts | 0 .../generated/api.ts => api/generated.ts} | 48 +++++- web/src/api/singleSource.guard.test.ts | 37 +++++ web/src/app/AccountChannelAccess.tsx | 8 +- web/src/app/App.tsx | 12 +- web/src/app/ChannelScreen.tsx | 2 +- web/src/app/ShareScreen.tsx | 4 +- web/src/app/ToastHost.tsx | 2 +- web/src/app/entry.ts | 4 +- web/src/app/navigation.ts | 2 +- web/src/app/useInvitationNotices.ts | 6 +- web/src/core-bridge/worker/fileService.ts | 69 -------- .../protocol => domain}/account.ts | 2 +- web/src/domain/capabilities.ts | 43 +++++ web/src/domain/poster.test.ts | 24 +++ web/src/domain/poster.ts | 10 ++ web/src/{core-bridge => domain}/settings.ts | 2 +- web/src/{core-bridge => domain}/types.ts | 24 ++- .../protocol => domain}/workspace.ts | 2 + .../{home => account}/AccountDock.module.css | 0 .../{home => account}/AccountDock.tsx | 2 +- .../{home => account}/AccountPanel.module.css | 0 .../{home => account}/AccountPanel.tsx | 18 +-- .../{home => account}/AccountScreen.tsx | 2 +- .../AccountSettings.module.css | 0 .../{home => account}/AccountSettings.tsx | 4 +- .../{home => account}/DeviceSettings.tsx | 10 +- .../{home => account}/InvitationInbox.tsx | 6 +- .../{home => account}/ProfileSettings.tsx | 14 +- .../{home => account}/SecuritySettings.tsx | 8 +- .../{home => account}/SyncSettings.tsx | 6 +- .../{home => avatar}/AvatarEditor.module.css | 0 .../{home => avatar}/AvatarEditor.tsx | 0 .../AvatarPreview.module.css | 0 .../{common => avatar}/AvatarPreview.tsx | 0 .../features/{home => avatar}/avatarCrop.ts | 0 .../features/{home => avatar}/avatarGif.ts | 0 .../features/{home => avatar}/avatarSource.ts | 0 .../features/channel/ChannelSettingsSheet.tsx | 28 ++-- web/src/features/channel/FilterBar.module.css | 55 ------- web/src/features/channel/FilterBar.tsx | 49 ------ web/src/features/channel/ItemList.module.css | 16 -- web/src/features/channel/ItemList.tsx | 30 ---- .../features/channel/LatestCard.module.css | 76 --------- web/src/features/channel/LatestCard.tsx | 59 ------- web/src/features/channel/UndoBar.module.css | 35 ---- web/src/features/channel/UndoBar.tsx | 12 -- web/src/features/channel/Workspace.tsx | 87 +++++----- .../{ => chat}/ChatAttachment.module.css | 38 +++++ .../channel/{ => chat}/ChatAttachment.tsx | 18 ++- .../channel/{ => chat}/ChatMessage.tsx | 23 +-- .../channel/{ => chat}/ChatPane.module.css | 0 .../features/channel/{ => chat}/ChatPane.tsx | 8 +- .../{ => explorer}/ExplorerFileRow.tsx | 4 +- .../{ => explorer}/ExplorerFolderRow.tsx | 2 +- .../channel/{ => explorer}/ExplorerHover.tsx | 10 +- .../{ => explorer}/ExplorerMoveDialog.tsx | 2 +- .../{ => explorer}/FileExplorer.module.css | 134 ++++++++++------ .../channel/{ => explorer}/FileExplorer.tsx | 52 +++--- .../channel/{ => explorer}/FilePaneResize.tsx | 2 +- .../channel/explorer/FilterBar.module.css | 20 +++ .../features/channel/explorer/FilterBar.tsx | 25 +++ .../UploadOptionsSheet.module.css | 0 .../{ => explorer}/UploadOptionsSheet.tsx | 8 +- .../{ => explorer}/useExplorerInteractions.ts | 4 +- .../channel/explorer/useFileDomain.ts | 48 ++++++ .../channel/{ => members}/InviteSheet.tsx | 6 +- .../{ => members}/MembersPane.module.css | 0 .../channel/{ => members}/MembersPane.tsx | 38 +++-- .../{ => members}/RoleBadges.module.css | 0 .../channel/{ => members}/RoleBadges.tsx | 6 +- .../{ => members}/RoleColorPicker.module.css | 0 .../channel/{ => members}/RoleColorPicker.tsx | 6 +- .../{ => members}/RolesEditor.module.css | 0 .../channel/{ => members}/RolesEditor.tsx | 14 +- .../{ => members}/SharePanel.module.css | 0 .../channel/{ => members}/SharePanel.tsx | 2 +- .../{ => navigation}/MobileDrawer.module.css | 0 .../channel/{ => navigation}/MobileDrawer.tsx | 6 +- .../{ => navigation}/MobileTopBar.module.css | 0 .../channel/{ => navigation}/MobileTopBar.tsx | 2 +- .../{ => navigation}/Notepad.module.css | 0 .../channel/{ => navigation}/Notepad.tsx | 2 +- .../{ => navigation}/PaneTabs.module.css | 0 .../channel/{ => navigation}/PaneTabs.tsx | 0 .../{ => navigation}/SpaceRail.module.css | 0 .../channel/{ => navigation}/SpaceRail.tsx | 8 +- .../{ => navigation}/ThreadSidebar.module.css | 0 .../{ => navigation}/ThreadSidebar.tsx | 23 ++- .../{ => navigation}/TopBar.module.css | 0 .../channel/{ => navigation}/TopBar.tsx | 4 +- .../{ => navigation}/useThreadReads.ts | 12 +- web/src/features/home/ChannelList.tsx | 10 +- web/src/features/home/CreateSheet.tsx | 16 +- web/src/features/home/Home.tsx | 16 +- web/src/features/home/QuickShare.tsx | 6 +- web/src/features/item/CompactRow.tsx | 4 +- web/src/features/item/HistoryList.tsx | 8 +- web/src/features/item/InlineContent.tsx | 6 +- web/src/features/item/ItemDetail.tsx | 8 +- web/src/features/item/PreviewPane.module.css | 10 -- web/src/features/item/PreviewPane.tsx | 16 -- web/src/features/item/PreviewSheet.module.css | 12 -- web/src/features/item/TextEditor.tsx | 2 +- web/src/features/item/Tile.tsx | 4 +- web/src/features/item/TransferProgress.tsx | 2 +- web/src/features/item/download.ts | 4 +- .../features/item/{ => hooks}/useBlobUrl.ts | 2 +- .../features/item/{ => hooks}/useMediaUrl.ts | 0 .../features/item/{ => hooks}/useStreamUrl.ts | 4 +- web/src/features/item/hooks/videoPoster.ts | 50 ++++++ .../features/item/previews/ArchivePreview.tsx | 4 +- .../features/item/previews/MediaPreview.tsx | 4 +- .../features/item/previews/TextPreview.tsx | 4 +- web/src/features/item/source.ts | 10 +- web/src/features/item/summary.ts | 2 +- web/src/features/scan/JoinPreview.tsx | 8 +- web/src/features/scan/JoinScreen.tsx | 6 +- web/src/features/scan/ScanSheet.tsx | 10 +- .../{channel => share}/ActionBar.module.css | 0 .../features/{channel => share}/ActionBar.tsx | 4 +- .../ComposeSheet.module.css | 0 .../{channel => share}/ComposeSheet.tsx | 0 web/src/features/share/DraftPreview.tsx | 6 +- web/src/features/share/DraftRow.tsx | 4 +- web/src/features/share/ShareDraft.tsx | 18 +-- web/src/features/share/ShareView.tsx | 4 +- web/src/lib/{ => color}/colorDraw.test.ts | 2 +- web/src/lib/{ => color}/colorDraw.ts | 2 +- web/src/lib/{ => color}/colorMath.test.ts | 0 web/src/lib/{ => color}/colorMath.ts | 0 web/src/lib/{ => files}/download.ts | 9 -- web/src/lib/{ => files}/explorer.test.ts | 0 web/src/lib/{ => files}/explorer.ts | 0 web/src/lib/{ => files}/fileKind.test.ts | 0 web/src/lib/{ => files}/fileKind.ts | 2 +- web/src/lib/{ => files}/filter.test.ts | 2 +- web/src/lib/{ => files}/filter.ts | 2 +- web/src/lib/{ => hooks}/useBackToClose.ts | 0 web/src/lib/{ => hooks}/useGlobalPaste.ts | 2 +- web/src/lib/{ => hooks}/useMediaQuery.ts | 0 web/src/lib/{ => links}/channelLink.test.ts | 0 web/src/lib/{ => links}/channelLink.ts | 0 web/src/lib/{ => links}/shareLink.test.ts | 0 web/src/lib/{ => links}/shareLink.ts | 0 web/src/lib/{ => platform}/buildInfo.ts | 0 web/src/lib/{ => platform}/clipboard.ts | 10 ++ web/src/lib/{ => platform}/theme.ts | 0 web/src/lib/{ => qr}/qr.fixtures.ts | 0 web/src/lib/{ => qr}/qr.test.ts | 0 web/src/lib/{ => qr}/qr.ts | 0 web/src/lib/{ => space}/roles.ts | 9 +- web/src/lib/{ => space}/sender.ts | 2 +- .../lib/{ => space}/settingOptions.test.ts | 0 web/src/lib/{ => space}/settingOptions.ts | 0 web/src/lib/{ => text}/format.ts | 0 web/src/lib/{ => text}/inline.test.ts | 0 web/src/lib/{ => text}/inline.ts | 2 +- .../lib/{ => text}/inlineMarkdown.test.tsx | 0 web/src/lib/{ => text}/inlineMarkdown.tsx | 0 web/src/main.tsx | 14 +- web/src/{core-bridge => services}/account.ts | 6 +- web/src/services/apiBase.ts | 2 + web/src/{core-bridge => services}/captcha.ts | 0 .../channelAccess.ts | 8 +- .../instance.ts => services/index.ts} | 2 +- .../{core-bridge/real.ts => services/live.ts} | 16 +- web/src/{core-bridge => services}/passkeys.ts | 4 +- web/src/{core-bridge => services}/server.ts | 4 +- .../shareApi.ts => services/shares.ts} | 4 +- .../{core-bridge => services}/streamClient.ts | 0 .../{core-bridge => services}/workerClient.ts | 4 +- .../api.ts => services/workspace.ts} | 13 +- web/src/store/{ => account}/account.ts | 20 +-- web/src/store/{ => account}/invitations.ts | 8 +- web/src/store/{ => app}/server.ts | 4 +- web/src/store/{ => app}/toast.ts | 0 web/src/store/{ => share}/shareReduce.test.ts | 2 +- web/src/store/{ => share}/shareReduce.ts | 2 +- web/src/store/{ => share}/shareSession.ts | 2 +- web/src/store/{ => share}/shares.ts | 10 +- web/src/store/{ => space}/channels.ts | 6 +- web/src/store/{ => space}/readState.ts | 0 web/src/store/{ => space}/reduce.test.ts | 4 +- web/src/store/{ => space}/reduce.ts | 7 +- web/src/store/space/useCapabilities.ts | 9 ++ web/src/store/{ => space}/workspace.ts | 26 +-- web/src/ui/ContextMenu.tsx | 2 +- web/src/ui/CopyButton.tsx | 2 +- web/src/ui/Drawer.tsx | 2 +- web/src/ui/QrCode.tsx | 2 +- web/src/ui/Sheet.tsx | 2 +- web/src/ui/ThemeMenu.tsx | 2 +- .../worker => worker/account}/account.ts | 34 ++-- .../account}/accountChannels.ts | 14 +- .../account}/accountCrypto.test.ts | 0 .../account}/accountCrypto.ts | 6 +- .../account}/accountPersistence.ts | 4 +- .../account}/accountProfile.ts | 6 +- .../account}/accountSession.ts | 2 +- .../account}/accountSigning.ts | 6 +- .../account}/accountVault.test.ts | 2 +- .../worker => worker/account}/accountVault.ts | 4 +- .../worker => worker/account}/avatars.ts | 2 +- .../account}/credentialCipher.test.ts | 0 .../account}/credentialCipher.ts | 0 .../account}/passkeyCrypto.test.ts | 0 .../account}/passkeyCrypto.ts | 0 .../worker => worker/account}/passkeys.ts | 4 +- .../crypto}/channelDataKeys.ts | 27 ++-- .../crypto}/channelGrant.test.ts | 2 +- .../worker => worker/crypto}/channelGrant.ts | 6 +- .../crypto}/domainCrypto.test.ts | 2 +- .../worker => worker/crypto}/domainCrypto.ts | 2 +- .../crypto}/subtleFallback.test.ts | 0 .../crypto}/subtleFallback.ts | 0 .../files}/downloadStorage.test.ts | 0 .../files}/downloadStorage.ts | 0 web/src/worker/files/fileService.ts | 150 ++++++++++++++++++ .../files}/frameStash.test.ts | 2 +- .../worker => worker/files}/frameStash.ts | 2 +- .../worker => worker/files}/playbackStream.ts | 2 +- .../worker => worker/files}/resumeUploads.ts | 34 +++- .../worker => worker/files}/transfer.ts | 84 ++++++---- .../files}/uploadPipeline.test.ts | 2 +- .../worker => worker/files}/uploadPipeline.ts | 2 +- .../worker => worker/files}/uploadQueue.ts | 53 ++++--- .../{core-bridge/worker.ts => worker/main.ts} | 51 +++--- .../runtime}/database.ts | 8 +- .../worker => worker/runtime}/http.test.ts | 0 .../worker => worker/runtime}/http.ts | 33 +++- .../worker => worker/runtime}/port.ts | 2 +- .../worker => worker/runtime}/presentation.ts | 4 +- .../worker => worker/runtime}/serial.ts | 0 .../worker => worker/runtime}/sessions.ts | 21 ++- .../worker => worker/runtime}/transports.ts | 2 +- .../worker => worker/runtime}/wire.ts | 16 +- .../worker => worker/shares}/shareDraft.ts | 30 ++-- .../worker => worker/shares}/shareManifest.ts | 6 +- .../worker => worker/shares}/shareRead.ts | 18 +-- .../shares}/shareResources.ts | 24 +-- .../worker => worker/shares}/shareSeal.ts | 16 +- .../worker => worker/shares}/shareState.ts | 16 +- .../worker => worker/space}/channelEvents.ts | 21 +-- .../worker => worker/space}/channelItems.ts | 27 ++-- .../space}/channelResources.ts | 38 ++--- .../worker => worker/space}/channelSend.ts | 18 +-- .../worker => worker/space}/channelThreads.ts | 27 ++-- .../space}/channelTransport.ts | 19 +-- .../worker => worker/space}/fileCatalog.ts | 6 +- .../worker => worker/space}/links.ts | 0 .../space}/resourceMetadata.test.ts | 6 +- .../space}/resourceMetadata.ts | 4 +- .../worker => worker/space}/senderSnapshot.ts | 4 +- .../worker => worker/space}/spaceWatch.ts | 6 +- .../space}/workspaceRecords.test.ts | 0 .../space}/workspaceRecords.ts | 11 +- .../space}/workspaceStructure.ts | 19 +-- 260 files changed, 1503 insertions(+), 1246 deletions(-) rename web/src/{core-bridge => api}/contract.test.ts (100%) rename web/src/{core-bridge => api}/contract.ts (100%) rename web/src/{core-bridge => api}/errors.ts (100%) rename web/src/{core-bridge/generated/api.ts => api/generated.ts} (81%) create mode 100644 web/src/api/singleSource.guard.test.ts delete mode 100644 web/src/core-bridge/worker/fileService.ts rename web/src/{core-bridge/protocol => domain}/account.ts (96%) create mode 100644 web/src/domain/capabilities.ts create mode 100644 web/src/domain/poster.test.ts create mode 100644 web/src/domain/poster.ts rename web/src/{core-bridge => domain}/settings.ts (88%) rename web/src/{core-bridge => domain}/types.ts (87%) rename web/src/{core-bridge/protocol => domain}/workspace.ts (83%) rename web/src/features/{home => account}/AccountDock.module.css (100%) rename web/src/features/{home => account}/AccountDock.tsx (96%) rename web/src/features/{home => account}/AccountPanel.module.css (100%) rename web/src/features/{home => account}/AccountPanel.tsx (95%) rename web/src/features/{home => account}/AccountScreen.tsx (90%) rename web/src/features/{home => account}/AccountSettings.module.css (100%) rename web/src/features/{home => account}/AccountSettings.tsx (95%) rename web/src/features/{home => account}/DeviceSettings.tsx (92%) rename web/src/features/{home => account}/InvitationInbox.tsx (88%) rename web/src/features/{home => account}/ProfileSettings.tsx (91%) rename web/src/features/{home => account}/SecuritySettings.tsx (93%) rename web/src/features/{home => account}/SyncSettings.tsx (93%) rename web/src/features/{home => avatar}/AvatarEditor.module.css (100%) rename web/src/features/{home => avatar}/AvatarEditor.tsx (100%) rename web/src/features/{common => avatar}/AvatarPreview.module.css (100%) rename web/src/features/{common => avatar}/AvatarPreview.tsx (100%) rename web/src/features/{home => avatar}/avatarCrop.ts (100%) rename web/src/features/{home => avatar}/avatarGif.ts (100%) rename web/src/features/{home => avatar}/avatarSource.ts (100%) delete mode 100644 web/src/features/channel/FilterBar.module.css delete mode 100644 web/src/features/channel/FilterBar.tsx delete mode 100644 web/src/features/channel/ItemList.module.css delete mode 100644 web/src/features/channel/ItemList.tsx delete mode 100644 web/src/features/channel/LatestCard.module.css delete mode 100644 web/src/features/channel/LatestCard.tsx delete mode 100644 web/src/features/channel/UndoBar.module.css delete mode 100644 web/src/features/channel/UndoBar.tsx rename web/src/features/channel/{ => chat}/ChatAttachment.module.css (66%) rename web/src/features/channel/{ => chat}/ChatAttachment.tsx (81%) rename web/src/features/channel/{ => chat}/ChatMessage.tsx (85%) rename web/src/features/channel/{ => chat}/ChatPane.module.css (100%) rename web/src/features/channel/{ => chat}/ChatPane.tsx (96%) rename web/src/features/channel/{ => explorer}/ExplorerFileRow.tsx (97%) rename web/src/features/channel/{ => explorer}/ExplorerFolderRow.tsx (96%) rename web/src/features/channel/{ => explorer}/ExplorerHover.tsx (87%) rename web/src/features/channel/{ => explorer}/ExplorerMoveDialog.tsx (96%) rename web/src/features/channel/{ => explorer}/FileExplorer.module.css (77%) rename web/src/features/channel/{ => explorer}/FileExplorer.tsx (78%) rename web/src/features/channel/{ => explorer}/FilePaneResize.tsx (96%) create mode 100644 web/src/features/channel/explorer/FilterBar.module.css create mode 100644 web/src/features/channel/explorer/FilterBar.tsx rename web/src/features/channel/{ => explorer}/UploadOptionsSheet.module.css (100%) rename web/src/features/channel/{ => explorer}/UploadOptionsSheet.tsx (87%) rename web/src/features/channel/{ => explorer}/useExplorerInteractions.ts (99%) create mode 100644 web/src/features/channel/explorer/useFileDomain.ts rename web/src/features/channel/{ => members}/InviteSheet.tsx (92%) rename web/src/features/channel/{ => members}/MembersPane.module.css (100%) rename web/src/features/channel/{ => members}/MembersPane.tsx (77%) rename web/src/features/channel/{ => members}/RoleBadges.module.css (100%) rename web/src/features/channel/{ => members}/RoleBadges.tsx (88%) rename web/src/features/channel/{ => members}/RoleColorPicker.module.css (100%) rename web/src/features/channel/{ => members}/RoleColorPicker.tsx (98%) rename web/src/features/channel/{ => members}/RolesEditor.module.css (100%) rename web/src/features/channel/{ => members}/RolesEditor.tsx (94%) rename web/src/features/channel/{ => members}/SharePanel.module.css (100%) rename web/src/features/channel/{ => members}/SharePanel.tsx (97%) rename web/src/features/channel/{ => navigation}/MobileDrawer.module.css (100%) rename web/src/features/channel/{ => navigation}/MobileDrawer.tsx (91%) rename web/src/features/channel/{ => navigation}/MobileTopBar.module.css (100%) rename web/src/features/channel/{ => navigation}/MobileTopBar.tsx (96%) rename web/src/features/channel/{ => navigation}/Notepad.module.css (100%) rename web/src/features/channel/{ => navigation}/Notepad.tsx (96%) rename web/src/features/channel/{ => navigation}/PaneTabs.module.css (100%) rename web/src/features/channel/{ => navigation}/PaneTabs.tsx (100%) rename web/src/features/channel/{ => navigation}/SpaceRail.module.css (100%) rename web/src/features/channel/{ => navigation}/SpaceRail.tsx (91%) rename web/src/features/channel/{ => navigation}/ThreadSidebar.module.css (100%) rename web/src/features/channel/{ => navigation}/ThreadSidebar.tsx (89%) rename web/src/features/channel/{ => navigation}/TopBar.module.css (100%) rename web/src/features/channel/{ => navigation}/TopBar.tsx (96%) rename web/src/features/channel/{ => navigation}/useThreadReads.ts (92%) delete mode 100644 web/src/features/item/PreviewPane.module.css delete mode 100644 web/src/features/item/PreviewPane.tsx delete mode 100644 web/src/features/item/PreviewSheet.module.css rename web/src/features/item/{ => hooks}/useBlobUrl.ts (94%) rename web/src/features/item/{ => hooks}/useMediaUrl.ts (100%) rename web/src/features/item/{ => hooks}/useStreamUrl.ts (89%) create mode 100644 web/src/features/item/hooks/videoPoster.ts rename web/src/features/{channel => share}/ActionBar.module.css (100%) rename web/src/features/{channel => share}/ActionBar.tsx (95%) rename web/src/features/{channel => share}/ComposeSheet.module.css (100%) rename web/src/features/{channel => share}/ComposeSheet.tsx (100%) rename web/src/lib/{ => color}/colorDraw.test.ts (95%) rename web/src/lib/{ => color}/colorDraw.ts (97%) rename web/src/lib/{ => color}/colorMath.test.ts (100%) rename web/src/lib/{ => color}/colorMath.ts (100%) rename web/src/lib/{ => files}/download.ts (58%) rename web/src/lib/{ => files}/explorer.test.ts (100%) rename web/src/lib/{ => files}/explorer.ts (100%) rename web/src/lib/{ => files}/fileKind.test.ts (100%) rename web/src/lib/{ => files}/fileKind.ts (93%) rename web/src/lib/{ => files}/filter.test.ts (94%) rename web/src/lib/{ => files}/filter.ts (95%) rename web/src/lib/{ => hooks}/useBackToClose.ts (100%) rename web/src/lib/{ => hooks}/useGlobalPaste.ts (93%) rename web/src/lib/{ => hooks}/useMediaQuery.ts (100%) rename web/src/lib/{ => links}/channelLink.test.ts (100%) rename web/src/lib/{ => links}/channelLink.ts (100%) rename web/src/lib/{ => links}/shareLink.test.ts (100%) rename web/src/lib/{ => links}/shareLink.ts (100%) rename web/src/lib/{ => platform}/buildInfo.ts (100%) rename web/src/lib/{ => platform}/clipboard.ts (76%) rename web/src/lib/{ => platform}/theme.ts (100%) rename web/src/lib/{ => qr}/qr.fixtures.ts (100%) rename web/src/lib/{ => qr}/qr.test.ts (100%) rename web/src/lib/{ => qr}/qr.ts (100%) rename web/src/lib/{ => space}/roles.ts (92%) rename web/src/lib/{ => space}/sender.ts (87%) rename web/src/lib/{ => space}/settingOptions.test.ts (100%) rename web/src/lib/{ => space}/settingOptions.ts (100%) rename web/src/lib/{ => text}/format.ts (100%) rename web/src/lib/{ => text}/inline.test.ts (100%) rename web/src/lib/{ => text}/inline.ts (95%) rename web/src/lib/{ => text}/inlineMarkdown.test.tsx (100%) rename web/src/lib/{ => text}/inlineMarkdown.tsx (100%) rename web/src/{core-bridge => services}/account.ts (90%) create mode 100644 web/src/services/apiBase.ts rename web/src/{core-bridge => services}/captcha.ts (100%) rename web/src/{core-bridge => services}/channelAccess.ts (58%) rename web/src/{core-bridge/instance.ts => services/index.ts} (58%) rename web/src/{core-bridge/real.ts => services/live.ts} (89%) rename web/src/{core-bridge => services}/passkeys.ts (96%) rename web/src/{core-bridge => services}/server.ts (53%) rename web/src/{core-bridge/shareApi.ts => services/shares.ts} (95%) rename web/src/{core-bridge => services}/streamClient.ts (100%) rename web/src/{core-bridge => services}/workerClient.ts (91%) rename web/src/{core-bridge/api.ts => services/workspace.ts} (81%) rename web/src/store/{ => account}/account.ts (91%) rename web/src/store/{ => account}/invitations.ts (76%) rename web/src/store/{ => app}/server.ts (90%) rename web/src/store/{ => app}/toast.ts (100%) rename web/src/store/{ => share}/shareReduce.test.ts (95%) rename web/src/store/{ => share}/shareReduce.ts (95%) rename web/src/store/{ => share}/shareSession.ts (95%) rename web/src/store/{ => share}/shares.ts (90%) rename web/src/store/{ => space}/channels.ts (92%) rename web/src/store/{ => space}/readState.ts (100%) rename web/src/store/{ => space}/reduce.test.ts (96%) rename web/src/store/{ => space}/reduce.ts (95%) create mode 100644 web/src/store/space/useCapabilities.ts rename web/src/store/{ => space}/workspace.ts (86%) rename web/src/{core-bridge/worker => worker/account}/account.ts (94%) rename web/src/{core-bridge/worker => worker/account}/accountChannels.ts (85%) rename web/src/{core-bridge/worker => worker/account}/accountCrypto.test.ts (100%) rename web/src/{core-bridge/worker => worker/account}/accountCrypto.ts (96%) rename web/src/{core-bridge/worker => worker/account}/accountPersistence.ts (97%) rename web/src/{core-bridge/worker => worker/account}/accountProfile.ts (84%) rename web/src/{core-bridge/worker => worker/account}/accountSession.ts (94%) rename web/src/{core-bridge/worker => worker/account}/accountSigning.ts (97%) rename web/src/{core-bridge/worker => worker/account}/accountVault.test.ts (96%) rename web/src/{core-bridge/worker => worker/account}/accountVault.ts (97%) rename web/src/{core-bridge/worker => worker/account}/avatars.ts (98%) rename web/src/{core-bridge/worker => worker/account}/credentialCipher.test.ts (100%) rename web/src/{core-bridge/worker => worker/account}/credentialCipher.ts (100%) rename web/src/{core-bridge/worker => worker/account}/passkeyCrypto.test.ts (100%) rename web/src/{core-bridge/worker => worker/account}/passkeyCrypto.ts (100%) rename web/src/{core-bridge/worker => worker/account}/passkeys.ts (98%) rename web/src/{core-bridge/worker => worker/crypto}/channelDataKeys.ts (95%) rename web/src/{core-bridge/worker => worker/crypto}/channelGrant.test.ts (97%) rename web/src/{core-bridge/worker => worker/crypto}/channelGrant.ts (94%) rename web/src/{core-bridge/worker => worker/crypto}/domainCrypto.test.ts (95%) rename web/src/{core-bridge/worker => worker/crypto}/domainCrypto.ts (94%) rename web/src/{core-bridge => worker/crypto}/subtleFallback.test.ts (100%) rename web/src/{core-bridge => worker/crypto}/subtleFallback.ts (100%) rename web/src/{core-bridge/worker => worker/files}/downloadStorage.test.ts (100%) rename web/src/{core-bridge/worker => worker/files}/downloadStorage.ts (100%) create mode 100644 web/src/worker/files/fileService.ts rename web/src/{core-bridge/worker => worker/files}/frameStash.test.ts (98%) rename web/src/{core-bridge/worker => worker/files}/frameStash.ts (98%) rename web/src/{core-bridge/worker => worker/files}/playbackStream.ts (99%) rename web/src/{core-bridge/worker => worker/files}/resumeUploads.ts (72%) rename web/src/{core-bridge/worker => worker/files}/transfer.ts (72%) rename web/src/{core-bridge/worker => worker/files}/uploadPipeline.test.ts (99%) rename web/src/{core-bridge/worker => worker/files}/uploadPipeline.ts (99%) rename web/src/{core-bridge/worker => worker/files}/uploadQueue.ts (74%) rename web/src/{core-bridge/worker.ts => worker/main.ts} (88%) rename web/src/{core-bridge => worker/runtime}/database.ts (97%) rename web/src/{core-bridge/worker => worker/runtime}/http.test.ts (100%) rename web/src/{core-bridge/worker => worker/runtime}/http.ts (80%) rename web/src/{core-bridge/worker => worker/runtime}/port.ts (82%) rename web/src/{core-bridge/worker => worker/runtime}/presentation.ts (97%) rename web/src/{core-bridge/worker => worker/runtime}/serial.ts (100%) rename web/src/{core-bridge/worker => worker/runtime}/sessions.ts (68%) rename web/src/{core-bridge/worker => worker/runtime}/transports.ts (98%) rename web/src/{core-bridge/worker => worker/runtime}/wire.ts (77%) rename web/src/{core-bridge/worker => worker/shares}/shareDraft.ts (86%) rename web/src/{core-bridge/worker => worker/shares}/shareManifest.ts (96%) rename web/src/{core-bridge/worker => worker/shares}/shareRead.ts (92%) rename web/src/{core-bridge/worker => worker/shares}/shareResources.ts (87%) rename web/src/{core-bridge/worker => worker/shares}/shareSeal.ts (81%) rename web/src/{core-bridge/worker => worker/shares}/shareState.ts (81%) rename web/src/{core-bridge/worker => worker/space}/channelEvents.ts (93%) rename web/src/{core-bridge/worker => worker/space}/channelItems.ts (72%) rename web/src/{core-bridge/worker => worker/space}/channelResources.ts (84%) rename web/src/{core-bridge/worker => worker/space}/channelSend.ts (86%) rename web/src/{core-bridge/worker => worker/space}/channelThreads.ts (76%) rename web/src/{core-bridge/worker => worker/space}/channelTransport.ts (89%) rename web/src/{core-bridge/worker => worker/space}/fileCatalog.ts (86%) rename web/src/{core-bridge/worker => worker/space}/links.ts (100%) rename web/src/{core-bridge/worker => worker/space}/resourceMetadata.test.ts (91%) rename web/src/{core-bridge/worker => worker/space}/resourceMetadata.ts (89%) rename web/src/{core-bridge/worker => worker/space}/senderSnapshot.ts (95%) rename web/src/{core-bridge/worker => worker/space}/spaceWatch.ts (94%) rename web/src/{core-bridge/worker => worker/space}/workspaceRecords.test.ts (100%) rename web/src/{core-bridge/worker => worker/space}/workspaceRecords.ts (77%) rename web/src/{core-bridge/worker => worker/space}/workspaceStructure.ts (91%) diff --git a/web/src/core-bridge/contract.test.ts b/web/src/api/contract.test.ts similarity index 100% rename from web/src/core-bridge/contract.test.ts rename to web/src/api/contract.test.ts diff --git a/web/src/core-bridge/contract.ts b/web/src/api/contract.ts similarity index 100% rename from web/src/core-bridge/contract.ts rename to web/src/api/contract.ts diff --git a/web/src/core-bridge/errors.ts b/web/src/api/errors.ts similarity index 100% rename from web/src/core-bridge/errors.ts rename to web/src/api/errors.ts diff --git a/web/src/core-bridge/generated/api.ts b/web/src/api/generated.ts similarity index 81% rename from web/src/core-bridge/generated/api.ts rename to web/src/api/generated.ts index 5300450..83aef53 100644 --- a/web/src/core-bridge/generated/api.ts +++ b/web/src/api/generated.ts @@ -1,5 +1,5 @@ // Generated from backend DTOs; do not edit. -// backend-contract-sha256: 6e4037d39ac5ae791724731810e3df5b791839e8ab29cf54643ec1a294132964 +// backend-contract-sha256: 340876061d7b324e4fd16972e276ffb0f238eeac66b214a043b3b4b98d560fcf export interface AccountChannelBody { channel_id: string; @@ -76,12 +76,21 @@ export interface ChannelHistoryRequest { } export interface ChannelInfo { avatar?: string | null; + can_create_channel: boolean; + can_delete_any: boolean; + can_delete_own: boolean; + can_edit_settings: boolean; + can_invite: boolean; + can_manage_folders: boolean; + can_manage_roles: boolean; can_manage_structure: boolean; + can_remove_members: boolean; can_upload: boolean; created_at: number; creator_account?: string | null; expires_at: number; id: string; + is_owner: boolean; last_seq: number; members_used: number; mode: ChannelMode; @@ -90,6 +99,7 @@ export interface ChannelInfo { settings: ChannelSettings; } export type ChannelMode = 'enc' | 'raw'; +export const ChannelMode = { Enc: 'enc', Raw: 'raw' } as const; export interface ChannelSettings { delete_policy: DeletePolicy; @@ -149,6 +159,7 @@ export interface ChannelAccess { username: string; } export type DeletePolicy = 'owner_only' | 'own' | 'anyone'; +export const DeletePolicy = { OwnerOnly: 'owner_only', Own: 'own', Anyone: 'anyone' } as const; export interface DomainEnvelope { channelId: string; @@ -158,6 +169,7 @@ export interface DomainEnvelope { username: string; } export type CryptoDomain = 'chat' | 'files'; +export const CryptoDomain = { Chat: 'chat', Files: 'files' } as const; export interface DomainPublishRequest { channelId: string; @@ -169,14 +181,15 @@ export interface ErrorBody { error: string; } export interface FileGrantBody { + domain: string; expires_at: number; - mode: string; - ticket?: string | null; - url?: string | null; + ticket: string; + url: string; } export interface FileGrantRequest { + channel?: string | null; from: number; - item: string; + item?: string | null; op: string; } export interface FinishShareRequest { @@ -187,6 +200,7 @@ export interface GoneLogBody { oldest_seq: number; } export interface InvitationBody { + can_revoke: boolean; channel_id: string; created_at: number; payload: string; @@ -209,6 +223,7 @@ export interface InvitationRequest { username: string; } export type JoinPolicy = 'open' | 'invite'; +export const JoinPolicy = { Open: 'open', Invite: 'invite' } as const; export interface JoinRequest { password?: string | null; @@ -222,6 +237,7 @@ export interface KeyPackageRequest { payload: string; } export interface MemberBody { + can_remove: boolean; devices: number; joined_at: number; last_seen: number; @@ -317,7 +333,6 @@ export interface LimitsBody { max_ttl_seconds: number; max_visitors: number; max_workspace_bytes: number; - max_workspace_channel_bytes: number; min_ttl_seconds: number; } export interface SettingChoicesBody { @@ -385,7 +400,7 @@ export interface WorkspaceChannel { chatHistory: boolean; createdAt: number; createdBy: string; - fileBytes: number; + fileDomain?: string | null; filesEpoch: number; filesHistory: boolean; groupId?: string | null; @@ -393,9 +408,13 @@ export interface WorkspaceChannel { name: string; } export interface WorkspaceChannelRequest { + fileDomain?: string | null; groupId?: string | null; name: string; } +export interface WorkspaceFileDomainRequest { + fileDomain: string; +} export interface WorkspaceFolderRequest { name: string; parentId?: string | null; @@ -467,3 +486,18 @@ export interface WorkspaceLocation { itemId: string; threadId: string; } +/** 错误响应里的 error 取值 */ +export const ErrorCode = { BadRequest: 'bad_request', Unauthorized: 'unauthorized', Forbidden: 'forbidden', NotJoined: 'not_joined', MembersFull: 'members_full', ModeMismatch: 'mode_mismatch', NotFound: 'not_found', Conflict: 'conflict', Gone: 'gone', TooLarge: 'too_large', CaptchaRequired: 'captcha_required', CaptchaUnavailable: 'captcha_unavailable', RateLimited: 'rate_limited', VisitorsFull: 'visitors_full', Sealed: 'sealed', AccountRequired: 'account_required', InvitationRequired: 'invitation_required', ChannelPasswordRequired: 'channel_password_required', ChannelPasswordInvalid: 'channel_password_invalid', SpaceNameRequired: 'space_name_required', UseFileApi: 'use_file_api', FilesUnavailable: 'files_unavailable' } as const; +export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** X-WD-Role 请求头:这次请求出示哪张令牌 */ +export const TokenRole = { Read: 'read', Write: 'write', Owner: 'owner' } as const; +export type TokenRole = (typeof TokenRole)[keyof typeof TokenRole]; + +/** 空间权限(身份组授予) */ +export const WorkspacePermission = { RemoveMembers: 'members.remove', ManageStructure: 'structure.manage' } as const; +export type WorkspacePermission = (typeof WorkspacePermission)[keyof typeof WorkspacePermission]; + +/** 便捷分享的状态 */ +export const ShareState = { Draft: 'draft', Sealed: 'sealed' } as const; +export type ShareState = (typeof ShareState)[keyof typeof ShareState]; diff --git a/web/src/api/singleSource.guard.test.ts b/web/src/api/singleSource.guard.test.ts new file mode 100644 index 0000000..7763c4d --- /dev/null +++ b/web/src/api/singleSource.guard.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +/** + * 前端不做权限和状态判断,只读后端给的能力、按后端的错误码处理。规则只在后端一处,这里守住前端不要重新实现: + * - 角色/权限:界面只通过 domain/capabilities 读后端给的 can_* 字段,不比较角色、不写 isOwner 判断; + * - 选哪张令牌:只有 tokenRole 一处; + * - 服务端为什么拒绝:只通过 hasCode(错误码),不比较 HTTP 状态码数字。 + * 允许的例外写在 allowed 里,并说明理由。 + */ +const modules = import.meta.glob('/src/**/*.{ts,tsx}', { query: '?raw', import: 'default', eager: true }); +const files = Object.entries(modules) + .map(([path, text]) => ({ path: path.replace(/^\/src\//, ''), text })) + .filter(({ path }) => !/\.test\.(ts|tsx)$/.test(path) && !path.startsWith('api/generated')); + +interface Rule { name: string; pattern: RegExp; allowed: string[] } +const RULES: Rule[] = [ + { name: '不用 session.can(...) 自己判断权限(选令牌的 tokenRole、必须持有所有者密钥才能做的密码学操作、分享的所有权释放除外)', pattern: /session\.can\('(owner|write)'\)/, + allowed: ['worker/runtime/sessions.ts', 'worker/crypto/channelDataKeys.ts', 'worker/shares/shareState.ts', 'worker/shares/shareSeal.ts', 'worker/space/channelResources.ts', 'worker/space/channelTransport.ts'] }, + { name: '不比较 HTTP 状态码数字(请用 hasCode / isRetryable)', pattern: /\.status\s*(===|!==|>=|<=|<|>)\s*\d{3}\b/, allowed: ['worker/runtime/http.ts'] }, + { name: '不比较角色字符串(成员行的展示、授权包的密码学校验除外;分享的 mine 待后端提供)', pattern: /\.role\s*(===|!==)\s*'(owner|member|write|read)'/, + allowed: ['features/channel/members/MembersPane.tsx', 'features/channel/members/RolesEditor.tsx', 'worker/account/accountChannels.ts', 'worker/crypto/channelGrant.ts', 'worker/runtime/wire.ts'] }, +]; + +describe('前端不重新实现后端的规则', () => { + it('扫描到了源码', () => { expect(files.length).toBeGreaterThan(100); }); + for (const rule of RULES) { + it(rule.name, () => { + const offenders = files + .filter(({ path }) => !rule.allowed.includes(path)) + .flatMap(({ path, text }) => text.split('\n') + .map((line, index) => ({ path, line: index + 1, text: line.trim() })) + .filter(({ text: line }) => !line.startsWith('//') && !line.startsWith('*') && rule.pattern.test(line)) + .map(({ path: p, line, text: source }) => `${p}:${line} ${source}`)); + expect(offenders, `这些地方重新实现了后端的规则:\n${offenders.join('\n')}`).toEqual([]); + }); + } +}); diff --git a/web/src/app/AccountChannelAccess.tsx b/web/src/app/AccountChannelAccess.tsx index 857026b..fa71fee 100644 --- a/web/src/app/AccountChannelAccess.tsx +++ b/web/src/app/AccountChannelAccess.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from 'react'; -import { accountApi } from '@/core-bridge/account'; -import { useAccount } from '@/store/account'; -import { useChannels } from '@/store/channels'; -import { AccountScreen } from '@/features/home/AccountScreen'; +import { accountApi } from '@/services/account'; +import { useAccount } from '@/store/account/account'; +import { useChannels } from '@/store/space/channels'; +import { AccountScreen } from '@/features/account/AccountScreen'; import { MissingAccess } from '@/features/scan/JoinScreen'; import { Button } from '@/ui/Button'; import styles from '@/features/scan/JoinScreen.module.css'; diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 48a7752..e9a7b72 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -2,16 +2,16 @@ import { useEffect, useState } from 'react'; import { Home } from '@/features/home/Home'; import { JoinScreen } from '@/features/scan/JoinScreen'; import { AccountChannelAccess } from './AccountChannelAccess'; -import { useChannels } from '@/store/channels'; -import { useShares } from '@/store/shares'; -import { useWorkspace } from '@/store/workspace'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; +import { useWorkspace } from '@/store/space/workspace'; import { ChannelScreen } from './ChannelScreen'; import { ShareScreen } from './ShareScreen'; import { channelPath, navigate, useRoute } from './navigation'; import { Splash } from './Splash'; -import { AccountScreen } from '@/features/home/AccountScreen'; -import { AccountDock } from '@/features/home/AccountDock'; -import { useAccount } from '@/store/account'; +import { AccountScreen } from '@/features/account/AccountScreen'; +import { AccountDock } from '@/features/account/AccountDock'; +import { useAccount } from '@/store/account/account'; import { ToastHost } from './ToastHost'; import { useInvitationNotices } from './useInvitationNotices'; diff --git a/web/src/app/ChannelScreen.tsx b/web/src/app/ChannelScreen.tsx index b6cf316..2ead33d 100644 --- a/web/src/app/ChannelScreen.tsx +++ b/web/src/app/ChannelScreen.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Workspace } from '@/features/channel/Workspace'; -import { useWorkspace } from '@/store/workspace'; +import { useWorkspace } from '@/store/space/workspace'; import { CreateSheet } from '@/features/home/CreateSheet'; import { ChannelSkeleton } from './ChannelSkeleton'; import { channelPath, navigate } from './navigation'; diff --git a/web/src/app/ShareScreen.tsx b/web/src/app/ShareScreen.tsx index 61d67c2..c58127d 100644 --- a/web/src/app/ShareScreen.tsx +++ b/web/src/app/ShareScreen.tsx @@ -3,8 +3,8 @@ import { ItemSourceContext, shareSource } from '@/features/item/source'; import { MissingAccess } from '@/features/scan/JoinScreen'; import { ShareDraft } from '@/features/share/ShareDraft'; import { ShareView } from '@/features/share/ShareView'; -import { useShares } from '@/store/shares'; -import { useShareSession } from '@/store/shareSession'; +import { useShares } from '@/store/share/shares'; +import { useShareSession } from '@/store/share/shareSession'; import { navigate } from './navigation'; /** diff --git a/web/src/app/ToastHost.tsx b/web/src/app/ToastHost.tsx index a13b794..784b5ca 100644 --- a/web/src/app/ToastHost.tsx +++ b/web/src/app/ToastHost.tsx @@ -1,4 +1,4 @@ -import { useToast } from '@/store/toast'; +import { useToast } from '@/store/app/toast'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './ToastHost.module.css'; diff --git a/web/src/app/entry.ts b/web/src/app/entry.ts index 893bd56..b99f959 100644 --- a/web/src/app/entry.ts +++ b/web/src/app/entry.ts @@ -1,5 +1,5 @@ -import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink'; -import { parseShareLink, type ParsedShareLink } from '@/lib/shareLink'; +import { parseChannelLink, type ParsedChannelLink } from '@/lib/links/channelLink'; +import { parseShareLink, type ParsedShareLink } from '@/lib/links/shareLink'; export type EntryLink = | { kind: 'channel'; link: ParsedChannelLink } diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts index 028a9f4..3fd2eb6 100644 --- a/web/src/app/navigation.ts +++ b/web/src/app/navigation.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from 'react'; -import { isSheetEntry } from '@/lib/useBackToClose'; +import { isSheetEntry } from '@/lib/hooks/useBackToClose'; export type Route = { name: 'home' } | { name: 'channel'; id: string } | { name: 'share'; id: string }; diff --git a/web/src/app/useInvitationNotices.ts b/web/src/app/useInvitationNotices.ts index 29b7c77..0294b8d 100644 --- a/web/src/app/useInvitationNotices.ts +++ b/web/src/app/useInvitationNotices.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; -import { accountApi } from '@/core-bridge/account'; -import { acceptInvitation, invitationLabel } from '@/store/invitations'; -import { useToast } from '@/store/toast'; +import { accountApi } from '@/services/account'; +import { acceptInvitation, invitationLabel } from '@/store/account/invitations'; +import { useToast } from '@/store/app/toast'; const POLL_MS = 8000; diff --git a/web/src/core-bridge/worker/fileService.ts b/web/src/core-bridge/worker/fileService.ts deleted file mode 100644 index 4c99b96..0000000 --- a/web/src/core-bridge/worker/fileService.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { fromBase64Url, type ChannelSession } from '@webdrop/core'; -import type { FileGrantBody } from '../generated/api'; -import type { SavedResource } from '../database'; -import { apiFetch, apiUpload, externalFetch, getServerInfo } from './http'; - -/** - * 文件的内容(不分大小)不放主后端,由文件服务(另一台机器)保存。流程: - * 上传:向主后端要上传票据 → 把整帧 PUT 给文件服务,得到回执 → 把 26 字节头部和回执交给主后端登记 - * 下载:向主后端要下载票据(按原来的规则计次)→ 带着票据向文件服务取 - * 聊天、头像、空间结构、文件目录不受影响,照旧走主后端。服务端没开文件服务时,下面的函数都退回原来的行为。 - */ - -const toHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); -export const channelHex = (resourceId: string): string => toHex(fromBase64Url(resourceId, 16) ?? new Uint8Array(16)); -export const itemHex = (itemId: string): string => toHex(fromBase64Url(itemId, 16) ?? new Uint8Array(16)); -export const frameItemHex = (bytes: Uint8Array): string => toHex(bytes.subarray(2, 18)); - -export async function filesEnabled(): Promise { - try { return (await getServerInfo()).files?.enabled === true; } catch { return false; } -} - -const roleOf = (session: ChannelSession): 'owner' | 'write' | 'read' => (session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'); - -async function requestGrant(resource: SavedResource, session: ChannelSession, item: string, op: 'put' | 'get', from: number): Promise { - const response = await apiFetch(`/channels/${resource.id}/files/ticket`, { - method: 'POST', headers: { ...session.authHeaders(roleOf(session)), 'Content-Type': 'application/json' }, - body: JSON.stringify({ item, op, from }), - }); - return response.json() as Promise; -} - -const uploadTickets = new Map(); -/** 上传票据在有效期内复用(一个条目的所有帧共用),快过期时换新的 */ -export async function uploadGrant(resource: SavedResource, session: ChannelSession, item: string, fresh = false): Promise { - const key = `${resource.id}:${item}`; - const known = uploadTickets.get(key); - if (!fresh && known && known.expires_at - Date.now() > 60_000) return known; - const grant = await requestGrant(resource, session, item, 'put', 0); - if (grant.mode === 'remote') uploadTickets.set(key, grant); else uploadTickets.delete(key); - return grant; -} -export const forgetUploadGrant = (resource: SavedResource, item: string): void => { uploadTickets.delete(`${resource.id}:${item}`); }; - -/** 把一帧整个传给文件服务,返回文件服务签的回执 */ -export async function putFrame( - grant: FileGrantBody, resource: SavedResource, bytes: Uint8Array, index: number, - onProgress: (loaded: number, total: number) => void, signal?: AbortSignal, stallMs?: number, -): Promise { - const url = `${grant.url!.replace(/\/$/, '')}/f/${channelHex(resource.id)}/${frameItemHex(bytes)}/${index}`; - const text = await apiUpload('', { 'X-WD-Ticket': grant.ticket! }, new Blob([bytes as BlobPart]), onProgress, signal, stallMs, { url, method: 'PUT' }); - const receipt = (JSON.parse(text) as { receipt?: string }).receipt; - if (!receipt) throw new Error('文件服务没有返回回执'); - return receipt; -} - -/** - * 打开一个条目自第 from 帧起的内容(下载、边下边播共用)。条目的帧在文件服务上就带着票据去那里取, - * 否则走主后端原来的接口(票据接口对本地条目回答 local,不计次,计次由原来的接口负责)。 - */ -export async function openItemFrames(resource: SavedResource, session: ChannelSession, itemId: string, from: number, signal?: AbortSignal): Promise { - if (resource.kind === 'channel' && await filesEnabled()) { - const grant = await requestGrant(resource, session, itemId, 'get', from); - if (grant.mode === 'remote') - return externalFetch(`${grant.url!.replace(/\/$/, '')}/f/${channelHex(resource.id)}/${itemHex(itemId)}?from=${from}`, { 'X-WD-Ticket': grant.ticket! }, signal); - } - const path = resource.kind === 'share' ? `/shares/${resource.id}/items/${itemId}/frames?from=${from}` : `/channels/${resource.id}/items/${itemId}/frames?from=${from}`; - const role = resource.kind === 'share' && resource.state === 'draft' ? 'owner' : session.can('read') ? 'read' : 'owner'; - return apiFetch(path, { headers: session.authHeaders(role), ...(signal ? { signal } : {}) }); -} diff --git a/web/src/core-bridge/protocol/account.ts b/web/src/domain/account.ts similarity index 96% rename from web/src/core-bridge/protocol/account.ts rename to web/src/domain/account.ts index 02349a9..735fbbf 100644 --- a/web/src/core-bridge/protocol/account.ts +++ b/web/src/domain/account.ts @@ -1,4 +1,4 @@ -import type { AccountProfile } from '../generated/api'; +import type { AccountProfile } from '@/api/generated'; export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } export interface SigningKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey } /** Account vault v2: credentials and cached metadata, encrypted before transport. */ diff --git a/web/src/domain/capabilities.ts b/web/src/domain/capabilities.ts new file mode 100644 index 0000000..8b63525 --- /dev/null +++ b/web/src/domain/capabilities.ts @@ -0,0 +1,43 @@ +import type { ChannelInfo } from './types'; + +/** + * 调用者在当前空间里能做什么。取值全部来自后端(ChannelInfo 里的 can_* 字段),这里只负责: + * - 没拿到空间信息时一律不能; + * - 把「删除」这种要结合条目作者的判断拼成一个入口。 + * 组件只通过这里读能力,不要再比较角色、写 isOwner 之类的判断。 + */ +export interface Capabilities { + /** 只用于展示(例如「所有者」标签),不用来决定能不能做某事 */ + readonly isOwner: boolean; + readonly canUpload: boolean; + readonly canManageStructure: boolean; + readonly canCreateChannel: boolean; + readonly canManageFolders: boolean; + readonly canInvite: boolean; + readonly canEditSettings: boolean; + readonly canManageRoles: boolean; + readonly canRemoveMembers: boolean; + /** 能不能删这条内容:作者是不是自己由调用方告诉我们,规则由后端的两个标志决定 */ + canDelete(isMine: boolean): boolean; +} + +const NONE: Capabilities = { + isOwner: false, canUpload: false, canManageStructure: false, canCreateChannel: false, canManageFolders: false, canInvite: false, + canEditSettings: false, canManageRoles: false, canRemoveMembers: false, canDelete: () => false, +}; + +export function capabilitiesOf(channel: ChannelInfo | undefined): Capabilities { + if (!channel) return NONE; + return { + isOwner: channel.isOwner, + canUpload: channel.canUpload ?? false, + canManageStructure: channel.canManageStructure ?? false, + canCreateChannel: channel.canCreateChannel, + canManageFolders: channel.canManageFolders, + canInvite: channel.canInvite, + canEditSettings: channel.canEditSettings, + canManageRoles: channel.canManageRoles, + canRemoveMembers: channel.canRemoveMembers, + canDelete: (isMine) => channel.canDeleteAny || (isMine && channel.canDeleteOwn), + }; +} diff --git a/web/src/domain/poster.test.ts b/web/src/domain/poster.test.ts new file mode 100644 index 0000000..0535a7d --- /dev/null +++ b/web/src/domain/poster.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { isPoster, POSTER_MAX_CHARS } from './poster'; + +describe('视频封面格式', () => { + it('只接受小的 JPEG 或 WebP data URL', () => { + expect(isPoster('data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ==')).toBe(true); + expect(isPoster('data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/v89WAAAAA==')).toBe(true); + }); + + it('拒绝别的格式和不是 data URL 的内容(封面来自别的成员,不能信)', () => { + expect(isPoster('data:image/png;base64,AAAA')).toBe(false); + expect(isPoster('data:image/gif;base64,AAAA')).toBe(false); + expect(isPoster('data:image/svg+xml;base64,AAAA')).toBe(false); + expect(isPoster('https://example.com/x.jpg')).toBe(false); + expect(isPoster('javascript:alert(1)')).toBe(false); + expect(isPoster('data:image/jpeg;base64,AAAA"onerror="x')).toBe(false); + expect(isPoster(undefined)).toBe(false); + expect(isPoster(42)).toBe(false); + }); + + it('拒绝超过上限的(目录条目是个小控制帧,放不下)', () => { + expect(isPoster(`data:image/jpeg;base64,${'A'.repeat(POSTER_MAX_CHARS)}`)).toBe(false); + }); +}); diff --git a/web/src/domain/poster.ts b/web/src/domain/poster.ts new file mode 100644 index 0000000..563e977 --- /dev/null +++ b/web/src/domain/poster.ts @@ -0,0 +1,10 @@ +/** + * 视频封面:上传时在浏览器里截一帧,缩小后以 data URL 的形式随文件目录(已加密)同步。 + * 目录条目是个小控制帧(明文上限 16 KB,服务端按档位收),所以封面要尽量清晰、又必须小: + * 优先用 WebP(同样大小画质比 JPEG 好得多),浏览器编不了 WebP 就用 JPEG。 + * 收到别人发来的封面要严格校验格式和大小。 + */ +export const POSTER_MAX_CHARS = 12_000; +const POSTER_FORMAT = /^data:image\/(jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/; + +export const isPoster = (value: unknown): value is string => typeof value === 'string' && value.length <= POSTER_MAX_CHARS && POSTER_FORMAT.test(value); diff --git a/web/src/core-bridge/settings.ts b/web/src/domain/settings.ts similarity index 88% rename from web/src/core-bridge/settings.ts rename to web/src/domain/settings.ts index 3f4557b..0cbc984 100644 --- a/web/src/core-bridge/settings.ts +++ b/web/src/domain/settings.ts @@ -1,4 +1,4 @@ -import type { DefaultsBody } from './generated/api'; +import type { DefaultsBody } from '@/api/generated'; import type { CreateOptions } from './types'; export function creationOptions(defaults: DefaultsBody): CreateOptions { diff --git a/web/src/core-bridge/types.ts b/web/src/domain/types.ts similarity index 87% rename from web/src/core-bridge/types.ts rename to web/src/domain/types.ts index 361c1fd..145d693 100644 --- a/web/src/core-bridge/types.ts +++ b/web/src/domain/types.ts @@ -1,4 +1,4 @@ -import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceRole, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api'; +import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceRole, WorkspaceFolder, WorkspaceState, AccountProfile } from '@/api/generated'; export type ConnectionState = 'online' | 'reconnecting' | 'offline'; export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none'; @@ -17,7 +17,16 @@ export interface ChannelInfo { /** 空间日志的最新序号,用来判断别的空间有没有新动静 */ lastSeq?: number; mode: ChannelMode; - role: 'owner' | 'member'; + /** 调用者在这个空间里的能力:全部由后端给出,前端只读不算 */ + isOwner: boolean; + canCreateChannel: boolean; + canManageFolders: boolean; + canInvite: boolean; + canEditSettings: boolean; + canManageRoles: boolean; + canRemoveMembers: boolean; + canDeleteAny: boolean; + canDeleteOwn: boolean; maxMembers: number; membersUsed?: number; membersCanUpload?: boolean; @@ -33,6 +42,11 @@ export interface ChannelInfo { canManageStructure?: boolean; } +/** 文件服务公布的选项:创建频道时可以分配的文件空间档位(字节) */ +export interface FileDomainOptions { quota_choices: number[]; default_quota: number } +/** 频道文件域的容量和已用字节(由文件服务统计) */ +export interface FileDomainUsage { quota: number; used: number } + /** 新建空间的选项(protocol §12 设置的子集) */ export interface CreateOptions { joinPolicy?: 'open' | 'invite'; @@ -46,8 +60,8 @@ export interface CreateOptions { } export interface FileUploadOptions { - /** 单帧文件可随空间同步,加入者能立即预览;多帧文件仍由空间上限保护。 */ - directPreview: boolean; + /** 视频封面(很小的 JPEG data URL),浏览器里截的,随文件目录同步 */ + poster?: string; maxDownloads: number; } @@ -131,6 +145,8 @@ export interface FileItem extends ItemBase { remaining: number | null; preview: PreviewKind; gated?: boolean; + /** 视频封面,见 domain/poster */ + poster?: string; /** announce-only placeholder; omit thread/folder/name until explicit item read. */ metadataHidden?: boolean; /** diff --git a/web/src/core-bridge/protocol/workspace.ts b/web/src/domain/workspace.ts similarity index 83% rename from web/src/core-bridge/protocol/workspace.ts rename to web/src/domain/workspace.ts index 716e783..6180152 100644 --- a/web/src/core-bridge/protocol/workspace.ts +++ b/web/src/domain/workspace.ts @@ -9,6 +9,8 @@ export interface PublishedFile { size: number; ts: number; gated: boolean; + /** 视频封面(很小的 JPEG data URL),见 domain/poster */ + poster?: string; } export type ResourceControl = { op: 'file.published'; item: PublishedFile }; export const WORKSPACE_APP = 'webdrop.workspace'; diff --git a/web/src/features/home/AccountDock.module.css b/web/src/features/account/AccountDock.module.css similarity index 100% rename from web/src/features/home/AccountDock.module.css rename to web/src/features/account/AccountDock.module.css diff --git a/web/src/features/home/AccountDock.tsx b/web/src/features/account/AccountDock.tsx similarity index 96% rename from web/src/features/home/AccountDock.tsx rename to web/src/features/account/AccountDock.tsx index 25d3f37..18ad55b 100644 --- a/web/src/features/home/AccountDock.tsx +++ b/web/src/features/account/AccountDock.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useAccount } from '@/store/account'; +import { useAccount } from '@/store/account/account'; import { Button } from '@/ui/Button'; import { Sheet } from '@/ui/Sheet'; import { AccountSettings } from './AccountSettings'; diff --git a/web/src/features/home/AccountPanel.module.css b/web/src/features/account/AccountPanel.module.css similarity index 100% rename from web/src/features/home/AccountPanel.module.css rename to web/src/features/account/AccountPanel.module.css diff --git a/web/src/features/home/AccountPanel.tsx b/web/src/features/account/AccountPanel.tsx similarity index 95% rename from web/src/features/home/AccountPanel.tsx rename to web/src/features/account/AccountPanel.tsx index 7850417..668d9ef 100644 --- a/web/src/features/home/AccountPanel.tsx +++ b/web/src/features/account/AccountPanel.tsx @@ -1,16 +1,16 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { accountApi } from '@/core-bridge/account'; -import { useAccount } from '@/store/account'; -import { useChannels } from '@/store/channels'; -import { useShares } from '@/store/shares'; +import { accountApi } from '@/services/account'; +import { useAccount } from '@/store/account/account'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; import { Button } from '@/ui/Button'; import styles from './AccountPanel.module.css'; import { navigate, channelPath } from '@/app/navigation'; -import { passkeysAvailable } from '@/core-bridge/passkeys'; -import { useToast } from '@/store/toast'; -import { errorMessage } from '@/core-bridge/errors'; -import { AvatarEditor, type AvatarPick } from './AvatarEditor'; -import { checkAvatarSource } from './avatarSource'; +import { passkeysAvailable } from '@/services/passkeys'; +import { useToast } from '@/store/app/toast'; +import { errorMessage } from '@/api/errors'; +import { AvatarEditor, type AvatarPick } from '@/features/avatar/AvatarEditor'; +import { checkAvatarSource } from '@/features/avatar/avatarSource'; /** 未登录时的登录 / 注册 / 解锁。登录后的账号设置见 AccountSettings */ export function AccountPanel({ variant = 'default' }: { variant?: 'default' | 'auth' }) { diff --git a/web/src/features/home/AccountScreen.tsx b/web/src/features/account/AccountScreen.tsx similarity index 90% rename from web/src/features/home/AccountScreen.tsx rename to web/src/features/account/AccountScreen.tsx index f45df2f..67a92dd 100644 --- a/web/src/features/home/AccountScreen.tsx +++ b/web/src/features/account/AccountScreen.tsx @@ -1,6 +1,6 @@ import { AccountPanel } from './AccountPanel'; import { ThemeMenu } from '@/ui/ThemeMenu'; -import styles from './Home.module.css'; +import styles from '@/features/home/Home.module.css'; export function AccountScreen() { return
diff --git a/web/src/features/home/AccountSettings.module.css b/web/src/features/account/AccountSettings.module.css similarity index 100% rename from web/src/features/home/AccountSettings.module.css rename to web/src/features/account/AccountSettings.module.css diff --git a/web/src/features/home/AccountSettings.tsx b/web/src/features/account/AccountSettings.tsx similarity index 95% rename from web/src/features/home/AccountSettings.tsx rename to web/src/features/account/AccountSettings.tsx index e0800fb..2feb339 100644 --- a/web/src/features/home/AccountSettings.tsx +++ b/web/src/features/account/AccountSettings.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import { accountApi, type AccountInvitation } from '@/core-bridge/account'; -import { useAccount } from '@/store/account'; +import { accountApi, type AccountInvitation } from '@/services/account'; +import { useAccount } from '@/store/account/account'; import { Icon, type IconName } from '@/ui/Icon'; import { DeviceSettings } from './DeviceSettings'; import { InvitationInbox } from './InvitationInbox'; diff --git a/web/src/features/home/DeviceSettings.tsx b/web/src/features/account/DeviceSettings.tsx similarity index 92% rename from web/src/features/home/DeviceSettings.tsx rename to web/src/features/account/DeviceSettings.tsx index 2430002..3aa7cae 100644 --- a/web/src/features/home/DeviceSettings.tsx +++ b/web/src/features/account/DeviceSettings.tsx @@ -1,9 +1,9 @@ import { useEffect, useState, type KeyboardEvent } from 'react'; -import { accountApi, type AccountDevice } from '@/core-bridge/account'; -import { errorMessage } from '@/core-bridge/errors'; -import { formatSeen } from '@/lib/format'; -import { useAccount } from '@/store/account'; -import { useToast } from '@/store/toast'; +import { accountApi, type AccountDevice } from '@/services/account'; +import { errorMessage } from '@/api/errors'; +import { formatSeen } from '@/lib/text/format'; +import { useAccount } from '@/store/account/account'; +import { useToast } from '@/store/app/toast'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './AccountSettings.module.css'; diff --git a/web/src/features/home/InvitationInbox.tsx b/web/src/features/account/InvitationInbox.tsx similarity index 88% rename from web/src/features/home/InvitationInbox.tsx rename to web/src/features/account/InvitationInbox.tsx index c763951..bc8c873 100644 --- a/web/src/features/home/InvitationInbox.tsx +++ b/web/src/features/account/InvitationInbox.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import type { AccountInvitation } from '@/core-bridge/account'; -import { acceptInvitation, invitationLabel } from '@/store/invitations'; -import { formatDate } from '@/lib/format'; +import type { AccountInvitation } from '@/services/account'; +import { acceptInvitation, invitationLabel } from '@/store/account/invitations'; +import { formatDate } from '@/lib/text/format'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './AccountSettings.module.css'; diff --git a/web/src/features/home/ProfileSettings.tsx b/web/src/features/account/ProfileSettings.tsx similarity index 91% rename from web/src/features/home/ProfileSettings.tsx rename to web/src/features/account/ProfileSettings.tsx index 88ff295..10ebe6a 100644 --- a/web/src/features/home/ProfileSettings.tsx +++ b/web/src/features/account/ProfileSettings.tsx @@ -1,12 +1,12 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; -import { errorMessage } from '@/core-bridge/errors'; -import { useAccount } from '@/store/account'; -import { useToast } from '@/store/toast'; +import { errorMessage } from '@/api/errors'; +import { useAccount } from '@/store/account/account'; +import { useToast } from '@/store/app/toast'; import { Button } from '@/ui/Button'; -import { AvatarPreview } from '../common/AvatarPreview'; -import { accountApi } from '@/core-bridge/account'; -import { AvatarEditor, type AvatarPick } from './AvatarEditor'; -import { checkAvatarSource } from './avatarSource'; +import { AvatarPreview } from '@/features/avatar/AvatarPreview'; +import { accountApi } from '@/services/account'; +import { AvatarEditor, type AvatarPick } from '@/features/avatar/AvatarEditor'; +import { checkAvatarSource } from '@/features/avatar/avatarSource'; import styles from './AccountSettings.module.css'; export function ProfileSettings() { diff --git a/web/src/features/home/SecuritySettings.tsx b/web/src/features/account/SecuritySettings.tsx similarity index 93% rename from web/src/features/home/SecuritySettings.tsx rename to web/src/features/account/SecuritySettings.tsx index 742c15b..13782a0 100644 --- a/web/src/features/home/SecuritySettings.tsx +++ b/web/src/features/account/SecuritySettings.tsx @@ -1,8 +1,8 @@ import { useEffect, useState, type FormEvent } from 'react'; -import { bindPasskey, passkeyApi, passkeysAvailable } from '@/core-bridge/passkeys'; -import type { PasskeyInfoBody } from '@/core-bridge/generated/api'; -import { formatDate } from '@/lib/format'; -import { useToast } from '@/store/toast'; +import { bindPasskey, passkeyApi, passkeysAvailable } from '@/services/passkeys'; +import type { PasskeyInfoBody } from '@/api/generated'; +import { formatDate } from '@/lib/text/format'; +import { useToast } from '@/store/app/toast'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './AccountSettings.module.css'; diff --git a/web/src/features/home/SyncSettings.tsx b/web/src/features/account/SyncSettings.tsx similarity index 93% rename from web/src/features/home/SyncSettings.tsx rename to web/src/features/account/SyncSettings.tsx index 4172470..7e608d7 100644 --- a/web/src/features/home/SyncSettings.tsx +++ b/web/src/features/account/SyncSettings.tsx @@ -1,7 +1,7 @@ import { navigate } from '@/app/navigation'; -import { errorMessage } from '@/core-bridge/errors'; -import { useAccount } from '@/store/account'; -import { useToast } from '@/store/toast'; +import { errorMessage } from '@/api/errors'; +import { useAccount } from '@/store/account/account'; +import { useToast } from '@/store/app/toast'; import { Button } from '@/ui/Button'; import styles from './AccountSettings.module.css'; diff --git a/web/src/features/home/AvatarEditor.module.css b/web/src/features/avatar/AvatarEditor.module.css similarity index 100% rename from web/src/features/home/AvatarEditor.module.css rename to web/src/features/avatar/AvatarEditor.module.css diff --git a/web/src/features/home/AvatarEditor.tsx b/web/src/features/avatar/AvatarEditor.tsx similarity index 100% rename from web/src/features/home/AvatarEditor.tsx rename to web/src/features/avatar/AvatarEditor.tsx diff --git a/web/src/features/common/AvatarPreview.module.css b/web/src/features/avatar/AvatarPreview.module.css similarity index 100% rename from web/src/features/common/AvatarPreview.module.css rename to web/src/features/avatar/AvatarPreview.module.css diff --git a/web/src/features/common/AvatarPreview.tsx b/web/src/features/avatar/AvatarPreview.tsx similarity index 100% rename from web/src/features/common/AvatarPreview.tsx rename to web/src/features/avatar/AvatarPreview.tsx diff --git a/web/src/features/home/avatarCrop.ts b/web/src/features/avatar/avatarCrop.ts similarity index 100% rename from web/src/features/home/avatarCrop.ts rename to web/src/features/avatar/avatarCrop.ts diff --git a/web/src/features/home/avatarGif.ts b/web/src/features/avatar/avatarGif.ts similarity index 100% rename from web/src/features/home/avatarGif.ts rename to web/src/features/avatar/avatarGif.ts diff --git a/web/src/features/home/avatarSource.ts b/web/src/features/avatar/avatarSource.ts similarity index 100% rename from web/src/features/home/avatarSource.ts rename to web/src/features/avatar/avatarSource.ts diff --git a/web/src/features/channel/ChannelSettingsSheet.tsx b/web/src/features/channel/ChannelSettingsSheet.tsx index f60c1ee..6fe5b55 100644 --- a/web/src/features/channel/ChannelSettingsSheet.tsx +++ b/web/src/features/channel/ChannelSettingsSheet.tsx @@ -1,20 +1,21 @@ +import { useCapabilities } from '@/store/space/useCapabilities'; import { useRef, useState } from 'react'; -import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types'; +import type { ChannelInfo, ChannelSettingsPatch } from '@/domain/types'; import { Button } from '@/ui/Button'; import { Icon, type IconName } from '@/ui/Icon'; import { Sheet } from '@/ui/Sheet'; -import { api } from '@/core-bridge/instance'; -import { AvatarPreview } from '../common/AvatarPreview'; -import { AvatarEditor, type AvatarPick } from '../home/AvatarEditor'; -import { checkAvatarSource } from '../home/avatarSource'; -import { errorMessage } from '@/core-bridge/errors'; -import { useToast } from '@/store/toast'; -import { RolesEditor } from './RolesEditor'; +import { api } from '@/services'; +import { AvatarPreview } from '@/features/avatar/AvatarPreview'; +import { AvatarEditor, type AvatarPick } from '@/features/avatar/AvatarEditor'; +import { checkAvatarSource } from '@/features/avatar/avatarSource'; +import { errorMessage } from '@/api/errors'; +import { useToast } from '@/store/app/toast'; +import { RolesEditor } from '@/features/channel/members/RolesEditor'; import styles from './ChannelSettingsSheet.module.css'; -import { useServerSettings } from '@/store/server'; -import type { ServerInfoBody } from '@/core-bridge/generated/api'; -import { ttlLabel, withCurrent } from '@/lib/settingOptions'; +import { useServerSettings } from '@/store/app/server'; +import type { ServerInfoBody } from '@/api/generated'; +import { ttlLabel, withCurrent } from '@/lib/space/settingOptions'; type Tab = 'basic' | 'content' | 'join' | 'roles'; const TABS: { id: Tab; label: string; icon: IconName }[] = [ @@ -38,7 +39,8 @@ export function ChannelSettingsSheet(props: Props) { return ; } function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) { - const owner = channel.role === 'owner'; + const caps = useCapabilities(); + const owner = caps.canEditSettings; const [name, setName] = useState(channel.name ?? ''); const [maxMembers, setMaxMembers] = useState(channel.maxMembers); const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!); @@ -91,7 +93,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
- {TABS.filter((item) => owner || item.id !== 'roles').map((item) => )}
diff --git a/web/src/features/channel/FilterBar.module.css b/web/src/features/channel/FilterBar.module.css deleted file mode 100644 index dc7da52..0000000 --- a/web/src/features/channel/FilterBar.module.css +++ /dev/null @@ -1,55 +0,0 @@ -.bar { - display: flex; - flex-direction: column; - gap: var(--space-2); - padding: var(--space-3) var(--space-4); - border-bottom: 1px solid var(--line); -} - -.input { - min-height: 36px; - padding: 0 var(--space-3); - border: 1px solid var(--line-strong); - border-radius: var(--radius-md); - background: var(--surface); -} - -.input:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 1px; -} - -.chips { - display: flex; - gap: var(--space-1); - overflow-x: auto; - scrollbar-width: none; -} - -.chip { - flex: none; - min-height: 32px; - padding: 0 var(--space-3); - border: 1px solid var(--line-strong); - border-radius: var(--radius-md); - background: transparent; - color: var(--text-dim); - cursor: pointer; -} - -.chip[aria-checked="true"] { - border-color: var(--accent); - background: var(--accent); - color: var(--on-accent); -} - -.chip:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -@media (pointer: coarse) { - .chip { - min-height: 40px; - } -} diff --git a/web/src/features/channel/FilterBar.tsx b/web/src/features/channel/FilterBar.tsx deleted file mode 100644 index 9149bcf..0000000 --- a/web/src/features/channel/FilterBar.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { ItemFilter, KindFilter } from '@/lib/filter'; -import styles from './FilterBar.module.css'; - -const KINDS: { value: KindFilter; label: string }[] = [ - { value: 'all', label: '全部' }, - { value: 'file', label: '文件' }, - { value: 'text', label: '文本' }, - { value: 'image', label: '图片' }, - { value: 'video', label: '视频' }, - { value: 'archive', label: '压缩包' }, -]; - -interface Props { - filter: ItemFilter; - searchOpen: boolean; - onChange: (f: ItemFilter) => void; -} - -/** 类型分组常驻;搜索框由顶栏的放大镜展开 */ -export function FilterBar({ filter, searchOpen, onChange }: Props) { - return ( -
- {searchOpen ? ( - onChange({ ...filter, query: e.target.value })} - /> - ) : null} -
- {KINDS.map((k) => ( - - ))} -
-
- ); -} diff --git a/web/src/features/channel/ItemList.module.css b/web/src/features/channel/ItemList.module.css deleted file mode 100644 index 569a413..0000000 --- a/web/src/features/channel/ItemList.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.list { - margin: 0; - padding: 0; - list-style: none; -} - -.list > li { - content-visibility: auto; - contain-intrinsic-size: auto 96px; -} - -.empty { - margin: 0; - padding: var(--space-6) var(--space-4); - color: var(--text-dim); -} diff --git a/web/src/features/channel/ItemList.tsx b/web/src/features/channel/ItemList.tsx deleted file mode 100644 index c5d4dc7..0000000 --- a/web/src/features/channel/ItemList.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { CompactRow } from '@/features/item/CompactRow'; -import styles from './ItemList.module.css'; - -interface Props { - ids: string[]; - selectedId?: string; - /** 桌面端才有多选 */ - checked?: ReadonlySet; - empty: string; - onSelect: (id: string) => void; - onToggle?: (id: string) => void; -} - -export function ItemList({ ids, selectedId, checked, empty, onSelect, onToggle }: Props) { - if (ids.length === 0) return

{empty}

; - return ( -
    - {ids.map((id) => ( - - ))} -
- ); -} diff --git a/web/src/features/channel/LatestCard.module.css b/web/src/features/channel/LatestCard.module.css deleted file mode 100644 index b87897e..0000000 --- a/web/src/features/channel/LatestCard.module.css +++ /dev/null @@ -1,76 +0,0 @@ -.card { - display: flex; - flex-direction: column; - gap: var(--space-3); - padding: var(--space-3) var(--space-4) var(--space-4); - border-bottom: 1px solid var(--line); - background: linear-gradient(to bottom, var(--surface-2), transparent); -} - -.label { - color: var(--text-dim); - font-size: 12px; - font-weight: 600; -} - -.open { - display: flex; - align-items: center; - gap: var(--space-3); - padding: 0; - border: 0; - background: transparent; - cursor: pointer; - text-align: left; -} - -.text { - display: flex; - flex-direction: column; - min-width: 0; -} - -.title { - display: -webkit-box; - overflow: hidden; - font-size: 16px; - font-weight: 600; - overflow-wrap: anywhere; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; -} - -.sub { - color: var(--text-dim); - font-size: 12px; -} - -.actions { - display: flex; - gap: var(--space-2); -} - -.actions > * { - flex: 1; - min-height: 44px; -} - -.bar { - height: 4px; - overflow: hidden; - border-radius: var(--radius-sm); - background: var(--busy-soft); -} - -.fill { - height: 100%; - background: var(--progress-gradient); - transition: width 0.25s linear; -} - -/* 横屏手机高度很小,首屏留给列表 */ -@media (height < 500px) { - .card { - display: none; - } -} diff --git a/web/src/features/channel/LatestCard.tsx b/web/src/features/channel/LatestCard.tsx deleted file mode 100644 index f980745..0000000 --- a/web/src/features/channel/LatestCard.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; -import { useDownload } from '@/features/item/download'; -import { InlineContent, inlineKindOf } from '@/features/item/InlineContent'; -import { senderLabel } from '@/lib/sender'; -import { titleOf } from '@/features/item/summary'; -import { Tile } from '@/features/item/Tile'; -import { useWorkspace } from '@/store/workspace'; -import { Button } from '@/ui/Button'; -import { CopyButton } from '@/ui/CopyButton'; -import styles from './LatestCard.module.css'; - -interface Props { - id: string; - onOpen: (id: string) => void; -} - -/** 手机首屏:最新一项放大,拇指区一键取走 */ -export function LatestCard({ id, onOpen }: Props) { - const item = useWorkspace((s) => s.byId[id]); - const download = useDownload(); - if (!item) return null; - - const receiving = item.state.kind === 'receiving' ? item.state.progress : null; - const inline = inlineKindOf(item); - const size = item.kind === 'file' ? item.size : new Blob([item.text]).size; - - return ( -
-
最新
- - {inline ? onOpen(id)} /> : null} - {receiving !== null ? ( -
-
-
- ) : ( -
- {item.kind === 'text' ? ( - - ) : ( - <> - {item.preview !== 'none' ? : null} - - - )} -
- )} -
- ); -} diff --git a/web/src/features/channel/UndoBar.module.css b/web/src/features/channel/UndoBar.module.css deleted file mode 100644 index 3a1a8b1..0000000 --- a/web/src/features/channel/UndoBar.module.css +++ /dev/null @@ -1,35 +0,0 @@ -.bar { - position: fixed; - bottom: calc(88px + env(safe-area-inset-bottom)); - left: 50%; - z-index: 30; - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-1) var(--space-2) var(--space-1) var(--space-4); - border: 1px solid var(--line); - border-radius: var(--radius-md); - background: var(--surface); - box-shadow: var(--shadow-pop); - translate: -50% 0; - animation: bar-in 0.15s ease-out; -} - -@media (width >= 900px) { - .bar { - bottom: var(--space-6); - } -} - -@keyframes bar-in { - from { - opacity: 0; - translate: -50% 8px; - } -} - -@media (prefers-reduced-motion: reduce) { - .bar { - animation: none; - } -} diff --git a/web/src/features/channel/UndoBar.tsx b/web/src/features/channel/UndoBar.tsx deleted file mode 100644 index 6b03acc..0000000 --- a/web/src/features/channel/UndoBar.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Button } from '@/ui/Button'; -import styles from './UndoBar.module.css'; - -/** 删除后 5 秒内可撤销(ui-design §8) */ -export function UndoBar({ onUndo }: { onUndo: () => void }) { - return ( -
- 已删除 - -
- ); -} diff --git a/web/src/features/channel/Workspace.tsx b/web/src/features/channel/Workspace.tsx index b3d20b4..a507c49 100644 --- a/web/src/features/channel/Workspace.tsx +++ b/web/src/features/channel/Workspace.tsx @@ -1,45 +1,43 @@ +import { useCapabilities } from '@/store/space/useCapabilities'; import { lazy, Suspense, useCallback, useEffect, useState, type CSSProperties, type DragEvent } from 'react'; import { useShallow } from 'zustand/react/shallow'; import { channelPath, navigate, sharePath } from '@/app/navigation'; import { useDownload } from '@/features/item/download'; -import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/filter'; -import { useGlobalPaste } from '@/lib/useGlobalPaste'; -import { useMediaQuery } from '@/lib/useMediaQuery'; -import { useWorkspace } from '@/store/workspace'; -import type { FileUploadOptions } from '@/core-bridge/types'; -import type { ClipboardPayload } from '@/lib/clipboard'; +import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/files/filter'; +import { useGlobalPaste } from '@/lib/hooks/useGlobalPaste'; +import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; +import { useWorkspace } from '@/store/space/workspace'; +import type { FileUploadOptions } from '@/domain/types'; +import type { ClipboardPayload } from '@/lib/platform/clipboard'; import { Sheet } from '@/ui/Sheet'; import { Button } from '@/ui/Button'; import { Drawer } from '@/ui/Drawer'; import { Icon, type IconName } from '@/ui/Icon'; -import { SpaceHeader, SpaceRail } from './SpaceRail'; -import { MobileDrawer } from './MobileDrawer'; -import { MobileTopBar } from './MobileTopBar'; -import { useAccount } from '@/store/account'; -import { permissionsOf } from '@/lib/roles'; -import { ThreadSidebar } from './ThreadSidebar'; -import { ChatPane } from './ChatPane'; -import { useThreadReads } from './useThreadReads'; -import { FileExplorer } from './FileExplorer'; -import { FilePaneResize } from './FilePaneResize'; -import { InviteSheet } from './InviteSheet'; -import { MembersPane } from './MembersPane'; -import { PaneTabs, type SideTab } from './PaneTabs'; -import { TopBar } from './TopBar'; -import { FilterBar } from './FilterBar'; -import { Notepad } from './Notepad'; -import { SharePanel } from './SharePanel'; +import { SpaceHeader, SpaceRail } from '@/features/channel/navigation/SpaceRail'; +import { MobileDrawer } from '@/features/channel/navigation/MobileDrawer'; +import { MobileTopBar } from '@/features/channel/navigation/MobileTopBar'; +import { ThreadSidebar } from '@/features/channel/navigation/ThreadSidebar'; +import { ChatPane } from '@/features/channel/chat/ChatPane'; +import { useThreadReads } from '@/features/channel/navigation/useThreadReads'; +import { useFileDomainUsage, useFileService } from '@/features/channel/explorer/useFileDomain'; +import { FileExplorer } from '@/features/channel/explorer/FileExplorer'; +import { FilePaneResize } from '@/features/channel/explorer/FilePaneResize'; +import { InviteSheet } from '@/features/channel/members/InviteSheet'; +import { MembersPane } from '@/features/channel/members/MembersPane'; +import { PaneTabs, type SideTab } from '@/features/channel/navigation/PaneTabs'; +import { TopBar } from '@/features/channel/navigation/TopBar'; +import { FilterBar } from '@/features/channel/explorer/FilterBar'; +import { Notepad } from '@/features/channel/navigation/Notepad'; +import { SharePanel } from '@/features/channel/members/SharePanel'; import { ChannelSettingsSheet } from './ChannelSettingsSheet'; -import { UploadOptionsSheet } from './UploadOptionsSheet'; +import { UploadOptionsSheet } from '@/features/channel/explorer/UploadOptionsSheet'; import styles from './Workspace.module.css'; -import { useServerSettings } from '@/store/server'; const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet')); const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet }))); export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) { const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)'); - const { info: serverInfo } = useServerSettings(); const channel = useWorkspace((s) => s.channel); const threads = useWorkspace((s) => s.threads); const groups = useWorkspace((s) => s.groups); @@ -80,21 +78,25 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const [fileWidth, setFileWidth] = useState(); const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>(); const locked = useWorkspace((s) => s.lockedThreads); - // 没有密钥的频道不能打开:优先用选中的,其次「常规」,再其次最新的可用频道 + const defaultThreadId = useWorkspace((s) => s.defaultThreadId); + // 没有密钥的频道不能打开:优先用选中的,其次空间的默认频道(后端指定),再其次最新的可用频道 const usable = threads.filter((thread) => !locked.includes(thread.id)); - const activeThread = usable.find((thread) => thread.id === threadId) ?? usable.find((thread) => thread.name === '常规') ?? usable[usable.length - 1]; + const activeThread = usable.find((thread) => thread.id === threadId) ?? usable.find((thread) => thread.id === defaultThreadId) ?? usable[usable.length - 1]; const activeThreadId = activeThread?.id; const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId); const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined; - const enabled = Boolean(activeThreadId && channel?.canUpload); - const owner = channel?.role === 'owner'; - const roles = useWorkspace((s) => s.roles); - const self = useAccount((s) => s.username); - const perms = permissionsOf(roles, self ?? undefined, owner); + // 文件服务现在能不能用、创建频道时可选的文件空间档位(文件服务公布) + const { status: fileService, options: fileOptions } = useFileService(channel?.id); + const caps = useCapabilities(); + // 服务端没有开启文件服务时不能上传(连不上只是暂时的,上传会排队,恢复后继续) + const enabled = Boolean(activeThreadId && caps.canUpload && fileService !== 'disabled'); const files = useWorkspace(useShallow((s) => s.order.filter((id) => { const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId && (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter); }))); + // 文件域的选项和用量由文件服务提供;传完或删除一个文件后(完成的文件数变化)重新读取用量 + const finishedFiles = useWorkspace((s) => s.order.filter((id) => { const item = s.byId[id]; return item?.kind === 'file' && item.threadId === activeThreadId && item.state.kind === 'complete'; }).length); + const fileUsage = useFileDomainUsage(activeThreadId, finishedFiles); const messages = useWorkspace(useShallow((s) => s.order.filter((id) => { const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden)); }))); @@ -137,36 +139,35 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) const channelId = channel?.id; useEffect(() => { setDrawerOpen(false); }, [channelId]); const rail = setScanOpen(true)} />; - const threadSidebar = { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} + const threadSidebar = { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} quotaChoices={fileOptions?.quota_choices ?? []} defaultQuota={fileOptions?.default_quota} onCreateThread={createThread} onRenameThread={renameThread} onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />; const navigation = <>{threadSidebar}{desktop ? : null}; const chat = sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const explorer = <> - - } scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} + fileBytes={fileUsage?.used ?? 0} maxFileBytes={fileUsage?.quota} folders={activeFolders} folderId={activeFolderId} ids={files} - selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && channel?.canManageStructure)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked} + selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && caps.canManageFolders)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked} onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })} onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))} onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))} onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} /> {restricted.length ?
等待同步文件目录{restricted.map((id) => )}
: null} ; - const members = setInviteOpen(true)} />; + const members = setInviteOpen(true)} />; function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); } const moreRows: { icon: IconName; label: string; run: () => void }[] = [ { icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) }, { icon: 'share', label: '分享', run: () => setPanelOpen(true) }, - ...(owner ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []), + ...(caps.canInvite ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []), ]; const mobileTitle = mobileView === 'files' ? '文件' : mobileView === 'members' ? '成员' : `# ${activeThread?.name ?? '选择频道'}`; function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); } return
setDragging(false)} onDragEndCapture={() => setDragging(false)} onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}> - {desktop ? navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : setDrawerOpen(true)} onBack={() => setMobileView('chat')} + {desktop ? navigate('/')} onOpenMembers={openMembers} onInvite={caps.canInvite ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : setDrawerOpen(true)} onBack={() => setMobileView('chat')} onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />} {desktop ? <>
{chat}
:
{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}
} @@ -174,7 +175,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {!desktop && drawerOpen ? setDrawerOpen(false)}> setScanOpen(true) }} threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')} - onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} /> + onInvite={caps.canInvite ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} /> : null} {!desktop && moreOpen ? setMoreOpen(false)}>{moreRows.map((row) => )} : null} {error ?
{error}
: null} diff --git a/web/src/features/channel/ChatAttachment.module.css b/web/src/features/channel/chat/ChatAttachment.module.css similarity index 66% rename from web/src/features/channel/ChatAttachment.module.css rename to web/src/features/channel/chat/ChatAttachment.module.css index ac29cb0..5ee08de 100644 --- a/web/src/features/channel/ChatAttachment.module.css +++ b/web/src/features/channel/chat/ChatAttachment.module.css @@ -24,6 +24,44 @@ cursor: pointer; } +/* 有封面的播放入口:封面铺满,标签压在底部 */ +.cover { + position: relative; + display: block; + width: 100%; + padding: 0; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface-2); + cursor: pointer; +} + +.coverImage { + display: block; + width: 100%; + max-height: 320px; + object-fit: cover; +} + +.coverLabel { + position: absolute; + inset: auto 0 0; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--scrim); + color: var(--on-accent); + font-size: 0.8125rem; +} + +.posterLabel { + display: flex; + align-items: center; + gap: var(--space-2); +} + .picture { display: block; padding: 0; diff --git a/web/src/features/channel/ChatAttachment.tsx b/web/src/features/channel/chat/ChatAttachment.tsx similarity index 81% rename from web/src/features/channel/ChatAttachment.tsx rename to web/src/features/channel/chat/ChatAttachment.tsx index c4a7edb..943241a 100644 --- a/web/src/features/channel/ChatAttachment.tsx +++ b/web/src/features/channel/chat/ChatAttachment.tsx @@ -1,18 +1,18 @@ import { useEffect, useRef, useState } from 'react'; -import type { FileItem } from '@/core-bridge/types'; -import { formatSize } from '@/lib/format'; +import type { FileItem } from '@/domain/types'; +import { formatSize } from '@/lib/text/format'; import { Icon } from '@/ui/Icon'; -import { ArchivePreview } from '../item/previews/ArchivePreview'; -import { useBlobUrl } from '../item/useBlobUrl'; -import { useMediaUrl } from '../item/useMediaUrl'; +import { ArchivePreview } from '@/features/item/previews/ArchivePreview'; +import { useBlobUrl } from '@/features/item/hooks/useBlobUrl'; +import { useMediaUrl } from '@/features/item/hooks/useMediaUrl'; import styles from './ChatAttachment.module.css'; /** 聊天里自动展开的大小上限:再大的只显示文件卡片,点开预览 */ const MB = 1024 * 1024; /** 不限制次数、不超过这个大小的,滚动到附近就自动加载;更大的显示占位,点一下才加载(整个文件要先解密到内存) */ const AUTO = { image: 30 * MB, video: 25 * MB, audio: 25 * MB, archive: 50 * MB, text: 128 * 1024 } as const; -/** 再大就不在聊天里展开了,只留文件卡片 */ -const MAX = { image: 100 * MB, video: 500 * MB, audio: 500 * MB, archive: 200 * MB, text: 128 * 1024 } as const; +/** 再大就不在聊天里展开了,只留文件卡片。视频和音频是边下边播(Range),大小不是问题,不设上限;自动加载仍只限小文件(AUTO),大的点一下再播 */ +const MAX = { image: 100 * MB, video: Number.POSITIVE_INFINITY, audio: Number.POSITIVE_INFINITY, archive: 200 * MB, text: 128 * 1024 } as const; /** 滚动到附近才加载,一屏几十张图时不会一下子全部解密 */ function useNearViewport(): [React.RefObject, boolean] { @@ -40,7 +40,9 @@ function Gate({ item, label, children }: { item: FileItem; label: string; childr return
{free && auto ? (near ? children() :
) : asked ? children() - : } + : }
; } diff --git a/web/src/features/channel/ChatMessage.tsx b/web/src/features/channel/chat/ChatMessage.tsx similarity index 85% rename from web/src/features/channel/ChatMessage.tsx rename to web/src/features/channel/chat/ChatMessage.tsx index 33bf0fb..258b995 100644 --- a/web/src/features/channel/ChatMessage.tsx +++ b/web/src/features/channel/chat/ChatMessage.tsx @@ -1,25 +1,26 @@ +import { useCapabilities } from '@/store/space/useCapabilities'; import { useState, type MouseEvent } from 'react'; -import { isLocalId, useWorkspace } from '@/store/workspace'; -import { useAccount } from '@/store/account'; +import { isLocalId, useWorkspace } from '@/store/space/workspace'; +import { useAccount } from '@/store/account/account'; import { useDownload } from '@/features/item/download'; import { iconOf } from '@/features/item/summary'; -import { senderLabel } from '@/lib/sender'; -import { formatSize, formatTime } from '@/lib/format'; +import { senderLabel } from '@/lib/space/sender'; +import { formatSize, formatTime } from '@/lib/text/format'; import { TransferProgress } from '@/features/item/TransferProgress'; import { Icon } from '@/ui/Icon'; import { Button } from '@/ui/Button'; import { ContextMenu, type MenuAction } from '@/ui/ContextMenu'; -import { accountApi } from '@/core-bridge/account'; -import { AvatarPreview } from '../common/AvatarPreview'; +import { accountApi } from '@/services/account'; +import { AvatarPreview } from '@/features/avatar/AvatarPreview'; import { ChatAttachment, hasInlinePreview } from './ChatAttachment'; -import { NameWithBadges } from './RoleBadges'; +import { NameWithBadges } from '@/features/channel/members/RoleBadges'; import styles from './ChatPane.module.css'; interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void } export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) { const item = useWorkspace((s) => s.byId[id]); - const owner = useWorkspace((s) => s.channel?.role === 'owner'); + const caps = useCapabilities(); const retry = useWorkspace((s) => s.retrySend); const cancelUpload = useWorkspace((s) => s.cancelUpload); const username = useAccount((s) => s.username); @@ -31,6 +32,8 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props const sender = item.author; const name = senderLabel(item); const self = sender?.username !== undefined && sender.username === username; + // 能不能删由后端的策略决定(所有者 / 删除策略),前端只按它显示;作者是自己才适用「只能删自己的」 + const canDelete = caps.canDelete(self); const local = isLocalId(id); const sending = local && item.state.kind === 'receiving'; const failed = local && item.state.kind === 'incomplete'; @@ -43,7 +46,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props // 触屏没有悬停:点一下消息弹出操作菜单(桌面仍是悬停按钮) const actions: MenuAction[] = [ ...(item.kind === 'text' ? [{ label: '复制', icon: 'copy' as const, action: () => { void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []), - ...(owner || self ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []), + ...(canDelete ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []), ]; function tap(event: MouseEvent) { if (!matchMedia('(hover: none)').matches || !actions.length || sending) return; @@ -76,7 +79,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props : failed ? 发送失败。 : item.state.kind === 'incomplete' ? 发送未完成,等待续传。 : null}
- {owner || self ? - - -
+
+ +
+ {props.threadName} + 频道文件 · {children.length} 个文件夹 · {ids.length} 个文件 +
+
+ {formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '…'} +
+ = (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.meterFull : undefined} style={{ width: `${usedRatio * 100}%` }} /> +
+
+
+ {props.fileService === 'disabled' ?

服务端没有开启文件服务,暂时不能上传和下载文件。聊天不受影响。

: null} + {props.fileService === 'down' ?

文件服务暂时连不上(可能还在部署或所在机器离线)。聊天不受影响;上传会排队,恢复后继续。

: null} + { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} /> + {props.filterBar} {edit ?
{ void submit(event); }}> setName(event.target.value)} /> @@ -97,23 +112,14 @@ export function FileExplorer(props: Props) { {ids.map((id) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }} onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)} - {ids.length === 0 && children.length === 0 ?

此文件夹还没有文件

上传文件或将文件拖到窗口中。
: null}
+ {ids.length === 0 && children.length === 0 ?

此频道内的文件夹还没有文件

上传文件或将文件拖到窗口中。
: null}
{props.checked.size > 0 ?
已选 {props.checked.size} 项 {props.enabled ? : null}
: null} {error || interaction.error ?

{error || interaction.error}

: null} -
{children.length} 个文件夹 · {ids.length} 个文件双击打开 · 右键操作 · 拖动移动
-
-
频道文件空间{formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '读取中'}
-
- = (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.quotaFull : undefined} - style={{ width: `${usedRatio * 100}%` }} /> -
- {maxFileBytes ? 剩余 {formatSize(Math.max(0, maxFileBytes - props.fileBytes))} : null} -
+
双击打开 · 右键操作 · 拖动移动
{interaction.menu ? : null} {interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? : null} {interaction.moveIds ? interaction.setMoveIds(undefined)} diff --git a/web/src/features/channel/FilePaneResize.tsx b/web/src/features/channel/explorer/FilePaneResize.tsx similarity index 96% rename from web/src/features/channel/FilePaneResize.tsx rename to web/src/features/channel/explorer/FilePaneResize.tsx index b70e3e5..e8f9a64 100644 --- a/web/src/features/channel/FilePaneResize.tsx +++ b/web/src/features/channel/explorer/FilePaneResize.tsx @@ -1,5 +1,5 @@ import { useRef } from 'react'; -import styles from './Workspace.module.css'; +import styles from '@/features/channel/Workspace.module.css'; export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (width: number) => void }) { const drag = useRef<{ right: number; maximum: number } | undefined>(undefined); diff --git a/web/src/features/channel/explorer/FilterBar.module.css b/web/src/features/channel/explorer/FilterBar.module.css new file mode 100644 index 0000000..530c972 --- /dev/null +++ b/web/src/features/channel/explorer/FilterBar.module.css @@ -0,0 +1,20 @@ +.bar { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--line); +} + +.input { + min-height: 36px; + padding: 0 var(--space-3); + border: 1px solid var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface); +} + +.input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} diff --git a/web/src/features/channel/explorer/FilterBar.tsx b/web/src/features/channel/explorer/FilterBar.tsx new file mode 100644 index 0000000..6a4135d --- /dev/null +++ b/web/src/features/channel/explorer/FilterBar.tsx @@ -0,0 +1,25 @@ +import type { ItemFilter } from '@/lib/files/filter'; +import styles from './FilterBar.module.css'; + +interface Props { + filter: ItemFilter; + searchOpen: boolean; + onChange: (f: ItemFilter) => void; +} + +/** 搜索框,由顶栏的放大镜展开;没展开时什么都不显示 */ +export function FilterBar({ filter, searchOpen, onChange }: Props) { + if (!searchOpen) return null; + return ( +
+ onChange({ ...filter, query: e.target.value })} + /> +
+ ); +} diff --git a/web/src/features/channel/UploadOptionsSheet.module.css b/web/src/features/channel/explorer/UploadOptionsSheet.module.css similarity index 100% rename from web/src/features/channel/UploadOptionsSheet.module.css rename to web/src/features/channel/explorer/UploadOptionsSheet.module.css diff --git a/web/src/features/channel/UploadOptionsSheet.tsx b/web/src/features/channel/explorer/UploadOptionsSheet.tsx similarity index 87% rename from web/src/features/channel/UploadOptionsSheet.tsx rename to web/src/features/channel/explorer/UploadOptionsSheet.tsx index afec29c..37bb9e0 100644 --- a/web/src/features/channel/UploadOptionsSheet.tsx +++ b/web/src/features/channel/explorer/UploadOptionsSheet.tsx @@ -1,11 +1,11 @@ import { useState } from 'react'; -import type { FileUploadOptions } from '@/core-bridge/types'; +import type { FileUploadOptions } from '@/domain/types'; import { Button } from '@/ui/Button'; import { Sheet } from '@/ui/Sheet'; import styles from './UploadOptionsSheet.module.css'; -import { useServerSettings } from '@/store/server'; -import { withCurrent } from '@/lib/settingOptions'; +import { useServerSettings } from '@/store/app/server'; +import { withCurrent } from '@/lib/space/settingOptions'; interface Props { encrypted?: boolean; @@ -22,7 +22,7 @@ export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUplo const [times, setTimes] = useState(defaultMaxDownloads > 0 ? defaultMaxDownloads : 10); // 不限制:聊天里直接预览,不消耗次数。限制:预览、下载每次都消耗一次 - const options: FileUploadOptions = limited ? { directPreview: false, maxDownloads: times } : { directPreview: true, maxDownloads: 0 }; + const options: FileUploadOptions = limited ? { maxDownloads: times } : { maxDownloads: 0 }; return ( ({ status: 'checking' }); + useEffect(() => { + if (!spaceId) return; + let cancelled = false; let timer: number | undefined; + const check = () => { + api.fileOptions().then((options) => { + if (cancelled) return; + setState(options ? { status: 'ok', options } : { status: 'disabled' }); + // 连上了就偶尔再确认一下(文件服务中途挂了界面也能知道);没连上就更勤地重试 + timer = window.setTimeout(check, options ? RETRY_MS * 4 : RETRY_MS); + }, () => { + if (cancelled) return; + setState((previous) => ({ status: 'down', ...(previous.options ? { options: previous.options } : {}) })); + timer = window.setTimeout(check, RETRY_MS); + }); + }; + check(); + return () => { cancelled = true; window.clearTimeout(timer); }; + }, [spaceId]); + return { status: state.status, options: state.options }; +} + +/** 当前频道文件域的容量和已用字节;换频道或文件数变化(上传、删除)后重新读取 */ +export function useFileDomainUsage(threadId: string | undefined, version: number): FileDomainUsage | undefined { + const [usage, setUsage] = useState<{ threadId: string; value: FileDomainUsage }>(); + useEffect(() => { + let cancelled = false; + if (!threadId) return; + api.fileUsage(threadId).then((value) => { if (!cancelled && value) setUsage({ threadId, value }); }, () => undefined); + return () => { cancelled = true; }; + }, [threadId, version]); + return usage && usage.threadId === threadId ? usage.value : undefined; +} diff --git a/web/src/features/channel/InviteSheet.tsx b/web/src/features/channel/members/InviteSheet.tsx similarity index 92% rename from web/src/features/channel/InviteSheet.tsx rename to web/src/features/channel/members/InviteSheet.tsx index 7bcc627..87a0680 100644 --- a/web/src/features/channel/InviteSheet.tsx +++ b/web/src/features/channel/members/InviteSheet.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from 'react'; -import { errorMessage } from '@/core-bridge/errors'; -import { useToast } from '@/store/toast'; -import { useWorkspace } from '@/store/workspace'; +import { errorMessage } from '@/api/errors'; +import { useToast } from '@/store/app/toast'; +import { useWorkspace } from '@/store/space/workspace'; import { Button } from '@/ui/Button'; import { Sheet } from '@/ui/Sheet'; import styles from './MembersPane.module.css'; diff --git a/web/src/features/channel/MembersPane.module.css b/web/src/features/channel/members/MembersPane.module.css similarity index 100% rename from web/src/features/channel/MembersPane.module.css rename to web/src/features/channel/members/MembersPane.module.css diff --git a/web/src/features/channel/MembersPane.tsx b/web/src/features/channel/members/MembersPane.tsx similarity index 77% rename from web/src/features/channel/MembersPane.tsx rename to web/src/features/channel/members/MembersPane.tsx index 2e5fed8..cbd34d4 100644 --- a/web/src/features/channel/MembersPane.tsx +++ b/web/src/features/channel/members/MembersPane.tsx @@ -1,20 +1,20 @@ +import { useCapabilities } from '@/store/space/useCapabilities'; import { useCallback, useEffect, useState, type ReactNode } from 'react'; -import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess'; -import type { AccountInvitation } from '@/core-bridge/account'; -import { errorMessage } from '@/core-bridge/errors'; -import type { SenderInfo } from '@/core-bridge/types'; -import { useToast } from '@/store/toast'; -import { useWorkspace } from '@/store/workspace'; -import { formatSeen } from '@/lib/format'; -import { OWNER_ROLE_ID, permissionsOf } from '@/lib/roles'; -import { useAccount } from '@/store/account'; -import { accountApi } from '@/core-bridge/account'; +import { channelAccessApi, type ChannelMember } from '@/services/channelAccess'; +import type { AccountInvitation } from '@/services/account'; +import { errorMessage } from '@/api/errors'; +import type { SenderInfo } from '@/domain/types'; +import { useToast } from '@/store/app/toast'; +import { useWorkspace } from '@/store/space/workspace'; +import { formatSeen } from '@/lib/text/format'; +import { OWNER_ROLE_ID } from '@/lib/space/roles'; +import { accountApi } from '@/services/account'; import { Button } from '@/ui/Button'; -import { AvatarPreview } from '../common/AvatarPreview'; +import { AvatarPreview } from '@/features/avatar/AvatarPreview'; import { NameWithBadges } from './RoleBadges'; import styles from './MembersPane.module.css'; -interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void } +interface Props { refreshKey: number; onInvite: () => void } function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) { return person?.avatar ? @@ -28,13 +28,13 @@ function Section({ title, count, children }: { title: string; count: number; chi /** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */ const PRESENCE_POLL_MS = 8000; -export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { +export function MembersPane({ refreshKey, onInvite }: Props) { + const { canInvite, canRemoveMembers: canRemove } = useCapabilities(); const channelId = useWorkspace((s) => s.channel?.id); const maxMembers = useWorkspace((s) => s.channel?.maxMembers); const people = useWorkspace((s) => s.people); const roles = useWorkspace((s) => s.roles); const creator = useWorkspace((s) => s.channel?.creatorAccount); - const self = useAccount((s) => s.username); const listInvitations = useWorkspace((s) => s.listInvitations); const revoke = useWorkspace((s) => s.revokeInvitation); const [members, setMembers] = useState([]); @@ -85,26 +85,24 @@ export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) { {removable ? : null} ; }; - // 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人 - const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove'))); // 在线的不用写时间;离线的写最后一次活跃(没登录过设备的老账号没有记录) const member = (item: ChannelMember) => row(item.username, !item.online && item.last_seen > 0 ? `最后在线 ${formatSeen(item.last_seen)}` : undefined, - false, removable(item), item.role === 'owner', item.online); + false, item.can_remove, item.role === 'owner', item.online); const viewed = viewing ? people[viewing] : undefined; return
{viewing && viewed?.avatar ? accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null}
成员 {members.length}{maxMembers ? `/${maxMembers}` : ''} - {owner ? : null} + {canInvite ? : null}
{online.map(member)}
{offline.map(member)}
-
{pending.map((invitation) => row(invitation.username, undefined, true, owner))}
+
{pending.map((invitation) => row(invitation.username, undefined, true, invitation.can_revoke))}
{error ?

{error}

: null} - {!owner ?

{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}

: null} + {!canInvite ?

{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}

: null}
; } diff --git a/web/src/features/channel/RoleBadges.module.css b/web/src/features/channel/members/RoleBadges.module.css similarity index 100% rename from web/src/features/channel/RoleBadges.module.css rename to web/src/features/channel/members/RoleBadges.module.css diff --git a/web/src/features/channel/RoleBadges.tsx b/web/src/features/channel/members/RoleBadges.tsx similarity index 88% rename from web/src/features/channel/RoleBadges.tsx rename to web/src/features/channel/members/RoleBadges.tsx index 588889e..dbc6347 100644 --- a/web/src/features/channel/RoleBadges.tsx +++ b/web/src/features/channel/members/RoleBadges.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react'; -import { InlineMarkdown } from '@/lib/inlineMarkdown'; -import { badgesOf, customColorStyle, effectOf, isGradient, type Badge } from '@/lib/roles'; -import { useWorkspace } from '@/store/workspace'; +import { InlineMarkdown } from '@/lib/text/inlineMarkdown'; +import { badgesOf, customColorStyle, effectOf, isGradient, type Badge } from '@/lib/space/roles'; +import { useWorkspace } from '@/store/space/workspace'; import styles from './RoleBadges.module.css'; function List({ items }: { items: Badge[] }) { diff --git a/web/src/features/channel/RoleColorPicker.module.css b/web/src/features/channel/members/RoleColorPicker.module.css similarity index 100% rename from web/src/features/channel/RoleColorPicker.module.css rename to web/src/features/channel/members/RoleColorPicker.module.css diff --git a/web/src/features/channel/RoleColorPicker.tsx b/web/src/features/channel/members/RoleColorPicker.tsx similarity index 98% rename from web/src/features/channel/RoleColorPicker.tsx rename to web/src/features/channel/members/RoleColorPicker.tsx index 704a1fa..5ce269f 100644 --- a/web/src/features/channel/RoleColorPicker.tsx +++ b/web/src/features/channel/members/RoleColorPicker.tsx @@ -1,7 +1,7 @@ import { useMemo, useRef, useState } from 'react'; -import { extractColors, hexToHsv, hsvToHex, parseHex, type Hsv } from '@/lib/colorMath'; -import { drawColors, type DrawnColor } from '@/lib/colorDraw'; -import { EFFECTS, gradientOf, isGradient, joinColor, splitColor, stopsOf } from '@/lib/roles'; +import { extractColors, hexToHsv, hsvToHex, parseHex, type Hsv } from '@/lib/color/colorMath'; +import { drawColors, type DrawnColor } from '@/lib/color/colorDraw'; +import { EFFECTS, gradientOf, isGradient, joinColor, splitColor, stopsOf } from '@/lib/space/roles'; import styles from './RoleColorPicker.module.css'; const MAX_STOPS = 5; diff --git a/web/src/features/channel/RolesEditor.module.css b/web/src/features/channel/members/RolesEditor.module.css similarity index 100% rename from web/src/features/channel/RolesEditor.module.css rename to web/src/features/channel/members/RolesEditor.module.css diff --git a/web/src/features/channel/RolesEditor.tsx b/web/src/features/channel/members/RolesEditor.tsx similarity index 94% rename from web/src/features/channel/RolesEditor.tsx rename to web/src/features/channel/members/RolesEditor.tsx index 353d64b..811b8ed 100644 --- a/web/src/features/channel/RolesEditor.tsx +++ b/web/src/features/channel/members/RolesEditor.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from 'react'; -import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess'; -import { errorMessage } from '@/core-bridge/errors'; -import type { RoleInfo } from '@/core-bridge/types'; -import { customColorStyle, effectOf, isGradient, normalizeColor, OWNER_ROLE_ID, PERMISSIONS, toCustomColor } from '@/lib/roles'; -import { InlineMarkdown } from '@/lib/inlineMarkdown'; -import { useToast } from '@/store/toast'; -import { useWorkspace } from '@/store/workspace'; +import { channelAccessApi, type ChannelMember } from '@/services/channelAccess'; +import { errorMessage } from '@/api/errors'; +import type { RoleInfo } from '@/domain/types'; +import { customColorStyle, effectOf, isGradient, normalizeColor, OWNER_ROLE_ID, PERMISSIONS, toCustomColor } from '@/lib/space/roles'; +import { InlineMarkdown } from '@/lib/text/inlineMarkdown'; +import { useToast } from '@/store/app/toast'; +import { useWorkspace } from '@/store/space/workspace'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import badgeStyles from './RoleBadges.module.css'; diff --git a/web/src/features/channel/SharePanel.module.css b/web/src/features/channel/members/SharePanel.module.css similarity index 100% rename from web/src/features/channel/SharePanel.module.css rename to web/src/features/channel/members/SharePanel.module.css diff --git a/web/src/features/channel/SharePanel.tsx b/web/src/features/channel/members/SharePanel.tsx similarity index 97% rename from web/src/features/channel/SharePanel.tsx rename to web/src/features/channel/members/SharePanel.tsx index 276652b..adcd131 100644 --- a/web/src/features/channel/SharePanel.tsx +++ b/web/src/features/channel/members/SharePanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useWorkspace } from '@/store/workspace'; +import { useWorkspace } from '@/store/space/workspace'; import { Button } from '@/ui/Button'; import { CopyButton } from '@/ui/CopyButton'; import { QrCode } from '@/ui/QrCode'; diff --git a/web/src/features/channel/MobileDrawer.module.css b/web/src/features/channel/navigation/MobileDrawer.module.css similarity index 100% rename from web/src/features/channel/MobileDrawer.module.css rename to web/src/features/channel/navigation/MobileDrawer.module.css diff --git a/web/src/features/channel/MobileDrawer.tsx b/web/src/features/channel/navigation/MobileDrawer.tsx similarity index 91% rename from web/src/features/channel/MobileDrawer.tsx rename to web/src/features/channel/navigation/MobileDrawer.tsx index f1bd0dc..88ce2de 100644 --- a/web/src/features/channel/MobileDrawer.tsx +++ b/web/src/features/channel/navigation/MobileDrawer.tsx @@ -1,6 +1,6 @@ -import { useChannels } from '@/store/channels'; -import { useWorkspace } from '@/store/workspace'; -import { AccountDock } from '@/features/home/AccountDock'; +import { useChannels } from '@/store/space/channels'; +import { useWorkspace } from '@/store/space/workspace'; +import { AccountDock } from '@/features/account/AccountDock'; import { Icon, type IconName } from '@/ui/Icon'; import { ThemeMenu } from '@/ui/ThemeMenu'; import { SpaceHeader, SpaceRail } from './SpaceRail'; diff --git a/web/src/features/channel/MobileTopBar.module.css b/web/src/features/channel/navigation/MobileTopBar.module.css similarity index 100% rename from web/src/features/channel/MobileTopBar.module.css rename to web/src/features/channel/navigation/MobileTopBar.module.css diff --git a/web/src/features/channel/MobileTopBar.tsx b/web/src/features/channel/navigation/MobileTopBar.tsx similarity index 96% rename from web/src/features/channel/MobileTopBar.tsx rename to web/src/features/channel/navigation/MobileTopBar.tsx index 19583d6..abd6968 100644 --- a/web/src/features/channel/MobileTopBar.tsx +++ b/web/src/features/channel/navigation/MobileTopBar.tsx @@ -1,4 +1,4 @@ -import { useWorkspace } from '@/store/workspace'; +import { useWorkspace } from '@/store/space/workspace'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './MobileTopBar.module.css'; diff --git a/web/src/features/channel/Notepad.module.css b/web/src/features/channel/navigation/Notepad.module.css similarity index 100% rename from web/src/features/channel/Notepad.module.css rename to web/src/features/channel/navigation/Notepad.module.css diff --git a/web/src/features/channel/Notepad.tsx b/web/src/features/channel/navigation/Notepad.tsx similarity index 96% rename from web/src/features/channel/Notepad.tsx rename to web/src/features/channel/navigation/Notepad.tsx index bea96eb..c072f20 100644 --- a/web/src/features/channel/Notepad.tsx +++ b/web/src/features/channel/navigation/Notepad.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { useWorkspace } from '@/store/workspace'; +import { useWorkspace } from '@/store/space/workspace'; import { Icon } from '@/ui/Icon'; import styles from './Notepad.module.css'; diff --git a/web/src/features/channel/PaneTabs.module.css b/web/src/features/channel/navigation/PaneTabs.module.css similarity index 100% rename from web/src/features/channel/PaneTabs.module.css rename to web/src/features/channel/navigation/PaneTabs.module.css diff --git a/web/src/features/channel/PaneTabs.tsx b/web/src/features/channel/navigation/PaneTabs.tsx similarity index 100% rename from web/src/features/channel/PaneTabs.tsx rename to web/src/features/channel/navigation/PaneTabs.tsx diff --git a/web/src/features/channel/SpaceRail.module.css b/web/src/features/channel/navigation/SpaceRail.module.css similarity index 100% rename from web/src/features/channel/SpaceRail.module.css rename to web/src/features/channel/navigation/SpaceRail.module.css diff --git a/web/src/features/channel/SpaceRail.tsx b/web/src/features/channel/navigation/SpaceRail.tsx similarity index 91% rename from web/src/features/channel/SpaceRail.tsx rename to web/src/features/channel/navigation/SpaceRail.tsx index 9562890..6a3efb3 100644 --- a/web/src/features/channel/SpaceRail.tsx +++ b/web/src/features/channel/navigation/SpaceRail.tsx @@ -1,8 +1,8 @@ -import { useChannels } from '@/store/channels'; -import { spaceSeqKey, useReadState } from '@/store/readState'; +import { useChannels } from '@/store/space/channels'; +import { spaceSeqKey, useReadState } from '@/store/space/readState'; import { useSpaceReads } from './useThreadReads'; -import { useAccount } from '@/store/account'; -import { useWorkspace } from '@/store/workspace'; +import { useAccount } from '@/store/account/account'; +import { useWorkspace } from '@/store/space/workspace'; import { channelPath, navigate } from '@/app/navigation'; import { Icon } from '@/ui/Icon'; import styles from './SpaceRail.module.css'; diff --git a/web/src/features/channel/ThreadSidebar.module.css b/web/src/features/channel/navigation/ThreadSidebar.module.css similarity index 100% rename from web/src/features/channel/ThreadSidebar.module.css rename to web/src/features/channel/navigation/ThreadSidebar.module.css diff --git a/web/src/features/channel/ThreadSidebar.tsx b/web/src/features/channel/navigation/ThreadSidebar.tsx similarity index 89% rename from web/src/features/channel/ThreadSidebar.tsx rename to web/src/features/channel/navigation/ThreadSidebar.tsx index 9bfa2ff..a6fab71 100644 --- a/web/src/features/channel/ThreadSidebar.tsx +++ b/web/src/features/channel/navigation/ThreadSidebar.tsx @@ -1,8 +1,9 @@ import { useState, type FormEvent } from 'react'; -import type { ChannelGroupInfo, ThreadInfo } from '@/core-bridge/types'; +import type { ChannelGroupInfo, ThreadInfo } from '@/domain/types'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import { Sheet } from '@/ui/Sheet'; +import { formatSize } from '@/lib/text/format'; import styles from './ThreadSidebar.module.css'; interface Props { @@ -17,10 +18,14 @@ interface Props { enabled: boolean; /** 新建频道涉及密钥,只有空间主 */ canCreate: boolean; - owner: boolean; + /** 能改频道的历史可读策略 */ + canManageHistory: boolean; + /** 创建频道时可以分配的文件空间档位(字节) */ + quotaChoices: number[]; + defaultQuota?: number | undefined; onHistory: (id: string, chatHistory: boolean, filesHistory: boolean) => Promise; onSelectThread: (id: string) => void; - onCreateThread: (name: string, groupId?: string) => Promise; + onCreateThread: (name: string, groupId?: string, fileQuota?: number) => Promise; onRenameThread: (id: string, name: string) => Promise; onCreateGroup: (name: string) => Promise; onRenameGroup: (id: string, name: string) => Promise; @@ -34,6 +39,7 @@ export function ThreadSidebar(props: Props) { const [edit, setEdit] = useState(); const [name, setName] = useState(''); const [groupId, setGroupId] = useState(''); + const [quota, setQuota] = useState(0); const [chatHistory, setChatHistory] = useState(true); const [filesHistory, setFilesHistory] = useState(true); const [busy, setBusy] = useState(false); @@ -43,7 +49,7 @@ export function ThreadSidebar(props: Props) { const knownGroups = new Set(props.groups.map((group) => group.id)); const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId)); function begin(target: Editor, initial = '') { - setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); setConfirmDelete(false); + setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setQuota(props.defaultQuota ?? 0); setError(''); setConfirmDelete(false); const channel = props.threads.find(t => t.id === target.id); setChatHistory(channel?.chatHistory ?? true); setFilesHistory(channel?.filesHistory ?? true); } function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } @@ -65,8 +71,8 @@ export function ThreadSidebar(props: Props) { const original = props.threads.find((thread) => thread.id === edit.id); if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name); if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined); - if (props.owner && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory); - } else props.onSelectThread(await props.onCreateThread(name, groupId || undefined)); + if (props.canManageHistory && (original?.chatHistory !== chatHistory || original?.filesHistory !== filesHistory)) await props.onHistory(edit.id, chatHistory, filesHistory); + } else props.onSelectThread(await props.onCreateThread(name, groupId || undefined, quota || undefined)); if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; }); setEdit(undefined); } catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); } @@ -106,7 +112,10 @@ export function ThreadSidebar(props: Props) { {edit.kind === 'channel' ? : null} - {edit.kind === 'channel' && edit.id && props.owner ? <> + {edit.kind === 'channel' && !edit.id && props.quotaChoices.length ? : null} + {edit.kind === 'channel' && edit.id && props.canManageHistory ? <> : null} diff --git a/web/src/features/channel/TopBar.module.css b/web/src/features/channel/navigation/TopBar.module.css similarity index 100% rename from web/src/features/channel/TopBar.module.css rename to web/src/features/channel/navigation/TopBar.module.css diff --git a/web/src/features/channel/TopBar.tsx b/web/src/features/channel/navigation/TopBar.tsx similarity index 96% rename from web/src/features/channel/TopBar.tsx rename to web/src/features/channel/navigation/TopBar.tsx index 6add86d..4668a82 100644 --- a/web/src/features/channel/TopBar.tsx +++ b/web/src/features/channel/navigation/TopBar.tsx @@ -1,5 +1,5 @@ -import { formatRemaining } from '@/lib/format'; -import { useWorkspace } from '@/store/workspace'; +import { formatRemaining } from '@/lib/text/format'; +import { useWorkspace } from '@/store/space/workspace'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import { ThemeMenu } from '@/ui/ThemeMenu'; diff --git a/web/src/features/channel/useThreadReads.ts b/web/src/features/channel/navigation/useThreadReads.ts similarity index 92% rename from web/src/features/channel/useThreadReads.ts rename to web/src/features/channel/navigation/useThreadReads.ts index efd0bae..3827d47 100644 --- a/web/src/features/channel/useThreadReads.ts +++ b/web/src/features/channel/navigation/useThreadReads.ts @@ -1,11 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; -import type { Item, SpaceEvent } from '@/core-bridge/types'; -import { callWorker, subscribeWorker } from '@/core-bridge/workerClient'; -import { useAccount } from '@/store/account'; -import { useChannels } from '@/store/channels'; -import { readKey, spaceSeqKey, useReadState } from '@/store/readState'; -import { useWorkspace } from '@/store/workspace'; +import type { Item, SpaceEvent } from '@/domain/types'; +import { callWorker, subscribeWorker } from '@/services/workerClient'; +import { useAccount } from '@/store/account/account'; +import { useChannels } from '@/store/space/channels'; +import { readKey, spaceSeqKey, useReadState } from '@/store/space/readState'; +import { useWorkspace } from '@/store/space/workspace'; const isMine = (item: Item, self: string | null | undefined) => item.localId !== undefined || Boolean(self && item.author?.username === self); const countable = (item: Item) => !(item.kind === 'file' && item.metadataHidden); diff --git a/web/src/features/home/ChannelList.tsx b/web/src/features/home/ChannelList.tsx index 94cbbb8..d07e45b 100644 --- a/web/src/features/home/ChannelList.tsx +++ b/web/src/features/home/ChannelList.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; -import type { ChannelInfo, Share } from '@/core-bridge/types'; -import { formatAgo, formatRemaining } from '@/lib/format'; -import { useChannels } from '@/store/channels'; -import { useShares } from '@/store/shares'; +import type { ChannelInfo, Share } from '@/domain/types'; +import { formatAgo, formatRemaining } from '@/lib/text/format'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import styles from './ChannelList.module.css'; @@ -47,7 +47,7 @@ export function ChannelList({ kind, title, onOpen, quietTitle = false }: Props) {c.mode === 'enc' ? <> 加密 : '明文'} - {c.role === 'owner' ? '所有者' : '成员'} + {c.isOwner ? '所有者' : '成员'} 最多 {c.maxMembers} 人 {formatAgo(c.createdAt)}创建 diff --git a/web/src/features/home/CreateSheet.tsx b/web/src/features/home/CreateSheet.tsx index b73c492..55f4834 100644 --- a/web/src/features/home/CreateSheet.tsx +++ b/web/src/features/home/CreateSheet.tsx @@ -1,16 +1,16 @@ import { useState } from 'react'; -import type { ChannelInfo, CreateOptions } from '@/core-bridge/types'; -import { useChannels } from '@/store/channels'; +import type { ChannelInfo, CreateOptions } from '@/domain/types'; +import { useChannels } from '@/store/space/channels'; import { Button } from '@/ui/Button'; import { Sheet } from '@/ui/Sheet'; -import { solveCreationCaptcha } from '@/core-bridge/captcha'; +import { solveCreationCaptcha } from '@/services/captcha'; import styles from './CreateSheet.module.css'; -import { useServerSettings } from '@/store/server'; -import { creationOptions } from '@/core-bridge/settings'; -import type { ServerInfoBody } from '@/core-bridge/generated/api'; -import { ttlLabel } from '@/lib/settingOptions'; -import { errorMessage } from '@/core-bridge/errors'; +import { useServerSettings } from '@/store/app/server'; +import { creationOptions } from '@/domain/settings'; +import type { ServerInfoBody } from '@/api/generated'; +import { ttlLabel } from '@/lib/space/settingOptions'; +import { errorMessage } from '@/api/errors'; interface Props { onClose: () => void; onCreated: (c: ChannelInfo) => void; diff --git a/web/src/features/home/Home.tsx b/web/src/features/home/Home.tsx index 166896d..ffbcecc 100644 --- a/web/src/features/home/Home.tsx +++ b/web/src/features/home/Home.tsx @@ -1,13 +1,13 @@ import { lazy, Suspense, useCallback, useState } from 'react'; import { channelPath, navigate, sharePath } from '@/app/navigation'; -import { buildInfo } from '@/lib/buildInfo'; -import type { ChannelInfo, Share } from '@/core-bridge/types'; -import type { ClipboardPayload } from '@/lib/clipboard'; -import { useGlobalPaste } from '@/lib/useGlobalPaste'; -import { useChannels } from '@/store/channels'; -import { useShares } from '@/store/shares'; -import { solveCreationCaptcha } from '@/core-bridge/captcha'; -import { errorMessage } from '@/core-bridge/errors'; +import { buildInfo } from '@/lib/platform/buildInfo'; +import type { ChannelInfo, Share } from '@/domain/types'; +import type { ClipboardPayload } from '@/lib/platform/clipboard'; +import { useGlobalPaste } from '@/lib/hooks/useGlobalPaste'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; +import { solveCreationCaptcha } from '@/services/captcha'; +import { errorMessage } from '@/api/errors'; import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; import { ThemeMenu } from '@/ui/ThemeMenu'; diff --git a/web/src/features/home/QuickShare.tsx b/web/src/features/home/QuickShare.tsx index 2e9d9d8..8dfe60d 100644 --- a/web/src/features/home/QuickShare.tsx +++ b/web/src/features/home/QuickShare.tsx @@ -1,7 +1,7 @@ import { useRef, useState, type DragEvent } from 'react'; -import type { ClipboardPayload } from '@/lib/clipboard'; -import { readClipboard } from '@/lib/clipboard'; -import { useMediaQuery } from '@/lib/useMediaQuery'; +import type { ClipboardPayload } from '@/lib/platform/clipboard'; +import { readClipboard } from '@/lib/platform/clipboard'; +import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; import { Icon, type IconName } from '@/ui/Icon'; import styles from './QuickShare.module.css'; diff --git a/web/src/features/item/CompactRow.tsx b/web/src/features/item/CompactRow.tsx index 4bab13e..9b958cb 100644 --- a/web/src/features/item/CompactRow.tsx +++ b/web/src/features/item/CompactRow.tsx @@ -1,11 +1,11 @@ import { memo } from 'react'; -import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; +import { formatSize, formatTime, formatTransferProgress } from '@/lib/text/format'; import { Button } from '@/ui/Button'; import { CopyButton } from '@/ui/CopyButton'; import { useDownload } from './download'; import { InlineContent, inlineKindOf } from './InlineContent'; import { useItemSource } from './source'; -import { senderLabel } from '@/lib/sender'; +import { senderLabel } from '@/lib/space/sender'; import { titleOf, versionOf } from './summary'; import { Tile } from './Tile'; import styles from './CompactRow.module.css'; diff --git a/web/src/features/item/HistoryList.tsx b/web/src/features/item/HistoryList.tsx index a078e7a..8688075 100644 --- a/web/src/features/item/HistoryList.tsx +++ b/web/src/features/item/HistoryList.tsx @@ -1,9 +1,9 @@ -import type { FileItem } from '@/core-bridge/types'; -import { formatSize, formatTime } from '@/lib/format'; -import { saveBlob } from '@/lib/download'; +import type { FileItem } from '@/domain/types'; +import { formatSize, formatTime } from '@/lib/text/format'; +import { saveBlob } from '@/lib/files/download'; import { Button } from '@/ui/Button'; import { useItemSource } from './source'; -import { senderLabel } from '@/lib/sender'; +import { senderLabel } from '@/lib/space/sender'; import styles from './HistoryList.module.css'; /** 文件的修改历史:当前版本在最上,旧版本可单独下载 */ diff --git a/web/src/features/item/InlineContent.tsx b/web/src/features/item/InlineContent.tsx index 5c4dbb9..442d3a3 100644 --- a/web/src/features/item/InlineContent.tsx +++ b/web/src/features/item/InlineContent.tsx @@ -1,8 +1,8 @@ import { useMemo, useState } from 'react'; -import type { FileItem, Item, TextItem } from '@/core-bridge/types'; -import { textView, VIDEO_INLINE_MAX } from '@/lib/inline'; +import type { FileItem, Item, TextItem } from '@/domain/types'; +import { textView, VIDEO_INLINE_MAX } from '@/lib/text/inline'; import { Icon } from '@/ui/Icon'; -import { useBlobUrl } from './useBlobUrl'; +import { useBlobUrl } from '@/features/item/hooks/useBlobUrl'; import styles from './InlineContent.module.css'; /** 列表里能直接画出来的内容:文本与 JSON(不管多短都画),不大的视频 */ diff --git a/web/src/features/item/ItemDetail.tsx b/web/src/features/item/ItemDetail.tsx index 2269a01..8fe828e 100644 --- a/web/src/features/item/ItemDetail.tsx +++ b/web/src/features/item/ItemDetail.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; -import type { Item } from '@/core-bridge/types'; -import { isLongText } from '@/lib/fileKind'; -import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; +import type { Item } from '@/domain/types'; +import { isLongText } from '@/lib/files/fileKind'; +import { formatSize, formatTime, formatTransferProgress } from '@/lib/text/format'; import { Button } from '@/ui/Button'; import { CopyButton } from '@/ui/CopyButton'; import { useDownload } from './download'; @@ -10,7 +10,7 @@ import { ArchivePreview } from './previews/ArchivePreview'; import { MediaPreview } from './previews/MediaPreview'; import { TextPreview } from './previews/TextPreview'; import { useItemSource } from './source'; -import { senderLabel } from '@/lib/sender'; +import { senderLabel } from '@/lib/space/sender'; import { titleOf } from './summary'; import { TextEditor } from './TextEditor'; import styles from './ItemDetail.module.css'; diff --git a/web/src/features/item/PreviewPane.module.css b/web/src/features/item/PreviewPane.module.css deleted file mode 100644 index 656d484..0000000 --- a/web/src/features/item/PreviewPane.module.css +++ /dev/null @@ -1,10 +0,0 @@ -.pane { - min-width: 0; - padding: var(--space-6); - overflow-y: auto; -} - -.empty { - margin: 0; - color: var(--text-dim); -} diff --git a/web/src/features/item/PreviewPane.tsx b/web/src/features/item/PreviewPane.tsx deleted file mode 100644 index 75ec9aa..0000000 --- a/web/src/features/item/PreviewPane.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import ItemDetail from './ItemDetail'; -import styles from './PreviewPane.module.css'; - -interface Props { - id: string | undefined; - onDelete: (id: string) => void; -} - -/** 电脑上的条目详情:列表右侧的内联预览区,不弹窗 */ -export default function PreviewPane({ id, onDelete }: Props) { - return ( -
- {id ? :

选择左侧的内容进行预览。

} -
- ); -} diff --git a/web/src/features/item/PreviewSheet.module.css b/web/src/features/item/PreviewSheet.module.css deleted file mode 100644 index 497d2b2..0000000 --- a/web/src/features/item/PreviewSheet.module.css +++ /dev/null @@ -1,12 +0,0 @@ -.size { - margin-left: var(--space-2); - color: var(--text-dim); - font-family: var(--font-mono); - font-size: 12px; - font-weight: 400; -} - -.none { - margin: 0; - color: var(--text-dim); -} diff --git a/web/src/features/item/TextEditor.tsx b/web/src/features/item/TextEditor.tsx index db2475c..c903c33 100644 --- a/web/src/features/item/TextEditor.tsx +++ b/web/src/features/item/TextEditor.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import type { TextItem } from '@/core-bridge/types'; +import type { TextItem } from '@/domain/types'; import { useItemSource } from './source'; import { Button } from '@/ui/Button'; import styles from './TextEditor.module.css'; diff --git a/web/src/features/item/Tile.tsx b/web/src/features/item/Tile.tsx index a0d4a12..7291025 100644 --- a/web/src/features/item/Tile.tsx +++ b/web/src/features/item/Tile.tsx @@ -1,7 +1,7 @@ -import type { Item } from '@/core-bridge/types'; +import type { Item } from '@/domain/types'; import { Icon } from '@/ui/Icon'; import { iconOf } from './summary'; -import { useBlobUrl } from './useBlobUrl'; +import { useBlobUrl } from '@/features/item/hooks/useBlobUrl'; import styles from './Tile.module.css'; function Thumb({ id }: { id: string }) { diff --git a/web/src/features/item/TransferProgress.tsx b/web/src/features/item/TransferProgress.tsx index c7e6a52..4bd12dd 100644 --- a/web/src/features/item/TransferProgress.tsx +++ b/web/src/features/item/TransferProgress.tsx @@ -1,4 +1,4 @@ -import { formatSize, formatTransferProgress, type TransferState } from '@/lib/format'; +import { formatSize, formatTransferProgress, type TransferState } from '@/lib/text/format'; import styles from './TransferProgress.module.css'; interface Props { diff --git a/web/src/features/item/download.ts b/web/src/features/item/download.ts index 22160d3..f1b6b4f 100644 --- a/web/src/features/item/download.ts +++ b/web/src/features/item/download.ts @@ -1,5 +1,5 @@ -import type { Item } from '@/core-bridge/types'; -import { saveBlob } from '@/lib/download'; +import type { Item } from '@/domain/types'; +import { saveBlob } from '@/lib/files/download'; import { useItemSource } from './source'; export function itemFileName(item: Item): string { diff --git a/web/src/features/item/useBlobUrl.ts b/web/src/features/item/hooks/useBlobUrl.ts similarity index 94% rename from web/src/features/item/useBlobUrl.ts rename to web/src/features/item/hooks/useBlobUrl.ts index ae17fcc..9c43c84 100644 --- a/web/src/features/item/useBlobUrl.ts +++ b/web/src/features/item/hooks/useBlobUrl.ts @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { useItemSource } from './source'; +import { useItemSource } from '@/features/item/source'; type BlobState = | { status: 'loading' } diff --git a/web/src/features/item/useMediaUrl.ts b/web/src/features/item/hooks/useMediaUrl.ts similarity index 100% rename from web/src/features/item/useMediaUrl.ts rename to web/src/features/item/hooks/useMediaUrl.ts diff --git a/web/src/features/item/useStreamUrl.ts b/web/src/features/item/hooks/useStreamUrl.ts similarity index 89% rename from web/src/features/item/useStreamUrl.ts rename to web/src/features/item/hooks/useStreamUrl.ts index 06c9dae..c298486 100644 --- a/web/src/features/item/useStreamUrl.ts +++ b/web/src/features/item/hooks/useStreamUrl.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { releasePlayback } from '@/core-bridge/streamClient'; -import { useItemSource } from './source'; +import { releasePlayback } from '@/services/streamClient'; +import { useItemSource } from '@/features/item/source'; type StreamState = { status: 'loading' } | { status: 'ready'; url: string } | { status: 'unavailable' }; diff --git a/web/src/features/item/hooks/videoPoster.ts b/web/src/features/item/hooks/videoPoster.ts new file mode 100644 index 0000000..7841cdd --- /dev/null +++ b/web/src/features/item/hooks/videoPoster.ts @@ -0,0 +1,50 @@ +import { isPoster } from '@/domain/poster'; + +/** 从大到小试:能放进预算里的最大尺寸、最高画质。聊天里封面显示约 360 像素宽,所以最大取 384 */ +const WIDTHS = [384, 320, 256, 224, 192]; +const QUALITIES = [0.85, 0.75, 0.65, 0.55, 0.45, 0.35]; +const FORMATS = ['image/webp', 'image/jpeg'] as const; + +function once(target: EventTarget, event: string, ms: number): Promise { + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { cleanup(); reject(new Error('timeout')); }, ms); + const done = () => { cleanup(); resolve(); }; + const fail = () => { cleanup(); reject(new Error('error')); }; + const cleanup = () => { window.clearTimeout(timer); target.removeEventListener(event, done); target.removeEventListener('error', fail); }; + target.addEventListener(event, done, { once: true }); + target.addEventListener('error', fail, { once: true }); + }); +} + +/** + * 从视频文件里截一帧做封面。不读整个文件(对象地址,只读元数据和那一帧附近),截不出来(格式不支持、超时)就返回 undefined, + * 上传照常进行,只是没有封面。结果是很小的 JPEG data URL,一定能放进文件目录里。 + */ +export async function videoPoster(file: File): Promise { + if (!file.type.startsWith('video/')) return undefined; + const url = URL.createObjectURL(file); + const video = document.createElement('video'); + video.muted = true; video.preload = 'metadata'; video.playsInline = true; video.src = url; + try { + await once(video, 'loadedmetadata', 6000); + if (!video.videoWidth || !video.videoHeight) return undefined; + // 取开头附近的一帧,避开全黑的第 0 帧 + video.currentTime = Number.isFinite(video.duration) ? Math.min(1, video.duration / 10) : 0; + await once(video, 'seeked', 6000); + for (const width of WIDTHS) { + const canvas = document.createElement('canvas'); + canvas.width = width; canvas.height = Math.max(1, Math.round(width * video.videoHeight / video.videoWidth)); + const context = canvas.getContext('2d'); + if (!context) return undefined; + context.imageSmoothingQuality = 'high'; + context.drawImage(video, 0, 0, canvas.width, canvas.height); + // 浏览器不支持编码某种格式时 toDataURL 会悄悄换成 PNG,所以要看返回值的前缀 + for (const format of FORMATS) for (const quality of QUALITIES) { + const poster = canvas.toDataURL(format, quality); + if (poster.startsWith(`data:${format};`) && isPoster(poster)) return poster; + } + } + return undefined; + } catch { return undefined; } + finally { video.removeAttribute('src'); video.load(); URL.revokeObjectURL(url); } +} diff --git a/web/src/features/item/previews/ArchivePreview.tsx b/web/src/features/item/previews/ArchivePreview.tsx index 163ebdc..1b1de80 100644 --- a/web/src/features/item/previews/ArchivePreview.tsx +++ b/web/src/features/item/previews/ArchivePreview.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import type { ArchiveEntry } from '@/core-bridge/types'; -import { formatSize } from '@/lib/format'; +import type { ArchiveEntry } from '@/domain/types'; +import { formatSize } from '@/lib/text/format'; import { Icon } from '@/ui/Icon'; import { useItemSource } from '../source'; import styles from './previews.module.css'; diff --git a/web/src/features/item/previews/MediaPreview.tsx b/web/src/features/item/previews/MediaPreview.tsx index 175e0f2..72496ed 100644 --- a/web/src/features/item/previews/MediaPreview.tsx +++ b/web/src/features/item/previews/MediaPreview.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { useBlobUrl } from '../useBlobUrl'; -import { useMediaUrl } from '../useMediaUrl'; +import { useBlobUrl } from '@/features/item/hooks/useBlobUrl'; +import { useMediaUrl } from '@/features/item/hooks/useMediaUrl'; import styles from './previews.module.css'; interface Props { diff --git a/web/src/features/item/previews/TextPreview.tsx b/web/src/features/item/previews/TextPreview.tsx index e0be08b..f330ddf 100644 --- a/web/src/features/item/previews/TextPreview.tsx +++ b/web/src/features/item/previews/TextPreview.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import type { Item } from '@/core-bridge/types'; -import { useBlobUrl } from '../useBlobUrl'; +import type { Item } from '@/domain/types'; +import { useBlobUrl } from '@/features/item/hooks/useBlobUrl'; import styles from './previews.module.css'; const MAX_CHARS = 200_000; diff --git a/web/src/features/item/source.ts b/web/src/features/item/source.ts index cfabf8b..87a1516 100644 --- a/web/src/features/item/source.ts +++ b/web/src/features/item/source.ts @@ -1,9 +1,9 @@ import { createContext, useContext } from 'react'; -import type { ArchiveEntry, Item } from '@/core-bridge/types'; -import { UNKNOWN_SENDER_LABEL } from '@/lib/sender'; -import { playbackUrl } from '@/core-bridge/streamClient'; -import { useShareSession } from '@/store/shareSession'; -import { useWorkspace } from '@/store/workspace'; +import type { ArchiveEntry, Item } from '@/domain/types'; +import { UNKNOWN_SENDER_LABEL } from '@/lib/space/sender'; +import { playbackUrl } from '@/services/streamClient'; +import { useShareSession } from '@/store/share/shareSession'; +import { useWorkspace } from '@/store/space/workspace'; export interface ItemActions { markSeen: (id: string) => Promise; diff --git a/web/src/features/item/summary.ts b/web/src/features/item/summary.ts index 6c01883..8914c96 100644 --- a/web/src/features/item/summary.ts +++ b/web/src/features/item/summary.ts @@ -1,4 +1,4 @@ -import type { Item } from '@/core-bridge/types'; +import type { Item } from '@/domain/types'; import type { IconName } from '@/ui/Icon'; const FILE_ICON = { diff --git a/web/src/features/scan/JoinPreview.tsx b/web/src/features/scan/JoinPreview.tsx index efb7e77..fe0366a 100644 --- a/web/src/features/scan/JoinPreview.tsx +++ b/web/src/features/scan/JoinPreview.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; -import type { ChannelInfo } from '@/core-bridge/types'; -import type { ParsedChannelLink } from '@/lib/channelLink'; +import type { ChannelInfo } from '@/domain/types'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; import { Button } from '@/ui/Button'; import styles from './ScanSheet.module.css'; -import { channelAccessApi } from '@/core-bridge/channelAccess'; -import { useChannels } from '@/store/channels'; +import { channelAccessApi } from '@/services/channelAccess'; +import { useChannels } from '@/store/space/channels'; import { navigate } from '@/app/navigation'; interface Props { diff --git a/web/src/features/scan/JoinScreen.tsx b/web/src/features/scan/JoinScreen.tsx index 7c7028f..e34699d 100644 --- a/web/src/features/scan/JoinScreen.tsx +++ b/web/src/features/scan/JoinScreen.tsx @@ -1,6 +1,6 @@ -import type { ChannelInfo } from '@/core-bridge/types'; -import type { ParsedChannelLink } from '@/lib/channelLink'; -import { useChannels } from '@/store/channels'; +import type { ChannelInfo } from '@/domain/types'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; +import { useChannels } from '@/store/space/channels'; import { Button } from '@/ui/Button'; import { JoinPreview } from './JoinPreview'; import styles from './JoinScreen.module.css'; diff --git a/web/src/features/scan/ScanSheet.tsx b/web/src/features/scan/ScanSheet.tsx index 3b81435..36ecb2b 100644 --- a/web/src/features/scan/ScanSheet.tsx +++ b/web/src/features/scan/ScanSheet.tsx @@ -1,9 +1,9 @@ import { useRef, useState } from 'react'; -import type { ChannelInfo, Share } from '@/core-bridge/types'; -import { parseChannelLink, type LinkError, type ParsedChannelLink } from '@/lib/channelLink'; -import { parseShareLink } from '@/lib/shareLink'; -import { useChannels } from '@/store/channels'; -import { useShares } from '@/store/shares'; +import type { ChannelInfo, Share } from '@/domain/types'; +import { parseChannelLink, type LinkError, type ParsedChannelLink } from '@/lib/links/channelLink'; +import { parseShareLink } from '@/lib/links/shareLink'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; import { Button } from '@/ui/Button'; import { Sheet } from '@/ui/Sheet'; import { JoinPreview } from './JoinPreview'; diff --git a/web/src/features/channel/ActionBar.module.css b/web/src/features/share/ActionBar.module.css similarity index 100% rename from web/src/features/channel/ActionBar.module.css rename to web/src/features/share/ActionBar.module.css diff --git a/web/src/features/channel/ActionBar.tsx b/web/src/features/share/ActionBar.tsx similarity index 95% rename from web/src/features/channel/ActionBar.tsx rename to web/src/features/share/ActionBar.tsx index 1e58f89..c4381c2 100644 --- a/web/src/features/channel/ActionBar.tsx +++ b/web/src/features/share/ActionBar.tsx @@ -1,6 +1,6 @@ import { useRef } from 'react'; -import { readClipboard } from '@/lib/clipboard'; -import { useMediaQuery } from '@/lib/useMediaQuery'; +import { readClipboard } from '@/lib/platform/clipboard'; +import { useMediaQuery } from '@/lib/hooks/useMediaQuery'; import { Icon, type IconName } from '@/ui/Icon'; import styles from './ActionBar.module.css'; diff --git a/web/src/features/channel/ComposeSheet.module.css b/web/src/features/share/ComposeSheet.module.css similarity index 100% rename from web/src/features/channel/ComposeSheet.module.css rename to web/src/features/share/ComposeSheet.module.css diff --git a/web/src/features/channel/ComposeSheet.tsx b/web/src/features/share/ComposeSheet.tsx similarity index 100% rename from web/src/features/channel/ComposeSheet.tsx rename to web/src/features/share/ComposeSheet.tsx diff --git a/web/src/features/share/DraftPreview.tsx b/web/src/features/share/DraftPreview.tsx index 5031a10..c37bc08 100644 --- a/web/src/features/share/DraftPreview.tsx +++ b/web/src/features/share/DraftPreview.tsx @@ -1,8 +1,8 @@ import { useRef, useState } from 'react'; -import type { FileItem, Item, TextItem } from '@/core-bridge/types'; +import type { FileItem, Item, TextItem } from '@/domain/types'; import ItemDetail from '@/features/item/ItemDetail'; -import { formatSize, formatTime } from '@/lib/format'; -import { useShareSession } from '@/store/shareSession'; +import { formatSize, formatTime } from '@/lib/text/format'; +import { useShareSession } from '@/store/share/shareSession'; import { Button } from '@/ui/Button'; import styles from './DraftPreview.module.css'; diff --git a/web/src/features/share/DraftRow.tsx b/web/src/features/share/DraftRow.tsx index ac88a6e..b422d44 100644 --- a/web/src/features/share/DraftRow.tsx +++ b/web/src/features/share/DraftRow.tsx @@ -1,5 +1,5 @@ -import { formatSize, formatTransferProgress } from '@/lib/format'; -import { useShareSession } from '@/store/shareSession'; +import { formatSize, formatTransferProgress } from '@/lib/text/format'; +import { useShareSession } from '@/store/share/shareSession'; import { Button } from '@/ui/Button'; import { titleOf } from '@/features/item/summary'; import { Tile } from '@/features/item/Tile'; diff --git a/web/src/features/share/ShareDraft.tsx b/web/src/features/share/ShareDraft.tsx index 4bf8e17..a0b5d3d 100644 --- a/web/src/features/share/ShareDraft.tsx +++ b/web/src/features/share/ShareDraft.tsx @@ -1,19 +1,19 @@ import { useCallback, useEffect, useState, type DragEvent } from 'react'; -import { ActionBar } from '@/features/channel/ActionBar'; -import { ComposeSheet } from '@/features/channel/ComposeSheet'; -import type { ShareSettings } from '@/core-bridge/types'; -import type { ClipboardPayload } from '@/lib/clipboard'; -import { useGlobalPaste } from '@/lib/useGlobalPaste'; -import { useShares } from '@/store/shares'; -import { useShareSession } from '@/store/shareSession'; +import { ActionBar } from './ActionBar'; +import { ComposeSheet } from './ComposeSheet'; +import type { ShareSettings } from '@/domain/types'; +import type { ClipboardPayload } from '@/lib/platform/clipboard'; +import { useGlobalPaste } from '@/lib/hooks/useGlobalPaste'; +import { useShares } from '@/store/share/shares'; +import { useShareSession } from '@/store/share/shareSession'; import { Button } from '@/ui/Button'; import { DraftRow } from './DraftRow'; import { DraftPreview } from './DraftPreview'; import { ShareBar } from './ShareBar'; import styles from './ShareDraft.module.css'; -import { useServerSettings } from '@/store/server'; -import { ttlLabel, withCurrent } from '@/lib/settingOptions'; +import { useServerSettings } from '@/store/app/server'; +import { ttlLabel, withCurrent } from '@/lib/space/settingOptions'; /** 分享草稿:创建者还握着所有者密钥,可以添加、删除、调整设置;点「完成分享」后无人可改 */ export function ShareDraft() { diff --git a/web/src/features/share/ShareView.tsx b/web/src/features/share/ShareView.tsx index ab87036..764a231 100644 --- a/web/src/features/share/ShareView.tsx +++ b/web/src/features/share/ShareView.tsx @@ -1,8 +1,8 @@ import { lazy, Suspense, useCallback, useState } from 'react'; import { CompactRow } from '@/features/item/CompactRow'; import { useDownload } from '@/features/item/download'; -import { formatRemaining } from '@/lib/format'; -import { useShareSession } from '@/store/shareSession'; +import { formatRemaining } from '@/lib/text/format'; +import { useShareSession } from '@/store/share/shareSession'; import { Button } from '@/ui/Button'; import { CopyButton } from '@/ui/CopyButton'; import { Icon } from '@/ui/Icon'; diff --git a/web/src/lib/colorDraw.test.ts b/web/src/lib/color/colorDraw.test.ts similarity index 95% rename from web/src/lib/colorDraw.test.ts rename to web/src/lib/color/colorDraw.test.ts index 082f9f6..9a463bd 100644 --- a/web/src/lib/colorDraw.test.ts +++ b/web/src/lib/color/colorDraw.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { drawColors } from './colorDraw'; -import { isCustomColor } from './roles'; +import { isCustomColor } from '@/lib/space/roles'; describe('drawColors', () => { it('每个候选都是合法的自定义颜色,渐变 2~5 个色标', () => { diff --git a/web/src/lib/colorDraw.ts b/web/src/lib/color/colorDraw.ts similarity index 97% rename from web/src/lib/colorDraw.ts rename to web/src/lib/color/colorDraw.ts index 7f8cb04..2c8c0c6 100644 --- a/web/src/lib/colorDraw.ts +++ b/web/src/lib/color/colorDraw.ts @@ -1,5 +1,5 @@ import { hexFromArgb, TonalPalette } from '@material/material-color-utilities'; -import { gradientOf } from './roles'; +import { gradientOf } from '@/lib/space/roles'; export interface DrawnColor { label: string; color: string } diff --git a/web/src/lib/colorMath.test.ts b/web/src/lib/color/colorMath.test.ts similarity index 100% rename from web/src/lib/colorMath.test.ts rename to web/src/lib/color/colorMath.test.ts diff --git a/web/src/lib/colorMath.ts b/web/src/lib/color/colorMath.ts similarity index 100% rename from web/src/lib/colorMath.ts rename to web/src/lib/color/colorMath.ts diff --git a/web/src/lib/download.ts b/web/src/lib/files/download.ts similarity index 58% rename from web/src/lib/download.ts rename to web/src/lib/files/download.ts index 9e02131..29a57f0 100644 --- a/web/src/lib/download.ts +++ b/web/src/lib/files/download.ts @@ -6,12 +6,3 @@ export function saveBlob(blob: Blob, name: string): void { a.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000); } - -export async function copyText(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} diff --git a/web/src/lib/explorer.test.ts b/web/src/lib/files/explorer.test.ts similarity index 100% rename from web/src/lib/explorer.test.ts rename to web/src/lib/files/explorer.test.ts diff --git a/web/src/lib/explorer.ts b/web/src/lib/files/explorer.ts similarity index 100% rename from web/src/lib/explorer.ts rename to web/src/lib/files/explorer.ts diff --git a/web/src/lib/fileKind.test.ts b/web/src/lib/files/fileKind.test.ts similarity index 100% rename from web/src/lib/fileKind.test.ts rename to web/src/lib/files/fileKind.test.ts diff --git a/web/src/lib/fileKind.ts b/web/src/lib/files/fileKind.ts similarity index 93% rename from web/src/lib/fileKind.ts rename to web/src/lib/files/fileKind.ts index 4ae9fd4..106ddf8 100644 --- a/web/src/lib/fileKind.ts +++ b/web/src/lib/files/fileKind.ts @@ -1,4 +1,4 @@ -import type { PreviewKind } from '@/core-bridge/types'; +import type { PreviewKind } from '@/domain/types'; const ARCHIVE_EXT = /\.(zip|tar|gz|tgz|7z|rar|xz|bz2)$/i; const TEXT_EXT = /\.(txt|md|log|json|csv|ya?ml|toml|ini|xml|js|ts|tsx|css|html|py|rs|go|c|cpp|h|sh)$/i; diff --git a/web/src/lib/filter.test.ts b/web/src/lib/files/filter.test.ts similarity index 94% rename from web/src/lib/filter.test.ts rename to web/src/lib/files/filter.test.ts index b3176e3..d3e56c9 100644 --- a/web/src/lib/filter.test.ts +++ b/web/src/lib/files/filter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { FileItem, TextItem } from '@/core-bridge/types'; +import type { FileItem, TextItem } from '@/domain/types'; import { isFiltering, matchesFilter, NO_FILTER } from './filter'; const base = { ts: 1, deviceId: 'self', state: { kind: 'complete' } as const }; diff --git a/web/src/lib/filter.ts b/web/src/lib/files/filter.ts similarity index 95% rename from web/src/lib/filter.ts rename to web/src/lib/files/filter.ts index cbdb5fe..837111d 100644 --- a/web/src/lib/filter.ts +++ b/web/src/lib/files/filter.ts @@ -1,4 +1,4 @@ -import type { Item } from '@/core-bridge/types'; +import type { Item } from '@/domain/types'; export type KindFilter = 'all' | 'text' | 'file' | 'image' | 'video' | 'archive'; diff --git a/web/src/lib/useBackToClose.ts b/web/src/lib/hooks/useBackToClose.ts similarity index 100% rename from web/src/lib/useBackToClose.ts rename to web/src/lib/hooks/useBackToClose.ts diff --git a/web/src/lib/useGlobalPaste.ts b/web/src/lib/hooks/useGlobalPaste.ts similarity index 93% rename from web/src/lib/useGlobalPaste.ts rename to web/src/lib/hooks/useGlobalPaste.ts index 9452c69..d2ec9ed 100644 --- a/web/src/lib/useGlobalPaste.ts +++ b/web/src/lib/hooks/useGlobalPaste.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import type { ClipboardPayload } from './clipboard'; +import type { ClipboardPayload } from '@/lib/platform/clipboard'; function isEditable(t: EventTarget | null): boolean { return t instanceof HTMLElement && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA'); diff --git a/web/src/lib/useMediaQuery.ts b/web/src/lib/hooks/useMediaQuery.ts similarity index 100% rename from web/src/lib/useMediaQuery.ts rename to web/src/lib/hooks/useMediaQuery.ts diff --git a/web/src/lib/channelLink.test.ts b/web/src/lib/links/channelLink.test.ts similarity index 100% rename from web/src/lib/channelLink.test.ts rename to web/src/lib/links/channelLink.test.ts diff --git a/web/src/lib/channelLink.ts b/web/src/lib/links/channelLink.ts similarity index 100% rename from web/src/lib/channelLink.ts rename to web/src/lib/links/channelLink.ts diff --git a/web/src/lib/shareLink.test.ts b/web/src/lib/links/shareLink.test.ts similarity index 100% rename from web/src/lib/shareLink.test.ts rename to web/src/lib/links/shareLink.test.ts diff --git a/web/src/lib/shareLink.ts b/web/src/lib/links/shareLink.ts similarity index 100% rename from web/src/lib/shareLink.ts rename to web/src/lib/links/shareLink.ts diff --git a/web/src/lib/buildInfo.ts b/web/src/lib/platform/buildInfo.ts similarity index 100% rename from web/src/lib/buildInfo.ts rename to web/src/lib/platform/buildInfo.ts diff --git a/web/src/lib/clipboard.ts b/web/src/lib/platform/clipboard.ts similarity index 76% rename from web/src/lib/clipboard.ts rename to web/src/lib/platform/clipboard.ts index d61949b..2492027 100644 --- a/web/src/lib/clipboard.ts +++ b/web/src/lib/platform/clipboard.ts @@ -3,6 +3,16 @@ export interface ClipboardPayload { text: string; } +/** 把文字写进剪贴板。失败(没有权限、非安全上下文)返回 false */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + /** 读取剪贴板(需要用户手势与权限)。失败或被拒绝返回 null */ export async function readClipboard(): Promise { try { diff --git a/web/src/lib/theme.ts b/web/src/lib/platform/theme.ts similarity index 100% rename from web/src/lib/theme.ts rename to web/src/lib/platform/theme.ts diff --git a/web/src/lib/qr.fixtures.ts b/web/src/lib/qr/qr.fixtures.ts similarity index 100% rename from web/src/lib/qr.fixtures.ts rename to web/src/lib/qr/qr.fixtures.ts diff --git a/web/src/lib/qr.test.ts b/web/src/lib/qr/qr.test.ts similarity index 100% rename from web/src/lib/qr.test.ts rename to web/src/lib/qr/qr.test.ts diff --git a/web/src/lib/qr.ts b/web/src/lib/qr/qr.ts similarity index 100% rename from web/src/lib/qr.ts rename to web/src/lib/qr/qr.ts diff --git a/web/src/lib/roles.ts b/web/src/lib/space/roles.ts similarity index 92% rename from web/src/lib/roles.ts rename to web/src/lib/space/roles.ts index 4f6a987..787553c 100644 --- a/web/src/lib/roles.ts +++ b/web/src/lib/space/roles.ts @@ -1,5 +1,5 @@ import type { CSSProperties } from 'react'; -import type { RoleInfo } from '@/core-bridge/types'; +import type { RoleInfo } from '@/domain/types'; /** 早期的预设色名(老数据里可能还有,渐变的色值在 tokens.css 的 --grad-*);新的颜色都是自定义的 */ export const ROLE_COLORS = ['gray', 'blue', 'green', 'amber', 'red', 'purple', 'sunset', 'ocean', 'aurora', 'candy', 'gold', 'rainbow'] as const; @@ -84,10 +84,3 @@ export function badgesOf(roles: readonly RoleInfo[], username: string | undefine } return { prefix, suffix }; } - -export function permissionsOf(roles: readonly RoleInfo[], username: string | undefined, owner: boolean): ReadonlySet { - if (owner) return new Set(PERMISSIONS.map((item) => item.id)); - const granted = new Set(); - if (username) for (const role of roles) if (role.members.includes(username)) for (const id of role.permissions) granted.add(id); - return granted; -} diff --git a/web/src/lib/sender.ts b/web/src/lib/space/sender.ts similarity index 87% rename from web/src/lib/sender.ts rename to web/src/lib/space/sender.ts index 75a9960..a6abfac 100644 --- a/web/src/lib/sender.ts +++ b/web/src/lib/space/sender.ts @@ -1,4 +1,4 @@ -import type { SenderInfo } from '@/core-bridge/types'; +import type { SenderInfo } from '@/domain/types'; /** 找不到作者信息时的显示名(频道只有登录账号,正常不会用到);设备只是账号的属性,不作为发送者展示 */ export const UNKNOWN_SENDER_LABEL = '未知成员'; diff --git a/web/src/lib/settingOptions.test.ts b/web/src/lib/space/settingOptions.test.ts similarity index 100% rename from web/src/lib/settingOptions.test.ts rename to web/src/lib/space/settingOptions.test.ts diff --git a/web/src/lib/settingOptions.ts b/web/src/lib/space/settingOptions.ts similarity index 100% rename from web/src/lib/settingOptions.ts rename to web/src/lib/space/settingOptions.ts diff --git a/web/src/lib/format.ts b/web/src/lib/text/format.ts similarity index 100% rename from web/src/lib/format.ts rename to web/src/lib/text/format.ts diff --git a/web/src/lib/inline.test.ts b/web/src/lib/text/inline.test.ts similarity index 100% rename from web/src/lib/inline.test.ts rename to web/src/lib/text/inline.test.ts diff --git a/web/src/lib/inline.ts b/web/src/lib/text/inline.ts similarity index 95% rename from web/src/lib/inline.ts rename to web/src/lib/text/inline.ts index 8decd27..f3129c0 100644 --- a/web/src/lib/inline.ts +++ b/web/src/lib/text/inline.ts @@ -1,4 +1,4 @@ -import { isLongText } from './fileKind'; +import { isLongText } from '@/lib/files/fileKind'; /** 不大的视频才直接放进列表播放 */ export const VIDEO_INLINE_MAX = 25 * 1024 * 1024; diff --git a/web/src/lib/inlineMarkdown.test.tsx b/web/src/lib/text/inlineMarkdown.test.tsx similarity index 100% rename from web/src/lib/inlineMarkdown.test.tsx rename to web/src/lib/text/inlineMarkdown.test.tsx diff --git a/web/src/lib/inlineMarkdown.tsx b/web/src/lib/text/inlineMarkdown.tsx similarity index 100% rename from web/src/lib/inlineMarkdown.tsx rename to web/src/lib/text/inlineMarkdown.tsx diff --git a/web/src/main.tsx b/web/src/main.tsx index a36ffcb..19d645a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,14 +1,14 @@ -import { installSubtleFallback } from '@/core-bridge/subtleFallback'; -import { forcedSoftCrypto } from '@/core-bridge/workerClient'; +import { installSubtleFallback } from '@/worker/crypto/subtleFallback'; +import { forcedSoftCrypto } from '@/services/workerClient'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from '@/app/App'; import { takeLinkFromLocation } from '@/app/entry'; -import { useChannels } from '@/store/channels'; -import { useShares } from '@/store/shares'; -import { initTheme } from '@/lib/theme'; -import { useAccount } from '@/store/account'; -import { useServer } from '@/store/server'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; +import { initTheme } from '@/lib/platform/theme'; +import { useAccount } from '@/store/account/account'; +import { useServer } from '@/store/app/server'; import '@/styles/tokens.css'; import '@/styles/base.css'; import '@/styles/transitions.css'; diff --git a/web/src/core-bridge/account.ts b/web/src/services/account.ts similarity index 90% rename from web/src/core-bridge/account.ts rename to web/src/services/account.ts index 20fff28..6c2ad08 100644 --- a/web/src/core-bridge/account.ts +++ b/web/src/services/account.ts @@ -1,8 +1,8 @@ import { callWorker } from './workerClient'; -import type { AccountDeviceBody, InvitationBody } from './generated/api'; -import type { AvatarChange, PersonalProfile } from './protocol/account'; +import type { AccountDeviceBody, InvitationBody } from '@/api/generated'; +import type { AvatarChange, PersonalProfile } from '@/domain/account'; -const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +import { API_BASE } from './apiBase'; export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string } export type AccountDevice = AccountDeviceBody; diff --git a/web/src/services/apiBase.ts b/web/src/services/apiBase.ts new file mode 100644 index 0000000..2769a98 --- /dev/null +++ b/web/src/services/apiBase.ts @@ -0,0 +1,2 @@ +/** 主后端的 API 根地址:开发时走 Vite 代理,部署时可以用环境变量指向别的域名。全站只在这里读一次 */ +export const API_BASE: string = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; diff --git a/web/src/core-bridge/captcha.ts b/web/src/services/captcha.ts similarity index 100% rename from web/src/core-bridge/captcha.ts rename to web/src/services/captcha.ts diff --git a/web/src/core-bridge/channelAccess.ts b/web/src/services/channelAccess.ts similarity index 58% rename from web/src/core-bridge/channelAccess.ts rename to web/src/services/channelAccess.ts index 8a5f39a..b5d5a66 100644 --- a/web/src/core-bridge/channelAccess.ts +++ b/web/src/services/channelAccess.ts @@ -1,8 +1,8 @@ import { callWorker } from './workerClient'; -import type { ChannelInfo } from './types'; -import type { ParsedChannelLink } from '@/lib/channelLink'; -import type { MemberBody } from './generated/api'; -const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +import type { ChannelInfo } from '@/domain/types'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; +import type { MemberBody } from '@/api/generated'; +import { API_BASE as apiBase } from './apiBase'; export type ChannelMember = MemberBody; export const channelAccessApi = { members: (id: string) => callWorker('channelMembers', { id, apiBase }), diff --git a/web/src/core-bridge/instance.ts b/web/src/services/index.ts similarity index 58% rename from web/src/core-bridge/instance.ts rename to web/src/services/index.ts index 3f6a926..eb6f9e6 100644 --- a/web/src/core-bridge/instance.ts +++ b/web/src/services/index.ts @@ -1,4 +1,4 @@ -import { realApi, realShareApi } from './real'; +import { realApi, realShareApi } from './live'; export const api = realApi; export const shareApi = realShareApi; diff --git a/web/src/core-bridge/real.ts b/web/src/services/live.ts similarity index 89% rename from web/src/core-bridge/real.ts rename to web/src/services/live.ts index 6074ef4..3cc30a3 100644 --- a/web/src/core-bridge/real.ts +++ b/web/src/services/live.ts @@ -1,12 +1,12 @@ -import type { ParsedChannelLink } from '@/lib/channelLink'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; import type { AccountInvitation } from './account'; -import type { ParsedShareLink } from '@/lib/shareLink'; -import type { WorkspaceApi } from './api'; -import type { ShareApi } from './shareApi'; +import type { ParsedShareLink } from '@/lib/links/shareLink'; +import type { WorkspaceApi } from './workspace'; +import type { ShareApi } from './shares'; import { callWorker, subscribeWorker } from './workerClient'; -import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileDomainOptions, FileDomainUsage, Share, ShareEvent, ShareSettings, WorkspaceEvent } from '@/domain/types'; -const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +import { API_BASE } from './apiBase'; function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) => void): void { onEvent({ t: 'connection', state: 'offline' }); @@ -39,7 +39,9 @@ export const realApi: WorkspaceApi = { sendText: (text, threadId, localId) => callWorker('sendText', { id: currentChannelId(), text, threadId, localId, apiBase: API_BASE }), sendFiles: (files, options, threadId, folderId, localIds) => callWorker('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, apiBase: API_BASE }), cancelUpload: (localId) => callWorker('cancelUpload', { id: currentChannelId(), localId, apiBase: API_BASE }), - createThread: (name, groupId) => callWorker('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }), + fileOptions: () => callWorker('fileOptions', { id: currentChannelId(), apiBase: API_BASE }), + fileUsage: (threadId) => callWorker('fileUsage', { id: currentChannelId(), threadId, apiBase: API_BASE }), + createThread: (name, groupId, fileQuota) => callWorker('createThread', { id: currentChannelId(), name, groupId, fileQuota, apiBase: API_BASE }), createGroup: (name) => callWorker('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }), createRole: (role) => callWorker('createRole', { id: currentChannelId(), role, apiBase: API_BASE }), updateRole: (roleId, role) => callWorker('updateRole', { id: currentChannelId(), roleId, role, apiBase: API_BASE }), diff --git a/web/src/core-bridge/passkeys.ts b/web/src/services/passkeys.ts similarity index 96% rename from web/src/core-bridge/passkeys.ts rename to web/src/services/passkeys.ts index aba29d7..3b89d7b 100644 --- a/web/src/core-bridge/passkeys.ts +++ b/web/src/services/passkeys.ts @@ -1,8 +1,8 @@ import { callWorker } from './workerClient'; import type { AccountStatus } from './account'; -import type { PasskeyOptionsBody, PasskeyInfoBody } from './generated/api'; +import type { PasskeyOptionsBody, PasskeyInfoBody } from '@/api/generated'; -const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +import { API_BASE as apiBase } from './apiBase'; const call = (operation: string, payload: object = {}) => callWorker(operation, { ...payload, apiBase }); export const passkeysAvailable = () => typeof PublicKeyCredential !== 'undefined' && window.isSecureContext; const decode = (value: string): Uint8Array => Uint8Array.from(atob(value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')), c => c.charCodeAt(0)); diff --git a/web/src/core-bridge/server.ts b/web/src/services/server.ts similarity index 53% rename from web/src/core-bridge/server.ts rename to web/src/services/server.ts index 30c6f16..7616d8e 100644 --- a/web/src/core-bridge/server.ts +++ b/web/src/services/server.ts @@ -1,5 +1,5 @@ import { callWorker } from './workerClient'; -import type { ServerInfoBody } from './generated/api'; +import type { ServerInfoBody } from '@/api/generated'; -const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1'; +import { API_BASE as apiBase } from './apiBase'; export const serverApi = { info: () => callWorker('serverInfo', { apiBase }) }; diff --git a/web/src/core-bridge/shareApi.ts b/web/src/services/shares.ts similarity index 95% rename from web/src/core-bridge/shareApi.ts rename to web/src/services/shares.ts index e92589b..3af9f93 100644 --- a/web/src/core-bridge/shareApi.ts +++ b/web/src/services/shares.ts @@ -1,5 +1,5 @@ -import type { ParsedShareLink } from '@/lib/shareLink'; -import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types'; +import type { ParsedShareLink } from '@/lib/links/shareLink'; +import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from '@/domain/types'; /** * 便捷分享的后端边界。和空间分开:分享没有设备、便签、冲突与历史,只有 diff --git a/web/src/core-bridge/streamClient.ts b/web/src/services/streamClient.ts similarity index 100% rename from web/src/core-bridge/streamClient.ts rename to web/src/services/streamClient.ts diff --git a/web/src/core-bridge/workerClient.ts b/web/src/services/workerClient.ts similarity index 91% rename from web/src/core-bridge/workerClient.ts rename to web/src/services/workerClient.ts index d3a1b53..fd2dc31 100644 --- a/web/src/core-bridge/workerClient.ts +++ b/web/src/services/workerClient.ts @@ -1,4 +1,4 @@ -import type { ShareEvent, SpaceEvent, WorkspaceEvent } from './types'; +import type { ShareEvent, SpaceEvent, WorkspaceEvent } from '@/domain/types'; type BridgeEvent = WorkspaceEvent | ShareEvent | SpaceEvent; type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void }; @@ -14,7 +14,7 @@ export function forcedSoftCrypto(): boolean { } function getWorker(): Worker { - worker ??= new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', name: forcedSoftCrypto() ? 'webdrop-crypto softcrypto' : 'webdrop-crypto' }); + worker ??= new Worker(new URL('@/worker/main.ts', import.meta.url), { type: 'module', name: forcedSoftCrypto() ? 'webdrop-crypto softcrypto' : 'webdrop-crypto' }); worker.onmessage = (message: MessageEvent) => { const reply = message.data; if ('requestId' in reply) { diff --git a/web/src/core-bridge/api.ts b/web/src/services/workspace.ts similarity index 81% rename from web/src/core-bridge/api.ts rename to web/src/services/workspace.ts index 9dbb0e6..d884393 100644 --- a/web/src/core-bridge/api.ts +++ b/web/src/services/workspace.ts @@ -1,7 +1,7 @@ -import type { ParsedChannelLink } from '@/lib/channelLink'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; import type { AccountInvitation } from './account'; -import type { WorkspaceRoleRequest } from './generated/api'; -import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types'; +import type { WorkspaceRoleRequest } from '@/api/generated'; +import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, CreateOptions, FileDomainOptions, FileDomainUsage, FileUploadOptions, WorkspaceEvent } from '@/domain/types'; /** * 常驻空间的 Worker 边界;业务结构来自后端,资源加解密由 @webdrop/core 处理。 @@ -29,7 +29,12 @@ export interface WorkspaceApi { sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise; /** 取消排队中或上传中的文件;返回是否找到这个任务 */ cancelUpload(localId: string): Promise; - createThread(name: string, groupId?: string): Promise; + /** 文件服务公布的选项(创建频道时可选的文件空间档位);服务端没开文件服务时为 undefined */ + fileOptions(): Promise; + /** 频道文件域的容量和已用字节 */ + fileUsage(threadId: string): Promise; + /** fileQuota:创建频道时分配给它的文件空间(字节),不填用文件服务的默认 */ + createThread(name: string, groupId?: string, fileQuota?: number): Promise; createGroup(name: string): Promise; renameGroup(id: string, name: string): Promise; deleteGroup(id: string): Promise; diff --git a/web/src/store/account.ts b/web/src/store/account/account.ts similarity index 91% rename from web/src/store/account.ts rename to web/src/store/account/account.ts index 0071b8b..6eab4b8 100644 --- a/web/src/store/account.ts +++ b/web/src/store/account/account.ts @@ -1,14 +1,14 @@ import { create } from 'zustand'; -import { accountApi } from '@/core-bridge/account'; -import type { AccountStatus } from '@/core-bridge/account'; -import type { AvatarChange } from '@/core-bridge/protocol/account'; -import { useChannels } from './channels'; -import { useShares } from './shares'; -import { useWorkspace } from './workspace'; -import { initialData } from './reduce'; -import { useShareSession } from './shareSession'; -import { initialShareData } from './shareReduce'; -import { loginWithPasskey, passkeyApi } from '@/core-bridge/passkeys'; +import { accountApi } from '@/services/account'; +import type { AccountStatus } from '@/services/account'; +import type { AvatarChange } from '@/domain/account'; +import { useChannels } from '@/store/space/channels'; +import { useShares } from '@/store/share/shares'; +import { useWorkspace } from '@/store/space/workspace'; +import { initialData } from '@/store/space/reduce'; +import { useShareSession } from '@/store/share/shareSession'; +import { initialShareData } from '@/store/share/shareReduce'; +import { loginWithPasskey, passkeyApi } from '@/services/passkeys'; interface AccountState extends AccountStatus { busy: boolean; diff --git a/web/src/store/invitations.ts b/web/src/store/account/invitations.ts similarity index 76% rename from web/src/store/invitations.ts rename to web/src/store/account/invitations.ts index b8e8e03..c67cd37 100644 --- a/web/src/store/invitations.ts +++ b/web/src/store/account/invitations.ts @@ -1,8 +1,8 @@ -import { accountApi, type AccountInvitation } from '@/core-bridge/account'; -import { errorMessage } from '@/core-bridge/errors'; +import { accountApi, type AccountInvitation } from '@/services/account'; +import { errorMessage } from '@/api/errors'; import { channelPath, navigate } from '@/app/navigation'; -import { useChannels } from './channels'; -import { useToast } from './toast'; +import { useChannels } from '@/store/space/channels'; +import { useToast } from '@/store/app/toast'; export function invitationLabel(row: AccountInvitation): string { return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`; diff --git a/web/src/store/server.ts b/web/src/store/app/server.ts similarity index 90% rename from web/src/store/server.ts rename to web/src/store/app/server.ts index ef08a02..d2d4b60 100644 --- a/web/src/store/server.ts +++ b/web/src/store/app/server.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { useEffect } from 'react'; -import { serverApi } from '@/core-bridge/server'; -import type { ServerInfoBody } from '@/core-bridge/generated/api'; +import { serverApi } from '@/services/server'; +import type { ServerInfoBody } from '@/api/generated'; interface ServerState { info?: ServerInfoBody; diff --git a/web/src/store/toast.ts b/web/src/store/app/toast.ts similarity index 100% rename from web/src/store/toast.ts rename to web/src/store/app/toast.ts diff --git a/web/src/store/shareReduce.test.ts b/web/src/store/share/shareReduce.test.ts similarity index 95% rename from web/src/store/shareReduce.test.ts rename to web/src/store/share/shareReduce.test.ts index 3ee2faf..5880c27 100644 --- a/web/src/store/shareReduce.test.ts +++ b/web/src/store/share/shareReduce.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { Share, TextItem } from '@/core-bridge/types'; +import type { Share, TextItem } from '@/domain/types'; import { initialShareData, reduceShare } from './shareReduce'; const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, state: { kind: 'complete' }, text: id }); diff --git a/web/src/store/shareReduce.ts b/web/src/store/share/shareReduce.ts similarity index 95% rename from web/src/store/shareReduce.ts rename to web/src/store/share/shareReduce.ts index cb19ee7..bde6129 100644 --- a/web/src/store/shareReduce.ts +++ b/web/src/store/share/shareReduce.ts @@ -1,4 +1,4 @@ -import type { Item, Share, ShareEvent } from '@/core-bridge/types'; +import type { Item, Share, ShareEvent } from '@/domain/types'; export interface ShareData { ready: boolean; diff --git a/web/src/store/shareSession.ts b/web/src/store/share/shareSession.ts similarity index 95% rename from web/src/store/shareSession.ts rename to web/src/store/share/shareSession.ts index 8b30059..26c0eb2 100644 --- a/web/src/store/shareSession.ts +++ b/web/src/store/share/shareSession.ts @@ -1,5 +1,5 @@ import { create } from 'zustand'; -import { shareApi } from '@/core-bridge/instance'; +import { shareApi } from '@/services'; import { initialShareData, reduceShare, type ShareData } from './shareReduce'; interface Actions { diff --git a/web/src/store/shares.ts b/web/src/store/share/shares.ts similarity index 90% rename from web/src/store/shares.ts rename to web/src/store/share/shares.ts index c760df4..769f31c 100644 --- a/web/src/store/shares.ts +++ b/web/src/store/share/shares.ts @@ -1,9 +1,9 @@ import { create } from 'zustand'; -import { shareApi } from '@/core-bridge/instance'; -import type { Share, ShareSettings } from '@/core-bridge/types'; -import type { ClipboardPayload } from '@/lib/clipboard'; -import type { ParsedShareLink } from '@/lib/shareLink'; -import { useServer } from './server'; +import { shareApi } from '@/services'; +import type { Share, ShareSettings } from '@/domain/types'; +import type { ClipboardPayload } from '@/lib/platform/clipboard'; +import type { ParsedShareLink } from '@/lib/links/shareLink'; +import { useServer } from '@/store/app/server'; interface SharesState { /** 我的分享:服务端状态及本机保存的访问凭据。 */ diff --git a/web/src/store/channels.ts b/web/src/store/space/channels.ts similarity index 92% rename from web/src/store/channels.ts rename to web/src/store/space/channels.ts index b6dc2e2..00ea808 100644 --- a/web/src/store/channels.ts +++ b/web/src/store/space/channels.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; -import { api } from '@/core-bridge/instance'; -import type { ChannelInfo, CreateOptions } from '@/core-bridge/types'; -import type { ParsedChannelLink } from '@/lib/channelLink'; +import { api } from '@/services'; +import type { ChannelInfo, CreateOptions } from '@/domain/types'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; interface ChannelsState { /** 服务端空间列表的界面投影,IndexedDB 提供离线缓存。 */ diff --git a/web/src/store/readState.ts b/web/src/store/space/readState.ts similarity index 100% rename from web/src/store/readState.ts rename to web/src/store/space/readState.ts diff --git a/web/src/store/reduce.test.ts b/web/src/store/space/reduce.test.ts similarity index 96% rename from web/src/store/reduce.test.ts rename to web/src/store/space/reduce.test.ts index 80e0699..fb3b1b1 100644 --- a/web/src/store/reduce.test.ts +++ b/web/src/store/space/reduce.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { TextItem } from '@/core-bridge/types'; +import type { TextItem } from '@/domain/types'; import { initialData, reduce } from './reduce'; const text = (id: string, ts: number, body = id): TextItem => ({ @@ -9,7 +9,7 @@ const text = (id: string, ts: number, body = id): TextItem => ({ describe('reduce', () => { it('renders server structure and preserves server author and location across late decrypted content', () => { const stateEvent = { t: 'structure' as const, state: { revision: 1, roles: [], rolesSeeded: true, - channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true, fileBytes: 0 }], + channels: [{ id: 'channel-a', name: '常规', groupId: 'group-a', createdAt: 1, createdBy: 'alice', chatEpoch: 1, filesEpoch: 1, chatHistory: true, filesHistory: true, fileBytes: 0, fileQuota: 0 }], groups: [{ id: 'group-a', name: '项目', createdAt: 1 }], folders: [{ id: 'folder-a', name: '目录', threadId: 'channel-a' }], locations: [{ itemId: 'a', threadId: 'channel-a', folderId: 'folder-a', authorAccount: 'alice' }] }, diff --git a/web/src/store/reduce.ts b/web/src/store/space/reduce.ts similarity index 95% rename from web/src/store/reduce.ts rename to web/src/store/space/reduce.ts index fa22297..8eac31e 100644 --- a/web/src/store/reduce.ts +++ b/web/src/store/space/reduce.ts @@ -9,13 +9,15 @@ import type { Snapshot, ThreadInfo, WorkspaceEvent, -} from '@/core-bridge/types'; +} from '@/domain/types'; export interface WorkspaceData { ready: boolean; connection: ConnectionState; channel: ChannelInfo | null; threads: ThreadInfo[]; + /** 后端指定的默认频道 */ + defaultThreadId: string | undefined; /** 没有密钥、不能读写的频道 id */ lockedThreads: string[]; groups: ChannelGroupInfo[]; @@ -37,6 +39,7 @@ export const initialData: WorkspaceData = { connection: 'offline', channel: null, threads: [], + defaultThreadId: undefined, lockedThreads: [], groups: [], roles: [], @@ -81,7 +84,7 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData { const author = fresh(authors[id] ?? item.author); return [id, { ...item, ...(locations[id] ?? {}), ...(author ? { author } : {}) }]; })); - return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [], + return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups, roles: e.state.roles ?? [], defaultThreadId: e.state.defaultChannelId ?? undefined, folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId }; } case 'access': diff --git a/web/src/store/space/useCapabilities.ts b/web/src/store/space/useCapabilities.ts new file mode 100644 index 0000000..4f799b9 --- /dev/null +++ b/web/src/store/space/useCapabilities.ts @@ -0,0 +1,9 @@ +import { useMemo } from 'react'; +import { capabilitiesOf, type Capabilities } from '@/domain/capabilities'; +import { useWorkspace } from './workspace'; + +/** 当前空间里调用者能做什么(后端给的能力,见 domain/capabilities) */ +export function useCapabilities(): Capabilities { + const channel = useWorkspace((s) => s.channel); + return useMemo(() => capabilitiesOf(channel ?? undefined), [channel]); +} diff --git a/web/src/store/workspace.ts b/web/src/store/space/workspace.ts similarity index 86% rename from web/src/store/workspace.ts rename to web/src/store/space/workspace.ts index 129f148..4615d86 100644 --- a/web/src/store/workspace.ts +++ b/web/src/store/space/workspace.ts @@ -1,10 +1,11 @@ +import { videoPoster } from '@/features/item/hooks/videoPoster'; import { create } from 'zustand'; -import { api } from '@/core-bridge/instance'; -import type { ChannelSettingsPatch, FileUploadOptions } from '@/core-bridge/types'; -import type { FileItem, Item } from '@/core-bridge/types'; -import { previewKindOf } from '@/lib/fileKind'; -import { errorMessage } from '@/core-bridge/errors'; -import { useAccount } from './account'; +import { api } from '@/services'; +import type { ChannelSettingsPatch, FileUploadOptions } from '@/domain/types'; +import type { FileItem, Item } from '@/domain/types'; +import { previewKindOf } from '@/lib/files/fileKind'; +import { errorMessage } from '@/api/errors'; +import { useAccount } from '@/store/account/account'; import { useChannels } from './channels'; import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce'; @@ -67,7 +68,7 @@ interface Actions { cancelUpload: (localId: string, itemId?: string) => Promise; updateChannelSettings: (patch: ChannelSettingsPatch) => Promise; sendFiles: (files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string) => Promise; - createThread: (name: string, groupId?: string) => Promise; + createThread: (name: string, groupId?: string, fileQuota?: number) => Promise; createGroup: typeof api.createGroup; renameGroup: typeof api.renameGroup; deleteGroup: typeof api.deleteGroup; @@ -119,18 +120,21 @@ export const useWorkspace = create()(() => ({ }, updateChannelSettings: (patch) => api.updateChannelSettings(patch), sendFiles: async (files, options, threadId, folderId) => { - const ids = files.map((file) => { + // 视频先截一张封面(很快,截不出来就没有封面),随文件目录一起同步 + const posters = await Promise.all(files.map((file) => videoPoster(file))); + const ids = files.map((file, index) => { const id = newLocalId(); - pendingJobs.set(id, { kind: 'file', file, ...(options ? { options } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}) }); + const jobOptions = posters[index] ? { ...options, poster: posters[index]! } as FileUploadOptions : options; + pendingJobs.set(id, { kind: 'file', file, ...(jobOptions ? { options: jobOptions } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}) }); const item: FileItem = { id, kind: 'file', localId: id, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: null, - preview: previewKindOf(file.type, file.name), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'queued' } }; + preview: previewKindOf(file.type, file.name), ...(posters[index] ? { poster: posters[index]! } : {}), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'queued' } }; putLocal(item); return id; }); // 全部交给 worker 的上传队列:一次传一个,其余显示「排队中」 await Promise.all(ids.map(deliver)); }, - createThread: (name, groupId) => api.createThread(name, groupId), + createThread: (name, groupId, fileQuota) => api.createThread(name, groupId, fileQuota), createGroup: (name) => api.createGroup(name), renameGroup: (id, name) => api.renameGroup(id, name), deleteGroup: (id) => api.deleteGroup(id), diff --git a/web/src/ui/ContextMenu.tsx b/web/src/ui/ContextMenu.tsx index b9f067c..bc965cc 100644 --- a/web/src/ui/ContextMenu.tsx +++ b/web/src/ui/ContextMenu.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { fitFloatingPoint } from '@/lib/explorer'; +import { fitFloatingPoint } from '@/lib/files/explorer'; import { Icon, type IconName } from './Icon'; import styles from './ContextMenu.module.css'; diff --git a/web/src/ui/CopyButton.tsx b/web/src/ui/CopyButton.tsx index 4b3231a..a9fe886 100644 --- a/web/src/ui/CopyButton.tsx +++ b/web/src/ui/CopyButton.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { copyText } from '@/lib/download'; +import { copyText } from '@/lib/platform/clipboard'; import { Button } from './Button'; interface Props { diff --git a/web/src/ui/Drawer.tsx b/web/src/ui/Drawer.tsx index 2ae7f14..8a32582 100644 --- a/web/src/ui/Drawer.tsx +++ b/web/src/ui/Drawer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { useBackToClose } from '@/lib/useBackToClose'; +import { useBackToClose } from '@/lib/hooks/useBackToClose'; import styles from './Drawer.module.css'; interface Props { label: string; onClose: () => void; children: ReactNode } diff --git a/web/src/ui/QrCode.tsx b/web/src/ui/QrCode.tsx index bf43ee3..a964b35 100644 --- a/web/src/ui/QrCode.tsx +++ b/web/src/ui/QrCode.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { encodeQr } from '@/lib/qr'; +import { encodeQr } from '@/lib/qr/qr'; import styles from './QrCode.module.css'; const QUIET = 4; // 码周围的静区(模块数),规范要求 ≥ 4 diff --git a/web/src/ui/Sheet.tsx b/web/src/ui/Sheet.tsx index 1076bca..306dabf 100644 --- a/web/src/ui/Sheet.tsx +++ b/web/src/ui/Sheet.tsx @@ -1,6 +1,6 @@ import { useEffect, useId, useRef, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { useBackToClose } from '@/lib/useBackToClose'; +import { useBackToClose } from '@/lib/hooks/useBackToClose'; import { Button } from './Button'; import styles from './Sheet.module.css'; diff --git a/web/src/ui/ThemeMenu.tsx b/web/src/ui/ThemeMenu.tsx index 81a52e6..342a1f7 100644 --- a/web/src/ui/ThemeMenu.tsx +++ b/web/src/ui/ThemeMenu.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import { setTheme, useTheme, type ThemePref } from '@/lib/theme'; +import { setTheme, useTheme, type ThemePref } from '@/lib/platform/theme'; import { Button } from './Button'; import { Icon, type IconName } from './Icon'; import { Popover } from './Popover'; diff --git a/web/src/core-bridge/worker/account.ts b/web/src/worker/account/account.ts similarity index 94% rename from web/src/core-bridge/worker/account.ts rename to web/src/worker/account/account.ts index 42cef8a..229cda5 100644 --- a/web/src/core-bridge/worker/account.ts +++ b/web/src/worker/account/account.ts @@ -1,23 +1,26 @@ -import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database'; -import { apiFetch, jsonBody } from './http'; -import { clearResourceSessions, getSession } from './sessions'; +import { hasCode } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; +import { tokenRole } from '../runtime/sessions'; +import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../runtime/database'; +import { apiFetch, jsonBody } from '../runtime/http'; +import { clearResourceSessions, getSession } from '../runtime/sessions'; import { setAccountIdentity, requireAccount } from './accountSession'; import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto'; import { mergeVault, validateVault, type VaultProfile } from './accountVault'; -import { joinChannel } from './channelResources'; -import { openChannelGrant, peekChannelGrant, sealChannelGrant } from './channelGrant'; +import { joinChannel } from '../space/channelResources'; +import { openChannelGrant, peekChannelGrant, sealChannelGrant } from '../crypto/channelGrant'; import { recoverAccountChannels } from './accountChannels'; import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence'; -import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api'; +import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '@/api/generated'; import { loadPersonal, personalProfile } from './accountProfile'; import { primeAvatar } from './avatars'; -import type { AvatarChange } from '../protocol/account'; -import type { AccountProfile, AccountProfileRequest } from '../generated/api'; +import type { AvatarChange } from '@/domain/account'; +import type { AccountProfile, AccountProfileRequest } from '@/api/generated'; -import type { PersonalProfile } from '../protocol/account'; +import type { PersonalProfile } from '@/domain/account'; import { passkeyUnlockUsername, clearPasskeyState } from './passkeys'; -import { packageInvitationDomains } from './channelDataKeys'; +import { packageInvitationDomains } from '../crypto/channelDataKeys'; import { clearDeviceSigner, ensureDeviceSigning } from './accountSigning'; type VaultBody = AccountVaultBody; @@ -88,13 +91,13 @@ async function syncVault(): Promise { await persistSession(session, { vault, revision: result.revision }); break; } catch (error) { - if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error; + if (attempt === 2 || !hasCode(error, ErrorCode.Conflict)) throw error; } } } while (session.dirty && account === session); })().catch((error: unknown) => { if (account === session) session.error = error instanceof Error ? error.message : '同步失败'; - if (account === session && error instanceof Error && 'status' in error && error.status === 401) { + if (account === session && hasCode(error, ErrorCode.Unauthorized, ErrorCode.AccountRequired)) { clearDeviceSigner(); account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null); @@ -134,7 +137,7 @@ export function restoreAccount(): Promise { try { remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise); } catch (error) { - if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); await forgetLoginFields(); return; } + if (hasCode(error, ErrorCode.Unauthorized, ErrorCode.AccountRequired)) { await forgetAccount(); await forgetLoginFields(); return; } remote = saved; } try { @@ -304,7 +307,6 @@ export async function acceptInvitation(channelId: string): Promise { } export async function inviteChannelUser(channelId: string, input: string): Promise { requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId); - if (!session.can('owner')) throw new Error('只有空间主可以邀请用户'); const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') }) .then(r => r.json() as Promise); const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise); @@ -313,8 +315,8 @@ export async function inviteChannelUser(channelId: string, input: string): Promi await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) }); } export async function listChannelInvitations(channelId: string): Promise { - const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read') }).then((r) => r.json() as Promise); + const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders(tokenRole(session)) }).then((r) => r.json() as Promise); } export async function revokeChannelInvitation(channelId: string, input: string): Promise { - const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read') }); + const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders(tokenRole(session)) }); } diff --git a/web/src/core-bridge/worker/accountChannels.ts b/web/src/worker/account/accountChannels.ts similarity index 85% rename from web/src/core-bridge/worker/accountChannels.ts rename to web/src/worker/account/accountChannels.ts index 0ee7952..97f253e 100644 --- a/web/src/core-bridge/worker/accountChannels.ts +++ b/web/src/worker/account/accountChannels.ts @@ -1,12 +1,12 @@ import { ChannelSession } from '@webdrop/core'; -import { getResource, importResources, listResourceDeletes, type SavedResource } from '../database'; -import { openChannelGrant } from './channelGrant'; -import { makeLink } from './links'; -import { apiFetch, readJson } from './http'; +import { getResource, importResources, listResourceDeletes, type SavedResource } from '../runtime/database'; +import { openChannelGrant } from '../crypto/channelGrant'; +import { makeLink } from '../space/links'; +import { apiFetch, readJson } from '../runtime/http'; import type { InvitationKeyPair } from './accountCrypto'; -import type { ChannelWire } from './wire'; -import type { AccountChannelBody } from '../generated/api'; -import { channelCache } from './resourceMetadata'; +import type { ChannelWire } from '../runtime/wire'; +import type { AccountChannelBody } from '@/api/generated'; +import { channelCache } from '../space/resourceMetadata'; export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise { const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise); diff --git a/web/src/core-bridge/worker/accountCrypto.test.ts b/web/src/worker/account/accountCrypto.test.ts similarity index 100% rename from web/src/core-bridge/worker/accountCrypto.test.ts rename to web/src/worker/account/accountCrypto.test.ts diff --git a/web/src/core-bridge/worker/accountCrypto.ts b/web/src/worker/account/accountCrypto.ts similarity index 96% rename from web/src/core-bridge/worker/accountCrypto.ts rename to web/src/worker/account/accountCrypto.ts index 2786578..f921099 100644 --- a/web/src/core-bridge/worker/accountCrypto.ts +++ b/web/src/worker/account/accountCrypto.ts @@ -38,9 +38,9 @@ export async function openJson(value: string, key: CryptoKey, context = vaultLab return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain)); } catch { throw new Error('加密数据校验失败,已拒绝导入'); } } -export type { InvitationKeyPair } from '../protocol/account'; -import type { InvitationKeyPair } from '../protocol/account'; -import type { SigningKeyPair } from '../protocol/account'; +export type { InvitationKeyPair } from '@/domain/account'; +import type { InvitationKeyPair } from '@/domain/account'; +import type { SigningKeyPair } from '@/domain/account'; import { generateSigningKeyPair } from '@webdrop/core'; export async function generateInvitationKeys(): Promise { const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); diff --git a/web/src/core-bridge/worker/accountPersistence.ts b/web/src/worker/account/accountPersistence.ts similarity index 97% rename from web/src/core-bridge/worker/accountPersistence.ts rename to web/src/worker/account/accountPersistence.ts index a10f4b2..6b9b9b0 100644 --- a/web/src/core-bridge/worker/accountPersistence.ts +++ b/web/src/worker/account/accountPersistence.ts @@ -1,6 +1,6 @@ import { openDB, type DBSchema } from 'idb'; -import { apiPath } from './http'; -import type { PersonalProfile } from '../protocol/account'; +import { apiPath } from '../runtime/http'; +import type { PersonalProfile } from '@/domain/account'; import { decryptLogin, encryptLogin, type EncryptedLogin, type LoginFields } from './credentialCipher'; export interface RememberedAccount { diff --git a/web/src/core-bridge/worker/accountProfile.ts b/web/src/worker/account/accountProfile.ts similarity index 84% rename from web/src/core-bridge/worker/accountProfile.ts rename to web/src/worker/account/accountProfile.ts index 3f5b8fe..0f07f05 100644 --- a/web/src/core-bridge/worker/accountProfile.ts +++ b/web/src/worker/account/accountProfile.ts @@ -1,7 +1,7 @@ -import { apiFetch } from './http'; +import { apiFetch } from '../runtime/http'; import { fetchAvatar } from './avatars'; -import type { AccountProfile } from '../generated/api'; -import type { PersonalProfile } from '../protocol/account'; +import type { AccountProfile } from '@/api/generated'; +import type { PersonalProfile } from '@/domain/account'; export function personalProfile(value: AccountProfile): PersonalProfile { return { displayName: value.displayName, hasAvatar: value.hasAvatar || Boolean(value.avatar), ...(value.avatar ? { avatar: value.avatar } : {}), modifiedAt: value.modifiedAt }; diff --git a/web/src/core-bridge/worker/accountSession.ts b/web/src/worker/account/accountSession.ts similarity index 94% rename from web/src/core-bridge/worker/accountSession.ts rename to web/src/worker/account/accountSession.ts index c2f1ef0..bd3bc2b 100644 --- a/web/src/core-bridge/worker/accountSession.ts +++ b/web/src/worker/account/accountSession.ts @@ -1,4 +1,4 @@ -import type { PersonalProfile } from '../protocol/account'; +import type { PersonalProfile } from '@/domain/account'; let identity: { username: string; token: string } | undefined; let publicKey: JsonWebKey | undefined; let personal: PersonalProfile | undefined; diff --git a/web/src/core-bridge/worker/accountSigning.ts b/web/src/worker/account/accountSigning.ts similarity index 97% rename from web/src/core-bridge/worker/accountSigning.ts rename to web/src/worker/account/accountSigning.ts index 64ba963..f938a5e 100644 --- a/web/src/core-bridge/worker/accountSigning.ts +++ b/web/src/worker/account/accountSigning.ts @@ -1,9 +1,9 @@ import { contentEventPayload, frameManifestHash, fromBase64Url, generateSigningKeyPair, signStatement, toBase64Url, verifyStatement } from '@webdrop/core'; -import type { AccountDeviceSigningRequest } from '../generated/api'; +import type { AccountDeviceSigningRequest } from '@/api/generated'; import { b64 } from './accountCrypto'; -import { apiFetch } from './http'; +import { apiFetch } from '../runtime/http'; import { localAccountDevice, localDeviceSigningKeys, pinSigningRoot, saveLocalDeviceSigningKeys } from './accountPersistence'; -import type { SigningKeyPair } from '../protocol/account'; +import type { SigningKeyPair } from '@/domain/account'; interface ActiveSigner { username: string; deviceId: string; pair: CryptoKeyPair } let activeSigner: ActiveSigner | undefined; diff --git a/web/src/core-bridge/worker/accountVault.test.ts b/web/src/worker/account/accountVault.test.ts similarity index 96% rename from web/src/core-bridge/worker/accountVault.test.ts rename to web/src/worker/account/accountVault.test.ts index 647fe12..4afb821 100644 --- a/web/src/core-bridge/worker/accountVault.test.ts +++ b/web/src/worker/account/accountVault.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { exportResource, mergeVault, type VaultProfile } from './accountVault'; -import type { SavedResource } from '../database'; +import type { SavedResource } from '../runtime/database'; const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner', link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), localScope: 'account:alice', createdAt: 1, modifiedAt, expiresAt: 100, maxMembers: 3, name: `version-${modifiedAt}` }); diff --git a/web/src/core-bridge/worker/accountVault.ts b/web/src/worker/account/accountVault.ts similarity index 97% rename from web/src/core-bridge/worker/accountVault.ts rename to web/src/worker/account/accountVault.ts index 74cd928..34aee92 100644 --- a/web/src/core-bridge/worker/accountVault.ts +++ b/web/src/worker/account/accountVault.ts @@ -1,5 +1,5 @@ -import type { SavedResource } from '../database'; -import type { ResourceDelete, VaultResource, VaultProfile } from '../protocol/account'; +import type { SavedResource } from '../runtime/database'; +import type { ResourceDelete, VaultResource, VaultProfile } from '@/domain/account'; export type { ResourceDelete, VaultResource, VaultProfile }; export function exportResource(resource: SavedResource): VaultResource { // Explicit protocol projection prevents browser draft/UI fields leaking into the vault. diff --git a/web/src/core-bridge/worker/avatars.ts b/web/src/worker/account/avatars.ts similarity index 98% rename from web/src/core-bridge/worker/avatars.ts rename to web/src/worker/account/avatars.ts index 8f717fa..e5ed9e3 100644 --- a/web/src/core-bridge/worker/avatars.ts +++ b/web/src/worker/account/avatars.ts @@ -1,4 +1,4 @@ -import { apiFetch } from './http'; +import { apiFetch } from '../runtime/http'; import { accountToken } from './accountSession'; /** diff --git a/web/src/core-bridge/worker/credentialCipher.test.ts b/web/src/worker/account/credentialCipher.test.ts similarity index 100% rename from web/src/core-bridge/worker/credentialCipher.test.ts rename to web/src/worker/account/credentialCipher.test.ts diff --git a/web/src/core-bridge/worker/credentialCipher.ts b/web/src/worker/account/credentialCipher.ts similarity index 100% rename from web/src/core-bridge/worker/credentialCipher.ts rename to web/src/worker/account/credentialCipher.ts diff --git a/web/src/core-bridge/worker/passkeyCrypto.test.ts b/web/src/worker/account/passkeyCrypto.test.ts similarity index 100% rename from web/src/core-bridge/worker/passkeyCrypto.test.ts rename to web/src/worker/account/passkeyCrypto.test.ts diff --git a/web/src/core-bridge/worker/passkeyCrypto.ts b/web/src/worker/account/passkeyCrypto.ts similarity index 100% rename from web/src/core-bridge/worker/passkeyCrypto.ts rename to web/src/worker/account/passkeyCrypto.ts diff --git a/web/src/core-bridge/worker/passkeys.ts b/web/src/worker/account/passkeys.ts similarity index 98% rename from web/src/core-bridge/worker/passkeys.ts rename to web/src/worker/account/passkeys.ts index 9742c34..4cb278b 100644 --- a/web/src/core-bridge/worker/passkeys.ts +++ b/web/src/worker/account/passkeys.ts @@ -1,5 +1,5 @@ -import type { AccountSaltBody, AccountVaultBody, PasskeyFinishRequest, PasskeyInfoBody, PasskeyOptionsBody, PasskeySessionBody } from '../generated/api'; -import { apiFetch } from './http'; +import type { AccountSaltBody, AccountVaultBody, PasskeyFinishRequest, PasskeyInfoBody, PasskeyOptionsBody, PasskeySessionBody } from '@/api/generated'; +import { apiFetch } from '../runtime/http'; import { b64, unb64, deriveAccountKeys, openJson } from './accountCrypto'; import { unlockedAccount, usernameOf, unlockAuthenticatedAccount, accountStatus } from './account'; import { localAccountDevice } from './accountPersistence'; diff --git a/web/src/core-bridge/worker/channelDataKeys.ts b/web/src/worker/crypto/channelDataKeys.ts similarity index 95% rename from web/src/core-bridge/worker/channelDataKeys.ts rename to web/src/worker/crypto/channelDataKeys.ts index cd309f0..abcb451 100644 --- a/web/src/core-bridge/worker/channelDataKeys.ts +++ b/web/src/worker/crypto/channelDataKeys.ts @@ -1,13 +1,14 @@ +import { tokenRole } from '../runtime/sessions'; import { contentEventPayload, fromBase64Url, importSigningPublicKey, toBase64Url, verifyStatement, type ChannelSession } from '@webdrop/core'; -import type { CryptoState, DomainEnvelope, InvitationCryptoPlan, ObjectEnvelope, PublicKeyBody } from '../generated/api'; -import { getResource, saveResource, type SavedResource } from '../database'; -import { apiFetch } from './http'; -import { accountPublicKey, accountToken, requireAccount } from './accountSession'; -import { decryptInvitation, encryptInvitation } from './accountCrypto'; +import type { CryptoState, DomainEnvelope, InvitationCryptoPlan, ObjectEnvelope, PublicKeyBody } from '@/api/generated'; +import { getResource, saveResource, type SavedResource } from '../runtime/database'; +import { apiFetch } from '../runtime/http'; +import { accountPublicKey, accountToken, requireAccount } from '../account/accountSession'; +import { decryptInvitation, encryptInvitation } from '../account/accountCrypto'; import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; -import { cachedWorkspace, loadWorkspace } from './workspaceStructure'; -import { pinSigningRoot } from './accountPersistence'; -import { signExistingContentEvent, verifyDeviceCertificate } from './accountSigning'; +import { cachedWorkspace, loadWorkspace } from '../space/workspaceStructure'; +import { pinSigningRoot } from '../account/accountPersistence'; +import { signExistingContentEvent, verifyDeviceCertificate } from '../account/accountSigning'; const objectKeys = new Map(); const states = new Map(); @@ -66,11 +67,11 @@ async function cachedKeys(resource: SavedResource, session: ChannelSession, enou return have && enough(have) ? have : readDataKeys(resource, session); } async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise { - const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise); + const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(tokenRole(session)) }).then(r => r.json() as Promise); states.set(resource.id, state); const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys }; for (const envelope of state.envelopes) { - const { unlockedAccount } = await import('./account'); const account = unlockedAccount(); + const { unlockedAccount } = await import('../account/account'); const account = unlockedAccount(); const id = domainId(envelope.channelId, envelope.domain, envelope.epoch); if (keys[id]) continue; const plain = await decryptInvitation(envelope.payload, account.username, contextId(resource.id, envelope.channelId, envelope.domain, envelope.epoch), account.profile.keys.privateKey); @@ -149,7 +150,7 @@ export async function prepareObject(resource: SavedResource, session: ChannelSes const created = await newObjectKey(key, { spaceId: resource.id, channelId, domain, epoch, itemId }); keyEnvelope = created.envelope; objectKeys.set(`${resource.id}:${itemId}`, created.key); } - const object = await apiFetch(`/spaces/${resource.id}/crypto/objects`, { method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'write'), 'Content-Type': 'application/json' }, + const object = await apiFetch(`/spaces/${resource.id}/crypto/objects`, { method: 'POST', headers: { ...session.authHeaders(tokenRole(session)), 'Content-Type': 'application/json' }, body: JSON.stringify({ itemId, channelId, domain, epoch, keyEnvelope }) }).then(r => r.json() as Promise); const state = states.get(resource.id); if (state) state.objects.push(object); return { itemIdBytes: idBytes, itemId, channelId, domain, epoch }; @@ -190,7 +191,7 @@ export async function verifyObjectEvent(resource: SavedResource, session: Channe const deviceCertificateSignature = fromBase64Url(signed.deviceCertificateSignature, 64); if (!expectedManifest || !spaceId || !eventId || !itemBytes || !eventSignature || !deviceCertificateSignature || expectedManifest.length !== manifestHash.length || !expectedManifest.every((byte, index) => byte === manifestHash[index])) return false; - const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); + const { unlockedAccount } = await import('../account/account'); const local = unlockedAccount(); const rootJwk = JSON.parse(signed.signerRootPublicKey) as JsonWebKey; if (object.authorAccount === local.username && local.profile.keys.signing && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; @@ -255,7 +256,7 @@ export async function verifyObjectDeletion(resource: SavedResource, session: Cha const rootJwk = JSON.parse(deleted.signerRootPublicKey) as JsonWebKey; const deviceJwk = JSON.parse(deleted.signerDevicePublicKey) as JsonWebKey; const root = await importSigningPublicKey(rootJwk); - const { unlockedAccount } = await import('./account'); const local = unlockedAccount(); + const { unlockedAccount } = await import('../account/account'); const local = unlockedAccount(); if (signer === local.username && local.profile.keys.signing && (rootJwk.x !== local.profile.keys.signing.publicKey.x || rootJwk.y !== local.profile.keys.signing.publicKey.y)) return false; await pinSigningRoot(signer, rootJwk); diff --git a/web/src/core-bridge/worker/channelGrant.test.ts b/web/src/worker/crypto/channelGrant.test.ts similarity index 97% rename from web/src/core-bridge/worker/channelGrant.test.ts rename to web/src/worker/crypto/channelGrant.test.ts index ab02168..fa0ec24 100644 --- a/web/src/core-bridge/worker/channelGrant.test.ts +++ b/web/src/worker/crypto/channelGrant.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { ChannelSession } from '@webdrop/core'; -import { generateInvitationKeys } from './accountCrypto'; +import { generateInvitationKeys } from '../account/accountCrypto'; import { openChannelGrant, sealChannelGrant } from './channelGrant'; describe('account-bound channel grants', () => { diff --git a/web/src/core-bridge/worker/channelGrant.ts b/web/src/worker/crypto/channelGrant.ts similarity index 94% rename from web/src/core-bridge/worker/channelGrant.ts rename to web/src/worker/crypto/channelGrant.ts index f82014a..aa0ba1a 100644 --- a/web/src/core-bridge/worker/channelGrant.ts +++ b/web/src/worker/crypto/channelGrant.ts @@ -1,6 +1,6 @@ -import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink'; -import { makeLink } from './links'; -import { decryptInvitation, encryptInvitation } from './accountCrypto'; +import { parseChannelLink, type ParsedChannelLink } from '@/lib/links/channelLink'; +import { makeLink } from '../space/links'; +import { decryptInvitation, encryptInvitation } from '../account/accountCrypto'; interface ChannelGrant { v: 1; diff --git a/web/src/core-bridge/worker/domainCrypto.test.ts b/web/src/worker/crypto/domainCrypto.test.ts similarity index 95% rename from web/src/core-bridge/worker/domainCrypto.test.ts rename to web/src/worker/crypto/domainCrypto.test.ts index c531cd8..43b70f5 100644 --- a/web/src/core-bridge/worker/domainCrypto.test.ts +++ b/web/src/worker/crypto/domainCrypto.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { randomDomainKey, newObjectKey, unwrapObjectKey } from './domainCrypto'; -import { openJson, sealJson } from './accountCrypto'; +import { openJson, sealJson } from '../account/accountCrypto'; describe('channel data-domain object keys', () => { it('uses random independent file keys and binds envelopes to channel, domain, epoch and object', async () => { diff --git a/web/src/core-bridge/worker/domainCrypto.ts b/web/src/worker/crypto/domainCrypto.ts similarity index 94% rename from web/src/core-bridge/worker/domainCrypto.ts rename to web/src/worker/crypto/domainCrypto.ts index 6e0d8e1..7f4d731 100644 --- a/web/src/core-bridge/worker/domainCrypto.ts +++ b/web/src/worker/crypto/domainCrypto.ts @@ -1,4 +1,4 @@ -import { bytes, b64, unb64, sealJson, openJson } from './accountCrypto'; +import { bytes, b64, unb64, sealJson, openJson } from '../account/accountCrypto'; export interface ObjectContext { spaceId: string; channelId: string; domain: 'chat' | 'files'; epoch: number; itemId: string } const label = (c: ObjectContext) => JSON.stringify(['webdrop/object-key/v1', c.spaceId, c.channelId, c.domain, c.epoch, c.itemId]); diff --git a/web/src/core-bridge/subtleFallback.test.ts b/web/src/worker/crypto/subtleFallback.test.ts similarity index 100% rename from web/src/core-bridge/subtleFallback.test.ts rename to web/src/worker/crypto/subtleFallback.test.ts diff --git a/web/src/core-bridge/subtleFallback.ts b/web/src/worker/crypto/subtleFallback.ts similarity index 100% rename from web/src/core-bridge/subtleFallback.ts rename to web/src/worker/crypto/subtleFallback.ts diff --git a/web/src/core-bridge/worker/downloadStorage.test.ts b/web/src/worker/files/downloadStorage.test.ts similarity index 100% rename from web/src/core-bridge/worker/downloadStorage.test.ts rename to web/src/worker/files/downloadStorage.test.ts diff --git a/web/src/core-bridge/worker/downloadStorage.ts b/web/src/worker/files/downloadStorage.ts similarity index 100% rename from web/src/core-bridge/worker/downloadStorage.ts rename to web/src/worker/files/downloadStorage.ts diff --git a/web/src/worker/files/fileService.ts b/web/src/worker/files/fileService.ts new file mode 100644 index 0000000..adf3203 --- /dev/null +++ b/web/src/worker/files/fileService.ts @@ -0,0 +1,150 @@ +import { hasCode } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; +import { tokenRole } from '../runtime/sessions'; +import { fromBase64Url, type ChannelSession } from '@webdrop/core'; +import type { FileGrantBody, FileGrantRequest } from '@/api/generated'; +import type { SavedResource } from '../runtime/database'; +import { apiFetch, apiUpload, externalFetch, externalRequest, getServerInfo } from '../runtime/http'; +import type { FileDomainOptions, FileDomainUsage } from '@/domain/types'; +import { cachedWorkspace, changeWorkspace } from '../space/workspaceStructure'; +import { getSession } from '../runtime/sessions'; + +/** + * 文件域:每个频道有一个,由文件服务(fileAPI)创建、计量、存放字节;主后端只管权限。 + * - 创建/查询/上传/下载/删除,前端都直接和文件服务通信,凭的是空间(主后端)按权限签发的短期票据。 + * - 主后端只记「这个频道的文件域 id」和每个文件的元数据、摘要。 + * 聊天、头像、空间结构、文件目录不受影响,仍走主后端。 + */ + +const toHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +export const itemHex = (itemId: string): string => toHex(fromBase64Url(itemId, 16) ?? new Uint8Array(16)); +/** 帧头部里的条目 id(第 2 到 18 字节) */ +export const frameItemHex = (bytes: Uint8Array): string => toHex(bytes.subarray(2, 18)); + +const base = (grant: FileGrantBody): string => grant.url.replace(/\/$/, ''); + +export async function filesEnabled(): Promise { + try { return (await getServerInfo()).files?.enabled === true; } catch { return false; } +} + +/** 文件服务连不上(还没部署好、放文件的机器离线)时给一句看得懂的提示,而不是笼统的「网络连接失败」。被服务端拒绝的错误照常往外抛 */ +export const FILE_SERVICE_DOWN = '文件服务暂时不可用,请稍后再试'; +async function reachable(call: () => Promise): Promise { + try { return await call(); } + catch (error) { if (error instanceof Error && !('status' in error)) throw new Error(FILE_SERVICE_DOWN); throw error; } +} + +async function fileServiceUrl(): Promise { + const files = (await getServerInfo()).files; + if (!files?.enabled || !files.url) throw new Error('服务端没有开启文件服务'); + return files.url.replace(/\/$/, ''); +} + +/** 向空间要一张文件服务的票据:权限由空间按成员身份判断 */ +export async function requestGrant(resource: SavedResource, session: ChannelSession, request: Omit & { from?: number }): Promise { + const response = await apiFetch(`/spaces/${resource.id}/file-grants`, { + method: 'POST', headers: { ...session.authHeaders(tokenRole(session)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 0, ...request }), + }); + return response.json() as Promise; +} + +/** 文件服务提供的选项:创建频道时可以选的文件空间档位。不含机密,直接问文件服务 */ +export async function fileDomainOptions(): Promise { + if (!await filesEnabled()) return undefined; + return reachable(async () => (await externalFetch(`${await fileServiceUrl()}/file/v1/options`, {})).json() as Promise); +} + +/** 在文件服务里创建一个文件域(空间所有者/管理员),返回域 id */ +export async function createFileDomain(resource: SavedResource, session: ChannelSession, quota?: number): Promise { + const grant = await requestGrant(resource, session, { op: 'create' }); + const response = await reachable(() => externalRequest(`${base(grant)}/file/v1/domains`, { method: 'POST', headers: { 'X-WD-Ticket': grant.ticket, 'Content-Type': 'application/json' }, body: JSON.stringify(quota ? { quota } : {}) })); + return ((await response.json()) as { id: string }).id; +} + +/** + * 保证这个频道有文件域:没有就创建一个并记到频道上(创建频道时没带的、旧频道)。 + * 只有所有者和管理员能分配;其他人遇到没有文件域的频道只能等管理员分配。 + */ +export async function ensureThreadFileDomain(resource: SavedResource, session: ChannelSession, threadId: string): Promise { + if (resource.kind !== 'channel') return; + const thread = (await cachedWorkspace(resource.id)).structure.channels.find((channel) => channel.id === threadId); + if (!thread) throw new Error('频道不存在'); + if (thread.fileDomain) return; + if (!await filesEnabled()) throw new Error('服务端没有开启文件服务,不能上传文件'); + let domain: string; + try { domain = await createFileDomain(resource, session); } + catch (error) { + if (hasCode(error, ErrorCode.Forbidden)) throw new Error('这个频道还没有文件空间,请让管理员先为它分配'); + throw error; + } + await changeWorkspace(resource.id, `channels/${threadId}/file-domain`, 'PUT', { fileDomain: domain }); +} + +/** 频道文件域的容量和已用字节 */ +export async function fileDomainUsage(resourceId: string, threadId: string): Promise { + const { session, resource } = await getSession(resourceId); + if (resource.kind !== 'channel' || !await filesEnabled()) return undefined; + const grant = await requestGrant(resource, session, { op: 'domain', channel: threadId }).catch(() => undefined); + if (!grant) return undefined; + const response = await externalFetch(`${base(grant)}/file/v1/domains/${grant.domain}`, { 'X-WD-Ticket': grant.ticket }); + const body = await response.json() as { quota: number; used: number }; + return { quota: body.quota, used: body.used }; +} + +const uploadTickets = new Map(); +/** 上传票据在有效期内复用(一个条目的所有帧共用),快过期时换新的 */ +export async function uploadGrant(resource: SavedResource, session: ChannelSession, item: string, fresh = false): Promise { + const key = `${resource.id}:${item}`; + const known = uploadTickets.get(key); + if (!fresh && known && known.expires_at - Date.now() > 60_000) return known; + const grant = await requestGrant(resource, session, { op: 'put', item }); + uploadTickets.set(key, grant); + return grant; +} +export const forgetUploadGrant = (resource: SavedResource, item: string): void => { uploadTickets.delete(`${resource.id}:${item}`); }; + +/** 把一帧整个存进文件域,返回文件服务算的摘要(之后拿它去主后端登记) */ +export async function putFrame( + grant: FileGrantBody, bytes: Uint8Array, index: number, + onProgress: (loaded: number, total: number) => void, signal?: AbortSignal, stallMs?: number, +): Promise { + const url = `${base(grant)}/file/v1/domains/${grant.domain}/items/${frameItemHex(bytes)}/frames/${index}`; + const text = await apiUpload('', { 'X-WD-Ticket': grant.ticket }, new Blob([bytes as BlobPart]), onProgress, signal, stallMs, { url, method: 'PUT' }); + const digest = (JSON.parse(text) as { digest?: string }).digest; + if (!digest) throw new Error('文件服务没有返回摘要'); + return digest; +} + +/** 整个文件已经登记到主后端:告诉文件服务这个条目不再是待确认,不会被清理。登记成功之后调用 */ +export async function commitItem(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + const grant = await uploadGrant(resource, session, itemId); + await externalRequest(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}/commit`, { method: 'POST', headers: { 'X-WD-Ticket': grant.ticket } }); + forgetUploadGrant(resource, itemId); +} + +/** 取消上传:清掉已经传给文件服务的字节(用的是上传这个条目的票据,主后端那边可能还没有这个条目) */ +export async function abandonItem(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + const grant = await uploadGrant(resource, session, itemId, true); + await externalRequest(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}`, { method: 'DELETE', headers: { 'X-WD-Ticket': grant.ticket } }); + forgetUploadGrant(resource, itemId); +} + +/** 删除文件域里一个条目的所有字节。先向空间要 delete 票据、删掉主后端的条目之后调用 */ +export async function deleteFileBytes(grant: FileGrantBody, itemId: string): Promise { + await externalRequest(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}`, { method: 'DELETE', headers: { 'X-WD-Ticket': grant.ticket } }); +} + +/** + * 打开一个条目自第 from 帧起的内容(下载、边下边播共用)。频道里的文件向空间要下载票据(按规则计次), + * 再带着票据去文件服务取;分享里的文件仍在主后端。 + */ +export async function openItemFrames(resource: SavedResource, session: ChannelSession, itemId: string, from: number, signal?: AbortSignal): Promise { + if (resource.kind === 'channel') { + const grant = await requestGrant(resource, session, { op: 'get', item: itemId, from }); + return externalFetch(`${base(grant)}/file/v1/domains/${grant.domain}/items/${itemHex(itemId)}/frames?from=${from}`, { 'X-WD-Ticket': grant.ticket }, signal); + } + const path = `/shares/${resource.id}/items/${itemId}/frames?from=${from}`; + const role = resource.kind === 'share' && resource.state === 'draft' ? 'owner' : session.can('read') ? 'read' : 'owner'; + return apiFetch(path, { headers: session.authHeaders(role), ...(signal ? { signal } : {}) }); +} diff --git a/web/src/core-bridge/worker/frameStash.test.ts b/web/src/worker/files/frameStash.test.ts similarity index 98% rename from web/src/core-bridge/worker/frameStash.test.ts rename to web/src/worker/files/frameStash.test.ts index ec5bf2b..13f7ce4 100644 --- a/web/src/core-bridge/worker/frameStash.test.ts +++ b/web/src/worker/files/frameStash.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { QueuedFrame } from '../database'; +import type { QueuedFrame } from '../runtime/database'; import { FrameStash } from './frameStash'; const frame = (index: number): QueuedFrame => ({ key: `k${index}`, resourceId: 'r', itemId: 'i', index, bytes: new Uint8Array(10), extraHeaders: {}, createdAt: index }); diff --git a/web/src/core-bridge/worker/frameStash.ts b/web/src/worker/files/frameStash.ts similarity index 98% rename from web/src/core-bridge/worker/frameStash.ts rename to web/src/worker/files/frameStash.ts index a1fe51f..9951948 100644 --- a/web/src/core-bridge/worker/frameStash.ts +++ b/web/src/worker/files/frameStash.ts @@ -1,4 +1,4 @@ -import type { QueuedFrame } from '../database'; +import type { QueuedFrame } from '../runtime/database'; interface Pending { frame: QueuedFrame; at: number } diff --git a/web/src/core-bridge/worker/playbackStream.ts b/web/src/worker/files/playbackStream.ts similarity index 99% rename from web/src/core-bridge/worker/playbackStream.ts rename to web/src/worker/files/playbackStream.ts index f9e3a7c..2521a91 100644 --- a/web/src/core-bridge/worker/playbackStream.ts +++ b/web/src/worker/files/playbackStream.ts @@ -1,6 +1,6 @@ import { fromBase64Url, IntegrityError } from '@webdrop/core'; import { openItemFrames } from './fileService'; -import { getSession } from './sessions'; +import { getSession } from '../runtime/sessions'; /** * 边下边播:按字节范围把加密条目解密后推给 Service Worker(stream-sw.js)。 diff --git a/web/src/core-bridge/worker/resumeUploads.ts b/web/src/worker/files/resumeUploads.ts similarity index 72% rename from web/src/core-bridge/worker/resumeUploads.ts rename to web/src/worker/files/resumeUploads.ts index c71bac1..15a0fca 100644 --- a/web/src/core-bridge/worker/resumeUploads.ts +++ b/web/src/worker/files/resumeUploads.ts @@ -1,7 +1,7 @@ -import { dequeueItem, queuedFrame, queuedFrameKeys, saveResource, type QueuedFrame, type SavedResource } from '../database'; +import { dequeueItem, queuedFrame, queuedFrameKeys, saveResource, type QueuedFrame, type SavedResource } from '../runtime/database'; import type { ChannelSession } from '@webdrop/core'; -import { publishWorkspace } from './presentation'; -import { pipelinedItems, uploadFrame } from './transfer'; +import { publishWorkspace } from '../runtime/presentation'; +import { flushOutbox, pipelinedItems, uploadFrame } from './transfer'; import { FrameUploader, TransferMeter } from './uploadPipeline'; const FRAME_CONCURRENCY = 4; @@ -16,24 +16,41 @@ const isFinalFrame = (frame: QueuedFrame): boolean => 'X-WD-Event-Signature' in */ export async function resumeOutbox(resource: SavedResource, session: ChannelSession, signal?: AbortSignal): Promise { const items = new Map(); + // 登记整个文件的请求(key 以 :register 结尾)不是帧:帧都传完之后才由 flushOutbox 发给主后端 + const registering = new Set(); for (const key of await queuedFrameKeys(resource.id)) { const { itemId, index } = keyOf(key); if (pipelinedItems.has(`${resource.id}:${itemId}`)) continue; + if (key.endsWith(':register')) { registering.add(itemId); continue; } (items.get(itemId) ?? items.set(itemId, []).get(itemId)!).push(index); } // 上次会话里还显示「传输中」的文件,如果既没有在续传、也没有可续传的帧,就是被打断的:标成未完成,用户可以删除 const stale = (resource.localItems ?? []).filter((item) => item.kind === 'file' && item.state.kind === 'receiving' - && !pipelinedItems.has(`${resource.id}:${item.id}`) && !items.has(item.id)); + && !pipelinedItems.has(`${resource.id}:${item.id}`) && !items.has(item.id) && !registering.has(item.id)); for (const item of stale) await markIncomplete(resource, item.id); let complete = true; for (const [itemId, indices] of items) { if (signal?.aborted) return false; - if (!await resumeItem(resource, session, itemId, indices.sort((a, b) => a - b), signal)) complete = false; + if (!await resumeItem(resource, session, itemId, indices.sort((a, b) => a - b), registering.has(itemId), signal)) complete = false; + } + // 帧都传完了、只差向主后端登记的文件 + for (const itemId of registering) { + if (items.has(itemId)) continue; + if (signal?.aborted) return false; + if (!await registerItem(resource, session, itemId)) complete = false; } return complete; } -async function resumeItem(resource: SavedResource, session: ChannelSession, itemId: string, indices: number[], signal?: AbortSignal): Promise { +/** 向主后端登记这个文件(所有帧已在文件域里),成功后把界面上的文件标成完成 */ +async function registerItem(resource: SavedResource, session: ChannelSession, itemId: string): Promise { + const done = await flushOutbox(resource, session, undefined, { only: new Set([itemId]) }); + const local = resource.localItems?.find((item) => item.id === itemId); + if (local && local.kind === 'file') publishWorkspace(resource.id, { t: 'item', item: { ...local, state: done ? { kind: 'complete' } : { kind: 'incomplete' } } }); + return done; +} + +async function resumeItem(resource: SavedResource, session: ChannelSession, itemId: string, indices: number[], needsRegistration: boolean, signal?: AbortSignal): Promise { const keyFor = (index: number) => `${resource.id}:${itemId}:${String(index).padStart(10, '0')}`; const last = indices[indices.length - 1]!; const lastFrame = await queuedFrame(keyFor(last)); @@ -57,7 +74,7 @@ async function resumeItem(resource: SavedResource, session: ChannelSession, item publishWorkspace(resource.id, { t: 'item', item: { ...local, state: { kind: 'receiving', progress: Math.min(0.99, (accepted.length + sent) / total), phase: 'uploading', ...(speed ? { speedBytesPerSecond: speed } : {}) } } }); }; const uploader = new FrameUploader({ - gated: local?.kind === 'file' ? Boolean(local.gated) : false, concurrency: FRAME_CONCURRENCY, signal: controller.signal, alreadyAccepted: accepted, + gated: true, concurrency: FRAME_CONCURRENCY, signal: controller.signal, alreadyAccepted: accepted, send: (frame, hooks, s) => uploadFrame(resource, session, frame, hooks, s), onBytes: (bytes) => { meter.add(bytes); publish(bytes / Math.max(1, lastFrame.bytes.byteLength)); }, }); @@ -66,7 +83,8 @@ async function resumeItem(resource: SavedResource, session: ChannelSession, item const frame = index === last ? lastFrame : await queuedFrame(keyFor(index)); if (frame) await uploader.push(frame, index === last); } - const done = await uploader.finish(); + let done = await uploader.finish(); + if (done && needsRegistration) done = await flushOutbox(resource, session, undefined, { only: new Set([itemId]) }); if (local && local.kind === 'file') publishWorkspace(resource.id, { t: 'item', item: { ...local, state: done ? { kind: 'complete' } : { kind: 'incomplete' } } }); return done; } catch (error) { diff --git a/web/src/core-bridge/worker/transfer.ts b/web/src/worker/files/transfer.ts similarity index 72% rename from web/src/core-bridge/worker/transfer.ts rename to web/src/worker/files/transfer.ts index 6bd347a..7b9cac5 100644 --- a/web/src/core-bridge/worker/transfer.ts +++ b/web/src/worker/files/transfer.ts @@ -1,12 +1,16 @@ +import { hasCode, isRetryable } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; +import { TokenRole } from '@/api/generated'; +import { tokenRole } from '../runtime/sessions'; import { fromBase64Url, toBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core'; -import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type QueuedFrame, type SavedResource } from '../database'; -import { registerItemLocation } from './workspaceStructure'; -import { verifyObjectEvent } from './channelDataKeys'; -import { apiUpload, type ApiError } from './http'; -import { filesEnabled, forgetUploadGrant, openItemFrames, putFrame, uploadGrant } from './fileService'; -import { signContentEvent } from './accountSigning'; -import { accountToken } from './accountSession'; -import { serial } from './serial'; +import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, nextQueuedLocation, dequeueLocation, type QueuedFrame, type SavedResource } from '../runtime/database'; +import { registerItemLocation } from '../space/workspaceStructure'; +import { verifyObjectEvent } from '../crypto/channelDataKeys'; +import { apiUpload, type ApiError } from '../runtime/http'; +import { commitItem, forgetUploadGrant, openItemFrames, putFrame, uploadGrant } from './fileService'; +import { signContentEvent } from '../account/accountSigning'; +import { accountToken } from '../account/accountSession'; +import { serial } from '../runtime/serial'; import { storeVerifiedDownload } from './downloadStorage'; // Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier. @@ -19,6 +23,13 @@ export { pipelined as pipelinedItems }; export function markPipelined(resourceId: string, itemId: string): void { pipelined.add(`${resourceId}:${itemId}`); } export function unmarkPipelined(resourceId: string, itemId: string): void { pipelined.delete(`${resourceId}:${itemId}`); } +const FRAME_HEADER_SIZE = 26; +/** 登记请求里每一帧占的字节:负载长度 u32 + 整帧 SHA-256 */ +const REGISTER_ENTRY_SIZE = 36; + +/** 登记整个文件的请求(见 QueuedFrame.register):请求体 = 末帧头部 + 每帧(长度,摘要),头部带下载次数和末帧的签名事件 */ +export interface FileRegistration { createdAt: number; body: Uint8Array; headers: Record } + export async function persistPlan( resource: SavedResource, plan: ItemPlan, @@ -26,11 +37,13 @@ export async function persistPlan( signedEvent?: { channelId: string; domain: 'chat' | 'files'; epoch: number; eventKind: string; targetItemId?: string }, /** 每一帧写入离线队列之后调用;上传流水线借此边加密边上传,返回的 Promise 用来给加密端施加背压 */ onFrame?: (frame: QueuedFrame, final: boolean) => Promise, -): Promise<{ frames: number; bytes: number }> { +): Promise<{ frames: number; bytes: number; registration?: FileRegistration }> { const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1); let frames = 0; let bytes = 0; const frameDigests: Uint8Array[] = []; + const payloadLengths: number[] = []; + let registration: FileRegistration | undefined; let pending: { index: number; bytes: Uint8Array; extraHeaders?: Record } | undefined; const persistFrame = async (frame: NonNullable, extraHeaders: Record = {}, final = false) => { const queued: QueuedFrame = { @@ -53,6 +66,7 @@ export async function persistPlan( for await (const frame of plan.frames) { if (pending) await persistFrame(pending); frameDigests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', frame.bytes))); + payloadLengths.push(frame.bytes.byteLength - FRAME_HEADER_SIZE); pending = frame; } if (pending) { @@ -62,14 +76,20 @@ export async function persistPlan( ...(signedEvent.targetItemId ? { targetItemId: signedEvent.targetItemId } : {}), frameDigests }) : {}; await persistFrame(pending, headers, true); + // 文件域的文件:字节只去文件服务,主后端一次登记整个文件(逐帧登记要为每个 1 MiB 往返一次主后端,大文件被它拖慢) + if (resource.kind === 'channel' && signedEvent?.domain === 'files' && signedEvent.eventKind === 'file.publish') { + const body = new Uint8Array(FRAME_HEADER_SIZE + frameDigests.length * REGISTER_ENTRY_SIZE); + body.set(pending.bytes.subarray(0, FRAME_HEADER_SIZE), 0); + const view = new DataView(body.buffer); + frameDigests.forEach((digest, index) => { + const at = FRAME_HEADER_SIZE + index * REGISTER_ENTRY_SIZE; + view.setUint32(at, payloadLengths[index]!, false); + body.set(digest, at + 4); + }); + registration = { createdAt: createdAt + frames, body, headers: { ...plan.frame0Headers, ...headers } }; + } } - return { frames, bytes }; -} - -function writeRole(session: ChannelSession): 'owner' | 'write' { - if (session.can('owner')) return 'owner'; - if (session.can('write')) return 'write'; - throw new Error('此链接没有上传权限'); + return { frames, bytes, ...(registration ? { registration } : {}) }; } /** 一次请求这么久没有任何上传进展就放弃重试:弱网下连接可能静默卡死,不能一直等 */ @@ -95,23 +115,19 @@ export async function uploadFrame( hooks?: { onLoaded?: (loadedBytes: number, totalBytes: number) => void; onAccepted?: () => void; onRetry?: () => void; manageQueue?: boolean }, signal?: AbortSignal, ): Promise { - const role = resource.kind === 'share' ? 'owner' : writeRole(session); - const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : `/channels/${resource.id}/frames`; + const role = resource.kind === 'share' ? TokenRole.Owner : tokenRole(session); + const path = resource.kind === 'share' ? `/shares/${resource.id}/frames` : frame.register ? `/channels/${resource.id}/files` : `/channels/${resource.id}/frames`; let maxLoadedBytes = 0; - // 受限帧在开启文件服务时,字节传给文件服务,主后端只登记 26 字节头部和回执 - let remote = resource.kind === 'channel' && frame.remote === true && await filesEnabled(); - let receipt: string | undefined; + // 文件域的帧:字节只存进文件服务的文件域,不经主后端(主后端在整个文件传完后一次登记,见 register) + let remote = resource.kind === 'channel' && frame.remote === true && !frame.register; let refreshedOnce = false; for (let attempt = 0; attempt < MAX_UPLOAD_ATTEMPTS; attempt += 1) { try { const onProgress = (loaded: number, total: number) => { maxLoadedBytes = Math.max(maxLoadedBytes, loaded); hooks?.onLoaded?.(maxLoadedBytes, total); }; const itemId = remote ? toBase64Url(frame.bytes.subarray(2, 18)) : ''; const grant = remote ? await uploadGrant(resource, session, itemId) : undefined; - if (grant && grant.mode === 'remote') { - receipt ??= await putFrame(grant, resource, frame.bytes, frame.index, onProgress, signal, FRAME_STALL_TIMEOUT_MS); - await apiUpload(path, { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders, 'X-WD-Receipt': receipt }, - new Blob([frame.bytes.subarray(0, 26) as BlobPart]), () => {}, signal, FRAME_STALL_TIMEOUT_MS); - } else await apiUpload( + if (grant) await putFrame(grant, frame.bytes, frame.index, onProgress, signal, FRAME_STALL_TIMEOUT_MS); + else await apiUpload( path, { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream', ...frame.extraHeaders }, // 用 Blob 发而不是字节数组:浏览器对字节数组请求体要多拷贝几次,实测上传只有 20–35 MB/s;Blob 走快速通道,单连接 100+ MB/s。 @@ -121,6 +137,8 @@ export async function uploadFrame( signal, FRAME_STALL_TIMEOUT_MS, ); + // 整个文件登记成功:通知文件服务不再待确认(登记是幂等的,这一步失败会连登记一起重试) + if (frame.register && resource.kind === 'channel') await commitItem(resource, session, frame.itemId); if (hooks?.manageQueue !== false) await dequeueFrame(frame.key); hooks?.onAccepted?.(); return true; @@ -129,15 +147,13 @@ export async function uploadFrame( const apiError = error as ApiError; // 老版本留在离线队列里的文件帧没有 remote 标记:服务端要求走文件服务,就改走文件服务重试 if (!remote && apiError.code === 'use_file_api' && resource.kind === 'channel') { remote = true; continue; } - // 票据或回执过期(401 来自文件服务,403 来自主后端验回执):换新的再试一次 - if (remote && !refreshedOnce && (apiError.status === 401 || apiError.status === 403)) { - refreshedOnce = true; receipt = undefined; forgetUploadGrant(resource, toBase64Url(frame.bytes.subarray(2, 18))); + // 票据过期(文件服务回 401):换新的再试一次 + if (remote && !refreshedOnce && hasCode(error, ErrorCode.Unauthorized)) { + refreshedOnce = true; forgetUploadGrant(resource, toBase64Url(frame.bytes.subarray(2, 18))); continue; } - const retryable = apiError.status === undefined || apiError.status === 408 || apiError.status === 425 - || apiError.status === 429 || apiError.status === 499 || apiError.status >= 500; - if (!retryable) throw error; - if (attempt === MAX_UPLOAD_ATTEMPTS - 1) return false; + if (!isRetryable(apiError)) throw error; + if (attempt === MAX_UPLOAD_ATTEMPTS - 1) { console.warn('[upload] 重试用尽,留在离线队列等待续传', frame.key, error); return false; } hooks?.onRetry?.(); maxLoadedBytes = 0; await new Promise((resolve) => setTimeout(resolve, retryDelayMs(attempt, apiError.retryAfter))); @@ -178,7 +194,7 @@ async function flush( await dequeueLocation(location.key); } catch (error) { if (error instanceof Error && error.message === '网络连接失败') return false; - if ((error as ApiError).status === 404 || (error as ApiError).status === 410) await dequeueLocation(location.key); + if (hasCode(error, ErrorCode.NotFound, ErrorCode.Gone)) await dequeueLocation(location.key); throw error; } continue; diff --git a/web/src/core-bridge/worker/uploadPipeline.test.ts b/web/src/worker/files/uploadPipeline.test.ts similarity index 99% rename from web/src/core-bridge/worker/uploadPipeline.test.ts rename to web/src/worker/files/uploadPipeline.test.ts index 9110a67..5761ef1 100644 --- a/web/src/core-bridge/worker/uploadPipeline.test.ts +++ b/web/src/worker/files/uploadPipeline.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { QueuedFrame } from '../database'; +import type { QueuedFrame } from '../runtime/database'; import { estimateCipherBytes, FrameUploader, TransferMeter, type SendFrame } from './uploadPipeline'; const frame = (index: number, size = 100): QueuedFrame => ({ key: `k${index}`, resourceId: 'r', itemId: 'i', index, bytes: new Uint8Array(size), extraHeaders: {}, createdAt: index }); diff --git a/web/src/core-bridge/worker/uploadPipeline.ts b/web/src/worker/files/uploadPipeline.ts similarity index 99% rename from web/src/core-bridge/worker/uploadPipeline.ts rename to web/src/worker/files/uploadPipeline.ts index 9966aa1..6aca635 100644 --- a/web/src/core-bridge/worker/uploadPipeline.ts +++ b/web/src/worker/files/uploadPipeline.ts @@ -1,4 +1,4 @@ -import type { QueuedFrame } from '../database'; +import type { QueuedFrame } from '../runtime/database'; /** * 单个文件的帧上传调度(protocol §5、服务端 AppendFrame 的约束): diff --git a/web/src/core-bridge/worker/uploadQueue.ts b/web/src/worker/files/uploadQueue.ts similarity index 74% rename from web/src/core-bridge/worker/uploadQueue.ts rename to web/src/worker/files/uploadQueue.ts index aaad71d..a19bdeb 100644 --- a/web/src/core-bridge/worker/uploadQueue.ts +++ b/web/src/worker/files/uploadQueue.ts @@ -1,16 +1,19 @@ -import { previewKindOf } from '@/lib/fileKind'; -import { dequeueFrame, dequeueItem, queueFrame, queueLocation, saveResource, type QueuedFrame, type SavedResource } from '../database'; -import { errorMessage } from '../errors'; -import type { FileItem, FileUploadOptions } from '../types'; -import { prepareObject } from './channelDataKeys'; -import { removeChannelItem } from './channelItems'; -import { queueFileCatalog } from './channelThreads'; -import { deviceIdentity, publishWorkspace } from './presentation'; -import { senderSnapshot } from './senderSnapshot'; -import { blobs, getSession } from './sessions'; +import { isPoster } from '@/domain/poster'; +import type { ChannelSession } from '@webdrop/core'; +import { previewKindOf } from '@/lib/files/fileKind'; +import { dequeueFrame, dequeueItem, queueFrame, queueLocation, saveResource, type QueuedFrame, type SavedResource } from '../runtime/database'; +import { errorMessage } from '@/api/errors'; +import type { FileItem, FileUploadOptions } from '@/domain/types'; +import { prepareObject } from '../crypto/channelDataKeys'; +import { removeChannelItem } from '../space/channelItems'; +import { queueFileCatalog } from '../space/channelThreads'; +import { deviceIdentity, publishWorkspace } from '../runtime/presentation'; +import { senderSnapshot } from '../space/senderSnapshot'; +import { blobs, getSession } from '../runtime/sessions'; import { flushOutbox, markPipelined, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan, unmarkPipelined, uploadFrame } from './transfer'; import { FrameStash } from './frameStash'; import { estimateCipherBytes, FrameUploader, TransferMeter } from './uploadPipeline'; +import { abandonItem, ensureThreadFileDomain } from './fileService'; /** 受限文件内部同时在途的帧数。再多对单条连接的带宽提升有限,还会占满浏览器的并发连接 */ const FRAME_CONCURRENCY = 4; @@ -74,8 +77,8 @@ async function runJob(job: Job): Promise { const { resourceId, file, threadId, folderId, localId } = job; const signal = job.controller.signal; const { session, resource } = await getSession(resourceId); - const options = job.options ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 }; - if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限'); + const options = job.options ?? { maxDownloads: resource.maxDownloads ?? 0 }; + if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); const device = await deviceIdentity(session.endpointSecret); const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; const author = await senderSnapshot(); @@ -84,11 +87,13 @@ async function runJob(job: Job): Promise { let stash: FrameStash | undefined; try { if (signal.aborted) return; + // 文件存在频道的文件域里:没有就先分配一个(所有者/管理员),成员遇到没有文件域的频道会收到提示 + await ensureThreadFileDomain(resource, session, threadId); const prepared = await prepareObject(resource, session, threadId, 'files'); const plan = session.encryptFile(file, {}, { itemId: prepared.itemIdBytes, - // 小文件(一帧放得下)且不限制次数:不设上限,加入者直接预览。其余都带上每个文件的下载次数(0 = 不限) - ...(options.directPreview && file.size < maxFrameData - 1024 ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }), + // 文件内容都在文件域里,一律受限发布:带上每个文件的下载次数(0 = 不限) + gated: true, maxDownloads: options.maxDownloads, ts, maxFrameData, threadId, folderId, }); item = { @@ -96,8 +101,10 @@ async function runJob(job: Job): Promise { ...(author ? { author } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}), name: file.name, mime: file.type || 'application/octet-stream', size: file.size, - remaining: plan.gated ? options.maxDownloads || null : null, - preview: previewKindOf(file.type, file.name), gated: plan.gated, + remaining: options.maxDownloads > 0 ? options.maxDownloads : null, + ...(options.poster && isPoster(options.poster) ? { poster: options.poster } : {}), + // gated 在界面上的意思是「有下载次数限制,不要自动拉取内容」:不限次数的文件可以直接预览。帧本身一律是受限帧(由文件域存放),与此无关 + preview: previewKindOf(file.type, file.name), gated: options.maxDownloads > 0, }; job.itemId = item.id; resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item]; @@ -123,6 +130,9 @@ async function runJob(job: Job): Promise { await uploader.push(frame, final); }); progress.exact(planned.bytes); + // 字节都在文件域里:登记整个文件的请求先落进离线队列,所有帧确认之后由 flushOutbox 一次发给主后端(断网、刷新后也能续) + if (planned.registration) await queueFrame({ key: `${resourceId}:${item.id}:register`, resourceId, itemId: item.id, index: 0xffffffff, bytes: planned.registration.body, + extraHeaders: planned.registration.headers, createdAt: planned.registration.createdAt, register: true }); await queueFileCatalog(resource, session, item); await queueLocation(resourceId, item.id, threadId, folderId); const framesUploaded = await uploader.finish(); @@ -132,11 +142,13 @@ async function runJob(job: Job): Promise { // 帧都确认后,再发目录条目和位置登记(以及上次没发完的遗留) const uploaded = framesUploaded && await flushOutbox(resource, session); await finish(resource, item, uploaded ? { kind: 'complete' } : { kind: 'incomplete' }); + if (!uploaded) console.warn('[upload] 没有传完:', framesUploaded ? '字节已传完,后续登记(整个文件/目录/位置)没有成功' : '帧上传暂停(重试用尽)'); if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' }); } catch (error) { if (item) unmarkPipelined(resourceId, item.id); - if (signal.aborted || isAbort(error)) { await stash?.discard(); await discard(resource, item); return; } + if (signal.aborted || isAbort(error)) { await stash?.discard(); await discard(resource, session, item); return; } job.controller.abort(); + console.warn('[upload] 上传失败', item?.id, error); // 失败时已加密还没确认的帧要保住,之后才能续传 await stash?.drain().catch(() => undefined); if (item) await finish(resource, item, { kind: 'incomplete' }); @@ -153,14 +165,17 @@ async function finish(resource: SavedResource, item: FileItem, state: FileItem[' } /** 取消:清掉离线队列里这个文件的帧和本地条目。服务端已收下的帧会随空间保留期自然过期 */ -async function discard(resource: SavedResource, item: FileItem | undefined): Promise { +async function discard(resource: SavedResource, session: ChannelSession, item: FileItem | undefined): Promise { if (!item) return; await dequeueItem(resource.id, item.id); resource.localItems = (resource.localItems ?? []).filter((entry) => entry.id !== item.id); await saveResource(resource); blobs.delete(`${resource.id}:${item.id}`); publishWorkspace(resource.id, { t: 'removed', id: item.id }); - // 服务端可能已收下前几帧:删掉这个没传完的条目,不然它会一直占着空间配额(没传完的条目服务端允许直接删)。第 0 帧都没到服务端时返回 404,忽略 + // 已经传给文件服务的字节要清掉,不然会占着文件域的容量(主后端那边没有这个条目,因为整个文件传完才登记);没传的话文件服务返回 404,忽略。 + // 万一这一步没成功(断网),文件服务会在保留期后自己清理这个一直没确认的条目 + await abandonItem(resource, session, item.id).catch(() => undefined); + // 兼容:旧版本逐帧登记留下的、主后端上的半截条目 void removeChannelItem(resource.id, item.id).catch(() => undefined); } diff --git a/web/src/core-bridge/worker.ts b/web/src/worker/main.ts similarity index 88% rename from web/src/core-bridge/worker.ts rename to web/src/worker/main.ts index 7ed5d94..2730576 100644 --- a/web/src/core-bridge/worker.ts +++ b/web/src/worker/main.ts @@ -1,26 +1,27 @@ -import { installSubtleFallback } from './subtleFallback'; -import { setApiBase, getServerInfo } from './worker/http'; -import type { WorkspaceRoleRequest } from './generated/api'; -import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys'; -import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems'; -import { createChannel, forgetChannel, getSpaceAvatar, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources'; -import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend'; -import { cancelFileUpload } from './worker/uploadQueue'; -import { stopChannel } from './worker/channelTransport'; -import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft'; -import { connectShare, getShareBlob, stopShare } from './worker/shareRead'; -import { createShare, joinShare, listSavedShares, removeShare, updateShareSettings } from './worker/shareResources'; -import { sealShare } from './worker/shareSeal'; -import { setEventSink } from './worker/port'; -import { stopWatchingSpaces, watchSpaces } from './worker/spaceWatch'; -import { serial } from './worker/serial'; -import { errorMessage } from './errors'; -import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account'; -import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './worker/channelThreads'; -import { fetchAvatar } from './worker/avatars'; -import { servePlaybackRange } from './worker/playbackStream'; -import { refreshLoadedWorkspaces } from './worker/workspaceStructure'; -import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './worker/account'; +import { installSubtleFallback } from './crypto/subtleFallback'; +import { setApiBase, getServerInfo } from './runtime/http'; +import type { WorkspaceRoleRequest } from '@/api/generated'; +import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './account/passkeys'; +import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './space/channelItems'; +import { createChannel, forgetChannel, getSpaceAvatar, joinChannel, listChannels, updateChannelSettings, previewChannel, channelMembers } from './space/channelResources'; +import { editChannelText, sendFiles, sendText, updateSnapshot } from './space/channelSend'; +import { cancelFileUpload } from './files/uploadQueue'; +import { stopChannel } from './space/channelTransport'; +import { addShareFiles, addShareText, editShareText, removeShareItem } from './shares/shareDraft'; +import { connectShare, getShareBlob, stopShare } from './shares/shareRead'; +import { createShare, joinShare, listSavedShares, removeShare, updateShareSettings } from './shares/shareResources'; +import { sealShare } from './shares/shareSeal'; +import { setEventSink } from './runtime/port'; +import { stopWatchingSpaces, watchSpaces } from './space/spaceWatch'; +import { serial } from './runtime/serial'; +import { errorMessage } from '@/api/errors'; +import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './account/account'; +import { fileDomainOptions, fileDomainUsage } from './files/fileService'; +import { createFolder, createThread, createGroup, renameGroup, deleteGroup, createRole, updateRole, deleteRole, setRoleMembers, groupThread, moveItems, renameFolder, renameThread, setChannelHistory } from './space/channelThreads'; +import { fetchAvatar } from './account/avatars'; +import { servePlaybackRange } from './files/playbackStream'; +import { refreshLoadedWorkspaces } from './space/workspaceStructure'; +import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice, getSavedLogin, clearSavedLogin } from './account/account'; interface WorkerRequest { requestId: number; operation: string; payload: unknown } interface WorkerScope { @@ -69,7 +70,9 @@ async function dispatch(operation: string, input: unknown): Promise { case 'revokeChannelInvitation': return revokeChannelInvitation(id, String(payload.username ?? '')); case 'createChannel': return createChannel(payload.options as Parameters[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined); case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters[1]); - case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined); + case 'fileOptions': return fileDomainOptions(); + case 'fileUsage': return fileDomainUsage(id, String(payload.threadId ?? '')); + case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined, typeof payload.fileQuota === 'number' ? payload.fileQuota : undefined); case 'createGroup': return createGroup(id, String(payload.name ?? '')); case 'createRole': return createRole(id, payload.role as WorkspaceRoleRequest); case 'updateRole': return updateRole(id, String(payload.roleId ?? ''), payload.role as WorkspaceRoleRequest); diff --git a/web/src/core-bridge/database.ts b/web/src/worker/runtime/database.ts similarity index 97% rename from web/src/core-bridge/database.ts rename to web/src/worker/runtime/database.ts index 65696d6..14d248f 100644 --- a/web/src/core-bridge/database.ts +++ b/web/src/worker/runtime/database.ts @@ -1,6 +1,6 @@ import { openDB, type DBSchema, type IDBPDatabase } from 'idb'; -import type { Item } from './types'; -import type { ChannelInfo as ChannelMetadata, ShareInfo as ShareMetadata, WorkspaceBody } from './generated/api'; +import type { Item } from '@/domain/types'; +import type { ChannelInfo as ChannelMetadata, ShareInfo as ShareMetadata, WorkspaceBody } from '@/api/generated'; export type ManifestEntry = | { t: 'file'; id: string; name: string; mime: string; size: number; ts: number } @@ -47,8 +47,10 @@ export interface QueuedFrame { bytes: Uint8Array; extraHeaders: Record; createdAt: number; - /** 文件内容的帧:开启文件服务时字节传给文件服务,只把头部和回执交给主后端 */ + /** 文件内容的帧:字节只传给文件服务(文件域),不经主后端 */ remote?: boolean; + /** 整个文件的登记:所有帧都存进文件域之后,一次请求把末帧头部和每一帧的(长度,摘要)交给主后端。不是帧,key 以 :register 结尾 */ + register?: boolean; } interface DraftFile { diff --git a/web/src/core-bridge/worker/http.test.ts b/web/src/worker/runtime/http.test.ts similarity index 100% rename from web/src/core-bridge/worker/http.test.ts rename to web/src/worker/runtime/http.test.ts diff --git a/web/src/core-bridge/worker/http.ts b/web/src/worker/runtime/http.ts similarity index 80% rename from web/src/core-bridge/worker/http.ts rename to web/src/worker/runtime/http.ts index 183d7dc..ba0e361 100644 --- a/web/src/core-bridge/worker/http.ts +++ b/web/src/worker/runtime/http.ts @@ -1,6 +1,7 @@ -import { supportsApiContract } from '../contract'; -import { accountToken } from './accountSession'; -import type { ServerInfoBody } from '../generated/api'; +import { ErrorCode } from '@/api/generated'; +import { supportsApiContract } from '@/api/contract'; +import { accountToken } from '../account/accountSession'; +import type { ServerInfoBody } from '@/api/generated'; export type ApiError = Error & { status?: number; code?: string; /** 服务端要求的等待秒数(429 的 Retry-After) */ retryAfter?: number }; let apiBase = '/api/v1'; @@ -13,6 +14,19 @@ export function setApiBase(value: string): void { } export const fail = (status: number, code: string): ApiError => Object.assign(new Error(code), { status, code }); + +/** + * 服务端回的错误是不是这些错误码之一。业务代码只能这样判断服务端拒绝的原因(取值来自后端生成的 ErrorCode), + * 不要自己比较 HTTP 状态码数字或字符串。 + */ +export const hasCode = (error: unknown, ...codes: ErrorCode[]): boolean => + error instanceof Error && typeof (error as ApiError).code === 'string' && (codes as string[]).includes((error as ApiError).code!); + +/** 这个失败值得重试:没连上、请求超时/被限流、服务端临时出错。其余(被拒绝)重试没有意义 */ +export function isRetryable(error: ApiError): boolean { + const status = error.status; + return status === undefined || status === 408 || status === 425 || status === 429 || status === 499 || status >= 500; +} const currentOrigin = (): string => typeof self !== 'undefined' && self.location?.origin ? self.location.origin : 'http://localhost'; @@ -110,7 +124,8 @@ export async function apiUpload( const responseHeaders = new Headers(); const contentType = xhr.getResponseHeader('Content-Type'); if (contentType) responseHeaders.set('Content-Type', contentType); - const retryAfter = xhr.getResponseHeader('Retry-After'); + // 只有失败的响应才可能带 Retry-After。跨域响应(文件服务)读没暴露的头会在控制台报错,成功的每一帧都读会刷屏 + const retryAfter = xhr.status >= 400 ? xhr.getResponseHeader('Retry-After') : null; if (retryAfter) responseHeaders.set('Retry-After', retryAfter); resolve(new Response(xhr.responseText, { status: xhr.status, statusText: xhr.statusText, headers: responseHeaders })); }; @@ -137,9 +152,15 @@ export async function apiUpload( } /** 直接向另一个服务(文件服务)取内容:只带它要的票据,不带账号令牌 */ -export async function externalFetch(url: string, headers: Record, signal?: AbortSignal): Promise { +export function externalFetch(url: string, headers: Record, signal?: AbortSignal): Promise { + return externalRequest(url, { headers, ...(signal ? { signal } : {}) }); +} + +/** 直接向另一个服务(文件服务)发请求(创建、删除、查询等):不带账号令牌 */ +export async function externalRequest(url: string, init: { method?: string; headers?: Record; body?: string; signal?: AbortSignal } = {}): Promise { + const { signal } = init; try { - const response = await fetch(url, { headers, signal, cache: 'no-store', credentials: 'omit', referrerPolicy: 'no-referrer' }); + const response = await fetch(url, { ...init, cache: 'no-store', credentials: 'omit', referrerPolicy: 'no-referrer' }); if (!response.ok) { const body = await response.json().catch(() => null) as { error?: unknown } | null; throw fail(response.status, typeof body?.error === 'string' && /^[a-z_]{1,40}$/.test(body.error) ? body.error : 'request_failed'); diff --git a/web/src/core-bridge/worker/port.ts b/web/src/worker/runtime/port.ts similarity index 82% rename from web/src/core-bridge/worker/port.ts rename to web/src/worker/runtime/port.ts index 610731e..e4351cf 100644 --- a/web/src/core-bridge/worker/port.ts +++ b/web/src/worker/runtime/port.ts @@ -1,4 +1,4 @@ -import type { ShareEvent, SpaceEvent, WorkspaceEvent } from '../types'; +import type { ShareEvent, SpaceEvent, WorkspaceEvent } from '@/domain/types'; type BridgeEvent = WorkspaceEvent | ShareEvent | SpaceEvent; type EventSink = (resourceId: string, event: BridgeEvent, blob?: Blob) => void; diff --git a/web/src/core-bridge/worker/presentation.ts b/web/src/worker/runtime/presentation.ts similarity index 97% rename from web/src/core-bridge/worker/presentation.ts rename to web/src/worker/runtime/presentation.ts index 271a968..34c9c61 100644 --- a/web/src/core-bridge/worker/presentation.ts +++ b/web/src/worker/runtime/presentation.ts @@ -1,6 +1,6 @@ import type { ItemEvent } from '@webdrop/core'; -import type { FileItem, ShareEvent, WorkspaceEvent } from '../types'; -import { previewKindOf } from '@/lib/fileKind'; +import type { FileItem, ShareEvent, WorkspaceEvent } from '@/domain/types'; +import { previewKindOf } from '@/lib/files/fileKind'; import { publish } from './port'; export type TerminalKind = 'desktop' | 'phone' | 'tablet'; diff --git a/web/src/core-bridge/worker/serial.ts b/web/src/worker/runtime/serial.ts similarity index 100% rename from web/src/core-bridge/worker/serial.ts rename to web/src/worker/runtime/serial.ts diff --git a/web/src/core-bridge/worker/sessions.ts b/web/src/worker/runtime/sessions.ts similarity index 68% rename from web/src/core-bridge/worker/sessions.ts rename to web/src/worker/runtime/sessions.ts index d99c5d0..237ce42 100644 --- a/web/src/core-bridge/worker/sessions.ts +++ b/web/src/worker/runtime/sessions.ts @@ -1,11 +1,12 @@ +import { TokenRole } from '@/api/generated'; import { ChannelSession } from '@webdrop/core'; -import { getResource, currentResourceScope, type SavedResource } from '../database'; +import { getResource, currentResourceScope, type SavedResource } from './database'; import { fail } from './http'; -import { toCoreLink } from './links'; -import { clearFileCatalog } from './fileCatalog'; -import { clearTemporaryDownloads } from './downloadStorage'; -import { clearWorkspaceStructures } from './workspaceStructure'; -import { attachDataKeys, clearDataKeys } from './channelDataKeys'; +import { toCoreLink } from '../space/links'; +import { clearFileCatalog } from '../space/fileCatalog'; +import { clearTemporaryDownloads } from '../files/downloadStorage'; +import { clearWorkspaceStructures } from '../space/workspaceStructure'; +import { attachDataKeys, clearDataKeys } from '../crypto/channelDataKeys'; export const sessions = new Map(); export const readers = new Map>(); /** 刚加入的频道 → 加入时间;connectChannel 据此跳过紧接着的重复 join(省一次往返) */ @@ -40,3 +41,11 @@ export async function getSession(id: string): Promise<{ session: ChannelSession; attachDataKeys(session, resource); return { session, resource }; } + +/** + * 这次请求出示哪张令牌:手里有的最强的一张(所有者令牌 > 写令牌 > 读令牌)。 + * 令牌够不够、这件事能不能做,由后端按令牌判断;前端不替它预先决定,更不在各处重复写这个选择。 + */ +export function tokenRole(session: ChannelSession): TokenRole { + return session.can('owner') ? TokenRole.Owner : session.can('write') ? TokenRole.Write : TokenRole.Read; +} diff --git a/web/src/core-bridge/worker/transports.ts b/web/src/worker/runtime/transports.ts similarity index 98% rename from web/src/core-bridge/worker/transports.ts rename to web/src/worker/runtime/transports.ts index b970daa..a0c0cfd 100644 --- a/web/src/core-bridge/worker/transports.ts +++ b/web/src/worker/runtime/transports.ts @@ -1,6 +1,6 @@ import { fromBase64Url, toBase64Url, type ChannelSession } from '@webdrop/core'; import { apiFetch, apiPath } from './http'; -import { accountToken } from './accountSession'; +import { accountToken } from '../account/accountSession'; export interface StreamConsumer { cursor: number; diff --git a/web/src/core-bridge/worker/wire.ts b/web/src/worker/runtime/wire.ts similarity index 77% rename from web/src/core-bridge/worker/wire.ts rename to web/src/worker/runtime/wire.ts index 8210943..65b4ef9 100644 --- a/web/src/core-bridge/worker/wire.ts +++ b/web/src/worker/runtime/wire.ts @@ -1,6 +1,6 @@ -import type { SavedResource } from '../database'; -import type { ChannelInfo, Share, ShareSettings } from '../types'; -import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '../generated/api'; +import type { SavedResource } from './database'; +import type { ChannelInfo, Share, ShareSettings } from '@/domain/types'; +import type { ChannelInfo as ChannelWire, ShareInfo as ShareWire, ShareItemInfo as ShareWireItem } from '@/api/generated'; export type { ChannelWire, ShareWire, ShareWireItem }; export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo { const joinPolicy = raw.settings.join_policy; @@ -13,7 +13,15 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn avatar: raw.avatar ?? undefined, lastSeq: raw.last_seq, mode: raw.mode, - role: raw.can_manage_structure ? 'owner' : 'member', + isOwner: raw.is_owner, + canCreateChannel: raw.can_create_channel, + canManageFolders: raw.can_manage_folders, + canInvite: raw.can_invite, + canEditSettings: raw.can_edit_settings, + canManageRoles: raw.can_manage_roles, + canRemoveMembers: raw.can_remove_members, + canDeleteAny: raw.can_delete_any, + canDeleteOwn: raw.can_delete_own, canUpload: raw.can_upload, canManageStructure: raw.can_manage_structure, maxMembers: raw.settings.max_members, diff --git a/web/src/core-bridge/worker/shareDraft.ts b/web/src/worker/shares/shareDraft.ts similarity index 86% rename from web/src/core-bridge/worker/shareDraft.ts rename to web/src/worker/shares/shareDraft.ts index cacfbf9..0ff6f58 100644 --- a/web/src/core-bridge/worker/shareDraft.ts +++ b/web/src/worker/shares/shareDraft.ts @@ -1,15 +1,17 @@ -import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../database'; -import type { FileItem, TextItem } from '../types'; -import { deviceIdentity, makeFileItem } from './presentation'; -import { getSession } from './sessions'; -import { apiFetch, fail } from './http'; +import { hasCode } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; +import { dequeueItem, removeDraftFile, saveDraftFile, saveResource, type ManifestEntry } from '../runtime/database'; +import type { FileItem, TextItem } from '@/domain/types'; +import { deviceIdentity, makeFileItem } from '../runtime/presentation'; +import { getSession } from '../runtime/sessions'; +import { apiFetch, fail } from '../runtime/http'; -import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer'; -import { publishShare } from './presentation'; +import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from '../files/transfer'; +import { publishShare } from '../runtime/presentation'; import { invalidateManifest } from './shareState'; -import { blobs } from './sessions'; -import { errorMessage } from '../errors'; +import { blobs } from '../runtime/sessions'; +import { errorMessage } from '@/api/errors'; function newTextEntry(text: string): { item: TextItem; entry: Extract } { @@ -23,7 +25,7 @@ function newTextEntry(text: string): { item: TextItem; entry: Extract { const { session, resource } = await getSession(id); - if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); + if (resource.kind !== 'share') throw new Error('资源类型不匹配'); await invalidateManifest(session, resource); const { item, entry } = newTextEntry(text); resource.draftItems = [...(resource.draftItems ?? []), item]; @@ -34,7 +36,7 @@ export async function addShareText(id: string, text: string): Promise { export async function addShareFiles(id: string, files: File[]): Promise { const { session, resource } = await getSession(id); - if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); + if (resource.kind !== 'share') throw new Error('资源类型不匹配'); await invalidateManifest(session, resource); const downloadLimit = resource.maxDownloads ?? 0; const maxFrameData = (await deviceIdentity(session.endpointSecret)).kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA; @@ -104,7 +106,7 @@ export async function addShareFiles(id: string, files: File[]): Promise { export async function editShareText(id: string, itemId: string, text: string): Promise { const { session, resource } = await getSession(id); - if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); + if (resource.kind !== 'share') throw new Error('资源类型不匹配'); const item = resource.draftItems?.find((entry): entry is TextItem => entry.id === itemId && entry.kind === 'text'); if (!item) throw fail(404, 'not_found'); await invalidateManifest(session, resource); @@ -119,13 +121,13 @@ export async function editShareText(id: string, itemId: string, text: string): P export async function removeShareItem(id: string, itemId: string): Promise { const { session, resource } = await getSession(id); - if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存'); + if (resource.kind !== 'share') throw new Error('资源类型不匹配'); await invalidateManifest(session, resource); const item = resource.draftItems?.find((entry) => entry.id === itemId); if (!item) throw fail(404, 'not_found'); if (item.kind === 'file') { try { await apiFetch(`/shares/${id}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders('owner') }); } - catch (error) { if ((error as { status?: number }).status !== 404) throw error; } + catch (error) { if (!hasCode(error, ErrorCode.NotFound)) throw error; } await dequeueItem(id, itemId); await removeDraftFile(id, itemId); blobs.delete(`${id}:${itemId}`); diff --git a/web/src/core-bridge/worker/shareManifest.ts b/web/src/worker/shares/shareManifest.ts similarity index 96% rename from web/src/core-bridge/worker/shareManifest.ts rename to web/src/worker/shares/shareManifest.ts index 7895185..aed78ee 100644 --- a/web/src/core-bridge/worker/shareManifest.ts +++ b/web/src/worker/shares/shareManifest.ts @@ -1,7 +1,7 @@ import { IntegrityError, type ChannelSession } from '@webdrop/core'; -import type { ManifestEntry } from '../database'; -import type { ShareWire } from './wire'; -import { apiFetch } from './http'; +import type { ManifestEntry } from '../runtime/database'; +import type { ShareWire } from '../runtime/wire'; +import { apiFetch } from '../runtime/http'; export type ManifestFile = { t: 'file'; id: string; name?: string; mime?: string; size: number; ts: number }; export type ManifestText = { t: 'text'; name?: string; text: string; ts: number }; diff --git a/web/src/core-bridge/worker/shareRead.ts b/web/src/worker/shares/shareRead.ts similarity index 92% rename from web/src/core-bridge/worker/shareRead.ts rename to web/src/worker/shares/shareRead.ts index 2bf5e78..09aece8 100644 --- a/web/src/core-bridge/worker/shareRead.ts +++ b/web/src/worker/shares/shareRead.ts @@ -1,16 +1,16 @@ import { ChannelSession, IntegrityError } from '@webdrop/core'; -import { getDraftFile, saveResource, type SavedResource } from '../database'; -import type { FileItem, TextItem } from '../types'; -import { makeFileItem } from './presentation'; -import { active, blobs, getSession } from './sessions'; -import { fail, readJson } from './http'; -import { toShareInfo, type ShareWire } from './wire'; +import { getDraftFile, saveResource, type SavedResource } from '../runtime/database'; +import type { FileItem, TextItem } from '@/domain/types'; +import { makeFileItem } from '../runtime/presentation'; +import { active, blobs, getSession } from '../runtime/sessions'; +import { fail, readJson } from '../runtime/http'; +import { toShareInfo, type ShareWire } from '../runtime/wire'; -import { flushOutbox, downloadDecrypted } from './transfer'; -import { publishShare } from './presentation'; +import { flushOutbox, downloadDecrypted } from '../files/transfer'; +import { publishShare } from '../runtime/presentation'; import { fetchManifest, type ManifestFile } from './shareManifest'; import { readShareInfo } from './shareState'; -import { errorMessage } from '../errors'; +import { errorMessage } from '@/api/errors'; export async function connectShare(id: string): Promise { active.get(id)?.abort(); diff --git a/web/src/core-bridge/worker/shareResources.ts b/web/src/worker/shares/shareResources.ts similarity index 87% rename from web/src/core-bridge/worker/shareResources.ts rename to web/src/worker/shares/shareResources.ts index eabd4ce..e93b53a 100644 --- a/web/src/core-bridge/worker/shareResources.ts +++ b/web/src/worker/shares/shareResources.ts @@ -1,15 +1,17 @@ +import { hasCode } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; import { ChannelSession } from '@webdrop/core'; -import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; -import { shareCache, isOfflineFailure } from './resourceMetadata'; -import type { ShareSettingsPatch } from '../generated/api'; +import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../runtime/database'; +import { shareCache, isOfflineFailure } from '../space/resourceMetadata'; +import type { ShareSettingsPatch } from '@/api/generated'; import { readShareInfo } from './shareState'; -import type { Share, ShareSettings } from '../types'; +import type { Share, ShareSettings } from '@/domain/types'; -import { active, blobs, getSession, sessions } from './sessions'; -import { apiFetch, fail, readJson } from './http'; -import { apiShareSettings, toShareInfo, type ShareWire } from './wire'; -import { makeLink, toShareLink } from './links'; -import { requireAccount } from './accountSession'; +import { active, blobs, getSession, sessions } from '../runtime/sessions'; +import { apiFetch, fail, readJson } from '../runtime/http'; +import { apiShareSettings, toShareInfo, type ShareWire } from '../runtime/wire'; +import { makeLink, toShareLink } from '../space/links'; +import { requireAccount } from '../account/accountSession'; @@ -79,7 +81,7 @@ export async function joinShare(input: { link: { id: string; key: string } }): P export async function updateShareSettings(id: string, patch: Partial): Promise { const { session, resource } = await getSession(id); - if (resource.kind !== 'share' || !session.can('owner')) throw new Error('此分享不再可修改'); + if (resource.kind !== 'share') throw new Error('资源类型不匹配'); const body: ShareSettingsPatch = {}; if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds; if (patch.maxVisitors !== undefined) body.max_visitors = patch.maxVisitors; @@ -110,7 +112,7 @@ export async function listSavedShares(): Promise { await cacheResource(resource, shareCache(raw)); return { ...info, dataSource: 'server' }; } catch (error) { - if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null; + if (hasCode(error, ErrorCode.NotFound, ErrorCode.Gone)) return null; if (!isOfflineFailure(error)) throw error; if (resource.shareMetadata) return { ...toShareInfo(resource.shareMetadata, resource), dataSource: 'cache' }; return { diff --git a/web/src/core-bridge/worker/shareSeal.ts b/web/src/worker/shares/shareSeal.ts similarity index 81% rename from web/src/core-bridge/worker/shareSeal.ts rename to web/src/worker/shares/shareSeal.ts index d218866..d59d125 100644 --- a/web/src/core-bridge/worker/shareSeal.ts +++ b/web/src/worker/shares/shareSeal.ts @@ -1,16 +1,16 @@ -import { saveResource } from '../database'; -import type { Item, Share } from '../types'; -import { getSession, sessions } from './sessions'; -import { apiFetch } from './http'; -import { toShareInfo, type ShareWire } from './wire'; -import { flushOutbox, persistPlan } from './transfer'; -import { publishShare } from './presentation'; +import { saveResource } from '../runtime/database'; +import type { Item, Share } from '@/domain/types'; +import { getSession, sessions } from '../runtime/sessions'; +import { apiFetch } from '../runtime/http'; +import { toShareInfo, type ShareWire } from '../runtime/wire'; +import { flushOutbox, persistPlan } from '../files/transfer'; +import { publishShare } from '../runtime/presentation'; import { encodeManifest } from './shareManifest'; import { readShareInfo, releaseOwner } from './shareState'; export async function sealShare(id: string): Promise { const { session, resource } = await getSession(id); - if (resource.kind !== 'share' || resource.state !== 'draft' || !session.can('owner')) throw new Error('此分享已封存'); + if (resource.kind !== 'share') throw new Error('资源类型不匹配'); if (!(await flushOutbox(resource, session))) throw new Error('网络连接失败,请稍后重试'); const before = await readShareInfo(session, resource); if (before.state === 'sealed') return toShareInfo(before, resource); diff --git a/web/src/core-bridge/worker/shareState.ts b/web/src/worker/shares/shareState.ts similarity index 81% rename from web/src/core-bridge/worker/shareState.ts rename to web/src/worker/shares/shareState.ts index 9428d15..08b6e76 100644 --- a/web/src/core-bridge/worker/shareState.ts +++ b/web/src/worker/shares/shareState.ts @@ -1,16 +1,18 @@ +import { hasCode } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; import { ChannelSession } from '@webdrop/core'; -import { clearDraftFiles, dequeueItem, saveResource, type SavedResource } from '../database'; -import { blobs, sessions } from './sessions'; -import { apiFetch, readJson } from './http'; -import { toCoreLink } from './links'; -import type { ShareWire } from './wire'; +import { clearDraftFiles, dequeueItem, saveResource, type SavedResource } from '../runtime/database'; +import { blobs, sessions } from '../runtime/sessions'; +import { apiFetch, readJson } from '../runtime/http'; +import { toCoreLink } from '../space/links'; +import type { ShareWire } from '../runtime/wire'; export async function readShareInfo(session: ChannelSession, resource: SavedResource): Promise { const role = resource.state === 'draft' && session.can('owner') ? 'owner' : 'read'; let raw: ShareWire; try { raw = await readJson(`/shares/${resource.id}`, session.authHeaders(role)); } catch (error) { - if (role !== 'owner' || (error as { status?: number }).status !== 401) throw error; + if (role !== 'owner' || !hasCode(error, ErrorCode.Unauthorized)) throw error; // finish 响应丢失时,读凭据仍可确认服务端已封存。 raw = await readJson(`/shares/${resource.id}`, session.authHeaders('read')); } @@ -42,7 +44,7 @@ export async function invalidateManifest(session: ChannelSession, resource: Save const id = resource.pendingManifestId; if (!id) return; try { await apiFetch(`/shares/${resource.id}/items/${id}`, { method: 'DELETE', headers: session.authHeaders('owner') }); } - catch (error) { if ((error as { status?: number }).status !== 404) throw error; } + catch (error) { if (!hasCode(error, ErrorCode.NotFound)) throw error; } await dequeueItem(resource.id, id); resource.pendingManifestId = undefined; await saveResource(resource); diff --git a/web/src/core-bridge/worker/channelEvents.ts b/web/src/worker/space/channelEvents.ts similarity index 93% rename from web/src/core-bridge/worker/channelEvents.ts rename to web/src/worker/space/channelEvents.ts index c3b5c57..364eb58 100644 --- a/web/src/core-bridge/worker/channelEvents.ts +++ b/web/src/worker/space/channelEvents.ts @@ -1,16 +1,17 @@ +import { tokenRole } from '../runtime/sessions'; import { parseLogRecords, parseEnvelope, toBase64Url, type ChannelSession, type LogRecord, type ReceivedItem } from '@webdrop/core'; -import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataKeys, verifyObjectDeletion, verifyObjectEvent } from './channelDataKeys'; -import { getResource, cacheResource, type SavedResource } from '../database'; +import { canDecryptRecord, objectAuthorAccount, objectEventProjection, readDataKeys, verifyObjectDeletion, verifyObjectEvent } from '../crypto/channelDataKeys'; +import { getResource, cacheResource, type SavedResource } from '../runtime/database'; import { channelCache } from './resourceMetadata'; import { cachedPerson, loadWorkspace } from './workspaceStructure'; -import { cachedAvatar } from './avatars'; +import { cachedAvatar } from '../account/avatars'; import { displayAvatar } from './senderSnapshot'; -import type { FileItem, Item, SenderInfo, Snapshot } from '../types'; -import { previewKindOf } from '@/lib/fileKind'; -import { blobs, readers } from './sessions'; -import { showRejected, publishWorkspace } from './presentation'; -import { type ChannelWire, toChannelInfo } from './wire'; -import { apiFetch, readJson } from './http'; +import type { FileItem, Item, SenderInfo, Snapshot } from '@/domain/types'; +import { previewKindOf } from '@/lib/files/fileKind'; +import { blobs, readers } from '../runtime/sessions'; +import { showRejected, publishWorkspace } from '../runtime/presentation'; +import { type ChannelWire, toChannelInfo } from '../runtime/wire'; +import { apiFetch, readJson } from '../runtime/http'; import { readResourceRecord } from './workspaceRecords'; import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog'; @@ -135,7 +136,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann } export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise { - const info = await readJson(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read')); + const info = await readJson(`/channels/${resource.id}`, session.authHeaders(tokenRole(session))); session.verifyChannelInfo(info); Object.assign(resource, await cacheResource(resource, channelCache(info))); publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) }); diff --git a/web/src/core-bridge/worker/channelItems.ts b/web/src/worker/space/channelItems.ts similarity index 72% rename from web/src/core-bridge/worker/channelItems.ts rename to web/src/worker/space/channelItems.ts index dfd344c..b1a10a1 100644 --- a/web/src/core-bridge/worker/channelItems.ts +++ b/web/src/worker/space/channelItems.ts @@ -1,20 +1,22 @@ -import { previewKindOf } from '@/lib/fileKind'; -import type { FileItem } from '../types'; -import { apiFetch } from './http'; -import { blobs, getSession } from './sessions'; +import { tokenRole } from '../runtime/sessions'; +import { previewKindOf } from '@/lib/files/fileKind'; +import type { FileItem } from '@/domain/types'; +import { apiFetch } from '../runtime/http'; +import { deleteFileBytes, filesEnabled, requestGrant } from '../files/fileService'; +import { blobs, getSession } from '../runtime/sessions'; import { stopChannel, connectChannel } from './channelTransport'; -import { downloadDecrypted } from './transfer'; -import { publishWorkspace } from './presentation'; +import { downloadDecrypted } from '../files/transfer'; +import { publishWorkspace } from '../runtime/presentation'; import { IntegrityError } from '@webdrop/core'; import { fileCatalogs, verifyFileCatalog } from './fileCatalog'; -import { dequeueItem, saveResource } from '../database'; -import { serial } from './serial'; -import { signObjectDeletion } from './channelDataKeys'; -import { accountToken } from './accountSession'; +import { dequeueItem, saveResource } from '../runtime/database'; +import { serial } from '../runtime/serial'; +import { signObjectDeletion } from '../crypto/channelDataKeys'; +import { accountToken } from '../account/accountSession'; export function connectChannelResource(id: string): () => void { void connectChannel(id); @@ -23,10 +25,13 @@ export function connectChannelResource(id: string): () => void { export async function removeChannelItem(resourceId: string, itemId: string): Promise { const { session, resource } = await getSession(resourceId); - const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'; + const role = tokenRole(session); await serial(`outbox:${resourceId}`, async () => { const eventHeaders = resource.kind === 'channel' && accountToken() ? await signObjectDeletion(resource, session, itemId) : {}; + // 文件的字节在文件域里:先向空间要删除票据(空间确认能删),删掉主后端的条目,再用票据清文件域。聊天条目没有文件域,拿不到票据就跳过 + const fileGrant = resource.kind === 'channel' && await filesEnabled() ? await requestGrant(resource, session, { op: 'delete', item: itemId }).catch(() => undefined) : undefined; await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: { ...session.authHeaders(role), ...eventHeaders } }); + if (fileGrant) await deleteFileBytes(fileGrant, itemId).catch(() => undefined); await dequeueItem(resourceId, itemId); resource.localItems = resource.localItems?.filter((item) => item.id !== itemId); await saveResource(resource); diff --git a/web/src/core-bridge/worker/channelResources.ts b/web/src/worker/space/channelResources.ts similarity index 84% rename from web/src/core-bridge/worker/channelResources.ts rename to web/src/worker/space/channelResources.ts index 8c1f8eb..38355fc 100644 --- a/web/src/core-bridge/worker/channelResources.ts +++ b/web/src/worker/space/channelResources.ts @@ -1,20 +1,23 @@ +import { hasCode } from '../runtime/http'; +import { ErrorCode } from '@/api/generated'; +import { tokenRole } from '../runtime/sessions'; import { ChannelSession } from '@webdrop/core'; -import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database'; -import type { ParsedChannelLink } from '@/lib/channelLink'; +import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../runtime/database'; +import type { ParsedChannelLink } from '@/lib/links/channelLink'; -import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types'; -import { jsonBody, apiFetch, readJson, getServerInfo } from './http'; +import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '@/domain/types'; +import { jsonBody, apiFetch, readJson, getServerInfo } from '../runtime/http'; import { channelCache, isOfflineFailure } from './resourceMetadata'; -import { accountToken } from './accountSession'; -import { attachDataKeys, initializeChannelKeys } from './channelDataKeys'; -import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api'; -import { freshlyJoined, getSession, sessions } from './sessions'; +import { accountToken } from '../account/accountSession'; +import { attachDataKeys, initializeChannelKeys } from '../crypto/channelDataKeys'; +import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '@/api/generated'; +import { freshlyJoined, getSession, sessions } from '../runtime/sessions'; import { makeLink } from './links'; -import { toChannelInfo, type ChannelWire } from './wire'; +import { toChannelInfo, type ChannelWire } from '../runtime/wire'; import { stopChannel } from './channelTransport'; -import { publishWorkspace } from './presentation'; -import { requireAccount, accountPublicKey } from './accountSession'; -import { sealChannelGrant } from './channelGrant'; +import { publishWorkspace } from '../runtime/presentation'; +import { requireAccount, accountPublicKey } from '../account/accountSession'; +import { sealChannelGrant } from '../crypto/channelGrant'; @@ -75,7 +78,7 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s const info = await readJson(`/channels/${id}`, session.authHeaders('read')); session.verifyChannelInfo(info); const joined = await apiFetch(`/channels/${id}/join`, { - method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }), + method: 'POST', headers: { ...session.authHeaders(tokenRole(session)), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }), }).then((response) => response.json() as Promise); info.members_used = joined.members_used; const resource: SavedResource = { @@ -111,7 +114,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise { const { session } = await getSession(id); - return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read')); + return readJson(`/channels/${id}/members`, session.authHeaders(tokenRole(session))); } export async function listChannels(): Promise { @@ -127,17 +130,17 @@ export async function listChannels(): Promise { try { if (catalog && !catalog.has(resource.id)) return null; const { session } = await getSession(resource.id); - const raw = catalog?.get(resource.id) ?? await readJson(`/channels/${resource.id}`, session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read')); + const raw = catalog?.get(resource.id) ?? await readJson(`/channels/${resource.id}`, session.authHeaders(tokenRole(session))); session.verifyChannelInfo(raw); const info = toChannelInfo(raw, resource); await cacheResource(resource, channelCache(raw)); return { ...info, dataSource: 'server' }; } catch (error) { - if (error instanceof Error && 'status' in error && (error.status === 404 || error.status === 410)) return null; + if (hasCode(error, ErrorCode.NotFound, ErrorCode.Gone)) return null; if (!isOfflineFailure(error)) throw error; if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' }; return { - id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined, + id: resource.id, name: resource.name, mode: resource.mode, isOwner: false, canCreateChannel: false, canManageFolders: false, canInvite: false, canEditSettings: false, canManageRoles: false, canRemoveMembers: false, canDeleteAny: false, canDeleteOwn: false, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined, creatorAccount: resource.creatorAccount, memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt, dataSource: 'cache', @@ -149,7 +152,6 @@ export async function listChannels(): Promise { export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise { const { session, resource } = await getSession(resourceId); - if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置'); const body: SettingsRequest = {}; if (patch.name !== undefined) body.name = patch.name.trim(); if (patch.avatar !== undefined) body.avatar = patch.avatar; diff --git a/web/src/core-bridge/worker/channelSend.ts b/web/src/worker/space/channelSend.ts similarity index 86% rename from web/src/core-bridge/worker/channelSend.ts rename to web/src/worker/space/channelSend.ts index 0041f28..35fbe72 100644 --- a/web/src/core-bridge/worker/channelSend.ts +++ b/web/src/worker/space/channelSend.ts @@ -1,19 +1,19 @@ import { removeChannelItem } from './channelItems'; -import { enqueueFileUploads } from './uploadQueue'; +import { enqueueFileUploads } from '../files/uploadQueue'; import { senderSnapshot } from './senderSnapshot'; -import { prepareObject } from './channelDataKeys'; -import { queueLocation } from '../database'; -import type { FileUploadOptions, Item } from '../types'; -import { blobs, getSession } from './sessions'; -import { accountDisplay } from './accountSession'; -import { flushOutbox, persistPlan } from './transfer'; -import { publishWorkspace } from './presentation'; +import { prepareObject } from '../crypto/channelDataKeys'; +import { queueLocation } from '../runtime/database'; +import type { FileUploadOptions, Item } from '@/domain/types'; +import { blobs, getSession } from '../runtime/sessions'; +import { accountDisplay } from '../account/accountSession'; +import { flushOutbox, persistPlan } from '../files/transfer'; +import { publishWorkspace } from '../runtime/presentation'; export async function sendText(resourceId: string, text: string, threadId?: string, event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }, localId?: string): Promise<{ itemId: string; uploaded: boolean }> { if (!threadId) throw new Error('请先选择频道'); const { session, resource } = await getSession(resourceId); - if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限'); + if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); const ts = Date.now(); const author = await senderSnapshot(); const prepared = await prepareObject(resource, session, threadId, 'chat'); diff --git a/web/src/core-bridge/worker/channelThreads.ts b/web/src/worker/space/channelThreads.ts similarity index 76% rename from web/src/core-bridge/worker/channelThreads.ts rename to web/src/worker/space/channelThreads.ts index 3c87696..14f3d88 100644 --- a/web/src/core-bridge/worker/channelThreads.ts +++ b/web/src/worker/space/channelThreads.ts @@ -1,21 +1,26 @@ import type { ChannelSession } from '@webdrop/core'; -import type { SavedResource } from '../database'; -import type { FileItem } from '../types'; -import { persistPlan } from './transfer'; +import type { SavedResource } from '../runtime/database'; +import type { FileItem } from '@/domain/types'; +import { persistPlan } from '../files/transfer'; import { changeWorkspace, createWorkspaceChannel, createWorkspaceGroup, createWorkspaceRole } from './workspaceStructure'; -import { getSession } from './sessions'; -import { initializeChannelKeys, prepareObject } from './channelDataKeys'; +import { getSession } from '../runtime/sessions'; +import { createFileDomain, filesEnabled } from '../files/fileService'; +import { initializeChannelKeys, prepareObject } from '../crypto/channelDataKeys'; import { toPublishedFile } from './fileCatalog'; -import { apiFetch } from './http'; -import { WORKSPACE_APP, type ResourceControl } from '../protocol/workspace'; -import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest, WorkspaceRoleRequest, WorkspaceRoleMembersRequest } from '../generated/api'; +import { apiFetch } from '../runtime/http'; +import { WORKSPACE_APP, type ResourceControl } from '@/domain/workspace'; +import type { WorkspaceChannelRequest, WorkspaceGroupRequest, WorkspaceNameRequest, WorkspaceFolderRequest, WorkspaceMoveRequest, WorkspaceRoleRequest, WorkspaceRoleMembersRequest } from '@/api/generated'; const encoder = new TextEncoder(); -export async function createThread(resourceId: string, name: string, groupId?: string): Promise { - const id = await createWorkspaceChannel(resourceId, { name, groupId } satisfies WorkspaceChannelRequest); - const { resource, session } = await getSession(resourceId); await initializeChannelKeys(resource, session, id); +export async function createThread(resourceId: string, name: string, groupId?: string, fileQuota?: number): Promise { + const { resource, session } = await getSession(resourceId); + // 创建频道时由前端先向文件服务要一个文件域(容量由所有者选),再把域 id 记到频道上;没开文件服务就没有 + // 文件服务没部署好、连不上时频道照样建:聊天不依赖它,文件域在第一次上传文件时再补(见 ensureThreadFileDomain) + const fileDomain = await filesEnabled() ? await createFileDomain(resource, session, fileQuota).catch(() => undefined) : undefined; + const id = await createWorkspaceChannel(resourceId, { name, groupId, ...(fileDomain ? { fileDomain } : {}) } satisfies WorkspaceChannelRequest); + await initializeChannelKeys(resource, session, id); return id; } export async function createGroup(resourceId: string, name: string): Promise { diff --git a/web/src/core-bridge/worker/channelTransport.ts b/web/src/worker/space/channelTransport.ts similarity index 89% rename from web/src/core-bridge/worker/channelTransport.ts rename to web/src/worker/space/channelTransport.ts index 8da25a9..ca505cd 100644 --- a/web/src/core-bridge/worker/channelTransport.ts +++ b/web/src/worker/space/channelTransport.ts @@ -1,14 +1,15 @@ +import { tokenRole } from '../runtime/sessions'; import { IntegrityError } from '@webdrop/core'; -import { active, freshlyJoined, getSession } from './sessions'; -import { apiFetch, type ApiError } from './http'; -import { publishWorkspace } from './presentation'; +import { active, freshlyJoined, getSession } from '../runtime/sessions'; +import { apiFetch, type ApiError } from '../runtime/http'; +import { publishWorkspace } from '../runtime/presentation'; import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents'; import { loadWorkspace } from './workspaceStructure'; -import { flushOutbox } from './transfer'; -import { resumeOutbox } from './resumeUploads'; -import { socketStream, sseStream, delay, type StreamConsumer } from './transports'; -import { errorMessage } from '../errors'; -import { saveResource } from '../database'; +import { flushOutbox } from '../files/transfer'; +import { resumeOutbox } from '../files/resumeUploads'; +import { socketStream, sseStream, delay, type StreamConsumer } from '../runtime/transports'; +import { errorMessage } from '@/api/errors'; +import { saveResource } from '../runtime/database'; export async function connectChannel(id: string): Promise { active.get(id)?.abort(); @@ -22,7 +23,7 @@ export async function connectChannel(id: string): Promise { if (resource.kind !== 'channel') throw new Error('资源类型不匹配'); // Device sessions can be revoked independently of account membership; rejoin idempotently. const joinedAt = freshlyJoined.get(id); freshlyJoined.delete(id); - const join = () => apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal }); + const join = () => apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(tokenRole(session)), signal }); // 已保存的成员重连只是确认身份:与首屏数据同时发出,失败就由下面的重连循环再来一次。首次加入(needsJoin)仍然先 join const joining = joinedAt !== undefined && Date.now() - joinedAt <= 60_000 ? undefined : resource.needsJoin ? await join().then(() => undefined) : join().then(() => undefined); diff --git a/web/src/core-bridge/worker/fileCatalog.ts b/web/src/worker/space/fileCatalog.ts similarity index 86% rename from web/src/core-bridge/worker/fileCatalog.ts rename to web/src/worker/space/fileCatalog.ts index f1489ed..09c9e9e 100644 --- a/web/src/core-bridge/worker/fileCatalog.ts +++ b/web/src/worker/space/fileCatalog.ts @@ -1,12 +1,12 @@ -import type { FileItem } from '../types'; +import type { FileItem } from '@/domain/types'; import { IntegrityError, type ItemMeta } from '@webdrop/core'; -import type { PublishedFile } from '../protocol/workspace'; +import type { PublishedFile } from '@/domain/workspace'; export function toPublishedFile(item: FileItem): PublishedFile { if (!item.threadId) throw new Error('请先选择频道'); return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId, name: item.name, mime: item.mime, size: item.size, ts: item.ts, - gated: item.gated ?? false }; + gated: item.gated ?? false, ...(item.poster ? { poster: item.poster } : {}) }; } export const fileCatalogs = new Map(); diff --git a/web/src/core-bridge/worker/links.ts b/web/src/worker/space/links.ts similarity index 100% rename from web/src/core-bridge/worker/links.ts rename to web/src/worker/space/links.ts diff --git a/web/src/core-bridge/worker/resourceMetadata.test.ts b/web/src/worker/space/resourceMetadata.test.ts similarity index 91% rename from web/src/core-bridge/worker/resourceMetadata.test.ts rename to web/src/worker/space/resourceMetadata.test.ts index 5661dd3..cd18beb 100644 --- a/web/src/core-bridge/worker/resourceMetadata.test.ts +++ b/web/src/worker/space/resourceMetadata.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest'; import { isOfflineFailure } from './resourceMetadata'; import { toPublishedFile } from './fileCatalog'; -import { exportResource } from './accountVault'; -import type { FileItem } from '../types'; -import type { SavedResource } from '../database'; +import { exportResource } from '../account/accountVault'; +import type { FileItem } from '@/domain/types'; +import type { SavedResource } from '../runtime/database'; describe('resource and rendering boundaries', () => { it('does not treat authorization, tampering or contract errors as offline cache hits', () => { diff --git a/web/src/core-bridge/worker/resourceMetadata.ts b/web/src/worker/space/resourceMetadata.ts similarity index 89% rename from web/src/core-bridge/worker/resourceMetadata.ts rename to web/src/worker/space/resourceMetadata.ts index b327fc0..d433a72 100644 --- a/web/src/core-bridge/worker/resourceMetadata.ts +++ b/web/src/worker/space/resourceMetadata.ts @@ -1,5 +1,5 @@ -import type { ChannelInfo, ShareInfo } from '../generated/api'; -import type { SavedResource } from '../database'; +import type { ChannelInfo, ShareInfo } from '@/api/generated'; +import type { SavedResource } from '../runtime/database'; /** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */ export function channelCache(raw: ChannelInfo): Partial { diff --git a/web/src/core-bridge/worker/senderSnapshot.ts b/web/src/worker/space/senderSnapshot.ts similarity index 95% rename from web/src/core-bridge/worker/senderSnapshot.ts rename to web/src/worker/space/senderSnapshot.ts index ccbfd41..10e6a5e 100644 --- a/web/src/core-bridge/worker/senderSnapshot.ts +++ b/web/src/worker/space/senderSnapshot.ts @@ -1,5 +1,5 @@ -import type { SenderInfo } from '../types'; -import { accountDisplay } from './accountSession'; +import type { SenderInfo } from '@/domain/types'; +import { accountDisplay } from '../account/accountSession'; const thumbnails = new Map>(); async function compactAvatar(value: string): Promise { diff --git a/web/src/core-bridge/worker/spaceWatch.ts b/web/src/worker/space/spaceWatch.ts similarity index 94% rename from web/src/core-bridge/worker/spaceWatch.ts rename to web/src/worker/space/spaceWatch.ts index 6271214..36fbccd 100644 --- a/web/src/core-bridge/worker/spaceWatch.ts +++ b/web/src/worker/space/spaceWatch.ts @@ -1,6 +1,6 @@ -import { apiFetch } from './http'; -import { publish } from './port'; -import { delay } from './transports'; +import { apiFetch } from '../runtime/http'; +import { publish } from '../runtime/port'; +import { delay } from '../runtime/transports'; /** 账号所在空间的实时进展(只有序号):空间列表据此提示「有新动静」,不必逐个空间订阅 */ export const SPACES_RESOURCE = '__spaces'; diff --git a/web/src/core-bridge/worker/workspaceRecords.test.ts b/web/src/worker/space/workspaceRecords.test.ts similarity index 100% rename from web/src/core-bridge/worker/workspaceRecords.test.ts rename to web/src/worker/space/workspaceRecords.test.ts diff --git a/web/src/core-bridge/worker/workspaceRecords.ts b/web/src/worker/space/workspaceRecords.ts similarity index 77% rename from web/src/core-bridge/worker/workspaceRecords.ts rename to web/src/worker/space/workspaceRecords.ts index 912df13..a6cff4f 100644 --- a/web/src/core-bridge/worker/workspaceRecords.ts +++ b/web/src/worker/space/workspaceRecords.ts @@ -1,7 +1,8 @@ +import { isPoster } from '@/domain/poster'; import type { ReceivedItem } from '@webdrop/core'; -import type { WorkspaceEvent } from '../types'; -import { WORKSPACE_APP, type PublishedFile } from '../protocol/workspace'; -import { previewKindOf } from '@/lib/fileKind'; +import type { WorkspaceEvent } from '@/domain/types'; +import { WORKSPACE_APP, type PublishedFile } from '@/domain/workspace'; +import { previewKindOf } from '@/lib/files/fileKind'; const isId = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value); const isItemId = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); @@ -18,9 +19,9 @@ export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false | const file = row.item as PublishedFile | undefined; if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId)) || file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256 - || !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean') + || !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean' || (file.poster !== undefined && !isPoster(file.poster))) return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' }; return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, folderId: file.folderId, name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, - gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } }; + gated: file.gated, ...(file.poster ? { poster: file.poster } : {}), remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } }; } diff --git a/web/src/core-bridge/worker/workspaceStructure.ts b/web/src/worker/space/workspaceStructure.ts similarity index 91% rename from web/src/core-bridge/worker/workspaceStructure.ts rename to web/src/worker/space/workspaceStructure.ts index 5e6006a..5bae370 100644 --- a/web/src/core-bridge/worker/workspaceStructure.ts +++ b/web/src/worker/space/workspaceStructure.ts @@ -1,10 +1,11 @@ -import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest, WorkspaceRoleCreatedBody, WorkspaceRoleRequest } from '../generated/api'; -import type { FileItem } from '../types'; -import { getResource, cacheResource, currentResourceScope } from '../database'; -import { getSession } from './sessions'; -import { apiFetch } from './http'; -import { publishWorkspace } from './presentation'; -import { cachedAvatar, fetchAvatar } from './avatars'; +import { tokenRole } from '../runtime/sessions'; +import type { WorkspaceBody, WorkspaceChannelCreatedBody, WorkspaceChannelRequest, WorkspaceGroupCreatedBody, WorkspaceMoveRequest, WorkspaceNameRequest, WorkspaceRoleCreatedBody, WorkspaceRoleRequest } from '@/api/generated'; +import type { FileItem } from '@/domain/types'; +import { getResource, cacheResource, currentResourceScope } from '../runtime/database'; +import { getSession } from '../runtime/sessions'; +import { apiFetch } from '../runtime/http'; +import { publishWorkspace } from '../runtime/presentation'; +import { cachedAvatar, fetchAvatar } from '../account/avatars'; import { displayAvatar } from './senderSnapshot'; const current = new Map(); @@ -41,7 +42,7 @@ async function publishAccess(id: string, body: WorkspaceBody): Promise { try { const resource = await getResource(id); if (!resource) return; const { session } = await getSession(id); - const { lockedThreads } = await import('./channelDataKeys'); + const { lockedThreads } = await import('../crypto/channelDataKeys'); const locked = await lockedThreads(resource, session, body.structure.channels); if (current.get(id) === body) publishWorkspace(id, { t: 'access', locked }); } catch { /* 账号没解锁或离线:不改变现有状态 */ } @@ -79,7 +80,7 @@ export async function refreshLoadedWorkspaces(): Promise { export async function changeWorkspace(id: string, path: string, method: 'POST' | 'PATCH' | 'PUT' | 'DELETE', value?: unknown): Promise { const { session } = await getSession(id); const body = await apiFetch(`/spaces/${id}/${path}`, { - method, headers: { ...session.authHeaders(session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify(value), + method, headers: { ...session.authHeaders(tokenRole(session)), 'Content-Type': 'application/json' }, body: JSON.stringify(value), }).then(r => r.json() as Promise); await publish(id, body); return body;