From 5c3f3b765373d4f9639b5efb291791bea7cebd4c Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 9 Sep 2026 20:16:33 +0800 Subject: [PATCH] feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes Rounds out the full rewrite with the remaining feature domains: group management (admin panel, mute, files, announcements, QR-based invite/join), contacts (friend profile, add-friend, blocklist, new-friends), favorites (real media rendering across text/image/video/voice/file/link), settings (profile/security/privacy/general/about), calls (Agora-backed audio/video signaling + call history), and system messages/file assistant. Also folds in a large batch of live-verified fixes surfaced by repeated fresh code-review passes and hands-on E2E testing against the real backend, including: WS reconnect not resubscribing any topic after a disconnect, missing x-im-client-mid on every send path (protocol compliance for retry dedup), group mute/member-settings/self-profile endpoint corrections, relationship-state type fixes across search/QR/ friend-profile, tenant-switch logout gap, password-policy now read from the real per-tenant config instead of guessed, NavRail highlight/ aria-current desync, several call-state-machine races, and responsive layout fixes across all four breakpoints (mobile/tablet/laptop/desktop) including a full mobile rebuild of favorites, call UI, and the settings home/sub-list split. Co-Authored-By: Claude Sonnet 5 --- web/package-lock.json | 872 ++++++++++++++++++ web/package.json | 16 +- web/src/App.tsx | 57 +- web/src/app/AppShell.tsx | 10 + web/src/app/Avatar.tsx | 31 + web/src/app/Icon.tsx | 7 +- web/src/app/NavRail.tsx | 38 +- web/src/app/RequireAuth.tsx | 44 +- web/src/app/SectionPlaceholder.tsx | 7 - web/src/app/Toast.tsx | 7 +- .../features/auth/EnterpriseRegisterPage.tsx | 19 +- web/src/features/auth/LoginPage.tsx | 12 +- web/src/features/auth/RegisterPage.tsx | 35 +- web/src/features/auth/ResetPasswordPage.tsx | 78 +- web/src/features/auth/TenantEntryPage.tsx | 24 +- .../auth/components/PasswordInput.tsx | 4 +- .../components/VerificationCodeButton.tsx | 2 +- web/src/features/calls/CallHistoryView.tsx | 112 +++ web/src/features/calls/CallRoot.tsx | 422 +++++++++ web/src/features/contacts/AddFriendModal.tsx | 302 ++++++ web/src/features/contacts/BlocklistView.tsx | 158 ++++ web/src/features/contacts/ContactsLayout.tsx | 29 + .../features/contacts/ContactsListColumn.tsx | 173 ++++ .../features/contacts/EmptyContactsMain.tsx | 10 + .../features/contacts/FriendProfileView.tsx | 249 +++++ web/src/features/contacts/GroupListView.tsx | 111 +++ web/src/features/contacts/NewFriendsView.tsx | 122 +++ web/src/features/contacts/QRCodeModal.tsx | 154 ++++ web/src/features/contacts/QRScannerModal.tsx | 229 +++++ .../features/favorites/FavoritesLayout.tsx | 517 +++++++++++ web/src/features/groups/CreateGroupModal.tsx | 198 ++++ web/src/features/groups/GroupAdminModal.tsx | 490 ++++++++++ .../groups/GroupAnnouncementModal.tsx | 130 +++ web/src/features/groups/GroupDetailColumn.tsx | 417 +++++++++ web/src/features/groups/GroupFilesModal.tsx | 297 ++++++ web/src/features/groups/GroupMembersModal.tsx | 144 +++ web/src/features/groups/JoinRequestsModal.tsx | 137 +++ .../features/messages/AttachmentPreview.tsx | 81 +- web/src/features/messages/ChatListColumn.tsx | 89 +- .../features/messages/ConversationView.tsx | 499 ++++++++-- web/src/features/messages/DetailColumn.tsx | 36 +- .../features/messages/ForwardPickerModal.tsx | 92 +- .../features/messages/HistorySearchModal.tsx | 205 +++- web/src/features/messages/MentionPicker.tsx | 79 ++ web/src/features/messages/MessageBubble.tsx | 121 ++- .../features/messages/MessageContextMenu.tsx | 2 +- web/src/features/messages/MessagesLayout.tsx | 121 ++- web/src/features/messages/OfflineBanner.tsx | 30 +- web/src/features/messages/SafeLink.tsx | 41 + web/src/features/messages/VoiceRecorder.tsx | 7 +- web/src/features/settings/AboutPage.tsx | 47 + .../features/settings/GeneralSettingsPage.tsx | 44 + .../settings/NotificationsSettingsPage.tsx | 42 + .../features/settings/PrivacySettingsPage.tsx | 161 ++++ .../features/settings/ProfileSettingsPage.tsx | 271 ++++++ .../settings/SecuritySettingsPage.tsx | 182 ++++ web/src/features/settings/SettingsHome.tsx | 15 + web/src/features/settings/SettingsLayout.tsx | 243 +++++ .../features/system/SystemMessagesView.tsx | 231 +++++ web/src/index.css | 15 +- web/src/lib/api/app.ts | 20 + web/src/lib/api/bootstrap.ts | 8 - web/src/lib/api/calls.ts | 70 ++ web/src/lib/api/client.ts | 49 +- web/src/lib/api/favorites.ts | 103 +++ web/src/lib/api/friends.ts | 66 +- web/src/lib/api/groups.ts | 431 +++++++++ web/src/lib/api/messages.ts | 9 +- web/src/lib/api/notifications.ts | 53 ++ web/src/lib/api/profile.ts | 80 ++ web/src/lib/api/qrcodes.ts | 94 ++ web/src/lib/api/upload.ts | 11 +- web/src/lib/calls/agora-client.ts | 137 +++ web/src/lib/config.ts | 7 +- web/src/lib/hooks.ts | 67 +- web/src/lib/messages/dispatch.ts | 70 ++ web/src/lib/messages/drafty.tsx | 174 ++-- web/src/lib/messages/mentions.ts | 94 ++ web/src/lib/messages/preview.ts | 22 +- web/src/lib/tinode/attachments.ts | 7 +- web/src/lib/tinode/calls.ts | 79 ++ web/src/lib/tinode/connection.ts | 99 +- web/src/lib/tinode/errors.ts | 19 + web/src/lib/tinode/send.ts | 50 +- web/src/lib/tinode/tinode-sdk.d.ts | 37 +- web/src/lib/tinode/topics.ts | 57 +- web/src/stores/calls.ts | 339 +++++++ web/src/stores/messageJump.ts | 25 + web/src/stores/session.ts | 5 + 89 files changed, 10158 insertions(+), 442 deletions(-) create mode 100644 web/src/app/Avatar.tsx delete mode 100644 web/src/app/SectionPlaceholder.tsx create mode 100644 web/src/features/calls/CallHistoryView.tsx create mode 100644 web/src/features/calls/CallRoot.tsx create mode 100644 web/src/features/contacts/AddFriendModal.tsx create mode 100644 web/src/features/contacts/BlocklistView.tsx create mode 100644 web/src/features/contacts/ContactsLayout.tsx create mode 100644 web/src/features/contacts/ContactsListColumn.tsx create mode 100644 web/src/features/contacts/EmptyContactsMain.tsx create mode 100644 web/src/features/contacts/FriendProfileView.tsx create mode 100644 web/src/features/contacts/GroupListView.tsx create mode 100644 web/src/features/contacts/NewFriendsView.tsx create mode 100644 web/src/features/contacts/QRCodeModal.tsx create mode 100644 web/src/features/contacts/QRScannerModal.tsx create mode 100644 web/src/features/favorites/FavoritesLayout.tsx create mode 100644 web/src/features/groups/CreateGroupModal.tsx create mode 100644 web/src/features/groups/GroupAdminModal.tsx create mode 100644 web/src/features/groups/GroupAnnouncementModal.tsx create mode 100644 web/src/features/groups/GroupDetailColumn.tsx create mode 100644 web/src/features/groups/GroupFilesModal.tsx create mode 100644 web/src/features/groups/GroupMembersModal.tsx create mode 100644 web/src/features/groups/JoinRequestsModal.tsx create mode 100644 web/src/features/messages/MentionPicker.tsx create mode 100644 web/src/features/messages/SafeLink.tsx create mode 100644 web/src/features/settings/AboutPage.tsx create mode 100644 web/src/features/settings/GeneralSettingsPage.tsx create mode 100644 web/src/features/settings/NotificationsSettingsPage.tsx create mode 100644 web/src/features/settings/PrivacySettingsPage.tsx create mode 100644 web/src/features/settings/ProfileSettingsPage.tsx create mode 100644 web/src/features/settings/SecuritySettingsPage.tsx create mode 100644 web/src/features/settings/SettingsHome.tsx create mode 100644 web/src/features/settings/SettingsLayout.tsx create mode 100644 web/src/features/system/SystemMessagesView.tsx create mode 100644 web/src/lib/api/app.ts create mode 100644 web/src/lib/api/calls.ts create mode 100644 web/src/lib/api/favorites.ts create mode 100644 web/src/lib/api/groups.ts create mode 100644 web/src/lib/api/notifications.ts create mode 100644 web/src/lib/api/profile.ts create mode 100644 web/src/lib/api/qrcodes.ts create mode 100644 web/src/lib/calls/agora-client.ts create mode 100644 web/src/lib/messages/mentions.ts create mode 100644 web/src/lib/tinode/calls.ts create mode 100644 web/src/stores/calls.ts create mode 100644 web/src/stores/messageJump.ts diff --git a/web/package-lock.json b/web/package-lock.json index 44a65cf0..2ca46d1c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.90.5", + "agora-rtc-sdk-ng": "^4.24.8", + "jsqr": "^1.4.0", + "qrcode": "^1.5.4", "react": "^19.2.8", "react-dom": "^19.2.8", "react-intl": "^7.1.6", @@ -19,6 +22,7 @@ "devDependencies": { "@tailwindcss/vite": "^4.1.16", "@types/node": "^24.13.3", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", @@ -28,6 +32,60 @@ "vite": "^8.2.2" } }, + "node_modules/@agora-js/media": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/@agora-js/media/-/media-4.24.8.tgz", + "integrity": "sha512-ULG18xmKeTA2P462lB+rBx46QK0bffj59e/MciCCivqVcn1ZEntFOUEnmkAOv8FzIn5wISC8tr02PZwUbinQVw==", + "license": "MIT", + "dependencies": { + "@agora-js/report": "4.24.8", + "@agora-js/shared": "4.24.8", + "agora-rte-extension": "^1.2.4", + "agora-rte-extension-ng": "^1.0.3", + "axios": "^1.19.0", + "webrtc-adapter": "8.2.0" + } + }, + "node_modules/@agora-js/protocol": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/@agora-js/protocol/-/protocol-4.24.8.tgz", + "integrity": "sha512-DDAJnLPY2JUP1YSyFp+Lcyn3FMM6aK/vCH9jk+O6yH/r4+WmruGijqtpUk3bf4iX3Fn8EU4F92woZOIDjjgQaA==", + "license": "MIT", + "dependencies": { + "@agora-js/media": "4.24.8", + "@agora-js/report": "4.24.8", + "@agora-js/shared": "4.24.8", + "@bufbuild/protobuf": "2.10.1", + "@types/pako": "^2.0.0", + "pako": "^2.1.0" + } + }, + "node_modules/@agora-js/report": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/@agora-js/report/-/report-4.24.8.tgz", + "integrity": "sha512-i4KW+Q632yDIHbRIHNK5w1AZhMUmV6Vh0bL6WxdQQdi7Pmcx2+w4k08W95li8Dcfodl6/ypRD0MbrkYqmEpcRg==", + "license": "MIT", + "dependencies": { + "@agora-js/shared": "4.24.8", + "axios": "^1.19.0" + } + }, + "node_modules/@agora-js/shared": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/@agora-js/shared/-/shared-4.24.8.tgz", + "integrity": "sha512-N5y8ZD3Rm6mPJT6obuuNYx5xwtjuU1hNIEyHgTrxfAhbfrK41XnH5aLFAaaHQOEJsQwsVRFrHTfEmMb36srkQA==", + "license": "MIT", + "dependencies": { + "axios": "^1.19.0", + "ua-parser-js": "^0.7.34" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.1.tgz", + "integrity": "sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@formatjs/ecma402-abstract": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", @@ -1065,6 +1123,22 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -1114,6 +1188,154 @@ } } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agora-rtc-sdk-ng": { + "version": "4.24.8", + "resolved": "https://registry.npmjs.org/agora-rtc-sdk-ng/-/agora-rtc-sdk-ng-4.24.8.tgz", + "integrity": "sha512-TkHDu1HxYcjfbvpmOfOAucSgMJRlphODQlVN6fwAMCh7yIKt6J4O3wITNCvUkEVi9aaZTjeJOZ+onFUu/zlnuw==", + "license": "MIT", + "dependencies": { + "@agora-js/media": "4.24.8", + "@agora-js/protocol": "4.24.8", + "@agora-js/report": "4.24.8", + "@agora-js/shared": "4.24.8", + "agora-rte-extension": "^1.2.4", + "agora-rte-extension-ng": "^1.0.3", + "axios": "^1.19.0", + "formdata-polyfill": "^4.0.7", + "pako": "^2.1.0", + "ua-parser-js": "^0.7.34", + "webrtc-adapter": "8.2.0" + } + }, + "node_modules/agora-rte-extension": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/agora-rte-extension/-/agora-rte-extension-1.2.4.tgz", + "integrity": "sha512-0ovZz1lbe30QraG1cU+ji7EnQ8aUu+Hf3F+a8xPml3wPOyUQEK6CTdxV9kMecr9t+fIDrGeW7wgJTsM1DQE7Nw==", + "license": "ISC" + }, + "node_modules/agora-rte-extension-ng": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/agora-rte-extension-ng/-/agora-rte-extension-ng-1.0.3.tgz", + "integrity": "sha512-Gkh60IomlIrhq7ljDIy4zHgKEhfNx3c0Kto27wKCQp2wCabmHz9ucHT/7u9telUCA6YcNjnFMhenjdJx1JZeEA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1133,12 +1355,47 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1149,6 +1406,32 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -1163,6 +1446,51 @@ "node": ">=10.13.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1181,6 +1509,90 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1196,6 +1608,73 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1203,6 +1682,45 @@ "dev": true, "license": "ISC" }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -1212,6 +1730,19 @@ "react-is": "^16.7.0" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/intl-messageformat": { "version": "10.7.18", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", @@ -1224,6 +1755,15 @@ "tslib": "^2.8.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1234,6 +1774,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -1495,6 +2041,18 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1505,6 +2063,42 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -1524,6 +2118,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/oxlint": { "version": "1.82.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", @@ -1573,6 +2187,67 @@ } } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1593,6 +2268,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1622,6 +2306,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1712,6 +2422,21 @@ "react-dom": ">=18" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -1752,6 +2477,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz", + "integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -1768,6 +2505,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -1832,6 +2595,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2178,6 +2967,89 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webrtc-adapter": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-8.2.0.tgz", + "integrity": "sha512-umxCMgedPAVq4Pe/jl3xmelLXLn4XZWFEMR5Iipb5wJ+k1xMX0yC4ZY9CueZUU1MjapFxai1tFGE7R/kotH6Ww==", + "license": "BSD-3-Clause", + "dependencies": { + "sdp": "^3.0.2" + }, + "engines": { + "node": ">=6.0.0", + "npm": ">=3.10.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/zustand": { "version": "5.0.15", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", diff --git a/web/package.json b/web/package.json index be3bf869..dcf4a1fc 100644 --- a/web/package.json +++ b/web/package.json @@ -10,23 +10,27 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.90.5", + "agora-rtc-sdk-ng": "^4.24.8", + "jsqr": "^1.4.0", + "qrcode": "^1.5.4", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-intl": "^7.1.6", "react-router-dom": "^7.9.6", - "zustand": "^5.0.9", - "@tanstack/react-query": "^5.90.5", "tinode-sdk": "^0.25.3", - "react-intl": "^7.1.6" + "zustand": "^5.0.9" }, "devDependencies": { + "@tailwindcss/vite": "^4.1.16", "@types/node": "^24.13.3", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.1.0", "oxlint": "^1.79.0", - "typescript": "^5.6.0", - "vite": "^8.2.2", "tailwindcss": "^4.1.16", - "@tailwindcss/vite": "^4.1.16" + "typescript": "^5.6.0", + "vite": "^8.2.2" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index d0a07f24..ac21002f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,16 +3,32 @@ import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom' import { AppShell } from '@/app/AppShell'; import { RequireAuth } from '@/app/RequireAuth'; -import { SectionPlaceholder } from '@/app/SectionPlaceholder'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { LoginPage } from '@/features/auth/LoginPage'; import { RegisterPage } from '@/features/auth/RegisterPage'; import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'; import { TenantEntryPage } from '@/features/auth/TenantEntryPage'; import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage'; +import { CallHistoryView } from '@/features/calls/CallHistoryView'; +import { BlocklistView } from '@/features/contacts/BlocklistView'; +import { ContactsLayout } from '@/features/contacts/ContactsLayout'; +import { EmptyContactsMain } from '@/features/contacts/EmptyContactsMain'; +import { FriendProfileView } from '@/features/contacts/FriendProfileView'; +import { GroupListView } from '@/features/contacts/GroupListView'; +import { NewFriendsView } from '@/features/contacts/NewFriendsView'; +import { FavoritesLayout } from '@/features/favorites/FavoritesLayout'; import { ConversationView } from '@/features/messages/ConversationView'; import { EmptyMain } from '@/features/messages/EmptyMain'; import { MessagesLayout } from '@/features/messages/MessagesLayout'; +import { AboutPage } from '@/features/settings/AboutPage'; +import { GeneralSettingsPage } from '@/features/settings/GeneralSettingsPage'; +import { NotificationsSettingsPage } from '@/features/settings/NotificationsSettingsPage'; +import { PrivacySettingsPage } from '@/features/settings/PrivacySettingsPage'; +import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage'; +import { SecuritySettingsPage } from '@/features/settings/SecuritySettingsPage'; +import { SettingsHome } from '@/features/settings/SettingsHome'; +import { SettingsLayout } from '@/features/settings/SettingsLayout'; +import { SystemMessagesView } from '@/features/system/SystemMessagesView'; const queryClient = new QueryClient(); @@ -35,15 +51,42 @@ const router = createBrowserRouter([ element: , children: [ { index: true, element: }, + { path: 'system', element: }, { path: ':topicId', element: }, ], }, - { path: 'contacts', element: }, - { path: 'favorites', element: }, - { path: 'files', element: }, - { path: 'chat/system', element: }, - { path: 'calls', element: }, - { path: 'settings', element: }, + { + path: 'contacts', + element: , + children: [ + { index: true, element: }, + { path: 'requests', element: }, + { path: 'groups', element: }, + { path: 'blocklist', element: }, + { path: ':userId', element: }, + ], + }, + { path: 'favorites', element: }, + // README §9.2's dedicated file-assistant browser (categories, storage quota, upload + // progress) has NO backing REST API at all (confirmed via a research pass -- the design + // spec's own state-management notes admit reuse of existing APIs, i.e. reading the `slf` + // topic's messages, same as any other conversation) -- redirecting into the real + // conversation on that topic is honest; a fake quota card with invented numbers isn't. + { path: 'files', element: }, + { path: 'calls', element: }, + { + path: 'settings', + element: , + children: [ + { index: true, element: }, + { path: 'profile', element: }, + { path: 'security', element: }, + { path: 'notifications', element: }, + { path: 'privacy', element: }, + { path: 'general', element: }, + { path: 'about', element: }, + ], + }, ], }, ], diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx index 4a2796d5..944a4fb2 100644 --- a/web/src/app/AppShell.tsx +++ b/web/src/app/AppShell.tsx @@ -1,7 +1,11 @@ +import { useEffect } from 'react'; + import { Outlet } from 'react-router-dom'; +import { CallRoot } from '@/features/calls/CallRoot'; import { useBootstrap } from '@/lib/api/hooks'; import { useChatList } from '@/lib/tinode/hooks'; +import { initCallSignaling } from '@/stores/calls'; import { BottomTabBar } from './BottomTabBar'; import { NavRail } from './NavRail'; @@ -15,6 +19,11 @@ import { NavRail } from './NavRail'; export function AppShell() { const contacts = useChatList(); const bootstrap = useBootstrap(); + // Once per app session, regardless of which page is open -- an incoming call must surface even + // while browsing contacts/settings/etc, not only while a conversation happens to be open. + useEffect(() => { + initCallSignaling(); + }, []); // "sys"/"slf" have their own authoritative unread source in entry_badges (bootstrap.ts) -- excluded // here in case they ever also show up as regular Tinode subscriptions in `contacts`, which would // otherwise double-count the same unread messages into this badge. @@ -36,6 +45,7 @@ export function AppShell() { + ); } diff --git a/web/src/app/Avatar.tsx b/web/src/app/Avatar.tsx new file mode 100644 index 00000000..a120f7a2 --- /dev/null +++ b/web/src/app/Avatar.tsx @@ -0,0 +1,31 @@ +import { avatarBgClass, avatarLetter } from '@/lib/avatar'; +import { useAuthedBlobUrl } from '@/lib/hooks'; + +interface AvatarProps { + /** Stable id (user_id/topic) used ONLY for the deterministic fallback color -- never the display name. */ + id: string; + name: string | undefined; + avatarFileId?: string; + /** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's + * own color/centering/weight are applied internally, not expected from the caller. */ + className?: string; +} + +/** + * A real uploaded avatar (`avatar_file_id`) was, until now, stored and successfully round-tripped + * through every profile-update API in this app but never actually RENDERED anywhere -- every avatar + * call site across the whole codebase drew only the letter-tile fallback, so uploading a custom + * avatar produced zero visible change anywhere, including on the very page the upload happened. + * `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the + * `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already + * used for chat attachments) expects. + */ +export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) { + const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined); + if (remote.url) { + return ; + } + return ( + {avatarLetter(name)} + ); +} diff --git a/web/src/app/Icon.tsx b/web/src/app/Icon.tsx index 737ff2c3..298d7761 100644 --- a/web/src/app/Icon.tsx +++ b/web/src/app/Icon.tsx @@ -1,7 +1,10 @@ -/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). */ +/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). + * Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g. + * "arrow_back_ios_new"), never something a screen reader should announce. An icon-only + * interactive element (button/link with no visible text) must set its own `aria-label`. */ export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) { return ( - + ); diff --git a/web/src/app/NavRail.tsx b/web/src/app/NavRail.tsx index 4e01e616..c4f8c2d8 100644 --- a/web/src/app/NavRail.tsx +++ b/web/src/app/NavRail.tsx @@ -1,4 +1,4 @@ -import { NavLink } from 'react-router-dom'; +import { Link, useLocation } from 'react-router-dom'; import { Icon } from './Icon'; @@ -21,16 +21,38 @@ const BOTTOM_ITEMS: NavRailItem[] = [ { to: '/settings', icon: 'settings', label: '设置' }, ]; +/** `/files` is `` (App.tsx) -- the real URL right after + * clicking "文件助手" is `/messages/slf`, which a plain (non-`end`) NavLink match makes "消息" + * light up for (a same-topicId prefix match) while "文件助手" itself (`to: '/files'`, a path that + * no longer exists once redirected) never matches at all. Overriding both items' active state off + * the real pathname instead of trusting NavLink's own prefix match fixes both sides of the mix-up. */ +function isNavItemActive(to: string, pathname: string): boolean { + if (to === '/files') return pathname === '/messages/slf'; + if (to === '/messages') return pathname.startsWith('/messages') && pathname !== '/messages/slf'; + return pathname === to || pathname.startsWith(`${to}/`); +} + function NavRailButton({ item }: { item: NavRailItem }) { + const location = useLocation(); + const isActive = isNavItemActive(item.to, location.pathname); return ( - - `relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${ - isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary' - }` - } + aria-label={item.label} + aria-current={isActive ? 'page' : undefined} + className={`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${ + isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary' + }`} > {item.badge ? ( @@ -38,7 +60,7 @@ function NavRailButton({ item }: { item: NavRailItem }) { {item.badge} ) : null} - + ); } diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx index 5b847fdf..dc2b10f9 100644 --- a/web/src/app/RequireAuth.tsx +++ b/web/src/app/RequireAuth.tsx @@ -1,9 +1,10 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Navigate, Outlet } from 'react-router-dom'; import { Toast } from '@/app/Toast'; import { restoreSession } from '@/lib/tinode/connection'; +import { isTenantMismatch } from '@/lib/tinode/errors'; import { subscribeMeTopic } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; @@ -27,10 +28,12 @@ export function RequireAuth() { const tenant = useSessionStore((s) => s.tenant); const myUserId = useSessionStore((s) => s.myUserId); const setMyUserId = useSessionStore((s) => s.setMyUserId); + const clearTenant = useSessionStore((s) => s.clearTenant); const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token')); const [restoreFailed, setRestoreFailed] = useState(false); const [restoreTimedOut, setRestoreTimedOut] = useState(false); const [contactsError, setContactsError] = useState(''); + const meSubscribedRef = useRef(false); useEffect(() => { if (myUserId || !tenant || !hasToken) return undefined; @@ -44,11 +47,16 @@ export function RequireAuth() { .then((uid) => { if (cancelled) return; setMyUserId(uid); - subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试')); }) - .catch(() => { + .catch((err) => { if (!cancelled) { sessionStorage.removeItem('im-session-token'); + // docs/IM-前后端对接文档.md: a stale token from a DIFFERENT tenant than the one currently + // resolved fails with this specific reason, whose prescribed recovery is "清 Token 并回 + // 企业码页" -- clearing the tenant here lets the fallback `` below + // do that for free, since LoginPage's own `if (!tenant)` guard bounces straight to + // `/tenant`, rather than adding a second, parallel navigation path. + if (isTenantMismatch(err)) clearTenant(); setRestoreFailed(true); } }) @@ -59,7 +67,19 @@ export function RequireAuth() { cancelled = true; window.clearTimeout(timeout); }; - }, [tenant, myUserId, hasToken, setMyUserId]); + }, [tenant, myUserId, hasToken, setMyUserId, clearTenant]); + + // Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless + // of HOW it became known (session restore above, or a fresh login/register that already called + // setMyUserId before this component ever mounted) -- LoginPage/RegisterPage used to each fire + // their own subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly + // logged-in user with an empty conversation list and no error on a transient failure. Centralizing + // here means it happens exactly once per authenticated mount and surfaces the same Toast either way. + useEffect(() => { + if (!myUserId || meSubscribedRef.current) return; + meSubscribedRef.current = true; + subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试')); + }, [myUserId]); if (!tenant) return ; if (myUserId) { @@ -70,10 +90,22 @@ export function RequireAuth() { ); } - if (hasToken && !restoreFailed && !restoreTimedOut) { + // `hasToken` is a mount-time snapshot, not live state -- re-checking sessionStorage directly here + // (rather than trusting that stale flag alone) catches the token having been cleared elsewhere in + // the meantime (connection.ts's reconnect-triggered restoreSession failure, or client.ts's global + // 401 handler both clear it without this component's own effect ever running). Without this, + // `hasToken` would still read true and the branch below would show "正在恢复登录状态…" forever -- + // there's no token left to restore and nothing would ever resolve that screen. + if (hasToken && !restoreFailed && sessionStorage.getItem('im-session-token')) { + // restoreTimedOut only switches the MESSAGE, it must never force a navigate here -- doing so + // would unmount this component, which sets the effect's `cancelled` flag and makes the still- + // in-flight restoreSession() promise discard its own result via `if (cancelled) return`. A + // merely-slow connection (10s+ round trip on cellular/VPN, not necessarily a dead host) would + // otherwise get bounced to a forced re-login even when the restore was about to succeed a moment + // later. Only an actual rejection (restoreFailed) or the user's own click below should leave. return (
- 正在恢复登录状态… + {restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'} 改为重新登录 diff --git a/web/src/app/SectionPlaceholder.tsx b/web/src/app/SectionPlaceholder.tsx deleted file mode 100644 index 24fa538a..00000000 --- a/web/src/app/SectionPlaceholder.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function SectionPlaceholder({ title }: { title: string }) { - return ( -
- {title}(后续阶段实现) -
- ); -} diff --git a/web/src/app/Toast.tsx b/web/src/app/Toast.tsx index 37e64db6..63f02279 100644 --- a/web/src/app/Toast.tsx +++ b/web/src/app/Toast.tsx @@ -29,7 +29,12 @@ export function Toast({ message, variant = 'success', onDone }: ToastProps) { return (
=640px, NavRail shown instead). + className="fixed bottom-[130px] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-2.5 text-sm text-white sm:bottom-12" > {message} diff --git a/web/src/features/auth/EnterpriseRegisterPage.tsx b/web/src/features/auth/EnterpriseRegisterPage.tsx index b6c0c150..85a9009e 100644 --- a/web/src/features/auth/EnterpriseRegisterPage.tsx +++ b/web/src/features/auth/EnterpriseRegisterPage.tsx @@ -84,13 +84,16 @@ export function EnterpriseRegisterPage() {
- setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))} - placeholder="请输入18位统一社会信用代码" - maxLength={18} - className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none" - /> +
+ setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))} + placeholder="请输入18位统一社会信用代码" + maxLength={18} + className="w-full bg-transparent text-[15px] outline-none" + /> + +

请填写营业执照上的统一社会信用代码

@@ -105,7 +108,7 @@ export function EnterpriseRegisterPage() { onChange={(e) => setFile(e.target.files?.[0] || null)} className="hidden" /> - + {file ? file.name : '点击上传营业执照'} 支持 JPG、PNG、WebP、PDF,不超过 10MB diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index a9e3c59d..c4eb32fa 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -6,9 +6,8 @@ import { Icon } from '@/app/Icon'; import { Agreement } from '@/features/auth/components/Agreement'; import { PasswordInput } from '@/features/auth/components/PasswordInput'; import { BASE_APP_NAME } from '@/lib/config'; -import { connect, getTinode } from '@/lib/tinode/connection'; +import { getTinode, loginBasic } from '@/lib/tinode/connection'; import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; -import { subscribeMeTopic } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; export function LoginPage() { @@ -43,10 +42,7 @@ export function LoginPage() { setError(''); try { const tinode = getTinode(); - if (!tinode.isConnected()) { - await connect(); - } - const ctrl = await tinode.loginBasic(login.trim(), password); + const ctrl = await loginBasic(login.trim(), password); if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)); return; @@ -54,7 +50,6 @@ export function LoginPage() { const authToken = tinode.getAuthToken(); sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); setMyUserId(tinode.getCurrentUserID() || null); - subscribeMeTopic().catch(() => {}); navigate('/messages'); } catch (err) { setError(loginErrorMessage(err)); @@ -132,10 +127,9 @@ export function LoginPage() { 马上注册

+
- -
); diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx index dda9986f..078e5f13 100644 --- a/web/src/features/auth/RegisterPage.tsx +++ b/web/src/features/auth/RegisterPage.tsx @@ -10,13 +10,21 @@ import { verifyRegisterCode } from '@/lib/api/auth'; import { ImApiError } from '@/lib/api/client'; import { getTinode, registerAccount } from '@/lib/tinode/connection'; import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; -import { subscribeMeTopic } from '@/lib/tinode/topics'; import { useSessionStore } from '@/stores/session'; const USERNAME_RE = /^[a-z0-9]{1,10}$/; const PHONE_RE = /^\d{11}$/; const INVITE_RE = /^[A-Za-z]{5}$/; +/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a + * 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently + * drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */ +function normalizePhoneInput(raw: string): string { + const digits = raw.replace(/\D/g, ''); + const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits; + return withoutCountryCode.slice(0, 11); +} + interface FormErrors { username?: string; phone?: string; @@ -52,6 +60,10 @@ export function RegisterPage() { if (!tenant) return ; const usingPhone = !!phone; + // `tenants/resolve`'s own real per-tenant password_policy (docs/IM-前后端对接文档.md:534) -- read + // off the tenant instead of a hardcoded guess, so this can never drift from ResetPasswordPage's + // own minimum (they'd silently diverged to two different hardcoded numbers before this existed). + const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8; const submit = async (e: React.FormEvent) => { e.preventDefault(); @@ -65,7 +77,7 @@ export function RegisterPage() { } else if (!INVITE_RE.test(invite)) { next.invite = '用户名注册需填写 5 位字母邀请码'; } - if (password.length < 8) next.password = '密码至少 8 位'; + if (password.length < minPasswordLength) next.password = `密码至少 ${minPasswordLength} 位`; setErrors(next); setFormError(''); if (Object.keys(next).length || !agreed || submitting) return; @@ -98,7 +110,6 @@ export function RegisterPage() { const authToken = getTinode().getAuthToken(); sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); setMyUserId((ctrl.params?.user as string | undefined) ?? null); - subscribeMeTopic().catch(() => {}); navigate('/messages'); } catch (err) { setFormError(registerErrorMessage(err)); @@ -128,7 +139,7 @@ export function RegisterPage() {
setUsername(e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10))} + onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))} placeholder="用户名(1-10位小写字母或数字)" autoComplete="username" className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring ${ @@ -153,9 +164,11 @@ export function RegisterPage() { +86 setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + onChange={(e) => setPhone(normalizePhoneInput(e.target.value))} placeholder="请输入手机号" + type="tel" inputMode="numeric" + autoComplete="tel" className="w-full bg-transparent text-[15px] outline-none" /> @@ -163,12 +176,13 @@ export function RegisterPage() {
-
+
setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="短信验证码" inputMode="numeric" + autoComplete="one-time-code" className={`h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${ errors.code ? 'border border-danger-text' : '' }`} @@ -183,7 +197,14 @@ export function RegisterPage() { {errors.code ?
{errors.code}
: null}
- +
{/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */} diff --git a/web/src/features/auth/ResetPasswordPage.tsx b/web/src/features/auth/ResetPasswordPage.tsx index 0b34da67..370db8e0 100644 --- a/web/src/features/auth/ResetPasswordPage.tsx +++ b/web/src/features/auth/ResetPasswordPage.tsx @@ -12,6 +12,15 @@ import { useSessionStore } from '@/stores/session'; const PHONE_RE = /^\d{11}$/; +/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a + * 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently + * drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */ +function normalizePhoneInput(raw: string): string { + const digits = raw.replace(/\D/g, ''); + const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits; + return withoutCountryCode.slice(0, 11); +} + export function ResetPasswordPage() { const navigate = useNavigate(); const tenant = useSessionStore((s) => s.tenant); @@ -22,6 +31,10 @@ export function ResetPasswordPage() { const [confirm, setConfirm] = useState(''); const [challengeId, setChallengeId] = useState(''); const [error, setError] = useState(''); + // README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE + // field error under the password input, not just the shared top banner every other validation + // failure here uses -- this mirrors that without touching the other (phone/code) failure paths. + const [passwordFieldError, setPasswordFieldError] = useState(''); const [submitting, setSubmitting] = useState(false); const [toast, setToast] = useState(''); @@ -34,15 +47,28 @@ export function ResetPasswordPage() { }, [phone]); if (!tenant) return ; + // Same real per-tenant policy RegisterPage reads (docs/IM-前后端对接文档.md:534) -- was hardcoded + // to 8 here, matching Register's OWN hardcode by coincidence, not because either ever read the + // real value; SecuritySettingsPage's in-place change had drifted to a DIFFERENT hardcoded 6. + const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8; const submit = async (e: React.FormEvent) => { e.preventDefault(); + setPasswordFieldError(''); + setError(''); if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号'); if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码'); - // Matches Register's rule (>= 8, no upper bound -- the backend's checkPasswordPolicy only - // enforces a minimum length, so the message says exactly that and nothing more). - if (password.length < 8) return setError('密码至少 8 位'); - if (password !== confirm) return setError('两次输入的密码不一致'); + // Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy + // only enforces a minimum length, so the message says exactly that and nothing more). Inline-only + // (no setError) -- these two are the password fields' OWN validation, already shown right under + // the relevant input; also raising the shared top banner with the identical text duplicated the + // same message on screen twice for no reason phone/code's genuinely-separate failures don't share. + if (password.length < minPasswordLength) { + return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`); + } + if (password !== confirm) { + return setPasswordFieldError('两次输入的密码不一致'); + } setSubmitting(true); setError(''); @@ -58,12 +84,20 @@ export function ResetPasswordPage() { }; return ( -
- +
+ {/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link + scaled down -- same pattern as RegisterPage's own min-[900px]:hidden header. */} +
+ + + + 重置密码 +
+ 返回登录 -
+

重置密码

@@ -82,14 +116,16 @@ export function ResetPasswordPage() { +86 setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + onChange={(e) => setPhone(normalizePhoneInput(e.target.value))} placeholder="请输入手机号" + type="tel" inputMode="numeric" + autoComplete="tel" className="w-full bg-transparent text-[15px] outline-none" /> -
+
setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} @@ -100,8 +136,28 @@ export function ResetPasswordPage() {
- - + { + setPassword(v); + setPasswordFieldError(''); + }} + placeholder="新密码" + autoComplete="new-password" + error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined} + maxLength={tenant.passwordPolicy?.maxLength} + /> + { + setConfirm(v); + setPasswordFieldError(''); + }} + placeholder="再次输入新密码" + autoComplete="new-password" + error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined} + maxLength={tenant.passwordPolicy?.maxLength} + /> diff --git a/web/src/features/calls/CallHistoryView.tsx b/web/src/features/calls/CallHistoryView.tsx new file mode 100644 index 00000000..a1911dc8 --- /dev/null +++ b/web/src/features/calls/CallHistoryView.tsx @@ -0,0 +1,112 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { Icon } from '@/app/Icon'; +import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls'; +import { avatarBgClass, avatarLetter } from '@/lib/avatar'; +import { formatEventTimestamp } from '@/lib/messages/time'; +import { useChatList } from '@/lib/tinode/hooks'; +import { useCallStore } from '@/stores/calls'; + +function formatDuration(ms: number | undefined): string { + const totalSeconds = Math.round((ms || 0) / 1000); + return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`; +} + +/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads + * as "未接听" from the callee's own history and "已取消" from the caller's. */ +function statusLabel(item: CallHistoryItem): string { + const result = item.result || item.status; + const incoming = item.direction === 'incoming'; + if (result === 'finished') return formatDuration(item.duration_ms); + if (result === 'missed') return incoming ? '未接听' : '已取消'; + if (result === 'declined') return '已拒绝'; + if (result === 'disconnected') return '异常结束'; + return result; +} + +/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in + * NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls` + * endpoint, whose response shape (see calls.ts's own comment) turned out completely different + * from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike + * `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to + * a display name via the chat list, same lookup ChatListColumn itself uses. + */ +export function CallHistoryView() { + const navigate = useNavigate(); + const contacts = useChatList(); + const query = useInfiniteQuery({ + queryKey: ['call-history'], + queryFn: ({ pageParam }) => getCallHistory(pageParam), + initialPageParam: '', + getNextPageParam: (lastPage) => lastPage.next_cursor || undefined, + }); + const items = query.data?.pages.flatMap((p) => p.items) ?? []; + + return ( +
+
+ 通话记录 +
+
+ {query.isLoading ? ( +
加载中…
+ ) : items.length === 0 ? ( +
暂无通话记录
+ ) : ( + items.map((item) => { + const result = item.result || item.status; + const incoming = item.direction === 'incoming'; + // Mock only reds out a genuine "未接听" (a call that rang and was never answered) -- + // "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not + // something the viewer missed and should feel warned about. This used to redden EVERY + // non-'finished' result alike. + const isMissed = result === 'missed' && incoming; + const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id; + const ts = item.started_at || item.created_at; + return ( +
+ + +
+ ); + }) + )} + {query.hasNextPage ? ( + + ) : null} +
+
+ ); +} diff --git a/web/src/features/calls/CallRoot.tsx b/web/src/features/calls/CallRoot.tsx new file mode 100644 index 00000000..17bafa3f --- /dev/null +++ b/web/src/features/calls/CallRoot.tsx @@ -0,0 +1,422 @@ +import { useEffect, useRef, useState } from 'react'; + +import { Icon } from '@/app/Icon'; +import { Toast } from '@/app/Toast'; +import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client'; +import { avatarBgClass, avatarLetter } from '@/lib/avatar'; +import { formatCallDuration } from '@/lib/messages/dispatch'; +import { useCallStore } from '@/stores/calls'; + +function useElapsedSeconds(connectedAt: number | null): number { + const [elapsed, setElapsed] = useState(0); + useEffect(() => { + if (!connectedAt) { + setElapsed(0); + return undefined; + } + const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000)); + tick(); + const timer = window.setInterval(tick, 1000); + return () => window.clearInterval(timer); + }, [connectedAt]); + return elapsed; +} + +function formatElapsed(totalSeconds: number): string { + const m = Math.floor(totalSeconds / 60); + const s = totalSeconds % 60; + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; +} + +function CircleButton({ + icon, + active, + danger, + label, + onClick, + size = 'h-13 w-13', + iconSize = 'text-2xl', +}: { + icon: string; + active?: boolean; + danger?: boolean; + label: string; + onClick: () => void; + size?: string; + iconSize?: string; +}) { + return ( + + ); +} + +/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone- + * width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with + * no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a + * 16px gutter on each side below the point where the fixed width alone would fit. */ +function IncomingCallCard() { + const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore(); + return ( +
+
+ + {avatarLetter(peerName)} + +
+ {peerName} + 邀请你{callType === 'video' ? '视频' : '语音'}通话 +
+
+
+ + +
+
+ ); +} + +/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout + * serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small + * top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two + * per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard` + * because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */ +function MobileIncomingCall() { + const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore(); + return ( +
+ + {avatarLetter(peerName)} + + {peerName} + 邀请你{callType === 'video' ? '视频' : '语音'}通话… +
+ + +
+
+ ); +} + +/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) -- + * minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never + * unmounted between full-screen and minimized). */ +function MinimizedCallBar() { + const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore(); + const elapsed = useElapsedSeconds(connectedAt); + return ( + // `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx + // and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of + // AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width, + // there's no `sm:hidden` gate on this component to make the overlap moot). +
+ + + +
+ ); +} + +/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the + * design calls for one) -- the old app's own real CallPanel is also just an overlay, never a + * separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a + * cross-window signaling bus this rewrite has no other need for. */ +function CallWindow() { + const { + topic, + callType, + phase, + peerName, + connectedAt, + muted, + cameraOff, + hasRemoteVideo, + hasLocalVideo, + minimized, + mediaError, + toggleMinimize, + toggleMute, + toggleCamera, + hangUp, + } = useCallStore(); + const remoteRef = useRef(null); + const localRef = useRef(null); + const elapsed = useElapsedSeconds(connectedAt); + + useEffect(() => { + if (remoteRef.current) playRemoteVideo(remoteRef.current); + }, [hasRemoteVideo]); + useEffect(() => { + if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current); + }, [hasLocalVideo, cameraOff]); + + if (minimized) return ; + + const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…'; + // 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own + // "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断" + // at all 3 call sites below regardless of phase, which is only actually correct once connected. + const hangUpLabel = phase === 'connected' ? '挂断' : '取消'; + + if (callType === 'audio') { + return ( +
+ +
+ + {avatarLetter(peerName)} + + {peerName} + {statusLabel} +
+
+ + + {}} /> +
+ useCallStore.setState({ mediaError: null })} /> +
+ ); + } + + return ( +
+ {/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10` + (here and on every other overlay piece below) is deliberate, not decorative -- without it, + this and the real `remoteRef` video div right below are BOTH `position:absolute` with no + z-index, so plain DOM order would decide stacking and the video (which comes second) would + paint over this header the moment real remote video is actually playing. That case never + got exercised live (this sandbox has no real camera, see the calls verification note in the + plan file), so it's a latent bug worth closing here rather than leaving for whoever first + tests a real 2-camera call to rediscover. */} +
+
+ {peerName} + + {phase === 'connected' ? ( + <> + {formatElapsed(elapsed)} · 网络良好 + + ) : ( + '正在等待对方接听…' + )} + +
+
+ +
+
+ + {/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text -- + those move into the centered overlay below (no remote video yet) or the small pill just + under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom + control grid, matching the mock exactly. */} +
+ + +
+ {hasRemoteVideo ? ( +
+ {peerName} + {phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''} +
+ ) : null} + +
+ {hasRemoteVideo ? ( +
+ ) : ( + <> + {/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */} +
+
+ {/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */} + + {avatarLetter(peerName)} + + {phase === 'connected' ? '对方已关闭摄像头' : ''} +
+
+ {/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile + has no header info bar to carry the name instead. */} +
+ + {avatarLetter(peerName)} + + {peerName} + + {phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'} + + {/* Desktop's version of this same fallback state shows this explanation (line 285 + above); mobile silently dropped it, showing only the elapsed-time line with no + indication of WHY there's no video. */} + {phase === 'connected' ? 对方已关闭摄像头 : null} +
+ + )} +
+ + {/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track + stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain + responsive classes, restoring the desktop values under `md:`. */} + {!cameraOff ? ( +
+ ) : null} + + {/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */} +
+ + + {}} /> + {}} /> + +
+ + {/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and + bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */} +
+ + + {}} size="h-15 w-15" iconSize="text-[26px]" /> + + + +
+ useCallStore.setState({ mediaError: null })} /> +
+ ); +} + +/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any + * call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's + * callResultDisplay) so the live moment and the message history agree on how a call is described. */ +function EndedCallOverlay() { + const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore(); + // Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s + // window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each + // render would make the displayed value drift depending on when React happens to re-render. + const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0)); + const icon = callType === 'video' ? 'videocam' : 'call'; + const { label, danger } = (() => { + switch (endReason) { + case 'finished': + return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false }; + case 'missed': + return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true }; + case 'declined': + return { label: '对方已拒绝', danger: true }; + case 'disconnected': + return { label: '通话连接异常,已结束', danger: true }; + case 'busy': + return { label: '对方忙线中', danger: true }; + default: + return { label: '通话已结束', danger: false }; + } + })(); + return ( +
+ {/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family -- + no mock draws this exact result-overlay state, so this follows the nearest one that does). */} + + {avatarLetter(peerName)} + + {peerName} + + + {label} + +
+ ); +} + +/** Mounted once in AppShell. */ +export function CallRoot() { + const { phase, minimized } = useCallStore(); + + if (phase === 'incoming') + return ( + <> +
+ +
+
+ +
+ + ); + if (phase === 'outgoing' || phase === 'connected') return minimized ? : ; + if (phase === 'ended') return ; + return null; +} diff --git a/web/src/features/contacts/AddFriendModal.tsx b/web/src/features/contacts/AddFriendModal.tsx new file mode 100644 index 00000000..2d8dbbb0 --- /dev/null +++ b/web/src/features/contacts/AddFriendModal.tsx @@ -0,0 +1,302 @@ +import { useState } from 'react'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; + +import { Avatar } from '@/app/Avatar'; +import { Icon } from '@/app/Icon'; +import { Toast } from '@/app/Toast'; +import { QRCodeModal } from '@/features/contacts/QRCodeModal'; +import { QRScannerModal } from '@/features/contacts/QRScannerModal'; +import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api/friends'; +import { submitGroupJoinRequest } from '@/lib/api/groups'; +import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes'; +import { useEscapeKey } from '@/lib/hooks'; + +const MAX_MESSAGE_LENGTH = 50; + +function notImplemented() { + window.alert('该功能暂未开放,如需联系客服。'); +} + +interface AddFriendModalProps { + onClose: () => void; + onSent?: () => void; + /** FriendProfileView's "添加好友" button already knows exactly who -- skips straight past the + * search step to the verification-message step, rather than making the user re-search someone + * whose profile is already open. */ + initialTarget?: { user_id: string; display_name: string; public_account?: string; avatar_file_id?: string }; +} + +/** + * README §5.4 (520px). 扫一扫/我的二维码 are real, confirmed-live features now (see + * lib/api/qrcodes.ts's header comment for the live-verification writeup) -- only "分享好友" stays + * the same "该功能暂未开放" placeholder GroupDetailColumn's own 投诉举报 button already uses, since + * sharing a friend's card into a conversation has no backend support anywhere. Search result rows + * drive their state from + * `relationship`/`can_add` (doc line 1423's confirmed response has no separate `action` field to + * read a label off of): already a friend or already pending are shown but not selectable, `can_add: + * false` shows a generic "无法添加" per the doc's own "不泄露具体关闭项" privacy principle, and only + * a genuinely addable result can be picked before writing the verification message. + */ +export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModalProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [query, setQuery] = useState(''); + const [searchedFor, setSearchedFor] = useState(''); + const [selected, setSelected] = useState( + initialTarget ? { ...initialTarget, relationship: 'none', can_add: true } : null, + ); + // Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real + // provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a + // dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable. + const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search'); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [sent, setSent] = useState(false); + const [showMyQrCode, setShowMyQrCode] = useState(false); + const [scanning, setScanning] = useState(false); + // A scanned `purpose:"join_group"` code -- a completely different target shape from `selected` + // (a person), so it gets its own state rather than being forced into UserSearchResult's fields. + const [groupJoinTarget, setGroupJoinTarget] = useState<(QRResolveGroup & { qrToken: string }) | null>(null); + useEscapeKey(onClose); + + const handleScanResolved = (user: QRResolveUser) => { + setScanning(false); + setSelectedSource('qr'); + setSelected({ user_id: user.user_id, display_name: user.display_name, public_account: user.public_account, relationship: 'none', can_add: true }); + }; + const handleScanAlreadyFriend = (user: QRResolveUser) => { + setScanning(false); + onClose(); + navigate(`/contacts/${user.user_id}`); + }; + const handleScanGroupResolved = (group: QRResolveGroup, qrToken: string) => { + setScanning(false); + setGroupJoinTarget({ ...group, qrToken }); + }; + const handleScanAlreadyMember = (group: QRResolveGroup) => { + setScanning(false); + onClose(); + navigate(`/messages/${group.topic}`); + }; + + // docs/IM-前后端对接文档.md:1308: phone-number search only actually happens under `type=phone` -- + // the default `type=account` never matches a bare phone number, so a search box that always sends + // `account` can never satisfy README §5.4's own placeholder implying phone-number search works. + const searchType = /^\d{11}$/.test(searchedFor) ? 'phone' : 'account'; + const searchQuery = useQuery({ + queryKey: ['user-search', searchedFor, searchType], + queryFn: () => searchUsers(searchedFor, searchType), + enabled: !!searchedFor, + }); + + const runSearch = () => { + setSelected(null); + setSearchedFor(query.trim()); + }; + + const sendMutation = useMutation({ + mutationFn: () => sendFriendRequest(selected?.user_id || '', message.trim() || undefined, selectedSource), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['friend-requests'] }); + setSent(true); + onSent?.(); + }, + onError: (err) => setError(err instanceof Error ? err.message : '发送申请失败'), + }); + + const groupJoinMutation = useMutation({ + mutationFn: () => + submitGroupJoinRequest(groupJoinTarget?.topic || '', { message: message.trim() || undefined, source: 'qr', qr_token: groupJoinTarget?.qrToken }), + onSuccess: () => setSent(true), + onError: (err) => setError(err instanceof Error ? err.message : '发送入群申请失败'), + }); + + const rowState = (item: UserSearchResult): { label: string; selectable: boolean } => { + if (item.relationship === 'friend') return { label: '已是好友', selectable: false }; + // docs/IM-前后端对接文档.md:1289 -- the real enum is direction-specific, never a bare "pending" + // (comparing against that literal never matched either real value, so a search result with an + // outstanding request either direction fell through to the `can_add` check below and could look + // fully selectable, risking a duplicate friend-requests submit). + if (item.relationship === 'pending_outbound') return { label: '已发送申请', selectable: false }; + if (item.relationship === 'pending_inbound') return { label: '对方已申请加你', selectable: false }; + if (!item.can_add) return { label: '无法添加', selectable: false }; + return { label: '', selectable: true }; + }; + + return ( + <> +
+
e.stopPropagation()}> +
+ 添加好友 + +
+ {sent ? ( +
+ + 申请已发送 + +
+ ) : ( +
+ {groupJoinTarget ? ( +
+ + {groupJoinTarget.name.slice(0, 1)} + + + {groupJoinTarget.name} + {groupJoinTarget.member_count} 人 + +
+ ) : initialTarget ? ( +
+ + + {initialTarget.display_name} + {initialTarget.public_account ? ID: {initialTarget.public_account} : null} + +
+ ) : ( + <> +
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && runSearch()} + placeholder="手机号/账号/昵称" + className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2" + /> +
+ +
+ +
+ {[ + { icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) }, + { icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) }, + { icon: 'share', label: '分享好友', onClick: notImplemented }, + ].map((action) => ( + + ))} +
+ + )} + + {!initialTarget && !groupJoinTarget && searchedFor ? ( +
+ {searchQuery.isLoading ? ( +
搜索中…
+ ) : !searchQuery.data?.items.length ? ( +
未找到相关用户
+ ) : ( + searchQuery.data.items.map((item) => { + const { label, selectable } = rowState(item); + const isSelected = selected?.user_id === item.user_id; + return ( + + ); + }) + )} +
+ ) : null} + + {selected || groupJoinTarget ? ( +
+
{groupJoinTarget ? '申请留言' : '验证留言'}
+
+