From 4e4676ed5041353b0caaf7f9815ea7c1b164d4fd Mon Sep 17 00:00:00 2001 From: steve02081504 Date: Fri, 7 Aug 2026 16:29:51 +0800 Subject: [PATCH 01/13] split deps: foundation + path/i18n for stacked PRs --- path/AGENTS.md | 8 +- path/src/cmd/version.ps1 | 4 + path/src/cmd/version.sh | 5 + path/src/env.sh | 12 +- path/src/git.ps1 | 130 +++++++- path/src/git.sh | 116 ++++++- path/src/i18n.ps1 | 1 + path/src/i18n.sh | 22 +- path/src/unix/termux.sh | 90 +++++- path/src/update.sh | 2 +- path/test/git.test.mjs | 163 ++++++++++ src/decl/locale_data.ts | 32 ++ src/public/locales/ar-SA.json | 24 ++ src/public/locales/de-DE.json | 24 ++ src/public/locales/emoji.json | 24 ++ src/public/locales/en-UK.json | 24 ++ src/public/locales/es-ES.json | 24 ++ src/public/locales/fr-FR.json | 24 ++ src/public/locales/hi-IN.json | 24 ++ src/public/locales/is-IS.json | 24 ++ src/public/locales/it-IT.json | 24 ++ src/public/locales/ja-JP.json | 24 ++ src/public/locales/ko-KR.json | 24 ++ src/public/locales/lzh.json | 24 ++ src/public/locales/nl-NL.json | 24 ++ src/public/locales/pt-PT.json | 24 ++ src/public/locales/ru-RU.json | 24 ++ src/public/locales/uk-UA.json | 24 ++ src/public/locales/vi-VN.json | 24 ++ src/public/locales/zh-CN.json | 24 ++ src/public/locales/zh-TW.json | 24 ++ src/public/pages/AGENTS.md | 15 +- src/public/pages/base.mjs | 2 +- src/public/pages/index.html | 2 +- src/public/pages/log_viewer/index.mjs | 2 +- src/public/pages/login/index.mjs | 8 +- src/public/pages/protocolhandler/index.mjs | 4 +- .../pages/scripts/api/p2p/evfsMedia.mjs | 9 - .../scripts/components/partpath_picker.mjs | 4 +- src/public/pages/scripts/endpoints/base.mjs | 283 ++++++++++++++++++ .../pages/scripts/endpoints/desktop.ini | 6 + .../pages/scripts/endpoints/p2p/desktop.ini | 6 + .../pages/scripts/endpoints/p2p/evfsMedia.mjs | 82 +++++ src/public/pages/scripts/endpoints/parts.mjs | 206 +++++++++++++ .../pages/scripts/endpoints/registries.mjs | 48 +++ .../pages/scripts/endpoints/server_events.mjs | 52 ++++ .../scripts/features/emoji/providers.mjs | 2 +- .../pages/scripts/features/errorHandlers.mjs | 43 +++ .../scripts/features/markdown/extensions.mjs | 2 +- .../pages/scripts/host/credentialManager.mjs | 2 +- src/public/pages/scripts/i18n/base.mjs | 2 +- src/public/parts/shells/AGENTS.md | 3 +- .../parts/shells/access/public/index.mjs | 2 +- .../shells/achievements/public/index.mjs | 4 +- .../browserIntegration/src/endpoints.mjs | 2 +- src/public/parts/shells/cabinet/AGENTS.md | 2 +- .../parts/shells/cabinet/public/src/api.mjs | 67 ----- .../shells/cabinet/public/src/endpoints.mjs | 254 ++++++++++++++++ .../cabinet/public/src/entryActions.mjs | 33 +- .../shells/cabinet/public/src/navigation.mjs | 24 +- .../shells/cabinet/public/src/properties.mjs | 4 +- .../cabinet/public/src/recoveryHistory.mjs | 19 +- .../shells/cabinet/public/src/wiring.mjs | 8 +- .../parts/shells/cabinet/src/cabinets.mjs | 6 +- .../parts/shells/config/public/index.mjs | 2 +- .../parts/shells/debug_info/public/index.mjs | 4 +- .../parts/shells/deskpet/public/index.mjs | 4 +- .../parts/shells/discordbot/public/index.mjs | 2 +- .../parts/shells/home/public/src/data.mjs | 2 +- .../parts/shells/home/public/src/events.mjs | 6 +- .../parts/shells/home/public/src/home.mjs | 4 +- .../parts/shells/home/public/src/ui.mjs | 2 +- .../shells/ideIntegration/public/index.mjs | 4 +- .../shells/install/public/uninstall/index.mjs | 2 +- .../parts/shells/proxy/public/index.mjs | 2 +- .../serviceSourceManage/public/index.mjs | 2 +- .../shells/subfounts/public/subfount.mjs | 4 +- .../helpers/subfount_client_worker.mjs | 4 +- .../parts/shells/telegrambot/public/index.mjs | 2 +- .../parts/shells/themeManage/public/index.mjs | 2 +- .../parts/shells/tutorial/public/index.mjs | 2 +- .../shells/userSettings/public/index.mjs | 2 +- .../parts/shells/wechatbot/public/index.mjs | 2 +- src/scripts/checks/AGENTS.md | 2 +- src/scripts/checks/i18n_refs.mjs | 2 +- src/scripts/errorHandlers.mjs | 31 ++ src/scripts/sentry_state.mjs | 1 + src/scripts/test/hub/apis/health.mjs | 2 +- src/scripts/test/playwright/pages_server.mjs | 4 +- src/server/test/manifest.json | 2 +- src/server/web_server/endpoints.mjs | 2 +- src/server/web_server/p2p_endpoints.mjs | 4 - 92 files changed, 2148 insertions(+), 205 deletions(-) create mode 100755 path/src/cmd/version.ps1 create mode 100755 path/src/cmd/version.sh delete mode 100644 src/public/pages/scripts/api/p2p/evfsMedia.mjs create mode 100644 src/public/pages/scripts/endpoints/base.mjs create mode 100644 src/public/pages/scripts/endpoints/desktop.ini create mode 100644 src/public/pages/scripts/endpoints/p2p/desktop.ini create mode 100644 src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs create mode 100644 src/public/pages/scripts/endpoints/parts.mjs create mode 100644 src/public/pages/scripts/endpoints/registries.mjs create mode 100644 src/public/pages/scripts/endpoints/server_events.mjs create mode 100644 src/public/pages/scripts/features/errorHandlers.mjs delete mode 100644 src/public/parts/shells/cabinet/public/src/api.mjs create mode 100644 src/public/parts/shells/cabinet/public/src/endpoints.mjs create mode 100644 src/scripts/errorHandlers.mjs diff --git a/path/AGENTS.md b/path/AGENTS.md index 3aae912ff..2c169a7de 100644 --- a/path/AGENTS.md +++ b/path/AGENTS.md @@ -21,19 +21,21 @@ Same logic is isomorphic across `foo.{ps1,sh}`; platform-only code under `path/s ## Updates - Sync: `update_fount_and_deno`. Background: `update_fount_and_deno_background` (after `data/installer/deno_upgraded`). `bootstrap_server` uses background; keepalive retries and exit-131 call sync before restart. +- `fount version` prints branch (or detached HEAD), HEAD sha, optional remote tip, and up-to-date / behind / ahead / diverged via one-shot `git_fetch_remote_branch` (same fetch shape as plain update; no widen). Detached HEAD skips remote compare; `.noupdate` is noted when present. - `fount update` is the sync CLI (also how PS Start-Job re-enters). -- Plain update refreshes only the current branch via one-shot `git_fetch_remote_branch` (does **not** widen `remote.origin.fetch`). +- Plain update refreshes only the current branch via one-shot `git_fetch_remote_branch` (does **not** expand `remote.origin.fetch` to `refs/heads/*`). - `fount update ` checks out that branch and removes `.noupdate`; unknown names `ls-remote` once then the same one-shot fetch. - `fount update ` detaches at that commit and creates `.noupdate`. - `fount update pr/` (also `pull/`, `#`, or a `https://github.com///pull/` URL) fetches `refs/pull//head` into `origin/pr/`, detaches there, and creates `.noupdate`. Re-run the same command to refresh the tip. - If the current upstream is confirmed gone on origin (not a network error), fall back to tracking `master`. +- Upstream for one-shot `origin/` refs: `git_track_origin_branch` adds that single head to `remote.origin.fetch` (so `@{u}` works under single-branch clones) and sets `branch..remote` / `merge`. Never uses `git branch --set-upstream-to` alone — that fatals when the refspec is outside configured fetch. - `git_valid_branch_name` gates one-shot fetch/ls-remote. In bash `case` patterns, escape glob chars (`*\?*`, `*\**`) — do **not** quote them as `*'?'*` / `/'*|*'/'`; a dangling `'` makes `bash -n path/src/git.sh` fail and every sourced function vanish (`git_remote_branch_status: command not found`). Keep `@{` out of `case` arms (`[[ "$branch" == *'@{'* ]]`) — shellcheck SC1083 treats `{` in `*@{*` as literal. Regression: `fount test path:git --no-parallel`. - `path/**/*.sh` is linted by ShellCheck in the same suite (`shellcheck.test.mjs`). Resolve via `@steve02081504/exec` `where_command` / `execFile`: PATH first; if missing or older than GitHub `releases/latest`, download into `data/test/shellcheck/v*` (zip / `.tar.gz` via system `tar`, same asset layout as [vscode-shellcheck](https://github.com/vscode-shellcheck/vscode-shellcheck)) and try to overwrite a writable PATH binary. Latest tag is cached in `data/test/shellcheck/latest.json` (24h). Non-ASCII entry names (`⛲.sh`, …) are copied to a temp ASCII path before lint — Windows ShellCheck crashes when printing those filenames. ## Termux -- `env.sh` sets `LANG` from `getprop persist.sys.locale` at CLI start (before i18n). -- `termux_ensure_sensor_api` (`unix/termux.sh`) installs `termux-api` for `termux-sensor` when missing on `fount logo` / `log` / `server`; tracked in `auto_installed_system_packages` for uninstall. Logo gravity details: [imgs/icon_anime/AGENTS.md](../imgs/icon_anime/AGENTS.md). +- `unix/termux.sh`: locale + sensor. `env.sh` on Termux `require unix/termux` then `termux_apply_android_lang` (before i18n) — Android locale chain (`persist.sys.locale` → language/country → `ro.product.locale` → `settings`) via `getprop` / `/system/bin/getprop`, BCP 47 → `zh_CN.UTF-8`, sets `LANG`, unsets `LC_ALL`. +- `termux_ensure_sensor_api` installs `termux-api` for `termux-sensor` when missing on `fount logo` / `log` / `server`; tracked in `auto_installed_system_packages` for uninstall. Logo gravity details: [imgs/icon_anime/AGENTS.md](../imgs/icon_anime/AGENTS.md). ## CI smoke diff --git a/path/src/cmd/version.ps1 b/path/src/cmd/version.ps1 new file mode 100755 index 000000000..66bfaab7e --- /dev/null +++ b/path/src/cmd/version.ps1 @@ -0,0 +1,4 @@ +function script:cmd_version { + require git + fount_show_version +} diff --git a/path/src/cmd/version.sh b/path/src/cmd/version.sh new file mode 100755 index 000000000..cd1541e8a --- /dev/null +++ b/path/src/cmd/version.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +cmd_version() { + require git + fount_show_version +} diff --git a/path/src/env.sh b/path/src/env.sh index 17b4a84af..3c96b3f30 100755 --- a/path/src/env.sh +++ b/path/src/env.sh @@ -26,15 +26,10 @@ in_docker() { [ "$IN_DOCKER" -eq 1 ]; } in_termux() { [ "$IN_TERMUX" -eq 1 ]; } in_container() { in_docker || in_termux; } -# Termux ships a weak default LANG; use the Android system locale for the whole CLI -# (i18n + git/bash messages), not only inside run(). +# Termux: Android system locale → LANG (before i18n). if [ "$IN_TERMUX" -eq 1 ]; then - termux_lang=$(getprop persist.sys.locale 2>/dev/null || true) - if [ -n "$termux_lang" ]; then - LANG="$termux_lang.UTF-8" - export LANG - fi - unset termux_lang + require unix/termux + termux_apply_android_lang fi # Installer data paths (exported for packages.sh / deno.sh / uninstall hooks) @@ -60,4 +55,3 @@ if echo "${LANG:-}" | grep -iqE "_(CN|KP|RU)|(^|-)(zh|ko|ru)(-|$)"; then done ) >/dev/null 2>&1 & fi - diff --git a/path/src/git.ps1 b/path/src/git.ps1 index 7d1613a75..50efce307 100755 --- a/path/src/git.ps1 +++ b/path/src/git.ps1 @@ -129,6 +129,41 @@ function script:git_sync_to_ref($Ref) { invoke_repo_git reset --hard $Ref } +# Ensure remote.origin.fetch maps refs/heads/ → origin/. +# Adds a single-branch refspec only — never expands to refs/heads/*. +function script:git_ensure_origin_fetch_branch($RemoteBranch) { + if (-not (git_valid_branch_name $RemoteBranch)) { + $global:LastExitCode = 1 + return + } + $specs = @(invoke_repo_git config --get-all remote.origin.fetch 2>$null) + if ($LastExitCode -ne 0) { $specs = @() } + foreach ($spec in $specs) { + if ($spec -match '^\+?refs/heads/\*:refs/remotes/origin/\*$') { return } + if ($spec -eq "+refs/heads/${RemoteBranch}:refs/remotes/origin/${RemoteBranch}") { return } + if ($spec -eq "refs/heads/${RemoteBranch}:refs/remotes/origin/${RemoteBranch}") { return } + } + invoke_repo_git config --add remote.origin.fetch "+refs/heads/${RemoteBranch}:refs/remotes/origin/${RemoteBranch}" +} + +# Point local branch at origin/ without requiring a prior wildcard fetch refspec. +# `git branch --set-upstream-to` rejects one-shot remote-tracking refs under single-branch clones; +# add the one head to remote.origin.fetch (not *) then set branch.*.remote / merge. +function script:git_track_origin_branch($Branch, $OriginRef = $null) { + if (-not $OriginRef) { $OriginRef = "origin/$Branch" } + if ($OriginRef -notlike 'origin/*') { + Write-Warning (Get-I18n -key 'git.remoteRefUnavailable' -params @{ ref = $OriginRef }) + $global:LastExitCode = 1 + return + } + $remoteBranch = $OriginRef.Substring('origin/'.Length) + git_ensure_origin_fetch_branch $remoteBranch + if ($LastExitCode -ne 0) { return } + invoke_repo_git config "branch.$Branch.remote" origin + if ($LastExitCode -ne 0) { return } + invoke_repo_git config "branch.$Branch.merge" "refs/heads/$remoteBranch" +} + # Switch/create local branch at StartPoint (default origin/). Does not move other branches. function script:git_checkout_branch($Branch, $StartPoint = $null) { if (-not $StartPoint) { $StartPoint = "origin/$Branch" } @@ -143,7 +178,7 @@ function script:git_checkout_branch($Branch, $StartPoint = $null) { invoke_repo_git checkout -B $Branch $StartPoint if ($LastExitCode -ne 0) { return } if ($StartPoint -like 'origin/*') { - invoke_repo_git branch --set-upstream-to $StartPoint $Branch + git_track_origin_branch $Branch $StartPoint } } @@ -170,7 +205,8 @@ function script:fount_resolve_upstream($Branch) { if (-not $hadUpstream) { Write-Warning (Get-I18n -key 'git.noUpstreamBranch' -params @{ branch = $Branch; remote = $script:remoteBranch }) } - invoke_repo_git branch --set-upstream-to $script:remoteBranch $Branch | Out-Null + git_track_origin_branch $Branch $script:remoteBranch + if ($LastExitCode -ne 0) { return } $script:currentBranch = $Branch $global:LastExitCode = 0 return @@ -305,3 +341,93 @@ function script:fount_upgrade { if ($status) { Write-Warning (Get-I18n -key 'git.dirtyWorkingDirectory') } } } + +# $Kind = version.status.* suffix; $Warn = Write-Warning instead of Write-Host. +function script:fount_print_version_status($Kind, [switch]$Warn) { + $statusText = Get-I18n -key "version.status.$Kind" + $line = Get-I18n -key 'version.status.title' -params @{ status = $statusText } + if ($Warn) { Write-Warning $line } + else { Write-Host $line } +} + +# $Branch = branch name, or HEAD for detached. +function script:fount_print_version_branch($Branch) { + $text = $Branch + if ($text -eq 'HEAD') { + $text = Get-I18n -key 'version.branch.detached' + } + Write-Host (Get-I18n -key 'version.branch.title' -params @{ branch = $text }) +} + +# Print branch, HEAD sha, and whether the current branch tip matches origin. +function script:fount_show_version { + $global:LastExitCode = 0 + if (!(Get-Command git -ErrorAction SilentlyContinue)) { + Write-Warning (Get-I18n -key 'version.noGit') + $global:LastExitCode = 1 + return + } + if (!(Test-Path -LiteralPath "$FOUNT_DIR/.git")) { + Write-Warning (Get-I18n -key 'version.noRepo') + $global:LastExitCode = 1 + return + } + + $branch = invoke_repo_git rev-parse --abbrev-ref HEAD 2>$null + if ($LastExitCode -ne 0 -or -not $branch) { $branch = 'HEAD' } + $sha = invoke_repo_git rev-parse HEAD 2>$null + if ($LastExitCode -ne 0 -or -not $sha) { + Write-Warning (Get-I18n -key 'version.noRepo') + $global:LastExitCode = 1 + return + } + + fount_print_version_branch $branch + Write-Host (Get-I18n -key 'version.commit' -params @{ ref = $sha }) + + if (Test-Path -LiteralPath "$FOUNT_DIR/.noupdate") { + Write-Host (Get-I18n -key 'version.autoUpdatePaused') + } + + if ($branch -eq 'HEAD') { + fount_print_version_status detachedNoCompare + $global:LastExitCode = 0 + return + } + + git_fetch_remote_branch $branch + if ($LastExitCode -ne 0) { + fount_print_version_status fetchFailed -Warn + $global:LastExitCode = 1 + return + } + $remoteSha = invoke_repo_git rev-parse "origin/$branch" 2>$null + if ($LastExitCode -ne 0 -or -not $remoteSha) { + fount_print_version_status fetchFailed -Warn + $global:LastExitCode = 1 + return + } + Write-Host (Get-I18n -key 'version.remote' -params @{ ref = $remoteSha }) + + if ($sha -eq $remoteSha) { + fount_print_version_status upToDate + $global:LastExitCode = 0 + return + } + $mergeBase = invoke_repo_git merge-base HEAD "origin/$branch" 2>$null + if ($LastExitCode -ne 0 -or -not $mergeBase) { + fount_print_version_status diverged -Warn + $global:LastExitCode = 0 + return + } + if ($mergeBase -eq $sha) { + fount_print_version_status behind -Warn + } + elseif ($mergeBase -eq $remoteSha) { + fount_print_version_status ahead + } + else { + fount_print_version_status diverged -Warn + } + $global:LastExitCode = 0 +} diff --git a/path/src/git.sh b/path/src/git.sh index 6e5d75ffb..4f9a38a8e 100755 --- a/path/src/git.sh +++ b/path/src/git.sh @@ -113,6 +113,43 @@ git_sync_to_ref() { invoke_repo_git reset --hard "$ref" } +# Ensure remote.origin.fetch maps refs/heads/ → origin/. +# Adds a single-branch refspec only — never expands to refs/heads/*. +git_ensure_origin_fetch_branch() { + local remote_branch="$1" specs + git_valid_branch_name "$remote_branch" || return 1 + specs=$(invoke_repo_git config --get-all remote.origin.fetch 2>/dev/null) || specs= + if printf '%s\n' "$specs" | grep -qE '^(\+)?refs/heads/\*:refs/remotes/origin/\*$'; then + return 0 + fi + if printf '%s\n' "$specs" | grep -qxF "+refs/heads/${remote_branch}:refs/remotes/origin/${remote_branch}"; then + return 0 + fi + if printf '%s\n' "$specs" | grep -qxF "refs/heads/${remote_branch}:refs/remotes/origin/${remote_branch}"; then + return 0 + fi + invoke_repo_git config --add remote.origin.fetch "+refs/heads/${remote_branch}:refs/remotes/origin/${remote_branch}" +} + +# Point local branch at origin/ without requiring a prior wildcard fetch refspec. +# `git branch --set-upstream-to` rejects one-shot remote-tracking refs under single-branch clones; +# add the one head to remote.origin.fetch (not *) then set branch.*.remote / merge. +git_track_origin_branch() { + local branch="$1" + local origin_ref="${2:-origin/$branch}" + local remote_branch + case "$origin_ref" in + origin/*) remote_branch="${origin_ref#origin/}" ;; + *) + print_i18n_yellow 'git.remoteRefUnavailable' 'ref' "$origin_ref" >&2 + return 1 + ;; + esac + git_ensure_origin_fetch_branch "$remote_branch" || return 1 + invoke_repo_git config "branch.${branch}.remote" origin || return 1 + invoke_repo_git config "branch.${branch}.merge" "refs/heads/${remote_branch}" +} + # Switch/create local branch at start_point (default origin/). Does not move other branches. git_checkout_branch() { local branch="$1" @@ -125,7 +162,7 @@ git_checkout_branch() { invoke_repo_git clean -fd || return 1 invoke_repo_git checkout -B "$branch" "$start_point" || return 1 case "$start_point" in - origin/*) invoke_repo_git branch --set-upstream-to "$start_point" "$branch" >/dev/null ;; + origin/*) git_track_origin_branch "$branch" "$start_point" || return 1 ;; esac } @@ -169,3 +206,80 @@ git_reset_and_clean() { fi } +# $1 = version.status.* suffix; $2 = green|yellow| (default plain stdout). +fount_print_version_status() { + local text color="${2:-}" + text=$(get_i18n "version.status.$1") + case "$color" in + green) print_i18n_green 'version.status.title' 'status' "$text" ;; + yellow) print_i18n_yellow 'version.status.title' 'status' "$text" >&2 ;; + *) get_i18n 'version.status.title' 'status' "$text" ;; + esac +} + +# $1 = branch name, or HEAD for detached. +fount_print_version_branch() { + local text="$1" + if [ "$text" = "HEAD" ]; then + text=$(get_i18n 'version.branch.detached') + fi + get_i18n 'version.branch.title' 'branch' "$text" +} + +# Print branch, HEAD sha, and whether the current branch tip matches origin. +fount_show_version() { + local branch sha remote_sha merge_base + if ! command -v git &>/dev/null; then + print_i18n_yellow 'version.noGit' >&2 + return 1 + fi + if [ ! -d "$FOUNT_DIR/.git" ]; then + print_i18n_yellow 'version.noRepo' >&2 + return 1 + fi + + branch=$(invoke_repo_git rev-parse --abbrev-ref HEAD 2>/dev/null) || branch=HEAD + sha=$(invoke_repo_git rev-parse HEAD 2>/dev/null) || { + print_i18n_yellow 'version.noRepo' >&2 + return 1 + } + + fount_print_version_branch "$branch" + get_i18n 'version.commit' 'ref' "$sha" + + if [ -f "$FOUNT_DIR/.noupdate" ]; then + get_i18n 'version.autoUpdatePaused' + fi + + if [ "$branch" = "HEAD" ]; then + fount_print_version_status detachedNoCompare + return 0 + fi + + if ! git_fetch_remote_branch "$branch"; then + fount_print_version_status fetchFailed yellow + return 1 + fi + remote_sha=$(invoke_repo_git rev-parse "origin/$branch" 2>/dev/null) || { + fount_print_version_status fetchFailed yellow + return 1 + } + get_i18n 'version.remote' 'ref' "$remote_sha" + + if [ "$sha" = "$remote_sha" ]; then + fount_print_version_status upToDate green + return 0 + fi + merge_base=$(invoke_repo_git merge-base HEAD "origin/$branch" 2>/dev/null) || { + fount_print_version_status diverged yellow + return 0 + } + if [ "$merge_base" = "$sha" ]; then + fount_print_version_status behind yellow + elif [ "$merge_base" = "$remote_sha" ]; then + fount_print_version_status ahead + else + fount_print_version_status diverged yellow + fi +} + diff --git a/path/src/i18n.ps1 b/path/src/i18n.ps1 index b35832348..1d27df8e5 100755 --- a/path/src/i18n.ps1 +++ b/path/src/i18n.ps1 @@ -71,6 +71,7 @@ $Script:I18nParamAnsiColors = @{ path = 36 ref = 34 branch = 33 + status = 33 target = 34 } diff --git a/path/src/i18n.sh b/path/src/i18n.sh index 0face303a..0e3263451 100755 --- a/path/src/i18n.sh +++ b/path/src/i18n.sh @@ -2,17 +2,25 @@ # --- Internationalization --- get_system_locales() { - local locales=() - if [ -n "$LANG" ]; then locales+=("$(echo "$LANG" | cut -d. -f1 | sed 's/_/-/')"); fi + local locales=() entry + if [ -n "$LANG" ]; then + entry=$(echo "$LANG" | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") + fi if [ -n "$LANGUAGE" ]; then IFS=':' read -r -a lang_array <<<"$LANGUAGE" for lang in "${lang_array[@]}"; do - locales+=("$(echo "$lang" | cut -d. -f1 | sed 's/_/-/')") + entry=$(echo "$lang" | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") done fi - if [ -n "$LC_ALL" ]; then locales+=("$(echo "$LC_ALL" | cut -d. -f1 | sed 's/_/-/')"); fi + if [ -n "$LC_ALL" ]; then + entry=$(echo "$LC_ALL" | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") + fi if command -v locale >/dev/null; then - locales+=("$(locale -uU 2>/dev/null | cut -d. -f1 | sed 's/_/-/')") + entry=$(locale -uU 2>/dev/null | cut -d. -f1 | sed 's/_/-/') + [ -n "$entry" ] && locales+=("$entry") fi locales+=("en-UK") # shellcheck disable=SC2207 @@ -38,6 +46,7 @@ get_best_locale() { local available_locales=($available_locales_str) for preferred in "${preferred_locales[@]}"; do + [ -n "$preferred" ] || continue for available in "${available_locales[@]}"; do if [ "$preferred" = "$available" ]; then echo "$preferred" @@ -47,8 +56,10 @@ get_best_locale() { done for preferred in "${preferred_locales[@]}"; do + [ -n "$preferred" ] || continue local prefix prefix=$(echo "$preferred" | cut -d- -f1) + [ -n "$prefix" ] || continue for available in "${available_locales[@]}"; do if [[ "$available" == "$prefix"* ]]; then echo "$available" @@ -99,6 +110,7 @@ i18n_format_param_value() { path) printf '\033[36m%s\033[0m' "$param_value" ; return ;; ref) printf '\033[34m%s\033[0m' "$param_value" ; return ;; branch) printf '\033[33m%s\033[0m' "$param_value" ; return ;; + status) printf '\033[33m%s\033[0m' "$param_value" ; return ;; target) printf '\033[34m%s\033[0m' "$param_value" ; return ;; esac fi diff --git a/path/src/unix/termux.sh b/path/src/unix/termux.sh index c944a556a..655bb0155 100755 --- a/path/src/unix/termux.sh +++ b/path/src/unix/termux.sh @@ -1,5 +1,93 @@ #!/usr/bin/env bash -# Termux-specific helpers (sensor API for logo/log/server) +# Termux-specific helpers (locale + sensor API) + +# Resolve getprop even when /system/bin is absent from PATH (some Termux setups). +android_getprop() { + local key="$1" path + path=$(command -v getprop 2>/dev/null) || path= + if [ -n "$path" ]; then + "$path" "$key" 2>/dev/null + return 0 + fi + for path in /system/bin/getprop /system/xbin/getprop; do + if [ -x "$path" ]; then + "$path" "$key" 2>/dev/null + return 0 + fi + done + return 1 +} + +# Android system locale tag (BCP 47), first of a comma-separated list. Echoes nothing on failure. +android_system_locale_tag() { + local tag lang country variant + tag=$(android_getprop persist.sys.locale || true) + if [ -z "$tag" ]; then + lang=$(android_getprop persist.sys.language || true) + if [ -n "$lang" ]; then + country=$(android_getprop persist.sys.country || true) + variant=$(android_getprop persist.sys.localevar || true) + tag="$lang" + [ -n "$country" ] && tag="$tag-$country" + [ -n "$variant" ] && tag="$tag-$variant" + fi + fi + if [ -z "$tag" ]; then + tag=$(android_getprop ro.product.locale || true) + fi + if [ -z "$tag" ]; then + lang=$(android_getprop ro.product.locale.language || true) + country=$(android_getprop ro.product.locale.region || true) + if [ -n "$lang" ]; then + tag="$lang" + [ -n "$country" ] && tag="$tag-$country" + fi + fi + if [ -z "$tag" ] && command -v settings >/dev/null 2>&1; then + tag=$(settings get system system_locales 2>/dev/null || true) + [ "$tag" = "null" ] && tag= + fi + tag="${tag%%,*}" + tag=$(printf '%s' "$tag" | tr -d '[:space:]') + [ -n "$tag" ] || return 1 + printf '%s\n' "$tag" +} + +# BCP 47 → POSIX LANG (zh-Hans-CN → zh_CN.UTF-8; en → en.UTF-8). +android_locale_to_lang() { + local tag="${1//_/-}" language="" region="" part + local IFS='-' + # shellcheck disable=SC2086 # intentional IFS split on - + set -- $tag + language="${1:-}" + [ -n "$language" ] || return 1 + shift || true + for part in "$@"; do + case "$part" in + [A-Za-z][A-Za-z][A-Za-z][A-Za-z]) ;; # script (Hans/Hant/Latn) — skip + [A-Za-z][A-Za-z] | [0-9][0-9][0-9]) region="$part" ;; + esac + done + if [ -n "$region" ]; then + printf '%s_%s.UTF-8\n' "$language" "$region" + else + printf '%s.UTF-8\n' "$language" + fi +} + +# Termux default LANG is weak; apply Android system locale for CLI i18n / gettext. +termux_apply_android_lang() { + local tag lang + [[ ${IN_TERMUX:-0} -eq 1 ]] || return 0 + tag=$(android_system_locale_tag || true) + [ -n "$tag" ] || return 0 + lang=$(android_locale_to_lang "$tag") || return 0 + [ -n "$lang" ] || return 0 + LANG="$lang" + export LANG + # Drop LC_ALL so LANG wins for gettext; fount i18n reads LANG first either way. + unset LC_ALL +} # Ensure termux-sensor CLI (pkg termux-api); tracked for uninstall. Soft-fail if missing. termux_ensure_sensor_api() { diff --git a/path/src/update.sh b/path/src/update.sh index 371ec8fc8..f11b3fcbb 100755 --- a/path/src/update.sh +++ b/path/src/update.sh @@ -11,7 +11,7 @@ fount_resolve_upstream() { if [ -z "$had_upstream" ]; then print_i18n_yellow 'git.noUpstreamBranch' 'branch' "$branch" 'remote' "$remoteBranch" >&2 fi - invoke_repo_git branch --set-upstream-to "$remoteBranch" "$branch" >/dev/null + git_track_origin_branch "$branch" "$remoteBranch" || return 1 currentBranch="$branch" return 0 fi diff --git a/path/test/git.test.mjs b/path/test/git.test.mjs index cb78b857b..f4cbf46e2 100644 --- a/path/test/git.test.mjs +++ b/path/test/git.test.mjs @@ -10,6 +10,7 @@ import { REPO_ROOT } from '../../src/scripts/test/core/repo_root.mjs' const gitShPath = join(REPO_ROOT, 'path', 'src', 'git.sh') const gitPs1Path = join(REPO_ROOT, 'path', 'src', 'git.ps1') +const termuxShPath = join(REPO_ROOT, 'path', 'src', 'unix', 'termux.sh') /** 应被拒绝的分支名(Git ref 规则 + apostrophe)。 */ const INVALID_BRANCH_NAMES = [ @@ -336,3 +337,165 @@ foreach ($base64Name in $encoded) { assertEquals(bashVerdicts.length, allTargets.length) assertEquals(psVerdicts, bashVerdicts) }) + +/** + * 单分支 clone + one-shot fetch 场景:`branch --set-upstream-to` 会 fatal; + * git_checkout_branch 必须补上该 head 的 fetch refspec(非 *)并建好 @{u}。 + */ +Deno.test('git_checkout_branch tracks one-shot origin ref under single-branch fetch', async () => { + const result = await runBash(` + set -euo pipefail + TMP=$(mktemp -d) + cleanup() { rm -rf "$TMP"; } + trap cleanup EXIT + + git init --bare -b master "$TMP/remote.git" >/dev/null + git clone "$TMP/remote.git" "$TMP/seed" >/dev/null 2>&1 + cd "$TMP/seed" + git config user.email t@t + git config user.name t + echo master > f.txt + git add f.txt && git commit -m master >/dev/null + git push origin master >/dev/null + git checkout -b lava >/dev/null 2>&1 + echo lava > f.txt + git add f.txt && git commit -m lava >/dev/null + git push origin lava >/dev/null + + git clone --single-branch --branch master "$TMP/remote.git" "$TMP/clone" >/dev/null 2>&1 + cd "$TMP/clone" + git config user.email t@t + git config user.name t + + FOUNT_DIR="$TMP/clone" + print_i18n_yellow() { :; } + print_i18n_green() { :; } + . ${JSON.stringify(gitShPath)} + + git_fetch_remote_branch lava + # Reproduce: raw --set-upstream-to must fail on this clone shape. + if git branch --set-upstream-to origin/lava lava >/dev/null 2>&1; then + echo 'expected set-upstream-to to fail' >&2 + exit 1 + fi + + git_checkout_branch lava origin/lava + upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}') + [ "$upstream" = 'origin/lava' ] || { echo "upstream=$upstream" >&2; exit 1; } + fetch_specs=$(git config --get-all remote.origin.fetch) + printf '%s\n' "$fetch_specs" | grep -qE '^(\\+)?refs/heads/\\*:refs/remotes/origin/\\*$' && { + echo "fetch widened to all heads:" >&2 + printf '%s\n' "$fetch_specs" >&2 + exit 1 + } + printf '%s\n' "$fetch_specs" | grep -qxF '+refs/heads/lava:refs/remotes/origin/lava' || { + echo "lava refspec missing:" >&2 + printf '%s\n' "$fetch_specs" >&2 + exit 1 + } + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) + +/** + * fount_show_version:本地 tip / 落后 / 分离 HEAD 状态键。 + */ +Deno.test('fount_show_version reports branch sha and freshness', async () => { + const result = await runBash(` + set -euo pipefail + TMP=$(mktemp -d) + cleanup() { rm -rf "$TMP"; } + trap cleanup EXIT + + git init --bare -b master "$TMP/remote.git" >/dev/null + git clone "$TMP/remote.git" "$TMP/seed" >/dev/null 2>&1 + cd "$TMP/seed" + git config user.email t@t + git config user.name t + echo v1 > f.txt + git add f.txt && git commit -m v1 >/dev/null + git push origin master >/dev/null + + git clone "$TMP/remote.git" "$TMP/clone" >/dev/null 2>&1 + cd "$TMP/clone" + git config user.email t@t + git config user.name t + + FOUNT_DIR="$TMP/clone" + get_i18n() { printf '%s' "$1"; shift; while [ $# -gt 0 ]; do printf ' %s=%s' "$1" "$2"; shift 2; done; printf '\\n'; } + print_i18n_green() { get_i18n "$@"; } + print_i18n_yellow() { get_i18n "$@" >&2; } + . ${JSON.stringify(gitShPath)} + + sha=$(git rev-parse HEAD) + out=$(fount_show_version) + printf '%s\\n' "$out" | grep -qxF "version.branch.title branch=master" || { echo "branch line:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.commit ref=$sha" || { echo "commit line:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.status.title status=version.status.upToDate" || { echo "expected upToDate:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + + cd "$TMP/seed" + echo v2 > f.txt + git add f.txt && git commit -m v2 >/dev/null + git push origin master >/dev/null + + cd "$TMP/clone" + out=$(fount_show_version 2>&1) + printf '%s\\n' "$out" | grep -qxF "version.status.title status=version.status.behind" || { echo "expected behind:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + + git checkout --detach HEAD >/dev/null 2>&1 + : >"$FOUNT_DIR/.noupdate" + out=$(fount_show_version) + printf '%s\\n' "$out" | grep -qxF "version.branch.title branch=version.branch.detached" || { echo "expected detached:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.autoUpdatePaused" || { echo "expected autoUpdatePaused:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + printf '%s\\n' "$out" | grep -qxF "version.status.title status=version.status.detachedNoCompare" || { echo "expected detachedNoCompare:" >&2; printf '%s\\n' "$out" >&2; exit 1; } + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) + +/** BCP 47 → POSIX LANG fixtures (Android/Termux). */ +const ANDROID_LOCALE_TO_LANG = [ + ['zh-Hans-CN', 'zh_CN.UTF-8'], + ['zh-CN', 'zh_CN.UTF-8'], + ['zh_CN', 'zh_CN.UTF-8'], + ['en-US', 'en_US.UTF-8'], + ['ja-JP', 'ja_JP.UTF-8'], + ['en', 'en.UTF-8'], +] + +Deno.test('android_locale_to_lang normalizes BCP47 script tags for Termux LANG', async () => { + const result = await runBash(` + set -e + . ${JSON.stringify(termuxShPath)} + ${ANDROID_LOCALE_TO_LANG.map(([tag, want]) => ` + got=$(android_locale_to_lang ${JSON.stringify(tag)}) + [ "$got" = ${JSON.stringify(want)} ] || { echo "mismatch:${tag}:$got" >&2; exit 1; } + `).join('')} + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) + +Deno.test('android_locale_to_lang feeds get_system_locales toward zh-CN', async () => { + const i18nShPath = join(REPO_ROOT, 'path', 'src', 'i18n.sh') + const result = await runBash(` + set -e + FOUNT_DIR=${JSON.stringify(REPO_ROOT)} + . ${JSON.stringify(termuxShPath)} + . ${JSON.stringify(i18nShPath)} + LANG=$(android_locale_to_lang 'zh-Hans-CN') + export LANG + unset LC_ALL + system_locales=$(get_system_locales) + available_locales=$(get_available_locales) + best=$(get_best_locale "$system_locales" "$available_locales") + [ "$best" = 'zh-CN' ] || { echo "best=$best system=$system_locales" >&2; exit 1; } + echo ok + `) + assertEquals(result.code, 0, result.stderr || result.stdout) + assertEquals(result.stdout.trim(), 'ok') +}) diff --git a/src/decl/locale_data.ts b/src/decl/locale_data.ts index 6816656c7..57085641e 100644 --- a/src/decl/locale_data.ts +++ b/src/decl/locale_data.ts @@ -346,6 +346,26 @@ export type LocaleData = { createdNoUpdate: string unknownTarget: string } + version: { + branch: { + title: string + detached: string + } + commit: string + remote: string + status: { + title: string + upToDate: string + behind: string + ahead: string + diverged: string + detachedNoCompare: string + fetchFailed: string + } + autoUpdatePaused: string + noRepo: string + noGit: string + } shortcut: { desktopShortcutCreated: string startMenuShortcutCreated: string @@ -1916,6 +1936,7 @@ export type LocaleData = { 'aria-label': string } operationFailed: string + warmCharCacheFailed: string shareGroupFailed: string replyInline: { title: string @@ -2132,6 +2153,7 @@ export type LocaleData = { } federation: { loadFailed: string + rebindFailed: string subtitle: string title: string tooltip: { @@ -4013,6 +4035,8 @@ export type LocaleData = { title: string description: string bootstrapFailed: string + connectNodeFailed: string + dwellFailed: string home_function_buttons: { main: { title: string @@ -5906,6 +5930,7 @@ export type LocaleKeyParams = { 'chat.hub.fed.repairJoinSnapshotFailed': { error: string | number } 'chat.hub.fed.repairJoinSnapshotOk': { channels: string | number } 'chat.hub.federation.loadFailed': { error: string | number } + 'chat.hub.federation.rebindFailed': { error: string | number } 'chat.hub.files.loadFailed': { error: string | number } 'chat.hub.files.renameFolderPrompt': { name: string | number } 'chat.hub.folder.renamePrompt': { name: string | number } @@ -5976,6 +6001,7 @@ export type LocaleKeyParams = { 'chat.hub.vote.createFailed': { error: string | number } 'chat.hub.vote.deadline': { date: string | number } 'chat.hub.vote.total': { total: string | number } + 'chat.hub.warmCharCacheFailed': { error: string | number } 'chat.message.view.logprobsMetricsFooter': { speed: string | number; time: string | number; tokens: string | number; ttft: string | number } 'chat.message.view.logprobsTopLogprobsMeta': { token: string | number } 'chat.message.view.share.success': { provider: string | number; sponsorLink: string | number } @@ -6048,6 +6074,10 @@ export type LocaleKeyParams = { 'fountConsole.path.update.pinningToPullRequest': { pr: string | number } 'fountConsole.path.update.switchingToBranch': { branch: string | number } 'fountConsole.path.update.unknownTarget': { target: string | number } + 'fountConsole.path.version.branch.title': { branch: string | number } + 'fountConsole.path.version.commit': { ref: string | number } + 'fountConsole.path.version.remote': { ref: string | number } + 'fountConsole.path.version.status.title': { status: string | number } 'fountConsole.route.setLanguagePreference': { preferredLanguages: string | number; username: string | number } 'fountConsole.server.localUrl': { url: string | number } 'fountConsole.server.mdns.bonjourFailed': { error: string | number } @@ -6190,9 +6220,11 @@ export type LocaleKeyParams = { 'social.actions.repostFailed': { error: string | number } 'social.actions.saveFailed': { error: string | number } 'social.bootstrapFailed': { error: string | number } + 'social.connectNodeFailed': { error: string | number } 'social.drafts.deleteFailed': { error: string | number } 'social.drafts.loadFailed': { error: string | number } 'social.drafts.saveFailed': { error: string | number } + 'social.dwellFailed': { error: string | number } 'social.feed.repostedBy': { author: string | number } 'social.feed.trending.postCount.textContent': { n: string | number } 'social.feed.trending.postCount.title': { n: string | number } diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 9e6eab2f2..816c00e50 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -363,6 +363,26 @@ "createdNoUpdate": "تم إنشاء `.noupdate`؛ عُلِّقت التحديثات التلقائية.", "unknownTarget": "تعذّر التعرف على «${target}»: ليس فرعًا معروفًا، ولا التزامًا، ولا طلب سحب." }, + "version": { + "branch": { + "title": "الفرع: ${branch}", + "detached": "HEAD منفصل" + }, + "commit": "الالتزام: `${ref}`", + "remote": "البعيد: `${ref}`", + "status": { + "title": "الحالة: ${status}", + "upToDate": "محدّث", + "behind": "متأخر عن البعيد", + "ahead": "متقدّم على البعيد", + "diverged": "متباعد عن البعيد", + "detachedNoCompare": "HEAD منفصل، لا يمكن المقارنة مع البعيد", + "fetchFailed": "تعذّر جلب حالة البعيد" + }, + "autoUpdatePaused": "تم إيقاف التحديثات التلقائية (يوجد `.noupdate`)", + "noRepo": "لم يتم العثور على مستودع Git", + "noGit": "لم يتم تثبيت Git" + }, "shortcut": { "desktopShortcutCreated": "تم إنشاء اختصار سطح المكتب في `${path}`", "startMenuShortcutCreated": "تم إنشاء اختصار قائمة ابدأ في `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "المجموعات والتنقل" }, "operationFailed": "فشلت العملية: ${error}", + "warmCharCacheFailed": "فشل تسخين ذاكرة التخزين المؤقت للشخصيات: ${error}", "shareGroupFailed": "فشلت مشاركة رابط المجموعة: ${error}", "replyInline": { "title": "رد" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "فشل التحميل: ${error}", + "rebindFailed": "فشلت إعادة ربط غرفة الاتحاد: ${error}", "subtitle": "التحكم في الاتصالات عبر الأجهزة؛ ترك الإعدادات الافتراضية في معظم الحالات", "title": "إعدادات الاتحاد P2P", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "الساحة", "description": "منشورات P2P مفتوحة ومتابعة ومشاركة ملفات", "bootstrapFailed": "فشل تهيئة واجهة التواصل: ${error}", + "connectNodeFailed": "فشل الاتصال بالعقدة: ${error}", + "dwellFailed": "فشل تحديث بيانات التفضيلات حسب مدة المشاهدة: ${error}", "home_function_buttons": { "main": { "title": "الساحة" diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index cee1c46c5..55dc6c01f 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` erstellt; automatische Updates pausiert.", "unknownTarget": "'${target}' kann nicht erkannt werden: kein bekannter Branch, Commit oder Pull Request." }, + "version": { + "branch": { + "title": "Branch: ${branch}", + "detached": "Abgetrennter HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Status: ${status}", + "upToDate": "Auf dem neuesten Stand", + "behind": "Hinter Remote zurück", + "ahead": "Remote voraus", + "diverged": "Von Remote abgewichen", + "detachedNoCompare": "Abgetrennter HEAD, kein Vergleich mit Remote", + "fetchFailed": "Remote-Status konnte nicht abgerufen werden" + }, + "autoUpdatePaused": "Automatische Updates pausiert (`.noupdate` vorhanden)", + "noRepo": "Git-Repository nicht gefunden", + "noGit": "Git ist nicht installiert" + }, "shortcut": { "desktopShortcutCreated": "Desktop-Verknüpfung erstellt unter `${path}`", "startMenuShortcutCreated": "Startmenü-Verknüpfung erstellt unter `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Gruppen und Navigation" }, "operationFailed": "Vorgang fehlgeschlagen: ${error}", + "warmCharCacheFailed": "Charakter-Cache konnte nicht vorgewärmt werden: ${error}", "shareGroupFailed": "Gruppenlink konnte nicht geteilt werden: ${error}", "replyInline": { "title": "Antwort" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Laden fehlgeschlagen: ${error}", + "rebindFailed": "Föderationsraum konnte nicht neu gebunden werden: ${error}", "subtitle": "Steuern Sie geräteübergreifende Verbindungen. Behalten Sie in den meisten Fällen die Standardeinstellungen bei", "title": "Föderierte P2P-Einstellungen", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Plaza", "description": "P2P-Social: Feeds, Follows und Dateifreigabe", "bootstrapFailed": "Social-Oberfläche konnte nicht initialisiert werden: ${error}", + "connectNodeFailed": "Verbindung zum Knoten fehlgeschlagen: ${error}", + "dwellFailed": "Vorlieben konnten anhand der Wiedergabezeit nicht aktualisiert werden: ${error}", "home_function_buttons": { "main": { "title": "Plaza" diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 401678e42..19cc7d294 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -363,6 +363,26 @@ "createdNoUpdate": "✨ `.noupdate` ⏸️⏫", "unknownTarget": "❓ '${target}' 🚫🌿🚫📌🚫🔀" }, + "version": { + "branch": { + "title": "🌳 ${branch}", + "detached": "HEAD 🔓" + }, + "commit": "📌 `${ref}`", + "remote": "☁️ `${ref}`", + "status": { + "title": "📊 ${status}", + "upToDate": "👍💯", + "behind": "☁️⬆️🏠", + "ahead": "🏠⬆️☁️", + "diverged": "🔀", + "detachedNoCompare": "HEAD 🔓 ⏭️☁️", + "fetchFailed": "❌☁️📥" + }, + "autoUpdatePaused": "⏸️⏫ (`.noupdate`)", + "noRepo": "❓ 🐙🐱", + "noGit": "❌ 🐙🐱" + }, "shortcut": { "desktopShortcutCreated": "🖥️✨ `${path}`", "startMenuShortcutCreated": "🏁✨ `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "👥🧭" }, "operationFailed": "⚙️❌:${error}", + "warmCharCacheFailed": "🎭🗄️🔥❌:${error}", "shareGroupFailed": "📤👥🔗❌:${error}", "replyInline": { "title": "💬↩️" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "📥❌:${error}", + "rebindFailed": "🔄🌐🏠❌:${error}", "subtitle": "🖥️🔗🎛️ · 👌🔧⭐", "title": "🌐🔗⚙️", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "🏛️", "description": "🔗🌍 🏛️📰 ➕ 👀 ➕ 📁🔗", "bootstrapFailed": "🏛️🚀❌: ${error}", + "connectNodeFailed": "🔗🌐❌:${error}", + "dwellFailed": "👀⏱️⭐❤️❌:${error}", "home_function_buttons": { "main": { "title": "🏛️" diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json index 70c910c60..ba9cb0777 100644 --- a/src/public/locales/en-UK.json +++ b/src/public/locales/en-UK.json @@ -358,6 +358,26 @@ "createdNoUpdate": "Created `.noupdate`; automatic updates paused.", "unknownTarget": "Unrecognised target '${target}': not a known branch, commit, or pull request." }, + "version": { + "branch": { + "title": "Branch: ${branch}", + "detached": "Detached HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Status: ${status}", + "upToDate": "Up to date", + "behind": "Behind remote", + "ahead": "Ahead of remote", + "diverged": "Diverged from remote", + "detachedNoCompare": "Detached HEAD; not comparing with remote", + "fetchFailed": "Unable to fetch remote status" + }, + "autoUpdatePaused": "Automatic updates paused (`.noupdate` exists)", + "noRepo": "Git repository not found", + "noGit": "Git is not installed" + }, "shortcut": { "desktopShortcutCreated": "Desktop shortcut created at `${path}`", "startMenuShortcutCreated": "Start menu shortcut created at `${path}`", @@ -1943,6 +1963,7 @@ "aria-label": "Groups and navigation" }, "operationFailed": "Operation failed: ${error}", + "warmCharCacheFailed": "Couldn't warm up the character cache: ${error}", "shareGroupFailed": "Failed to share group link: ${error}", "replyInline": { "title": "Reply" @@ -2155,6 +2176,7 @@ }, "federation": { "loadFailed": "Couldn't load: ${error}", + "rebindFailed": "Couldn't rebind the federation room: ${error}", "subtitle": "Control cross-device connections — defaults are fine for most people", "title": "Federated P2P settings", "tooltip": { @@ -4036,6 +4058,8 @@ "title": "Square", "description": "P2P open social posts, following and file sharing", "bootstrapFailed": "Social interface initialization failed: ${error}", + "connectNodeFailed": "Couldn't connect to node: ${error}", + "dwellFailed": "Couldn't update taste data from watch time: ${error}", "home_function_buttons": { "main": { "title": "Square" diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index 236b1c13c..42e1aff0c 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Se creó `.noupdate`; actualizaciones automáticas suspendidas.", "unknownTarget": "No se puede identificar «${target}»: no es una rama conocida, un commit ni una pull request." }, + "version": { + "branch": { + "title": "Rama: ${branch}", + "detached": "HEAD separado" + }, + "commit": "Commit: `${ref}`", + "remote": "Remoto: `${ref}`", + "status": { + "title": "Estado: ${status}", + "upToDate": "Actualizado", + "behind": "Por detrás del remoto", + "ahead": "Por delante del remoto", + "diverged": "Divergido del remoto", + "detachedNoCompare": "HEAD separado, sin comparación con el remoto", + "fetchFailed": "No se pudo obtener el estado del remoto" + }, + "autoUpdatePaused": "Actualizaciones automáticas en pausa (existe `.noupdate`)", + "noRepo": "Repositorio Git no encontrado", + "noGit": "Git no está instalado" + }, "shortcut": { "desktopShortcutCreated": "Acceso directo de escritorio creado en `${path}`", "startMenuShortcutCreated": "Acceso directo al menú Inicio creado en `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Grupos y navegación" }, "operationFailed": "Operación fallida: ${error}", + "warmCharCacheFailed": "No se pudo precalentar la caché de personajes: ${error}", "shareGroupFailed": "No se pudo compartir el enlace del grupo: ${error}", "replyInline": { "title": "responder" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Error al cargar: ${error}", + "rebindFailed": "No se pudo volver a vincular la sala de federación: ${error}", "subtitle": "Controlar conexiones entre dispositivos; Deje los valores predeterminados en la mayoría de los casos.", "title": "Configuración de P2P federado", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Plaza", "description": "Social P2P abierto: publicaciones, seguimientos y archivos compartidos", "bootstrapFailed": "La inicialización de la interfaz social falló: ${error}", + "connectNodeFailed": "No se pudo conectar al nodo: ${error}", + "dwellFailed": "No se pudieron actualizar las preferencias según el tiempo de visualización: ${error}", "home_function_buttons": { "main": { "title": "Plaza" diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index d8b6cf151..12f73fff9 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` créé ; mises à jour automatiques suspendues.", "unknownTarget": "Impossible d'identifier « ${target} » : ni branche connue, ni commit, ni pull request." }, + "version": { + "branch": { + "title": "Branche : ${branch}", + "detached": "HEAD détaché" + }, + "commit": "Commit : `${ref}`", + "remote": "Distant : `${ref}`", + "status": { + "title": "État : ${status}", + "upToDate": "À jour", + "behind": "En retard sur le distant", + "ahead": "En avance sur le distant", + "diverged": "Divergé du distant", + "detachedNoCompare": "HEAD détaché, pas de comparaison avec le distant", + "fetchFailed": "Impossible de récupérer l'état du distant" + }, + "autoUpdatePaused": "Mises à jour automatiques suspendues (`.noupdate` présent)", + "noRepo": "Dépôt Git introuvable", + "noGit": "Git n'est pas installé" + }, "shortcut": { "desktopShortcutCreated": "Raccourci sur le bureau créé à `${path}`", "startMenuShortcutCreated": "Raccourci du menu Démarrer créé à `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Groupes et navigation" }, "operationFailed": "Échec de l'opération : ${error}", + "warmCharCacheFailed": "Échec du préchargement du cache des personnages : ${error}", "shareGroupFailed": "Échec du partage du lien du groupe : ${error}", "replyInline": { "title": "Répondre" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Échec du chargement : ${error}", + "rebindFailed": "Échec de la reconnexion de la salle de fédération : ${error}", "subtitle": "Contrôler les connexions entre appareils ; laissez les valeurs par défaut dans la plupart des cas", "title": "Paramètres P2P fédérés", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Social", "description": "Fil social P2P ouvert, abonnements et partage de fichiers", "bootstrapFailed": "Échec d'initialisation de l'interface sociale : ${error}", + "connectNodeFailed": "Échec de la connexion au nœud : ${error}", + "dwellFailed": "Échec de la mise à jour des préférences selon le temps de visionnage : ${error}", "home_function_buttons": { "main": { "title": "Social" diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index ee11aaa0a..b1e116937 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` बनाया गया; स्वचालित अपडेट रोक दिए गए।", "unknownTarget": "'${target}' को पहचाना नहीं जा सका: यह कोई ज्ञात शाखा, कमिट या पुल अनुरोध नहीं है।" }, + "version": { + "branch": { + "title": "शाखा: ${branch}", + "detached": "अलग HEAD" + }, + "commit": "कमिट: `${ref}`", + "remote": "रिमोट: `${ref}`", + "status": { + "title": "स्थिति: ${status}", + "upToDate": "नवीनतम", + "behind": "रिमोट से पीछे", + "ahead": "रिमोट से आगे", + "diverged": "रिमोट से विचलित", + "detachedNoCompare": "अलग HEAD, रिमोट से तुलना नहीं", + "fetchFailed": "रिमोट स्थिति प्राप्त नहीं कर सके" + }, + "autoUpdatePaused": "स्वचालित अपडेट रोक दिए गए (`.noupdate` मौजूद है)", + "noRepo": "Git रिपॉजिटरी नहीं मिली", + "noGit": "Git स्थापित नहीं है" + }, "shortcut": { "desktopShortcutCreated": "`${path}` पर डेस्कटॉप शॉर्टकट बनाया गया", "startMenuShortcutCreated": "`${path}` पर प्रारंभ मेनू शॉर्टकट बनाया गया", @@ -1948,6 +1968,7 @@ "aria-label": "समूह और नेविगेशन" }, "operationFailed": "ऑपरेशन विफल: ${error}", + "warmCharCacheFailed": "कैरेक्टर कैश वार्म-अप विफल: ${error}", "shareGroupFailed": "समूह लिंक साझा करने में विफल: ${error}", "replyInline": { "title": "उत्तर" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "लोडिंग विफल: ${error}", + "rebindFailed": "फ़ेडरेशन रूम रीबाइंड करने में विफल: ${error}", "subtitle": "क्रॉस-डिवाइस कनेक्शन नियंत्रित करें; अधिकांश मामलों में डिफ़ॉल्ट को छोड़ दें", "title": "फ़ेडरेटेड P2P सेटिंग्स", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "फ़ीड", "description": "P2P सोशल पोस्ट, फ़ॉलो और फ़ाइल शेयरिंग", "bootstrapFailed": "सामाजिक इंटरफ़ेस आरंभीकरण विफल: ${error}", + "connectNodeFailed": "नोड से कनेक्ट करने में विफल: ${error}", + "dwellFailed": "देखने के समय के आधार पर प्राथमिकता डेटा अपडेट विफल: ${error}", "home_function_buttons": { "main": { "title": "फ़ीड" diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index 1b732ea40..650ff227f 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` búið til; sjálfvirkar uppfærslur í bið.", "unknownTarget": "Get ekki borið kennsl á '${target}': ekki þekkt grein, commit né pull request." }, + "version": { + "branch": { + "title": "Grein: ${branch}", + "detached": "Aðskilið HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Fjarlægt: `${ref}`", + "status": { + "title": "Staða: ${status}", + "upToDate": "Uppfært", + "behind": "Á eftir fjarlægu", + "ahead": "Á undan fjarlægu", + "diverged": "Greinist frá fjarlægu", + "detachedNoCompare": "Aðskilið HEAD, ekki borið saman við fjarlægt", + "fetchFailed": "Gat ekki sótt fjarlægt ástand" + }, + "autoUpdatePaused": "Sjálfvirkar uppfærslur í bið (`.noupdate` er til)", + "noRepo": "Git geymsla fannst ekki", + "noGit": "Git er ekki uppsett" + }, "shortcut": { "desktopShortcutCreated": "Flýtileið á skjáborði búin til á `${path}`", "startMenuShortcutCreated": "Flýtileið fyrir upphafsvalmynd búin til á `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Hópar og leiðsögn" }, "operationFailed": "Aðgerð mistókst: ${error}", + "warmCharCacheFailed": "Forhleðsla skyndiminnis persónu mistókst: ${error}", "shareGroupFailed": "Mistókst að deila hóptengli: ${error}", "replyInline": { "title": "Svara" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Mistókst að hlaða: ${error}", + "rebindFailed": "Endurtenging sambandsherbergis mistókst: ${error}", "subtitle": "Stjórna tengingum milli tækja; yfirgefa vanskil í flestum tilfellum", "title": "Sambands-P2P-stillingar", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Torg", "description": "P2P opin samfélagsfærslur, fylgni og skráadeiling", "bootstrapFailed": "Frumstilling samfélagsviðmóts mistókst: ${error}", + "connectNodeFailed": "Tenging við hnút mistókst: ${error}", + "dwellFailed": "Uppfærsla óskagagna út frá áhorfstíma mistókst: ${error}", "home_function_buttons": { "main": { "title": "Torg" diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 0374224f8..cd8d66c17 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Creato `.noupdate`; aggiornamenti automatici sospesi.", "unknownTarget": "Impossibile identificare «${target}»: non è un ramo noto, un commit né una pull request." }, + "version": { + "branch": { + "title": "Ramo: ${branch}", + "detached": "HEAD scollegato" + }, + "commit": "Commit: `${ref}`", + "remote": "Remoto: `${ref}`", + "status": { + "title": "Stato: ${status}", + "upToDate": "Aggiornato", + "behind": "Indietro rispetto al remoto", + "ahead": "Avanti rispetto al remoto", + "diverged": "Divergente dal remoto", + "detachedNoCompare": "HEAD scollegato, nessun confronto con il remoto", + "fetchFailed": "Impossibile recuperare lo stato del remoto" + }, + "autoUpdatePaused": "Aggiornamenti automatici sospesi (`.noupdate` presente)", + "noRepo": "Repository Git non trovato", + "noGit": "Git non è installato" + }, "shortcut": { "desktopShortcutCreated": "Collegamento sul desktop creato in `${path}`", "startMenuShortcutCreated": "Collegamento al menu Start creato in `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Gruppi e navigazione" }, "operationFailed": "Operazione non riuscita: ${error}", + "warmCharCacheFailed": "Precaricamento della cache dei personaggi non riuscito: ${error}", "shareGroupFailed": "Impossibile condividere il collegamento al gruppo: ${error}", "replyInline": { "title": "Rispondere" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Impossibile caricare: ${error}", + "rebindFailed": "Ricollegamento della stanza federata non riuscito: ${error}", "subtitle": "Controlla le connessioni tra dispositivi: le impostazioni predefinite vanno bene per la maggior parte delle persone", "title": "Impostazioni P2P federate", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "piazza", "description": "P2P apre aggiornamenti social, follower e condivisione di file", "bootstrapFailed": "Inizializzazione dell'interfaccia social non riuscita: ${error}", + "connectNodeFailed": "Connessione al nodo non riuscita: ${error}", + "dwellFailed": "Aggiornamento delle preferenze in base al tempo di visualizzazione non riuscito: ${error}", "home_function_buttons": { "main": { "title": "piazza" diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index e95dc4224..f6f69ea78 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` を作成しました。自動更新を一時停止しました。", "unknownTarget": "「${target}」を識別できません:既知のブランチ、コミット、プルリクエストのいずれでもありません。" }, + "version": { + "branch": { + "title": "ブランチ: ${branch}", + "detached": "detached HEAD" + }, + "commit": "コミット: `${ref}`", + "remote": "リモート: `${ref}`", + "status": { + "title": "状態: ${status}", + "upToDate": "最新", + "behind": "リモートより遅れています", + "ahead": "リモートより進んでいます", + "diverged": "リモートと分岐しています", + "detachedNoCompare": "detached HEAD のためリモートと比較できません", + "fetchFailed": "リモートの状態を取得できません" + }, + "autoUpdatePaused": "自動更新を一時停止中(`.noupdate` あり)", + "noRepo": "Git リポジトリが見つかりません", + "noGit": "Git がインストールされていません" + }, "shortcut": { "desktopShortcutCreated": "`${path}` にデスクトップ ショートカットが作成されました", "startMenuShortcutCreated": "`${path}` にスタート メニューのショートカットが作成されました", @@ -1948,6 +1968,7 @@ "aria-label": "グループとナビゲーション" }, "operationFailed": "操作が失敗しました: ${error}", + "warmCharCacheFailed": "キャラクターキャッシュのウォームアップに失敗しました:${error}", "shareGroupFailed": "グループリンクを共有できませんでした: ${error}", "replyInline": { "title": "返事" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "読み込みに失敗しました: ${error}", + "rebindFailed": "フェデレーションルームの再バインドに失敗しました:${error}", "subtitle": "クロスデバイス接続を制御します。ほとんどの場合はデフォルトのままにします", "title": "フェデレーション P2P 設定", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "広場", "description": "P2P オープンソーシャル・フォロー・ファイル共有", "bootstrapFailed": "ソーシャル インターフェイスの初期化に失敗しました: ${error}", + "connectNodeFailed": "ノードへの接続に失敗しました:${error}", + "dwellFailed": "視聴時間に基づくおすすめデータの更新に失敗しました:${error}", "home_function_buttons": { "main": { "title": "広場" diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index 5cff162a6..9f1c618ac 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate`를 생성했습니다. 자동 업데이트가 일시 중지되었습니다.", "unknownTarget": "'${target}'을(를) 식별할 수 없습니다: 알려진 브랜치, 커밋 또는 풀 리퀘스트가 아닙니다." }, + "version": { + "branch": { + "title": "브랜치: ${branch}", + "detached": "HEAD 분리됨" + }, + "commit": "커밋: `${ref}`", + "remote": "원격: `${ref}`", + "status": { + "title": "상태: ${status}", + "upToDate": "최신 상태", + "behind": "원격보다 뒤쳐짐", + "ahead": "원격보다 앞섬", + "diverged": "원격과 분기됨", + "detachedNoCompare": "HEAD 분리 상태, 원격과 비교 불가", + "fetchFailed": "원격 상태를 가져올 수 없음" + }, + "autoUpdatePaused": "자동 업데이트 일시 중지됨 (`.noupdate` 존재)", + "noRepo": "Git 저장소를 찾을 수 없음", + "noGit": "Git이 설치되지 않음" + }, "shortcut": { "desktopShortcutCreated": "`${path}`에 바탕화면 바로가기가 생성되었습니다.", "startMenuShortcutCreated": "`${path}`에 시작 메뉴 바로가기가 생성되었습니다.", @@ -1948,6 +1968,7 @@ "aria-label": "그룹 및 탐색" }, "operationFailed": "작업 실패: ${error}", + "warmCharCacheFailed": "캐릭터 캐시 준비 실패: ${error}", "shareGroupFailed": "그룹 링크 공유 실패: ${error}", "replyInline": { "title": "답글" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "불러오기 실패: ${error}", + "rebindFailed": "연합 룸 리바인딩 실패: ${error}", "subtitle": "교차 장치 연결을 제어합니다. 대부분의 경우 기본값을 그대로 둡니다.", "title": "연합 P2P 설정", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "정사각형", "description": "P2P 공개 소셜 업데이트, 팔로우 및 파일 공유", "bootstrapFailed": "소셜 인터페이스 초기화 실패: ${error}", + "connectNodeFailed": "노드 연결 실패: ${error}", + "dwellFailed": "시청 시간 기반 취향 데이터 업데이트 실패: ${error}", "home_function_buttons": { "main": { "title": "정사각형" diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index 650fa5d57..93b81e92c 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -363,6 +363,26 @@ "createdNoUpdate": "已立 `.noupdate` 檔,自動更新暫止。", "unknownTarget": "未能辨識「${target}」:非已知分支、提交或拉取請求。" }, + "version": { + "branch": { + "title": "分支:${branch}", + "detached": "HEAD 已離" + }, + "commit": "提交:`${ref}`", + "remote": "遠端:`${ref}`", + "status": { + "title": "態:${status}", + "upToDate": "已為最新", + "behind": "遜於遠端", + "ahead": "先於遠端", + "diverged": "與遠端歧", + "detachedNoCompare": "HEAD 已離,未與遠端較", + "fetchFailed": "未能察遠端態" + }, + "autoUpdatePaused": "自動更新暫止(有 `.noupdate`)", + "noRepo": "未尋得 Git 倉", + "noGit": "未裝 Git" + }, "shortcut": { "desktopShortcutCreated": "桌面快捷已建於 `${path}`", "startMenuShortcutCreated": "開始綱目快捷已建於 `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "群與導覽" }, "operationFailed": "未竟其功:${error}", + "warmCharCacheFailed": "角色緩存預熱未遂:${error}", "shareGroupFailed": "分享群鏈接未遂:${error}", "replyInline": { "title": "回復" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "取錄有失:${error}", + "rebindFailed": "諸邦房間重新綁定未遂:${error}", "subtitle": "掌跨器連繫;大抵守常制即可", "title": "諸邦 P2P 樞機", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "廣場", "description": "P2P 開放社交言、關注與文卷分享", "bootstrapFailed": "社交界面初設未遂:${error}", + "connectNodeFailed": "連接節點未遂:${error}", + "dwellFailed": "依觀看時長更新偏好未遂:${error}", "home_function_buttons": { "main": { "title": "廣場" diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index 753fbf76a..602ee7383 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` aangemaakt; automatische updates gepauzeerd.", "unknownTarget": "Kan '${target}' niet herkennen: geen bekende branch, commit of pull request." }, + "version": { + "branch": { + "title": "Branch: ${branch}", + "detached": "Losgekoppeld HEAD" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Status: ${status}", + "upToDate": "Bijgewerkt", + "behind": "Achter op remote", + "ahead": "Voor op remote", + "diverged": "Uit elkaar gelopen met remote", + "detachedNoCompare": "Losgekoppeld HEAD, geen vergelijking met remote", + "fetchFailed": "Kan remotestatus niet ophalen" + }, + "autoUpdatePaused": "Automatische updates gepauzeerd (`.noupdate` bestaat)", + "noRepo": "Git-repository niet gevonden", + "noGit": "Git is niet geïnstalleerd" + }, "shortcut": { "desktopShortcutCreated": "Snelkoppeling op het bureaublad gemaakt op `${path}`", "startMenuShortcutCreated": "Snelkoppeling naar startmenu gemaakt op `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Groepen en navigatie" }, "operationFailed": "Bewerking mislukt: ${error}", + "warmCharCacheFailed": "Voorladen van personagecache mislukt: ${error}", "shareGroupFailed": "Kan groepslink niet delen: ${error}", "replyInline": { "title": "Antwoord" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Laden mislukt: ${error}", + "rebindFailed": "Opnieuw koppelen van federatieruimte mislukt: ${error}", "subtitle": "Beheer verbindingen tussen apparaten; laat in de meeste gevallen de standaardwaarden staan", "title": "Federatieve P2P-instellingen", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Plein", "description": "Open P2P-socialfeed, volgen en bestandsdeling", "bootstrapFailed": "Initialisatie van sociale interface mislukt: ${error}", + "connectNodeFailed": "Verbinding met knooppunt mislukt: ${error}", + "dwellFailed": "Voorkeursgegevens bijwerken op basis van kijktijd mislukt: ${error}", "home_function_buttons": { "main": { "title": "Plein" diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index 6237fa355..4d3bfed27 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -363,6 +363,26 @@ "createdNoUpdate": "`.noupdate` criado; atualizações automáticas pausadas.", "unknownTarget": "Não foi possível identificar «${target}»: não é um ramo conhecido, um commit nem uma pull request." }, + "version": { + "branch": { + "title": "Ramo: ${branch}", + "detached": "HEAD desanexado" + }, + "commit": "Commit: `${ref}`", + "remote": "Remoto: `${ref}`", + "status": { + "title": "Estado: ${status}", + "upToDate": "Atualizado", + "behind": "Atrasado em relação ao remoto", + "ahead": "À frente do remoto", + "diverged": "Divergido do remoto", + "detachedNoCompare": "HEAD desanexado, sem comparação com o remoto", + "fetchFailed": "Não foi possível obter o estado do remoto" + }, + "autoUpdatePaused": "Atualizações automáticas pausadas (`.noupdate` existe)", + "noRepo": "Repositório Git não encontrado", + "noGit": "Git não está instalado" + }, "shortcut": { "desktopShortcutCreated": "Atalho na área de trabalho criado em `${path}`", "startMenuShortcutCreated": "Atalho do menu Iniciar criado em `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Grupos e navegação" }, "operationFailed": "Falha na operação: ${error}", + "warmCharCacheFailed": "Falha ao pré-aquecer a cache de personagens: ${error}", "shareGroupFailed": "Falha ao share group link: ${error}", "replyInline": { "title": "Responder" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Falha no carregamento: ${error}", + "rebindFailed": "Falha ao religar a sala da federação: ${error}", "subtitle": "Controlar conexões entre dispositivos; deixe os padrões na maioria dos casos", "title": "Definições P2P federadas", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Praça", "description": "Publicações sociais abertas P2P, seguir e partilha de ficheiros", "bootstrapFailed": "Falha na inicialização da interface social: ${error}", + "connectNodeFailed": "Falha ao ligar ao nó: ${error}", + "dwellFailed": "Falha ao atualizar preferências com base no tempo de visualização: ${error}", "home_function_buttons": { "main": { "title": "Praça" diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index be344f283..019cc501f 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Создан `.noupdate`; автоматические обновления приостановлены.", "unknownTarget": "Не удалось распознать «${target}»: это неизвестная ветка, коммит или pull request." }, + "version": { + "branch": { + "title": "Ветка: ${branch}", + "detached": "Отсоединённый HEAD" + }, + "commit": "Коммит: `${ref}`", + "remote": "Удалённый: `${ref}`", + "status": { + "title": "Статус: ${status}", + "upToDate": "Актуально", + "behind": "Отстаёт от удалённого", + "ahead": "Опережает удалённый", + "diverged": "Разошёлся с удалённым", + "detachedNoCompare": "Отсоединённый HEAD, сравнение с удалённым невозможно", + "fetchFailed": "Не удалось получить состояние удалённого репозитория" + }, + "autoUpdatePaused": "Автообновление приостановлено (есть `.noupdate`)", + "noRepo": "Репозиторий Git не найден", + "noGit": "Git не установлен" + }, "shortcut": { "desktopShortcutCreated": "Ярлык на рабочем столе создан по адресу `${path}`.", "startMenuShortcutCreated": "Ярлык меню «Пуск», созданный по адресу `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Группы и навигация" }, "operationFailed": "Ошибка операции: ${error}", + "warmCharCacheFailed": "Не удалось прогреть кэш персонажей: ${error}", "shareGroupFailed": "Не удалось поделиться ссылкой на группу: ${error}", "replyInline": { "title": "Ответить" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Не удалось загрузить: ${error}", + "rebindFailed": "Не удалось перепривязать комнату федерации: ${error}", "subtitle": "Контролировать соединения между устройствами; в большинстве случаев оставьте значения по умолчанию", "title": "Настройки федеративного P2P", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Площадь", "description": "P2P-соцсеть: лента, подписки и обмен файлами", "bootstrapFailed": "Не удалось инициализировать социнтерфейс: ${error}", + "connectNodeFailed": "Не удалось подключиться к узлу: ${error}", + "dwellFailed": "Не удалось обновить данные о предпочтениях по времени просмотра: ${error}", "home_function_buttons": { "main": { "title": "Площадь" diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index 7e67d3d1a..5c9733af8 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Створено `.noupdate`; автоматичні оновлення призупинено.", "unknownTarget": "Неможливо розпізнати «${target}»: це не відома гілка, коміт або pull request." }, + "version": { + "branch": { + "title": "Гілка: ${branch}", + "detached": "Від'єднаний HEAD" + }, + "commit": "Коміт: `${ref}`", + "remote": "Віддалений: `${ref}`", + "status": { + "title": "Статус: ${status}", + "upToDate": "Актуально", + "behind": "Відстає від віддаленого", + "ahead": "Випереджає віддалений", + "diverged": "Розійшовся з віддаленим", + "detachedNoCompare": "Від'єднаний HEAD, порівняння з віддаленим неможливе", + "fetchFailed": "Не вдалося отримати стан віддаленого репозиторію" + }, + "autoUpdatePaused": "Автооновлення призупинено (існує `.noupdate`)", + "noRepo": "Репозиторій Git не знайдено", + "noGit": "Git не встановлено" + }, "shortcut": { "desktopShortcutCreated": "Ярлик на робочому столі створено в `${path}`", "startMenuShortcutCreated": "Ярлик меню «Пуск» створено в `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Групи та навігація" }, "operationFailed": "Помилка операції: ${error}", + "warmCharCacheFailed": "Не вдалося розігріти кеш персонажів: ${error}", "shareGroupFailed": "Не вдалося поділитися посиланням на групу: ${error}", "replyInline": { "title": "Відповісти" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Помилка завантаження: ${error}", + "rebindFailed": "Не вдалося переприв’язати кімнату федерації: ${error}", "subtitle": "Контроль з'єднань між пристроями; у більшості випадків залишайте значення за замовчуванням", "title": "Об'єднані налаштування P2P", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Стрічка", "description": "P2P відкриті оновлення в соціальних мережах, підписка та обмін файлами", "bootstrapFailed": "Помилка ініціалізації соціального інтерфейсу: ${error}", + "connectNodeFailed": "Не вдалося підключитися до вузла: ${error}", + "dwellFailed": "Не вдалося оновити дані про вподобання за часом перегляду: ${error}", "home_function_buttons": { "main": { "title": "Стрічка" diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index b45aff963..d51db4568 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -363,6 +363,26 @@ "createdNoUpdate": "Đã tạo `.noupdate`; cập nhật tự động đã tạm dừng.", "unknownTarget": "Không thể nhận dạng «${target}»: không phải nhánh, commit hoặc pull request đã biết." }, + "version": { + "branch": { + "title": "Nhánh: ${branch}", + "detached": "HEAD tách rời" + }, + "commit": "Commit: `${ref}`", + "remote": "Remote: `${ref}`", + "status": { + "title": "Trạng thái: ${status}", + "upToDate": "Đã cập nhật", + "behind": "Chậm hơn remote", + "ahead": "Đi trước remote", + "diverged": "Phân nhánh với remote", + "detachedNoCompare": "HEAD tách rời, không so sánh với remote", + "fetchFailed": "Không thể lấy trạng thái remote" + }, + "autoUpdatePaused": "Đã tạm dừng cập nhật tự động (có `.noupdate`)", + "noRepo": "Không tìm thấy kho Git", + "noGit": "Git chưa được cài đặt" + }, "shortcut": { "desktopShortcutCreated": "Lối tắt trên màn hình được tạo tại `${path}`", "startMenuShortcutCreated": "Phím tắt menu bắt đầu được tạo tại `${path}`", @@ -1948,6 +1968,7 @@ "aria-label": "Nhóm và điều hướng" }, "operationFailed": "Thao tác không thành công: ${error}", + "warmCharCacheFailed": "Không thể làm nóng bộ nhớ đệm nhân vật: ${error}", "shareGroupFailed": "Không chia sẻ được liên kết nhóm: ${error}", "replyInline": { "title": "hồi đáp" @@ -2160,6 +2181,7 @@ }, "federation": { "loadFailed": "Tải không thành công: ${error}", + "rebindFailed": "Không thể liên kết lại phòng liên đoàn: ${error}", "subtitle": "Kiểm soát kết nối thiết bị chéo; để mặc định trong hầu hết các trường hợp", "title": "Cài đặt P2P liên kết", "tooltip": { @@ -4041,6 +4063,8 @@ "title": "Quảng trường", "description": "P2P mở cập nhật xã hội, theo dõi và chia sẻ tệp", "bootstrapFailed": "Khởi tạo giao diện xã hội không thành công: ${error}", + "connectNodeFailed": "Không thể kết nối tới nút: ${error}", + "dwellFailed": "Không thể cập nhật dữ liệu sở thích theo thời gian xem: ${error}", "home_function_buttons": { "main": { "title": "Quảng trường" diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index cd2d224b2..b082ef1b7 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -363,6 +363,26 @@ "createdNoUpdate": "已创建 `.noupdate`,自动更新已暂停。", "unknownTarget": "无法识别「${target}」:不是已知分支、提交或拉取请求。" }, + "version": { + "branch": { + "title": "分支:${branch}", + "detached": "HEAD 已分离" + }, + "commit": "提交:`${ref}`", + "remote": "远程:`${ref}`", + "status": { + "title": "状态:${status}", + "upToDate": "已是最新", + "behind": "落后于远程", + "ahead": "领先于远程", + "diverged": "与远程已分叉", + "detachedNoCompare": "HEAD 已分离,无法与远程比较", + "fetchFailed": "无法获取远程状态" + }, + "autoUpdatePaused": "自动更新已暂停(存在 `.noupdate`)", + "noRepo": "未找到 Git 仓库", + "noGit": "未安装 Git" + }, "shortcut": { "desktopShortcutCreated": "已在 `${path}` 创建桌面快捷方式", "startMenuShortcutCreated": "已在 `${path}` 创建开始菜单快捷方式", @@ -1948,6 +1968,7 @@ "aria-label": "群组与导航" }, "operationFailed": "操作失败:${error}", + "warmCharCacheFailed": "角色缓存预热失败:${error}", "shareGroupFailed": "分享群链接失败:${error}", "replyInline": { "title": "回复" @@ -2164,6 +2185,7 @@ }, "federation": { "loadFailed": "加载失败:${error}", + "rebindFailed": "联邦房间重新绑定失败:${error}", "subtitle": "控制跨设备连接;大多数情况下保持默认即可", "title": "联邦 P2P 设置", "tooltip": { @@ -4045,6 +4067,8 @@ "title": "广场", "description": "P2P 开放社交动态、关注与文件分享", "bootstrapFailed": "社交界面初始化失败:${error}", + "connectNodeFailed": "连接节点失败:${error}", + "dwellFailed": "观看时长偏好更新失败:${error}", "home_function_buttons": { "main": { "title": "广场" diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index ce09f149d..297e3f4b8 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -362,6 +362,26 @@ "createdNoUpdate": "已建立 `.noupdate`,自動更新已暫停。", "unknownTarget": "無法識別「${target}」:不是已知分支、提交或拉取請求。" }, + "version": { + "branch": { + "title": "分支:${branch}", + "detached": "HEAD 已分離" + }, + "commit": "提交:`${ref}`", + "remote": "遠端:`${ref}`", + "status": { + "title": "狀態:${status}", + "upToDate": "已是最新", + "behind": "落後於遠端", + "ahead": "領先於遠端", + "diverged": "與遠端已分叉", + "detachedNoCompare": "HEAD 已分離,無法與遠端比較", + "fetchFailed": "無法取得遠端狀態" + }, + "autoUpdatePaused": "自動更新已暫停(存在 `.noupdate`)", + "noRepo": "找不到 Git 儲存庫", + "noGit": "未安裝 Git" + }, "shortcut": { "desktopShortcutCreated": "已在 `${path}` 建立桌面捷徑", "startMenuShortcutCreated": "已在 `${path}` 建立開始選單捷徑", @@ -1947,6 +1967,7 @@ "aria-label": "群組與導覽" }, "operationFailed": "操作失敗:${error}", + "warmCharCacheFailed": "角色快取預熱失敗:${error}", "shareGroupFailed": "分享群連結失敗:${error}", "replyInline": { "title": "回覆" @@ -2159,6 +2180,7 @@ }, "federation": { "loadFailed": "載入失敗:${error}", + "rebindFailed": "聯邦房間重新綁定失敗:${error}", "subtitle": "控制跨裝置連線;大多數情況下保持預設即可", "title": "聯邦 P2P 設定", "tooltip": { @@ -4040,6 +4062,8 @@ "title": "廣場", "description": "P2P 開放社交動態、關注與檔案分享", "bootstrapFailed": "社交介面初始化失敗:${error}", + "connectNodeFailed": "連接節點失敗:${error}", + "dwellFailed": "觀看時長偏好更新失敗:${error}", "home_function_buttons": { "main": { "title": "廣場" diff --git a/src/public/pages/AGENTS.md b/src/public/pages/AGENTS.md index 0765a9cab..3ac3c6498 100644 --- a/src/public/pages/AGENTS.md +++ b/src/public/pages/AGENTS.md @@ -13,10 +13,15 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes ## API & Communication -- **`endpoints.mjs`**: Core auth/system APIs (`login`, `register`, `whoami`, `getUserSetting`, etc.). +- **Global HTTP lives in `scripts/endpoints/`** (`base.mjs`, `parts.mjs`, `registries.mjs`, `server_events.mjs`, `p2p/evfsMedia.mjs`). Import via `/scripts/endpoints/…`. **Named functions only** — no path-string clients. Shell-local REST belongs in that shell’s `public/src/endpoints.mjs` / `endpoints/*` (see shells AGENTS). +- **No client-side timeouts on backend links.** Do not wrap local `/api/*` (or same-origin backend) `fetch` / WS with `AbortSignal.timeout`, artificial deadlines, or “give up if slow” logic. If the backend hangs or times out, that is a backend bug — fix the server, do not add frontend complexity to paper over it. UI supersession abort (e.g. user navigated away / started a newer enter) is fine; do not pass that signal into backend fetch just to simulate a timeout. +- **`endpoints/base.mjs`**: Core auth/system APIs (`login`, `register`, `whoami`, `getUserSetting`, etc.). +- **`endpoints/parts.mjs`**: `runPart`, `loadPart`, `getPartList`, `getPartDetails`, `setDefaultPart`. +- **`endpoints/server_events.mjs`**: `onServerEvent` — server-sent event bus. +- **`endpoints/registries.mjs`**: `GET /api/registries/:name` + dynamic `import()`. +- **`endpoints/p2p/evfsMedia.mjs`**: EVFS GET/PUT (`fetchEvfsFile`, `fetchMediaRef`, `uploadEvfsFile`, `uploadEvfsAttachment`). Pure URL helpers stay Deno-pure in chat `shared/evfsMedia.mjs` (`entityFileUrl`, `mediaRefUrl`). - **`debug_log.mjs`**: `debugLog(name, data)` → `debug_logs/`. -- **`parts.mjs`**: `runPart`, `loadPart`, `getPartList`, `setDefaultPart`. -- **`server_events.mjs`**: `onServerEvent` — server-sent event bus. +- **HTML templates**: use `renderTemplate` / `mountTemplate` / `withTemplates` — never `fetch(…html)`. ## UI & Theming @@ -32,6 +37,7 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`contentReveal.mjs`**: `wrapSensitiveMediaHtml`, `wrapContentWarningHtml`, `bindContentReveal`. - **`translate.mjs`**: `mountTranslationBlock`, `requestTranslation`, `resolveTargetLang` (-> `primaryLocale()`). - **`toast.mjs`**: `showToast`, `showToastI18n`. +- **`errorHandlers.mjs`**: `handleError(i18nKey, toastParams?)` returns a `.catch` closure (toast + console + Sentry). Immediate form: `handleError(i18nKey, toastParams, error)`. **Only for fount faults** — user mistakes use `showToastI18n` directly. Backend twin: `fount/scripts/errorHandlers.mjs` (`handleError(error, ...extras)`). ## Rendering & Content @@ -40,7 +46,6 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes - **`markdown/standaloneDocument.mjs`**: `renderMarkdownAsStandaloneDocument` / `wrapStandaloneMarkdownDocument` — offline full HTML for Chat/Social download/share/drag. Filenames from document `` via `fileNameFromHtmlTitle` / `downloadHtmlDocument`. - **`sanitizeHtml.mjs`**: `sanitizePermissiveHtml` — rich displayName HTML minus script / `style` / `on*` / dangerous URLs. `scrubHtmlActivePayload(string|root)` — string → `<template>` scrub → `DocumentFragment`; DOM root → in-place; strips `on*` / all `srcset` / unsafe URLs (keeps `style`). Prefer the string path over live-`innerHTML` then scrub. `isSafeHtmlUrl` rejects `javascript:` / `data:` / protocol-relative `//…` and `/\…`. - **`embedCard.mjs`**: `ALL /api/no-cors?url=` + OG parse; `MutationObserver` hydration; session LRU. Proxy details: [markdown-notes.md](markdown-notes.md#no-cors-proxy). -- **`registries.mjs`**: `GET /api/registries/:name` + dynamic `import()`. - **`emojiPicker.mjs`**: Shared emoji picker (click inserts token; Hub long-press/right-click sends sticker). Section headers / Alt·right-click on the rail open `emojiPackPreview`. Floating placement in `components/floatingPanel.mjs`. Hub mounts via `mountDockedEmojiPicker`. - **`emojiPackPreview.mjs`**: Pack preview card (info + join/follow/favorite); `showEmojiPackPreview(anchor, { pack, provider, available })`. - **`i18n.mjs`**: Sole public entry. Call `initTranslations()` early. Switch UI language with **`setLanguage(string[])`** (writes preferredLangs + reloads via platform `i18n/base.mjs`). Raw bundle without applying: **`loadLocaleData(string[])`** (fount → `/api/getlocaledata`; Pages → static `locales/*.json`). Prefer these over ad-hoc fetch. `data-i18n`, `geti18n`, `setElementI18n`, `primaryLocale()` (preferredLangs[0] → `main_locale`, default `en-UK`). Use for content locale / translation target — do not hardcode `zh-CN` or bare `navigator.language`. Missing keys → `console.warn('[i18n:missing] …')`; Playwright fixtures hard-fail on that prefix. Locale map slices: `matchLocale` / `getBestLocale` / `pickLocalizedSlice` (`i18n/locale_match.mjs`, same as backend). Params / placeholders: [i18n-notes.md](i18n-notes.md). @@ -59,4 +64,4 @@ Markdown convertor traps (rehype order, `{:lang}`, trust tiers): [markdown-notes ## P2P (Browser) -Import via `esm.sh`. Shared primitives live in `shells/chat/public/shared/` (`/parts/shells:chat/shared/…`). Entity HTTP: `/api/parts/shells:chat/{viewer,entities…}`; network: `/api/p2p/{network,denylist,mailbox,federation}`. +Import via `esm.sh`. Shared primitives live in `shells/chat/public/shared/` (`/parts/shells:chat/shared/…`). EVFS URL helpers (`entityFileUrl` / `mediaRefUrl`) stay Deno-pure in chat `shared/evfsMedia.mjs`; browser fetch/upload is `/scripts/endpoints/p2p/evfsMedia.mjs`. Entity HTTP: `/api/parts/shells:chat/{viewer,entities…}`; network: `/api/p2p/{network,denylist,mailbox,federation}`. diff --git a/src/public/pages/base.mjs b/src/public/pages/base.mjs index 584fd30e1..e4c2db758 100644 --- a/src/public/pages/base.mjs +++ b/src/public/pages/base.mjs @@ -6,7 +6,7 @@ */ import * as Sentry from 'https://esm.sh/@sentry/browser' -import { onServerEvent } from './scripts/api/server_events.mjs' +import { onServerEvent } from './scripts/endpoints/server_events.mjs' let skipBreadcrumb = false if (!globalThis.fount?.test?.enabled) try { diff --git a/src/public/pages/index.html b/src/public/pages/index.html index 5ca45cb03..2ee993b24 100644 --- a/src/public/pages/index.html +++ b/src/public/pages/index.html @@ -20,7 +20,7 @@ <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4" crossorigin="anonymous"></script> <script type="module" src="/base.mjs"></script> <script type="module"> - import { getAnyDefaultPart } from './scripts/api/parts.mjs' + import { getAnyDefaultPart } from './scripts/endpoints/parts.mjs' import { redirectToLoginInfo } from './scripts/host/credentialManager.mjs' import { initTranslations } from './scripts/i18n/index.mjs' import { setTheme } from './scripts/theme/index.mjs' diff --git a/src/public/pages/log_viewer/index.mjs b/src/public/pages/log_viewer/index.mjs index 269879372..5028e5753 100644 --- a/src/public/pages/log_viewer/index.mjs +++ b/src/public/pages/log_viewer/index.mjs @@ -4,7 +4,7 @@ import { geti18n, initTranslations } from '/scripts/i18n/index.mjs' import { createVirtualList } from '/scripts/lib/virtualList.mjs' import { attachLogWire } from 'https://esm.sh/@steve02081504/virtual-console/wire/client' -import { ping } from '/scripts/api/base.mjs' +import { ping } from '/scripts/endpoints/base.mjs' import { createLogsWs, openSource } from './endpoints.mjs' import { renderLogItem, createLogToolbar, entryMatchesFilter } from './log.mjs' import { initRepl, mountReplPanel } from './repl/index.mjs' diff --git a/src/public/pages/login/index.mjs b/src/public/pages/login/index.mjs index a3c049275..94775656e 100644 --- a/src/public/pages/login/index.mjs +++ b/src/public/pages/login/index.mjs @@ -1,3 +1,5 @@ +import { initPasswordStrengthMeter } from '../scripts/components/passwordStrength.mjs' +import { createPOWCaptcha } from '../scripts/components/POWcaptcha.mjs' import { ping, generateVerificationCode, @@ -5,10 +7,8 @@ import { register, webauthnLoginBegin, webauthnLoginComplete, -} from '../scripts/api/base.mjs' -import { getAnyDefaultPart } from '../scripts/api/parts.mjs' -import { initPasswordStrengthMeter } from '../scripts/components/passwordStrength.mjs' -import { createPOWCaptcha } from '../scripts/components/POWcaptcha.mjs' +} from '../scripts/endpoints/base.mjs' +import { getAnyDefaultPart } from '../scripts/endpoints/parts.mjs' import { showToast } from '../scripts/features/toast.mjs' import { retrieveAndDecryptCredentials, redirectToLoginInfo } from '../scripts/host/credentialManager.mjs' import { initTranslations, console, savePreferredLangs, onLanguageChange } from '../scripts/i18n/index.mjs' diff --git a/src/public/pages/protocolhandler/index.mjs b/src/public/pages/protocolhandler/index.mjs index 0e6e4da41..3f8059365 100644 --- a/src/public/pages/protocolhandler/index.mjs +++ b/src/public/pages/protocolhandler/index.mjs @@ -1,5 +1,5 @@ -import { authenticate } from '../scripts/api/base.mjs' -import { runPart } from '../scripts/api/parts.mjs' +import { authenticate } from '../scripts/endpoints/base.mjs' +import { runPart } from '../scripts/endpoints/parts.mjs' import { initTranslations, console } from '../scripts/i18n/index.mjs' import { applyTheme } from '../scripts/theme/index.mjs' diff --git a/src/public/pages/scripts/api/p2p/evfsMedia.mjs b/src/public/pages/scripts/api/p2p/evfsMedia.mjs deleted file mode 100644 index c08ea4e8a..000000000 --- a/src/public/pages/scripts/api/p2p/evfsMedia.mjs +++ /dev/null @@ -1,9 +0,0 @@ -/** 重导出 chat 共用 EVFS 媒体上传与解析 API。 */ -export { - entityFileUrl, - fetchEvfsFile, - getViewerEntityHash, - mediaRefUrl, - uploadEvfsAttachment, - uploadEvfsFile, -} from '/parts/shells:chat/shared/evfsMedia.mjs' diff --git a/src/public/pages/scripts/components/partpath_picker.mjs b/src/public/pages/scripts/components/partpath_picker.mjs index 494400f0c..526314aa9 100644 --- a/src/public/pages/scripts/components/partpath_picker.mjs +++ b/src/public/pages/scripts/components/partpath_picker.mjs @@ -1,5 +1,5 @@ -import { getPartBranches, getPartDetails } from '../api/parts.mjs' -import { onServerEvent } from '../api/server_events.mjs' +import { getPartBranches, getPartDetails } from '../endpoints/parts.mjs' +import { onServerEvent } from '../endpoints/server_events.mjs' import { geti18n } from '../i18n/index.mjs' /** diff --git a/src/public/pages/scripts/endpoints/base.mjs b/src/public/pages/scripts/endpoints/base.mjs new file mode 100644 index 000000000..8bb1b9374 --- /dev/null +++ b/src/public/pages/scripts/endpoints/base.mjs @@ -0,0 +1,283 @@ +/** + * 解析 JSON:`response.ok` 为 false 则 reject 并附带响应体。 + * @param {Response} response - fetch 响应。 + * @returns {Promise<object>} 解析后的 JSON 对象。 + */ +async function finishAuthenticatedJsonMutation(response) { + if (!response.ok) { + const data = await response.json().catch(() => ({})) + return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), data, { response })) + } + return response.json() +} + +/** + * Ping 服务器。 + * @param {boolean} [with_cache=false] - 是否使用缓存。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function ping(with_cache = false) { + const response = await fetch('/api/ping', { + credentials: 'omit', + cache: with_cache ? 'default' : 'no-cache', + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取本地 IP 中的主机 URL。 + * @returns {Promise<string>} - 本地 IP 中的主机 URL。 + */ +export async function hosturl_in_local_ip() { + return ping(true).then(data => data.hosturl_in_local_ip).catch(() => window.location.origin) +} + +/** + * 获取 PoW 挑战。 + * @returns {Promise<object>} - PoW 挑战。 + */ +export async function getPoWChallenge() { + const response = await fetch('/api/pow/challenge', { method: 'POST' }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 兑换 PoW 令牌。 + * @param {string} token - PoW 令牌。 + * @param {object[]} solutions - PoW 解决方案。 + * @returns {Promise<object>} - 兑换结果。 + */ +export async function redeemPoWToken(token, solutions) { + const response = await fetch('/api/pow/redeem', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, solutions }), + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取区域设置数据。 + * @param {string[]} [preferred] - 首选语言列表。 + * @returns {Promise<object>} - 区域设置数据。 + */ +export async function getLocaleData(preferred) { + const url = new URL(window.location.origin + '/api/getlocaledata') + if (preferred) url.searchParams.set('preferred', preferred.join(',')) + const response = await fetch(url) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取可用的区域设置。 + * @returns {Promise<string[]>} - 可用区域设置的列表。 + */ +export async function getAvailableLocales() { + const response = await fetch('/api/getavailablelocales') + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 生成验证码。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function generateVerificationCode() { + return await fetch('/api/register/generateverificationcode', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) +} + +/** + * 获取当前用户信息。 + * @returns {Promise<object>} - 当前用户信息。 + */ +export async function whoami() { + const response = await fetch('/api/whoami', { + headers: { Accept: 'application/json' }, + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 登录。 + * @param {string} username - 用户名。 + * @param {string} password - 密码。 + * @param {string} deviceid - 设备 ID。 + * @param {string} powToken - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function login(username, password, deviceid, powToken) { + return await fetch('/api/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password, deviceid, powToken }), + }) +} + +/** + * Passkey 登录:请求挑战选项。 + * @param {string} [powToken] - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function webauthnLoginBegin(powToken) { + return await fetch('/api/webauthn/login/begin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ powToken }), + }) +} + +/** + * Passkey 登录:提交断言并完成会话。 + * @param {object} credential - 浏览器返回的凭证 JSON。 + * @param {string} authSessionToken - begin 返回的会话令牌。 + * @param {string} deviceid - 设备 ID。 + * @param {string} [powToken] - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function webauthnLoginComplete(credential, authSessionToken, deviceid, powToken) { + return await fetch('/api/webauthn/login/complete', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + credential, + deviceid, + powToken, + authSessionToken: (authSessionToken ?? '').trim(), + }), + }) +} + +/** + * 注册。 + * @param {string} username - 用户名。 + * @param {string} password - 密码。 + * @param {string} deviceid - 设备 ID。 + * @param {string} verificationcode - 验证码。 + * @param {string} powToken - POW 令牌。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function register(username, password, deviceid, verificationcode, powToken) { + return await fetch('/api/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password, deviceid, verificationcode, powToken }), + }) +} + +/** + * 验证身份。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function authenticate() { + return await fetch('/api/authenticate', { + method: 'POST', + }) +} + + +/** + * 获取用户设置。 + * @param {string} key - 键。 + * @returns {Promise<any>} - 用户设置值。 + */ +export async function getUserSetting(key) { + const response = await fetch(`/api/getusersetting?key=${encodeURIComponent(key)}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + const { value } = await response.json() + return value +} + +/** + * 设置用户设置。 + * @param {string} key - 键。 + * @param {any} value - 值。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function setUserSetting(key, value) { + return await fetch('/api/setusersetting', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ key, value }), + }) +} + +/** + * 注销。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function logout() { + const response = await fetch('/api/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + return finishAuthenticatedJsonMutation(response) +} + +/** + * 获取 API 密钥。 + * @returns {Promise<object>} - API 密钥列表。 + */ +export async function getApiKeys() { + const response = await fetch('/api/apikey/list') + return finishAuthenticatedJsonMutation(response) +} + +/** + * 创建 API 密钥。 + * @param {string} description - 描述。 + * @returns {Promise<object>} - 新的 API 密钥。 + */ +export async function createApiKey(description) { + const response = await fetch('/api/apikey/create', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description }), + }) + return finishAuthenticatedJsonMutation(response) +} + +/** + * 撤销 API 密钥。 + * @param {string} jti - JTI。 + * @param {string} password - 密码。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function revokeApiKey(jti, password) { + const response = await fetch('/api/apikey/revoke', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jti, password }), + }) + return finishAuthenticatedJsonMutation(response) +} + +/** + * 验证 API 密钥。 + * @param {string} apiKey - API 密钥。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export async function verifyApiKey(apiKey) { + return await fetch('/api/apikey/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ apiKey }), + }) +} + diff --git a/src/public/pages/scripts/endpoints/desktop.ini b/src/public/pages/scripts/endpoints/desktop.ini new file mode 100644 index 000000000..8c602c72e --- /dev/null +++ b/src/public/pages/scripts/endpoints/desktop.ini @@ -0,0 +1,6 @@ +[.ShellClassInfo] +IconResource=%SystemRoot%\System32\SHELL32.dll,293 +[ViewState] +Mode= +Vid= +FolderType=Generic diff --git a/src/public/pages/scripts/endpoints/p2p/desktop.ini b/src/public/pages/scripts/endpoints/p2p/desktop.ini new file mode 100644 index 000000000..b4c3de45e --- /dev/null +++ b/src/public/pages/scripts/endpoints/p2p/desktop.ini @@ -0,0 +1,6 @@ +[.ShellClassInfo] +IconResource=%SystemRoot%\System32\SHELL32.dll,18 +[ViewState] +Mode= +Vid= +FolderType=Generic diff --git a/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs b/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs new file mode 100644 index 000000000..b5a5a2a74 --- /dev/null +++ b/src/public/pages/scripts/endpoints/p2p/evfsMedia.mjs @@ -0,0 +1,82 @@ +/** + * 浏览器端 EVFS 媒体 HTTP(Chat / Social 共用)。 + * URL 纯函数见 `/parts/shells:chat/shared/evfsMedia.mjs`。 + */ +import { entityFileUrl, mediaRefUrl } from '/parts/shells:chat/shared/evfsMedia.mjs' + +/** + * + */ +export { entityFileUrl, mediaRefUrl } + +const CHAT_SHELL_API_PREFIX = '/api/parts/shells:chat' + +/** + * @returns {Promise<string | null>} viewer entityHash + */ +export async function getViewerEntityHash() { + const resp = await fetch(`${CHAT_SHELL_API_PREFIX}/viewer`, { credentials: 'include' }) + if (!resp.ok) throw new Error(`viewer ${resp.status}`) + const data = await resp.json() + return data.viewerEntityHash +} + +/** + * @param {string} entityHash owner + * @param {string} logicalPath 路径 + * @param {File | Blob} file 文件 + * @returns {Promise<{ entityHash: string, path: string, url: string }>} 上传结果 + */ +export async function uploadEvfsFile(entityHash, logicalPath, file) { + const url = entityFileUrl(entityHash, logicalPath) + const res = await fetch(url, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/octet-stream' }, + body: file, + }) + if (!res.ok) throw new Error((await res.json()).error || `evfs upload failed: ${res.status}`) + const { url: resolvedUrl } = await res.json() + return { entityHash, path: logicalPath, url: resolvedUrl } +} + +/** + * @param {string} entityHash owner + * @param {string} logicalPath 路径 + * @returns {Promise<{ buffer: ArrayBuffer, mimeType: string }>} 文件字节与 Content-Type + */ +export async function fetchEvfsFile(entityHash, logicalPath) { + const res = await fetch(entityFileUrl(entityHash, logicalPath), { credentials: 'include' }) + if (!res.ok) throw new Error(`evfs fetch failed: ${res.status}`) + return { + buffer: await res.arrayBuffer(), + mimeType: res.headers.get('Content-Type') || 'application/octet-stream', + } +} + +/** + * 按 mediaRef(url 或 entityHash+path)下载字节。 + * @param {{ entityHash?: string, path?: string, url?: string, mimeType?: string }} ref 媒体引用 + * @returns {Promise<{ buffer: ArrayBuffer, mimeType: string }>} 文件字节与 Content-Type + */ +export async function fetchMediaRef(ref) { + if (ref?.entityHash && ref?.path && !ref.url) + return fetchEvfsFile(ref.entityHash, ref.path) + const res = await fetch(mediaRefUrl(ref), { credentials: 'include' }) + if (!res.ok) throw new Error(`mediaRef fetch failed: ${res.status}`) + return { + buffer: await res.arrayBuffer(), + mimeType: String(ref?.mimeType || res.headers.get('Content-Type') || 'application/octet-stream'), + } +} + +/** + * @param {File | Blob} file 文件 + * @param {string} logicalPathPrefix 逻辑路径前缀(如 shells/chat/attachments) + * @returns {Promise<{ entityHash: string, path: string, url: string }>} 上传结果 + */ +export async function uploadEvfsAttachment(file, logicalPathPrefix) { + const entityHash = await getViewerEntityHash() + if (!entityHash) throw new Error('identity required for attachments') + return uploadEvfsFile(entityHash, `${logicalPathPrefix}/${crypto.randomUUID()}`, file) +} diff --git a/src/public/pages/scripts/endpoints/parts.mjs b/src/public/pages/scripts/endpoints/parts.mjs new file mode 100644 index 000000000..91d593ade --- /dev/null +++ b/src/public/pages/scripts/endpoints/parts.mjs @@ -0,0 +1,206 @@ +/** + * 部件相关的 API 函数。 + * + * **partpath 约定(勿在此文件做 colon→slash 转换)** + * - 本模块 POST body 与服务端 `loadPart` 只认斜杠路径:`shells/chat`、`chars/Foo`。 + * - URL / `fount://run/` 里的 `shells:chat` 是另一套语法;调用方自行规整后再传入 + * (例:`protocolhandler/index.mjs` 在调 `runPart` 前 `replaceAll(':', '/')`)。 + * - 不要在 `runPart` / `loadPart` 里偷偷 `replace(/:/g, '/')`:会掩盖调用方 bug,且与 + * 「规整是调用者职责」不一致。 + */ + +/** + * 运行部件。 + * @param {string} partpath - 斜杠 partpath(如 `shells/install`),非 URL colon 形式。 + * @param {object} args - 参数。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function runPart(partpath, args) { + const response = await fetch('/api/runpart', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ partpath, args }), + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 加载部件。 + * @param {string} partpath - 斜杠 partpath(如 `shells/chat`),非 URL colon 形式。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function loadPart(partpath) { + const response = await fetch('/api/loadpart', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ partpath }), + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取部件类型列表。 + * @returns {Promise<string[]>} - 部件类型列表。 + */ +export async function getPartTypeList() { + const response = await fetch('/api/getparttypelist') + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取部件列表。 + * @param {string} path - 路径。 + * @returns {Promise<object>} - 部件列表。 + */ +export async function getPartList(path) { + const response = await fetch(`/api/getlist/${path}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取已加载部件的列表。 + * @param {string} path - 路径。 + * @returns {Promise<object>} - 已加载的部件列表。 + */ +export async function getLoadedPartList(path) { + const response = await fetch(`/api/getloadedlist/${path}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取所有缓存的部件详细信息。 + * @param {string} path - 路径。 + * @returns {Promise<object>} - 缓存的部件详细信息。 + */ +export async function getAllCachedPartDetails(path) { + const response = await fetch(`/api/getallcacheddetails/${path}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取部件详细信息。 + * @param {string} path - 路径。 + * @param {boolean} [nocache=false] - 是否不使用缓存。 + * @returns {Promise<object>} - 部件详细信息。 + */ +export async function getPartDetails(path, nocache = false) { + const url = new URL(window.location.origin + `/api/getdetails/${path}`) + if (nocache) url.searchParams.set('nocache', 'true') + const response = await fetch(url) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取所有默认部件。 + * @returns {Promise<object>} - 默认部件。 + */ +export async function getAllDefaultParts() { + const response = await fetch('/api/defaultpart/getall') + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 设置默认部件。 + * @param {string} parent - 父部件。 + * @param {string} child - 子部件。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function setDefaultPart(parent, child) { + const response = await fetch('/api/defaultpart/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent, child }), + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 取消设置默认部件。 + * @param {string} parent - 父部件。 + * @param {string} child - 子部件。 + * @returns {Promise<object>} - 服务器响应。 + */ +export async function unsetDefaultPart(parent, child) { + const response = await fetch('/api/defaultpart/unset', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent, child }), + }) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取任何默认部件。 + * @param {string} parent - 父部件。 + * @returns {Promise<string>} - 默认子部件。 + */ +export async function getAnyDefaultPart(parent) { + const response = await fetch(`/api/defaultpart/getany/${parent}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 按类型获取所有默认部件。 + * @param {string} parent - 父部件。 + * @returns {Promise<object>} - 默认子部件列表。 + */ +export async function getAllDefaultPartsByType(parent) { + const response = await fetch(`/api/defaultpart/getallbytype/${parent}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取任何首选的默认部件。 + * @param {string} parent - 父部件。 + * @returns {Promise<string>} - 首选的默认子部件。 + */ +export async function getAnyPreferredDefaultPart(parent) { + const response = await fetch(`/api/defaultpart/getanypreferred/${parent}`) + if (!response.ok) return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + return response.json() +} + +/** + * 获取部件分支树。 + * @param {boolean} [nocache=false] - 是否绕过缓存。 + * @returns {Promise<object>} - 部件分支对象。 + */ +export async function getPartBranches(nocache = false) { + const url = new URL('/api/getpartbranches', window.location.origin) + if (nocache) url.searchParams.set('nocache', 'true') + return fetch(url).then(async response => { + if (response.ok) return response.json() + else return Promise.reject(Object.assign(new Error(`API request failed with status ${response.status}`), await response.json().catch(() => ({})), { response })) + }) +} + + +/** + * 解锁成就。 + * @param {string} partpath - 部件路径。 + * @param {string} achievementId - 成就 ID。 + * @returns {Promise<Response>} - 服务器响应。 + */ +export function unlockAchievement(partpath, achievementId) { + return fetch('/api/parts/shells:achievements/unlock', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ partpath, id: achievementId }), + }).catch(() => { /* Fail silently */ }) +} diff --git a/src/public/pages/scripts/endpoints/registries.mjs b/src/public/pages/scripts/endpoints/registries.mjs new file mode 100644 index 000000000..8ca389469 --- /dev/null +++ b/src/public/pages/scripts/endpoints/registries.mjs @@ -0,0 +1,48 @@ +/** + * @typedef {{ id: string, level: number, path: string }} RegistryEntry + */ + +/** @type {Map<string, Promise<RegistryEntry[]>>} */ +const registryFetchCache = new Map() + +/** + * 获取指定 name 的 registry 条目(path 已为前端 URL)。 + * @param {string} name - registry 名称。 + * @param {{ nocache?: boolean }} [options] - 可选项。 + * @returns {Promise<RegistryEntry[]>} registry 条目列表。 + */ +export async function getRegistry(name, { nocache = false } = {}) { + if (!nocache && registryFetchCache.has(name)) + return registryFetchCache.get(name) + + const fetchPromise = (async () => { + const qs = nocache ? '?nocache=1' : '' + const res = await fetch(`/api/registries/${encodeURIComponent(name)}${qs}`) + if (!res.ok) + throw new Error(`registry fetch failed: ${name} ${res.status}`) + return res.json() + })() + + if (!nocache) registryFetchCache.set(name, fetchPromise) + return fetchPromise +} + +/** + * 动态 import registry 条目指向的模块。 + * @param {string} name - registry 名称。 + * @param {{ nocache?: boolean }} [options] - 可选项。 + * @returns {Promise<Array<{ entry: RegistryEntry, module: unknown }>>} 已加载的模块列表。 + */ +export async function importRegistryModules(name, { nocache = false } = {}) { + const entries = await getRegistry(name, { nocache }) + return (await Promise.all(entries.map(async entry => { + try { + const module = await import(entry.path) + return { entry, module } + } + catch (error) { + console.warn(`registry import failed ${name}/${entry.id}:`, error) + return null + } + }))).filter(Boolean) +} diff --git a/src/public/pages/scripts/endpoints/server_events.mjs b/src/public/pages/scripts/endpoints/server_events.mjs new file mode 100644 index 000000000..b4dbb2d9f --- /dev/null +++ b/src/public/pages/scripts/endpoints/server_events.mjs @@ -0,0 +1,52 @@ +const handlers = new Map() + +/** + * 注册一个服务器事件回调。 + * @param {string} type - 事件类型。 + * @param {(data: any) => void} callback - 回调函数。 + * @returns {void} + */ +export function onServerEvent(type, callback) { + if (!handlers.has(type)) + handlers.set(type, []) + + handlers.get(type).push(callback) +} + +/** + * 注销一个服务器事件回调。 + * @param {string} type - 事件类型。 + * @param {(data: any) => void} callback - 回调函数。 + * @returns {void} + */ +export function offServerEvent(type, callback) { + if (handlers.has(type)) { + const typeHandlers = handlers.get(type) + const index = typeHandlers.indexOf(callback) + if (index > -1) + typeHandlers.splice(index, 1) + } +} + +/** + * 由 base.mjs 在收到来自 SW 的消息时调用。 + * @param {{type: string, data: any}} message - 消息。 + * @returns {void} + */ +function dispatchMessage(message) { + const { type, data } = message + if (handlers.has(type)) + for (const handler of handlers.get(type)) try { + handler(data) + } catch (e) { + console.error(`Error in message handler for type "${type}":`, e) + } +} + +try { + navigator.serviceWorker?.addEventListener?.('message', event => { + if (event.data) dispatchMessage(event.data) + }) +} catch (error) { + if (error.name != 'SecurityError') throw error +} diff --git a/src/public/pages/scripts/features/emoji/providers.mjs b/src/public/pages/scripts/features/emoji/providers.mjs index 43bd33d62..a80345d62 100644 --- a/src/public/pages/scripts/features/emoji/providers.mjs +++ b/src/public/pages/scripts/features/emoji/providers.mjs @@ -1,7 +1,7 @@ /** * 聚合 registries.emoji 全部 provider。 */ -import { importRegistryModules } from '../../api/registries.mjs' +import { importRegistryModules } from '../../endpoints/registries.mjs' /** * @returns {Promise<object[]>} 全部可用 emoji provider diff --git a/src/public/pages/scripts/features/errorHandlers.mjs b/src/public/pages/scripts/features/errorHandlers.mjs new file mode 100644 index 000000000..e84659c4e --- /dev/null +++ b/src/public/pages/scripts/features/errorHandlers.mjs @@ -0,0 +1,43 @@ +/** + * 前端错误处理:只报 fount 自身故障(toast + console + Sentry)。 + * 用户输入/操作过错请直接 `showToastI18n`,不要走这里。 + * 工厂形式便于 `.catch(handleError('key'))`。 + */ +import * as Sentry from 'https://esm.sh/@sentry/browser' + +import { showToastI18n } from './toast.mjs' + +/** + * @param {unknown} error 异常或字符串 + * @returns {Error} 规范化 Error + */ +function toError(error) { + if (error instanceof Error) return error + if (Object(error?.message) instanceof String) return new Error(error.message) + return new Error(String(error)) +} + +/** + * 用户可见的 fount 故障:toast + console + Sentry。 + * @param {string} i18nKey toast 文案键 + * @param {Record<string, unknown>} [toastParams] 额外 i18n 插值与 console 上下文(`error` 由本函数注入) + * @param {unknown} [error] 若传入则立即处理,否则返回 `.catch` 闭包 + * @param {...unknown} extras 额外 console.error 参数 + * @returns {Error | ((error: unknown, ...extras: unknown) => Error)} 传入 error 时立即处理并返回 Error,否则返回 `.catch` 闭包 + */ +export function handleError(i18nKey, toastParams = {}, error, ...extras) { + /** + * @param {unknown} error 异常或字符串 + * @param {...unknown} extras 额外 console.error 参数 + * @returns {Error} 规范化 Error + */ + const handler = (error, ...extras) => { + const err = toError(error) + console.error(`[fount-ui] ${i18nKey}`, err, ...extras) + showToastI18n('error', i18nKey, { ...toastParams, error: err.message }) + Sentry.captureException(err) + return err + } + if (error) return handler(error, ...extras) + return handler +} diff --git a/src/public/pages/scripts/features/markdown/extensions.mjs b/src/public/pages/scripts/features/markdown/extensions.mjs index d56880435..98b317e07 100644 --- a/src/public/pages/scripts/features/markdown/extensions.mjs +++ b/src/public/pages/scripts/features/markdown/extensions.mjs @@ -1,4 +1,4 @@ -import { importRegistryModules } from '../../api/registries.mjs' +import { importRegistryModules } from '../../endpoints/registries.mjs' /** @type {Promise<{ remarkPlugins: unknown[], rehypePlugins: unknown[], css: string, inits: Array<() => void>, version: string }> | null} */ let loadPromise = null diff --git a/src/public/pages/scripts/host/credentialManager.mjs b/src/public/pages/scripts/host/credentialManager.mjs index 0bb1dac8c..4670727a4 100644 --- a/src/public/pages/scripts/host/credentialManager.mjs +++ b/src/public/pages/scripts/host/credentialManager.mjs @@ -5,7 +5,7 @@ */ import CryptoJS from 'https://esm.sh/crypto-js' -import { ping } from '../api/base.mjs' +import { ping } from '../endpoints/base.mjs' import { downloadFromCatbox, uploadToCatbox } from './catbox.mjs' diff --git a/src/public/pages/scripts/i18n/base.mjs b/src/public/pages/scripts/i18n/base.mjs index 093e4165e..b08c325a5 100644 --- a/src/public/pages/scripts/i18n/base.mjs +++ b/src/public/pages/scripts/i18n/base.mjs @@ -1,4 +1,4 @@ -import { onServerEvent } from '../api/server_events.mjs' +import { onServerEvent } from '../endpoints/server_events.mjs' import { loadPreferredLangs, diff --git a/src/public/parts/shells/AGENTS.md b/src/public/parts/shells/AGENTS.md index efb44ea2a..e105044ba 100644 --- a/src/public/parts/shells/AGENTS.md +++ b/src/public/parts/shells/AGENTS.md @@ -16,7 +16,8 @@ alwaysApply: false - `main.mjs`: Backend entry. Default export must include `Load({ router })`. - `public/`: Frontend assets. `public/llms.txt`: AI-readable API docs. -- `src/endpoints.mjs`: Routes via `router.get/post/ws`. Path: `/api/parts/shells\:<name>/...`. +- `src/endpoints.mjs`: **Backend** Express routes via `router.get/post/ws`. Path: `/api/parts/shells\:<name>/...`. +- **Frontend HTTP**: `public/src/endpoints.mjs` or `public/src/endpoints/*.mjs` — **named exports only**. No path-string clients (`socialApi('/…')`, `api(method, path)`, UI-facing `groupFetch(path)`). UI / shared / providers must not `fetch` shell REST; use endpoints. Global `/api/whoami`, `/api/getdetails…`, EVFS → `@src/public/pages/scripts/endpoints/`. HTML templates → `renderTemplate` / `mountTemplate` / `withTemplates`, never `fetch(…html)`. - **HTTP API**: Success = 2xx JSON (no `success` wrapper); failures = `throw httpError(code, message, { json?, skip_report? })` from `@src/scripts/http_error.mjs`. - **`fount.json` → `registries`**: `[{ id, level, path }]` for `markdown_extensions`, `emoji`, `locales`, `home_*`, `achievements`. - **`home_function_buttons.info`**: locale **object** with `title` (e.g. `achievements.home_function_buttons.main`), not a page-level string. Home reads `geti18n(info).title`. diff --git a/src/public/parts/shells/access/public/index.mjs b/src/public/parts/shells/access/public/index.mjs index b2091da4a..31ade2f3c 100644 --- a/src/public/parts/shells/access/public/index.mjs +++ b/src/public/parts/shells/access/public/index.mjs @@ -3,7 +3,7 @@ */ import qrcode from 'https://esm.sh/qrcode-generator' -import { hosturl_in_local_ip, ping } from '../../scripts/api/base.mjs' +import { hosturl_in_local_ip, ping } from '../../scripts/endpoints/base.mjs' import { showToast, showToastI18n } from '../../scripts/features/toast.mjs' import { redirectToLoginInfo, diff --git a/src/public/parts/shells/achievements/public/index.mjs b/src/public/parts/shells/achievements/public/index.mjs index f77ec9b09..1b8429315 100644 --- a/src/public/parts/shells/achievements/public/index.mjs +++ b/src/public/parts/shells/achievements/public/index.mjs @@ -1,8 +1,8 @@ /** * 成就页面的主要客户端逻辑。 */ -import { unlockAchievement, loadPart } from '../../../scripts/api/parts.mjs' -import { onServerEvent } from '../../../scripts/api/server_events.mjs' +import { unlockAchievement, loadPart } from '../../../scripts/endpoints/parts.mjs' +import { onServerEvent } from '../../../scripts/endpoints/server_events.mjs' import { renderTemplate, usingTemplates } from '../../../scripts/features/template.mjs' import { geti18n, geti18n_nowarn, initTranslations } from '../../../scripts/i18n/index.mjs' import { applyTheme } from '../../../scripts/theme/index.mjs' diff --git a/src/public/parts/shells/browserIntegration/src/endpoints.mjs b/src/public/parts/shells/browserIntegration/src/endpoints.mjs index 592e99032..e6845da2e 100644 --- a/src/public/parts/shells/browserIntegration/src/endpoints.mjs +++ b/src/public/parts/shells/browserIntegration/src/endpoints.mjs @@ -40,7 +40,7 @@ export function setEndpoints(router) { } }) - router.get('/virtual_files/parts/shells\\:browserIntegration/script.meta.js', async (_req, res) => { + router.get('/virtual_files/parts/shells\\:browserIntegration/script.meta.js', async (req, res) => { const scriptPublicPath = path.join(import.meta.dirname, '..', 'public') const publicScriptPath = path.join(scriptPublicPath, 'script.user.js') const content = await fs.promises.readFile(publicScriptPath, 'utf-8') diff --git a/src/public/parts/shells/cabinet/AGENTS.md b/src/public/parts/shells/cabinet/AGENTS.md index 8547e3e9b..2a72c7f64 100644 --- a/src/public/parts/shells/cabinet/AGENTS.md +++ b/src/public/parts/shells/cabinet/AGENTS.md @@ -14,5 +14,5 @@ alwaysApply: false - **Recoverable delete**: `DELETE …/entries` + `recoverable:true` → `recovery_token`; restore / finalize-delete endpoints. UI undo must finalize discarded tokens. History factories capture unlock at push time — do not re-resolve `currentUnlockToken()` in undo/redo. - **Clipboard / shortcuts**: app-level (`sessionStorage` + `BroadcastChannel`), not OS. Keymap: `public/shared/keyboard`. - **Entity profile**: `#user:{entityHash}` — `/parts/shells:chat/shared/entityProfilePopup.mjs`; stamps via `formatEntityAtId` / `formatHashShort`. -- **UI**: Bootstrap `public/index.mjs` (`applyTheme` + `initTranslations`); state `cabinetStore`; DOM under `public/src/`; Deno-pure helpers under `public/shared/` (`keyboard`, `commandHistory`). Prefer DaisyUI + shared `promptDialog` / `positionContextMenu`. Entry grid: `role="listbox"` + `aria-multiselectable`, cards `role="option"` + `aria-selected`. Context menu: `#contextMenu` = `ul.menu[role=menu]`. SPA hashes: `` `${location.pathname}#…` `` (not bare `href="#…"`). No explanatory `data-i18n` on invisible controls; omit unavailable context-menu items. Custom CSS only for thumb/safe-area/remote chrome. +- **UI**: Bootstrap `public/index.mjs` (`applyTheme` + `initTranslations`); state `cabinetStore`; frontend HTTP named endpoints in `public/src/endpoints.mjs`; DOM under `public/src/`; Deno-pure helpers under `public/shared/` (`keyboard`, `commandHistory`). Prefer DaisyUI + shared `promptDialog` / `positionContextMenu`. Entry grid: `role="listbox"` + `aria-multiselectable`, cards `role="option"` + `aria-selected`. Context menu: `#contextMenu` = `ul.menu[role=menu]`. SPA hashes: `` `${location.pathname}#…` `` (not bare `href="#…"`). No explanatory `data-i18n` on invisible controls; omit unavailable context-menu items. Custom CSS only for thumb/safe-area/remote chrome. - **Tests**: `fount test shells/cabinet --no-parallel` (frontend subtests: `shortcuts`, `daisyui`). Pure suites import `public/shared/` only — not `public/src/`. Keep Social `visibilitySpec` behind dynamic import in `remote.mjs` fetch paths so pure suites do not statically pull Social. diff --git a/src/public/parts/shells/cabinet/public/src/api.mjs b/src/public/parts/shells/cabinet/public/src/api.mjs deleted file mode 100644 index 2139c0196..000000000 --- a/src/public/parts/shells/cabinet/public/src/api.mjs +++ /dev/null @@ -1,67 +0,0 @@ -import { cabinetStore, currentUnlockToken } from './state.mjs' - -const API = '/api/parts/shells:cabinet' - -/** - * @param {string} method HTTP 方法 - * @param {string} path 路径 - * @param {object} [body] body - * @param {Record<string, string>} [headers] 额外头 - * @returns {Promise<any>} JSON - */ -export async function api(method, path, body, headers = {}) { - const res = await fetch(`${API}${path}`, { - method, - credentials: 'include', - headers: { - ...body ? { 'Content-Type': 'application/json' } : {}, - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - }) - if (!res.ok) { - const err = await res.json().catch(() => ({})) - throw new Error(err.error || err.message || `${method} ${path} ${res.status}`) - } - if (res.headers.get('Content-Type')?.includes('application/zip')) - return res.blob() - return res.json() -} - -/** - * @param {string} [unlockToken] token - * @returns {Record<string, string>} headers - */ -export function unlockHeaders(unlockToken) { - return unlockToken ? { 'X-Cabinet-Unlock': unlockToken } : {} -} - -/** - * 当前柜路径请求(自动带 unlock)。 - * @param {string} method HTTP - * @param {string} subpath 相对 `/cabinets/:id` 的路径 - * @param {object} [body] body - * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 - * @returns {Promise<any>} JSON / blob - */ -export function cabinetApi(method, subpath, body, opts = {}) { - const id = opts.cabinetId ?? cabinetStore.currentCabinetId - return api( - method, - `/cabinets/${encodeURIComponent(id)}${subpath}`, - body, - unlockHeaders(opts.unlock !== undefined ? opts.unlock : currentUnlockToken()), - ) -} - -/** - * @param {string} href 链接 - * @param {string} [filename] 下载名 - * @returns {void} - */ -export function triggerDownload(href, filename) { - const a = document.createElement('a') - a.href = href - if (filename) a.download = filename - a.click() -} diff --git a/src/public/parts/shells/cabinet/public/src/endpoints.mjs b/src/public/parts/shells/cabinet/public/src/endpoints.mjs new file mode 100644 index 000000000..c49a94b3d --- /dev/null +++ b/src/public/parts/shells/cabinet/public/src/endpoints.mjs @@ -0,0 +1,254 @@ +/** + * Cabinet shell 前端 named HTTP 端点。 + */ +import { cabinetStore, currentUnlockToken } from './state.mjs' + +const API = '/api/parts/shells:cabinet' + +/** + * @param {string} method HTTP 方法 + * @param {string} path 路径 + * @param {object} [body] body + * @param {Record<string, string>} [headers] 额外头 + * @returns {Promise<any>} JSON / blob + */ +async function request(method, path, body, headers = {}) { + const res = await fetch(`${API}${path}`, { + method, + credentials: 'include', + headers: { + ...body ? { 'Content-Type': 'application/json' } : {}, + ...headers, + }, + body: body ? JSON.stringify(body) : undefined, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({})) + throw new Error(err.error || err.message || `${method} ${path} ${res.status}`) + } + if (res.headers.get('Content-Type')?.includes('application/zip')) + return res.blob() + return res.json() +} + +/** + * @param {string} [unlockToken] token + * @returns {Record<string, string>} headers + */ +export function unlockHeaders(unlockToken) { + return unlockToken ? { 'X-Cabinet-Unlock': unlockToken } : {} +} + +/** + * @param {string} href 链接 + * @param {string} [filename] 下载名 + * @returns {void} + */ +export function triggerDownload(href, filename) { + const a = document.createElement('a') + a.href = href + if (filename) a.download = filename + a.click() +} + +/** + * 当前柜路径请求(自动带 unlock)。 + * @param {string} method HTTP + * @param {string} subpath 相对 `/cabinets/:id` 的路径 + * @param {object} [body] body + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} JSON / blob + */ +function cabinetRequest(method, subpath, body, opts = {}) { + const id = opts.cabinetId ?? cabinetStore.currentCabinetId + return request( + method, + `/cabinets/${encodeURIComponent(id)}${subpath}`, + body, + unlockHeaders(opts.unlock !== undefined ? opts.unlock : currentUnlockToken()), + ) +} + +/** + * 列出本地柜。 + * @returns {Promise<{ cabinets: object[] }>} 柜列表 + */ +export function listCabinets() { + return request('GET', '/cabinets') +} + +/** + * 创建个人柜。 + * @param {object} body 创建参数 + * @returns {Promise<{ cabinet: object }>} 新建柜 + */ +export function createCabinet(body) { + return request('POST', '/cabinets', body) +} + +/** + * 更新柜元数据。 + * @param {string} cabinetId 柜 + * @param {object} patch 补丁 + * @returns {Promise<any>} 更新结果 + */ +export function patchCabinet(cabinetId, patch) { + return request('PATCH', `/cabinets/${encodeURIComponent(cabinetId)}`, patch) +} + +/** + * 删除柜。 + * @param {string} cabinetId 柜 + * @returns {Promise<any>} 删除结果 + */ +export function deleteCabinet(cabinetId) { + return request('DELETE', `/cabinets/${encodeURIComponent(cabinetId)}`) +} + +/** + * 列出柜内条目(index)。 + * @param {string} cabinetId 柜 + * @param {URLSearchParams | string} query index 查询 + * @param {Record<string, string>} [headers] 额外头(如 unlock) + * @returns {Promise<any>} index 响应 + */ +export function listEntries(cabinetId, query, headers) { + const q = String(query) + return request('GET', `/cabinets/${encodeURIComponent(cabinetId)}/index${q ? `?${q}` : ''}`, null, headers) +} + +/** + * 列出远端实体可见柜。 + * @param {string} entityHash 远端实体 + * @returns {Promise<{ cabinets: object[] }>} 柜列表 + */ +export function listRemoteCabinets(entityHash) { + return request('GET', `/remote/${encodeURIComponent(entityHash)}/cabinets`) +} + +/** + * 列出远端柜内条目。 + * @param {string} entityHash 远端实体 + * @param {string} cabinetId 柜 + * @param {URLSearchParams | string} query index 查询 + * @returns {Promise<any>} index 响应 + */ +export function listRemoteEntries(entityHash, cabinetId, query) { + const q = String(query) + return request( + 'GET', + `/remote/${encodeURIComponent(entityHash)}/cabinets/${encodeURIComponent(cabinetId)}/index${q ? `?${q}` : ''}`, + ) +} + +/** + * 当前 viewer 实体信息。 + * @returns {Promise<{ viewer_entity_hash: string }>} viewer + */ +export function getViewer() { + return request('GET', '/viewer') +} + +/** + * 解锁密码文件夹。 + * @param {{ folder_id: string, password: string }} body 解锁参数 + * @returns {Promise<{ unlock_token: string }>} unlock token + */ +export function unlockCabinet(body) { + return cabinetRequest('POST', '/unlock', body, { unlock: undefined }) +} + +/** + * 解析链接条目目标。 + * @param {string} entryId 条目 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} resolve 结果 + */ +export function resolveEntry(entryId, opts) { + return cabinetRequest('GET', `/entries/${encodeURIComponent(entryId)}/resolve`, null, opts) +} + +/** + * 上传条目预览图。 + * @param {object} body 预览上传 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<{ url: string }>} 预览 URL + */ +export function uploadPreview(body, opts) { + return cabinetRequest('POST', '/preview', body, opts) +} + +/** + * 创建条目(文件 / 文件夹 / 链接等)。 + * @param {object} body 创建参数 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<{ entry: object }>} 新建条目 + */ +export function createEntry(body, opts) { + return cabinetRequest('POST', '/entries', body, opts) +} + +/** + * 更新条目。 + * @param {string} entryId 条目 + * @param {object} patch 补丁 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} 更新结果 + */ +export function patchEntry(entryId, patch, opts) { + return cabinetRequest('PATCH', `/entries/${encodeURIComponent(entryId)}`, patch, opts) +} + +/** + * 复制条目到目标柜/目录。 + * @param {string} sourceCabinetId 源柜 + * @param {object} body 复制参数 + * @param {Record<string, string>} [headers] 额外头(目标 unlock) + * @returns {Promise<{ entries: object[] }>} 新建条目 + */ +export function copyEntries(sourceCabinetId, body, headers) { + return request('POST', `/cabinets/${encodeURIComponent(sourceCabinetId)}/entries/copy`, body, headers) +} + +/** + * 下载柜/文件夹 zip。 + * @param {string} [query] zip 查询串(可含 folder_id=…) + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<Blob>} zip blob + */ +export function downloadZip(query = '', opts) { + return cabinetRequest('GET', `/zip${query ? `?${query}` : ''}`, null, opts) +} + +/** + * 可恢复删除条目。 + * @param {string[]} entryIds 条目 + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<{ deleted: string[], recovery_token?: string }>} 删除结果 + */ +export function deleteEntries(entryIds, opts) { + return cabinetRequest('DELETE', '/entries', { entry_ids: entryIds, recoverable: true }, opts) +} + +/** + * 按 recovery token 恢复条目。 + * @param {string} recoveryToken token + * @param {{ cabinetId?: string, unlock?: string }} [opts] 覆盖 + * @returns {Promise<any>} 恢复结果 + */ +export function restoreEntries(recoveryToken, opts) { + return cabinetRequest('POST', '/entries/restore', { recovery_token: recoveryToken }, opts) +} + +/** + * 永久确认可恢复删除(丢弃 recovery token)。 + * @param {string} recoveryToken token + * @param {{ cabinetId?: string }} [opts] 覆盖(不带 unlock) + * @returns {Promise<any>} 确认结果 + */ +export function finalizeDelete(recoveryToken, opts = {}) { + return cabinetRequest('POST', '/entries/finalize-delete', { recovery_token: recoveryToken }, { + ...opts, + unlock: undefined, + }) +} diff --git a/src/public/parts/shells/cabinet/public/src/entryActions.mjs b/src/public/parts/shells/cabinet/public/src/entryActions.mjs index 34ce2bb28..7b59534fc 100644 --- a/src/public/parts/shells/cabinet/public/src/entryActions.mjs +++ b/src/public/parts/shells/cabinet/public/src/entryActions.mjs @@ -5,8 +5,19 @@ import { confirmAction, promptText } from '/scripts/features/promptDialog.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { arrayBufferToBase64, blobToBase64 } from '/scripts/lib/base64.mjs' -import { api, cabinetApi, triggerDownload, unlockHeaders } from './api.mjs' import { writeClipboard } from './clipboard.mjs' +import { + copyEntries, + createEntry, + downloadZip, + getViewer, + patchEntry, + resolveEntry, + triggerDownload, + unlockCabinet, + unlockHeaders, + uploadPreview, +} from './endpoints.mjs' import { renderEntries, selectedEntries } from './entryGrid.mjs' import { openCabinet, refreshEntries } from './navigation.mjs' import { @@ -44,7 +55,7 @@ export async function promptUnlock(folderId) { submit.onclick = async () => { try { const password = document.getElementById('unlockPassword').value - const result = await cabinetApi('POST', '/unlock', { folder_id: folderId, password }, { unlock: undefined }) + const result = await unlockCabinet({ folder_id: folderId, password }) cabinetStore.unlockTokens.set(folderId, result.unlock_token) dialog.close() await refreshEntries() @@ -72,7 +83,7 @@ export async function onEntryOpen(entry) { } if (entry.kind === 'link') { if (remoteEntityHash) return - const resolved = await cabinetApi('GET', `/entries/${encodeURIComponent(entry.id)}/resolve`) + const resolved = await resolveEntry(entry.id) if (!resolved.ok) { showToastI18n('warning', 'cabinet.brokenLink', { reason: resolved.reason }) entry._broken = true @@ -116,7 +127,7 @@ export async function downloadEntry(entry, cabinetId = cabinetStore.currentCabin ) return } - const entity = ownerEntityHash || (await api('GET', '/viewer')).viewer_entity_hash + const entity = ownerEntityHash || (await getViewer()).viewer_entity_hash triggerDownload( `/api/parts/shells:chat/entities/${encodeURIComponent(entity)}/files/${entry.evfs_path.split('/').map(encodeURIComponent).join('/')}`, entry.name, @@ -137,7 +148,7 @@ export async function uploadFiles(files) { try { const previewBlob = await generateUploadPreview(file) if (previewBlob) { - const uploaded = await cabinetApi('POST', '/preview', { + const uploaded = await uploadPreview({ plaintext_base64: await blobToBase64(previewBlob), name: `preview.${previewBlob.type.includes('avif') ? 'avif' : 'webp'}`, mime_type: previewBlob.type, @@ -146,7 +157,7 @@ export async function uploadFiles(files) { } } catch { /* 预览失败不阻断上传 */ } - const { entry } = await cabinetApi('POST', '/entries', { + const { entry } = await createEntry({ plaintext_base64: arrayBufferToBase64(await file.arrayBuffer()), name: file.name, mime_type: file.type || 'application/octet-stream', @@ -167,7 +178,7 @@ export async function createFolder() { if (!canWrite()) return const name = await promptText('cabinet.new.folderPrompt') if (!name) return - const { entry } = await cabinetApi('POST', '/entries', { + const { entry } = await createEntry({ kind: 'folder', name, parent_id: cabinetStore.currentParentId, @@ -210,7 +221,7 @@ export async function pasteClipboard(asLinks = false) { const sourceParent = clip.source_parent_id ?? null const movedIds = [...clip.entry_ids] for (const id of movedIds) - await cabinetApi('PATCH', `/entries/${encodeURIComponent(id)}`, { parent_id: targetParent }) + await patchEntry(id, { parent_id: targetParent }) writeClipboard(null) cabinetStore.clipboard = null await refreshEntries() @@ -224,7 +235,7 @@ export async function pasteClipboard(asLinks = false) { } const sourceUnlock = clip.source_parent_id ? cabinetStore.unlockTokens.get(clip.source_parent_id) : undefined - const created = await api('POST', `/cabinets/${encodeURIComponent(clip.cabinet_id)}/entries/copy`, { + const created = await copyEntries(clip.cabinet_id, { entry_ids: clip.entry_ids, target_parent_id: cabinetStore.currentParentId, target_cabinet_id: cabinetStore.currentCabinetId, @@ -287,7 +298,7 @@ export async function renameSelection() { if (!entry) return const name = await promptText('cabinet.renamePrompt', entry.name) if (!name || name === entry.name) return - await cabinetApi('PATCH', `/entries/${encodeURIComponent(entry.id)}`, { name }) + await patchEntry(entry.id, { name }) await refreshEntries() await cabinetStore.history.push(makePatchHistory({ entryId: entry.id, @@ -322,7 +333,7 @@ export async function deleteSelection() { */ export async function downloadFolder(folderId, name) { const query = folderId ? `folder_id=${encodeURIComponent(folderId)}` : '' - const blob = await cabinetApi('GET', `/zip?${query}`, null, folderId + const blob = await downloadZip(query, folderId ? { unlock: cabinetStore.unlockTokens.get(folderId) } : {}) const url = URL.createObjectURL(blob) diff --git a/src/public/parts/shells/cabinet/public/src/navigation.mjs b/src/public/parts/shells/cabinet/public/src/navigation.mjs index e88144c72..6ea3a1d4e 100644 --- a/src/public/parts/shells/cabinet/public/src/navigation.mjs +++ b/src/public/parts/shells/cabinet/public/src/navigation.mjs @@ -3,7 +3,7 @@ */ import { confirmAction, promptText } from '/scripts/features/promptDialog.mjs' -import { api, unlockHeaders } from './api.mjs' +import { listCabinets, listEntries, listRemoteCabinets, listRemoteEntries, deleteCabinet, patchCabinet, unlockHeaders } from './endpoints.mjs' import { promptUnlock } from './entryActions.mjs' import { renderEntries, renderStatus } from './entryGrid.mjs' import { renderRemoteEntityBar } from './remoteBrowse.mjs' @@ -40,7 +40,7 @@ export function locationHashFor(cabinetId, parentId = null) { * @returns {Promise<void>} */ export async function refreshCabinets() { - const data = await api('GET', '/cabinets') + const data = await listCabinets() cabinetStore.cabinets = data.cabinets || [] cabinetStore.cabinets.sort((a, b) => { if (a.type !== b.type) return a.type === 'personal' ? -1 : 1 @@ -95,13 +95,13 @@ async function cabinetContext(cabinet) { if (action === 'rename') { const name = await promptText('cabinet.renamePrompt', cabinet.name) if (!name) return - await api('PATCH', `/cabinets/${encodeURIComponent(cabinet.cabinet_id)}`, { name }) + await patchCabinet(cabinet.cabinet_id, { name }) await refreshCabinets() } else if (action === 'delete') { if (!await confirmAction('cabinet.confirmDeleteCabinet')) return const wasCurrent = cabinetStore.currentCabinetId === cabinet.cabinet_id - await api('DELETE', `/cabinets/${encodeURIComponent(cabinet.cabinet_id)}`) + await deleteCabinet(cabinet.cabinet_id) await refreshCabinets() if (wasCurrent) { const next = cabinetStore.cabinets[0]?.cabinet_id @@ -115,7 +115,7 @@ async function cabinetContext(cabinet) { cabinet.visibility?.visibility || 'private', ) if (!visibility) return - await api('PATCH', `/cabinets/${encodeURIComponent(cabinet.cabinet_id)}`, { visibility: { visibility } }) + await patchCabinet(cabinet.cabinet_id, { visibility: { visibility } }) await refreshCabinets() } } @@ -146,16 +146,8 @@ export async function refreshEntries() { if (currentParentId) query.set('parent_id', currentParentId) if (showHidden) query.set('show_hidden', '1') const data = remoteEntityHash - ? await api( - 'GET', - `/remote/${encodeURIComponent(remoteEntityHash)}/cabinets/${encodeURIComponent(currentCabinetId)}/index?${query}`, - ) - : await api( - 'GET', - `/cabinets/${encodeURIComponent(currentCabinetId)}/index?${query}`, - null, - unlockHeaders(currentUnlockToken()), - ) + ? await listRemoteEntries(remoteEntityHash, currentCabinetId, query) + : await listEntries(currentCabinetId, query, unlockHeaders(currentUnlockToken())) cabinetStore.currentCabinet = data.cabinet cabinetStore.folderTrail = data.folder_trail || [] await renderBreadcrumb() @@ -281,7 +273,7 @@ export async function bootFromHash() { const parts = hash.slice(5).split('/') const entityHash = parts[0].toLowerCase() setBrowseMode(entityHash) - const data = await api('GET', `/remote/${encodeURIComponent(entityHash)}/cabinets`) + const data = await listRemoteCabinets(entityHash) cabinetStore.cabinets = data.cabinets || [] renderCabinetList() const cabinetId = parts[1] || cabinetStore.cabinets[0]?.cabinet_id diff --git a/src/public/parts/shells/cabinet/public/src/properties.mjs b/src/public/parts/shells/cabinet/public/src/properties.mjs index a22996aca..3b5231751 100644 --- a/src/public/parts/shells/cabinet/public/src/properties.mjs +++ b/src/public/parts/shells/cabinet/public/src/properties.mjs @@ -3,7 +3,7 @@ */ import { setElementI18n } from '/scripts/i18n/index.mjs' -import { cabinetApi } from './api.mjs' +import { patchEntry } from './endpoints.mjs' import { formatStamp, selectedEntries } from './entryGrid.mjs' import { refreshEntries } from './navigation.mjs' import { makePatchHistory } from './recoveryHistory.mjs' @@ -78,7 +78,7 @@ export async function saveProps() { const password = document.getElementById('propFolderPassword').value if (entry.kind === 'folder' && password) patch.set_password = password - await cabinetApi('PATCH', `/entries/${encodeURIComponent(entry.id)}`, patch) + await patchEntry(entry.id, patch) document.getElementById('propsDialog').close() await refreshEntries() if (!password) diff --git a/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs b/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs index 8ff742c47..cc2ad9d7f 100644 --- a/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs +++ b/src/public/parts/shells/cabinet/public/src/recoveryHistory.mjs @@ -1,7 +1,9 @@ /** * 可恢复删除 / 创建 / 补丁 的撤销历史工厂。 */ -import { cabinetApi } from './api.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' + +import { deleteEntries, finalizeDelete, patchEntry, restoreEntries } from './endpoints.mjs' import { currentUnlockToken } from './state.mjs' /** @returns {Promise<void>} */ @@ -17,9 +19,7 @@ async function refreshEntries() { */ export async function finalizeRecovery(cabinetId, recoveryToken) { if (!recoveryToken) return - await cabinetApi('POST', '/entries/finalize-delete', { recovery_token: recoveryToken }, { - cabinetId, unlock: undefined, - }).catch(() => { }) + await finalizeDelete(recoveryToken, { cabinetId }).catch(handleError('cabinet.bootstrapFailed')) } /** @@ -29,7 +29,7 @@ export async function finalizeRecovery(cabinetId, recoveryToken) { * @returns {Promise<{ deleted: string[], recovery_token?: string }>} 删除结果 */ export async function recoverableDelete(cabinetId, entryIds, unlock) { - return cabinetApi('DELETE', '/entries', { entry_ids: entryIds, recoverable: true }, { cabinetId, unlock }) + return deleteEntries(entryIds, { cabinetId, unlock }) } /** @@ -39,7 +39,7 @@ export async function recoverableDelete(cabinetId, entryIds, unlock) { * @returns {Promise<void>} */ export async function restoreRecovery(cabinetId, recoveryToken, unlock) { - await cabinetApi('POST', '/entries/restore', { recovery_token: recoveryToken }, { cabinetId, unlock }) + await restoreEntries(recoveryToken, { cabinetId, unlock }) } /** @@ -105,17 +105,16 @@ export function makeDeleteHistory(ids, initialToken, cabinetId, unlock = current * @returns {import('./commandHistory.mjs').HistoryEntry} 历史 */ export function makePatchHistory({ entryId, before, after, label = 'patch', cabinetId }) { - const path = `/entries/${encodeURIComponent(entryId)}` return { label, /** 撤销 PATCH:写回修改前快照。 */ async undo() { - await cabinetApi('PATCH', path, before, { cabinetId }) + await patchEntry(entryId, before, { cabinetId }) await refreshEntries() }, /** 重做 PATCH:应用修改后快照。 */ async redo() { - await cabinetApi('PATCH', path, after, { cabinetId }) + await patchEntry(entryId, after, { cabinetId }) await refreshEntries() }, } @@ -132,7 +131,7 @@ export function makeMoveHistory({ entryIds, fromParent, toParent, label = 'cut', */ async function moveAll(parentId) { for (const entryId of entryIds) - await cabinetApi('PATCH', `/entries/${encodeURIComponent(entryId)}`, { parent_id: parentId }, { cabinetId }) + await patchEntry(entryId, { parent_id: parentId }, { cabinetId }) await refreshEntries() } return { diff --git a/src/public/parts/shells/cabinet/public/src/wiring.mjs b/src/public/parts/shells/cabinet/public/src/wiring.mjs index f1c71604b..ed9b15a30 100644 --- a/src/public/parts/shells/cabinet/public/src/wiring.mjs +++ b/src/public/parts/shells/cabinet/public/src/wiring.mjs @@ -5,9 +5,9 @@ import { promptText } from '/scripts/features/promptDialog.mjs' import { matchCabinetShortcut } from '../shared/keyboard.mjs' -import { api } from './api.mjs' import { runCommand } from './commands.mjs' import { hideContextMenu, showContextMenu } from './contextMenu.mjs' +import { createCabinet } from './endpoints.mjs' import { uploadFiles } from './entryActions.mjs' import { refreshCabinets, openCabinet, refreshEntries } from './navigation.mjs' import { saveProps } from './properties.mjs' @@ -20,16 +20,16 @@ export function wireBootstrap() { /** * @returns {Promise<void>} 创建个人柜并打开 */ - const createCabinet = async () => { + const onCreateCabinet = async () => { const name = await promptText('cabinet.new.cabinetPrompt') if (!name) return const visibility = await promptText('cabinet.visibilityPrompt', 'private') || 'private' - const { cabinet } = await api('POST', '/cabinets', { name, visibility: { visibility }, type: 'personal' }) + const { cabinet } = await createCabinet({ name, visibility: { visibility }, type: 'personal' }) await refreshCabinets() if (cabinet?.cabinet_id) await openCabinet(cabinet.cabinet_id) } for (const el of document.querySelectorAll('[data-action="new-cabinet"]')) - el.onclick = createCabinet + el.onclick = onCreateCabinet /** * @param {Event} event 文件/文件夹选择变更 * @returns {Promise<void>} diff --git a/src/public/parts/shells/cabinet/src/cabinets.mjs b/src/public/parts/shells/cabinet/src/cabinets.mjs index 8b577c72f..0bd77d308 100644 --- a/src/public/parts/shells/cabinet/src/cabinets.mjs +++ b/src/public/parts/shells/cabinet/src/cabinets.mjs @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto' +import { handleError } from 'fount/scripts/errorHandlers.mjs' + import { normalizeVisibilitySpec } from '../../social/src/lib/visibilitySpec.mjs' import { normalizeIndex } from './entryModel.mjs' @@ -25,7 +27,7 @@ export async function loadCabinets(username, entityHash) { */ export async function saveCabinets(username, entityHash, cabinets) { await writeJsonFile(cabinetsListPath(username, entityHash), { cabinets }) - await publishCabinetLists(username, entityHash, cabinets).catch(() => { }) + await publishCabinetLists(username, entityHash, cabinets).catch(handleError) } /** @@ -162,5 +164,5 @@ export async function savePersonalIndex(username, entityHash, cabinetId, index) await writeJsonFile(cabinetIndexPath(username, entityHash, cabinetId), normalized) const cabinet = await getCabinet(username, entityHash, cabinetId) if (cabinet?.type === 'personal') - await publishCabinetIndex(username, entityHash, cabinet, normalized).catch(() => { }) + await publishCabinetIndex(username, entityHash, cabinet, normalized).catch(handleError) } diff --git a/src/public/parts/shells/config/public/index.mjs b/src/public/parts/shells/config/public/index.mjs index 5096c6306..68334b523 100644 --- a/src/public/parts/shells/config/public/index.mjs +++ b/src/public/parts/shells/config/public/index.mjs @@ -5,7 +5,7 @@ import { async_eval } from 'https://esm.sh/@steve02081504/async-eval' import { initTranslations, i18nElement, geti18n, confirmI18n, console } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartDetails } from '/scripts/api/parts.mjs' +import { getPartDetails } from '/scripts/endpoints/parts.mjs' import { svgInliner } from '/scripts/lib/svgInliner.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' diff --git a/src/public/parts/shells/debug_info/public/index.mjs b/src/public/parts/shells/debug_info/public/index.mjs index 7910e2c39..1358b012e 100644 --- a/src/public/parts/shells/debug_info/public/index.mjs +++ b/src/public/parts/shells/debug_info/public/index.mjs @@ -2,9 +2,9 @@ import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { initTranslations } from '/scripts/i18n/index.mjs' import { mountTemplate, renderTemplate, usingTemplates } from '/scripts/features/template.mjs' -import { onServerEvent } from '/scripts/api/server_events.mjs' +import { onServerEvent } from '/scripts/endpoints/server_events.mjs' -import { ping } from '/scripts/api/base.mjs' +import { ping } from '/scripts/endpoints/base.mjs' import { getAutoUpdateEnabled, getSystemInfo, postRestart } from './src/endpoints.mjs' applyTheme() diff --git a/src/public/parts/shells/deskpet/public/index.mjs b/src/public/parts/shells/deskpet/public/index.mjs index 94fae4960..097768134 100644 --- a/src/public/parts/shells/deskpet/public/index.mjs +++ b/src/public/parts/shells/deskpet/public/index.mjs @@ -6,8 +6,8 @@ import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' import { renderTemplate, usingTemplates } from '/scripts/features/template.mjs' -import { onServerEvent } from '/scripts/api/server_events.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { onServerEvent } from '/scripts/endpoints/server_events.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { getRunningPetList, diff --git a/src/public/parts/shells/discordbot/public/index.mjs b/src/public/parts/shells/discordbot/public/index.mjs index f743f5d9d..cfb7894cd 100644 --- a/src/public/parts/shells/discordbot/public/index.mjs +++ b/src/public/parts/shells/discordbot/public/index.mjs @@ -3,7 +3,7 @@ */ import { initTranslations, geti18n, promptI18n, confirmI18n } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToast, showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' diff --git a/src/public/parts/shells/home/public/src/data.mjs b/src/public/parts/shells/home/public/src/data.mjs index 11736dcf0..fc0565f52 100644 --- a/src/public/parts/shells/home/public/src/data.mjs +++ b/src/public/parts/shells/home/public/src/data.mjs @@ -1,4 +1,4 @@ -import { getAllCachedPartDetails, getPartDetails } from '../../../../../scripts/api/parts.mjs' +import { getAllCachedPartDetails, getPartDetails } from '../../../../../scripts/endpoints/parts.mjs' /** * 部件详细信息缓存,以便其他模块可以访问。 diff --git a/src/public/parts/shells/home/public/src/events.mjs b/src/public/parts/shells/home/public/src/events.mjs index d02cb8888..76c98c830 100644 --- a/src/public/parts/shells/home/public/src/events.mjs +++ b/src/public/parts/shells/home/public/src/events.mjs @@ -1,6 +1,6 @@ -import { setUserSetting } from '../../../scripts/api/base.mjs' -import { unlockAchievement } from '../../../scripts/api/parts.mjs' -import { onServerEvent } from '../../../scripts/api/server_events.mjs' +import { setUserSetting } from '../../../scripts/endpoints/base.mjs' +import { unlockAchievement } from '../../../scripts/endpoints/parts.mjs' +import { onServerEvent } from '../../../scripts/endpoints/server_events.mjs' import { showToastI18n } from '../../../scripts/features/toast.mjs' import { confirmI18n, onLanguageChange } from '../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/home/public/src/home.mjs b/src/public/parts/shells/home/public/src/home.mjs index a70d6fd56..c2dcb6ac3 100644 --- a/src/public/parts/shells/home/public/src/home.mjs +++ b/src/public/parts/shells/home/public/src/home.mjs @@ -2,8 +2,8 @@ * 主页 shell 的客户端逻辑。 */ -import { getUserSetting } from '../../../scripts/api/base.mjs' -import { unlockAchievement, getAllDefaultParts, getPartBranches } from '../../../scripts/api/parts.mjs' +import { getUserSetting } from '../../../scripts/endpoints/base.mjs' +import { unlockAchievement, getAllDefaultParts, getPartBranches } from '../../../scripts/endpoints/parts.mjs' import { showToast } from '../../../scripts/features/toast.mjs' import { applyUrlParamsTransferStrategy } from '../../../scripts/host/urlDataTransfer.mjs' import { initTranslations, console } from '../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/home/public/src/ui.mjs b/src/public/parts/shells/home/public/src/ui.mjs index 614f7e9fc..8c80bdc76 100644 --- a/src/public/parts/shells/home/public/src/ui.mjs +++ b/src/public/parts/shells/home/public/src/ui.mjs @@ -1,7 +1,7 @@ import { async_eval } from 'https://esm.sh/@steve02081504/async-eval' -import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAllDefaultParts } from '../../../scripts/api/parts.mjs' import { getFiltersFromString, compileFilter, makeSearchable } from '../../../scripts/components/search.mjs' +import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAllDefaultParts } from '../../../scripts/endpoints/parts.mjs' import { renderMarkdown } from '../../../scripts/features/markdown/index.mjs' import { mountTemplate, renderTemplate, usingTemplates } from '../../../scripts/features/template.mjs' import { geti18n, console } from '../../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/ideIntegration/public/index.mjs b/src/public/parts/shells/ideIntegration/public/index.mjs index 69c41b6c8..0d0588894 100644 --- a/src/public/parts/shells/ideIntegration/public/index.mjs +++ b/src/public/parts/shells/ideIntegration/public/index.mjs @@ -1,8 +1,8 @@ /** * IDE 集成配置页:API 密钥、角色选择、一站式 Agent 脚本 URL(Zed 用 deno 跑远端脚本)。 */ -import { verifyApiKey, createApiKey } from '/scripts/api/base.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { verifyApiKey, createApiKey } from '/scripts/endpoints/base.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { initTranslations } from '/scripts/i18n/index.mjs' import { renderMarkdown } from '/scripts/features/markdown/index.mjs' diff --git a/src/public/parts/shells/install/public/uninstall/index.mjs b/src/public/parts/shells/install/public/uninstall/index.mjs index 79e443a5c..c3a1a8fb9 100644 --- a/src/public/parts/shells/install/public/uninstall/index.mjs +++ b/src/public/parts/shells/install/public/uninstall/index.mjs @@ -2,7 +2,7 @@ * 卸载 shell 的客户端逻辑。 */ import { initTranslations, geti18n } from '/scripts/i18n/index.mjs' -import { onServerEvent } from '/scripts/api/server_events.mjs' +import { onServerEvent } from '/scripts/endpoints/server_events.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToastI18n, setToastContainer, getToastContainer, setDefaultToastDuration } from '/scripts/features/toast.mjs' import { uninstallPart as uninstallPartEndpoint } from '../src/endpoints.mjs' diff --git a/src/public/parts/shells/proxy/public/index.mjs b/src/public/parts/shells/proxy/public/index.mjs index c52688274..87db3e7e6 100644 --- a/src/public/parts/shells/proxy/public/index.mjs +++ b/src/public/parts/shells/proxy/public/index.mjs @@ -1,7 +1,7 @@ /** * 代理 shell 的客户端逻辑。 */ -import { verifyApiKey, createApiKey } from '/scripts/api/base.mjs' +import { verifyApiKey, createApiKey } from '/scripts/endpoints/base.mjs' import { initTranslations, console } from '/scripts/i18n/index.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { mountTemplate, usingTemplates } from '/scripts/features/template.mjs' diff --git a/src/public/parts/shells/serviceSourceManage/public/index.mjs b/src/public/parts/shells/serviceSourceManage/public/index.mjs index 72e7171a2..527dda9da 100644 --- a/src/public/parts/shells/serviceSourceManage/public/index.mjs +++ b/src/public/parts/shells/serviceSourceManage/public/index.mjs @@ -3,8 +3,8 @@ */ import { async_eval } from 'https://esm.sh/@steve02081504/async-eval' -import { unlockAchievement, getPartList, getPartBranches, getAllDefaultParts, getAnyPreferredDefaultPart, setDefaultPart, unsetDefaultPart } from '../../scripts/api/parts.mjs' import { createJsonEditor } from '../../scripts/components/jsonEditor.mjs' +import { unlockAchievement, getPartList, getPartBranches, getAllDefaultParts, getAnyPreferredDefaultPart, setDefaultPart, unsetDefaultPart } from '../../scripts/endpoints/parts.mjs' import { renderTemplate, usingTemplates } from '../../scripts/features/template.mjs' import { showToast, showToastI18n } from '../../scripts/features/toast.mjs' import { initTranslations, i18nElement, console, geti18n, confirmI18n, promptI18n } from '../../scripts/i18n/index.mjs' diff --git a/src/public/parts/shells/subfounts/public/subfount.mjs b/src/public/parts/shells/subfounts/public/subfount.mjs index 3161348a6..d1b439418 100644 --- a/src/public/parts/shells/subfounts/public/subfount.mjs +++ b/src/public/parts/shells/subfounts/public/subfount.mjs @@ -512,9 +512,11 @@ async function connectViaP2P() { const { getLink, ensureLinkToNode } = await import('npm:@steve02081504/fount-p2p/transport/link_registry') for (let attempt = 0; attempt < 30; attempt++) { if (getLink(hostNodeHashHint)) break - void ensureLinkToNode(hostNodeHashHint).catch(() => null) + void ensureLinkToNode(hostNodeHashHint).catch(() => { }) await new Promise(resolve => setTimeout(resolve, 1000)) } + if (!getLink(hostNodeHashHint)) + console.warn('subfount: no link to host after warmup', hostNodeHashHint) } room = p2p.createGroupLinkSet({ diff --git a/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs b/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs index b7c3679b6..0a7403b50 100644 --- a/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs +++ b/src/public/parts/shells/subfounts/test/integration/helpers/subfount_client_worker.mjs @@ -69,9 +69,11 @@ if (hostNodeHashHint) { await markStage('link-warmup') for (let attempt = 0; attempt < 90; attempt++) { if (getLink(hostNodeHashHint)) break - void ensureLinkToNode(hostNodeHashHint).catch(() => null) + void ensureLinkToNode(hostNodeHashHint).catch(() => { }) await sleep(1000) } + if (!getLink(hostNodeHashHint)) + console.warn('subfount worker: no link to host after warmup', hostNodeHashHint) } await markStage('link-ready') diff --git a/src/public/parts/shells/telegrambot/public/index.mjs b/src/public/parts/shells/telegrambot/public/index.mjs index 76255f88f..b6532335b 100644 --- a/src/public/parts/shells/telegrambot/public/index.mjs +++ b/src/public/parts/shells/telegrambot/public/index.mjs @@ -3,7 +3,7 @@ */ import { initTranslations, geti18n, promptI18n, confirmI18n } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToast, showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' diff --git a/src/public/parts/shells/themeManage/public/index.mjs b/src/public/parts/shells/themeManage/public/index.mjs index 2f6a0d5cb..85f254bfa 100644 --- a/src/public/parts/shells/themeManage/public/index.mjs +++ b/src/public/parts/shells/themeManage/public/index.mjs @@ -2,7 +2,7 @@ import { confirmI18n, initTranslations, promptI18n } from '/scripts/i18n/index.m import { showToastI18n } from '/scripts/features/toast.mjs' import { makeSearchable } from '/scripts/components/search.mjs' import { renderTemplate, usingTemplates } from '/scripts/features/template.mjs' -import { unlockAchievement } from '/scripts/api/parts.mjs' +import { unlockAchievement } from '/scripts/endpoints/parts.mjs' import { applyTheme, builtin_themes, diff --git a/src/public/parts/shells/tutorial/public/index.mjs b/src/public/parts/shells/tutorial/public/index.mjs index d4a633029..803dd110e 100644 --- a/src/public/parts/shells/tutorial/public/index.mjs +++ b/src/public/parts/shells/tutorial/public/index.mjs @@ -4,7 +4,7 @@ import * as Sentry from 'https://esm.sh/@sentry/browser' /* global confetti */ -import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAnyDefaultPart } from '../../scripts/api/parts.mjs' +import { unlockAchievement, setDefaultPart, unsetDefaultPart, getAnyDefaultPart } from '../../scripts/endpoints/parts.mjs' import { initTranslations, geti18n } from '../../scripts/i18n/index.mjs' import { svgInliner } from '../../scripts/lib/svgInliner.mjs' import { applyTheme } from '../../scripts/theme/index.mjs' diff --git a/src/public/parts/shells/userSettings/public/index.mjs b/src/public/parts/shells/userSettings/public/index.mjs index 02f152923..0e71822a6 100644 --- a/src/public/parts/shells/userSettings/public/index.mjs +++ b/src/public/parts/shells/userSettings/public/index.mjs @@ -1,7 +1,7 @@ /** * 用户设置 shell 的客户端逻辑。 */ -import { getApiKeys, createApiKey, revokeApiKey, logout } from '../../scripts/api/base.mjs' +import { getApiKeys, createApiKey, revokeApiKey, logout } from '../../scripts/endpoints/base.mjs' import { showToastI18n } from '../../scripts/features/toast.mjs' import { initTranslations, geti18n, promptI18n, confirmI18n, console } from '../../scripts/i18n/index.mjs' import { applyTheme } from '../../scripts/theme/index.mjs' diff --git a/src/public/parts/shells/wechatbot/public/index.mjs b/src/public/parts/shells/wechatbot/public/index.mjs index 3669a0272..acb1969c3 100644 --- a/src/public/parts/shells/wechatbot/public/index.mjs +++ b/src/public/parts/shells/wechatbot/public/index.mjs @@ -3,7 +3,7 @@ import qrcode from 'https://esm.sh/qrcode-generator' import { initTranslations, geti18n, promptI18n, confirmI18n } from '/scripts/i18n/index.mjs' import { createJsonEditor } from '/scripts/components/jsonEditor.mjs' -import { getPartList } from '/scripts/api/parts.mjs' +import { getPartList } from '/scripts/endpoints/parts.mjs' import { applyTheme } from '/scripts/theme/index.mjs' import { showToast, showToastI18n } from '/scripts/features/toast.mjs' import { createSearchableDropdown } from '/scripts/components/search.mjs' diff --git a/src/scripts/checks/AGENTS.md b/src/scripts/checks/AGENTS.md index 338c1c05f..c2feb07d8 100644 --- a/src/scripts/checks/AGENTS.md +++ b/src/scripts/checks/AGENTS.md @@ -36,7 +36,7 @@ Playwright and `[i18n:missing]` miss these cases: Rules: - Element binding (`data-i18n`, `setElementI18n`): the key must exist; objects need ≥1 applicator (`placeholder`, `title`, `label`, `value`, `alt`, `aria-label`, `textContent`, `innerHTML`, `dataset`). Prefer `.main` for “string plus sibling messages” clusters. -- String binding (`showToastI18n`, `confirmI18n`, `handleUIError`, …) and `path/fount.{ps1,sh}` `Get-I18n` / `get_i18n`: must resolve to a string (or tip array). Raw `geti18n` may return objects (e.g. `util.zxcvbn`); only missing keys fail. +- String binding (`showToastI18n`, `confirmI18n`, `handleError`, …) and `path/fount.{ps1,sh}` `Get-I18n` / `get_i18n`: must resolve to a string (or tip array). Raw `geti18n` may return objects (e.g. `util.zxcvbn`); only missing keys fail. `handleError('key')` is factory form — first arg is the i18n key. - Static keys only (`a.b.c`); skip template interpolations. Rewrite suffixes include `.sh` (shared `walk.mjs` / `reshape_i18n_keys.py`). ## Agent docs language diff --git a/src/scripts/checks/i18n_refs.mjs b/src/scripts/checks/i18n_refs.mjs index 278015b19..70edea05a 100644 --- a/src/scripts/checks/i18n_refs.mjs +++ b/src/scripts/checks/i18n_refs.mjs @@ -168,7 +168,7 @@ export function extractI18nRefsFromSource(text) { promptText: 'string', promptTextArea: 'string', confirmAction: 'string', - handleUIError: 'string', + handleError: 'string', } for (const [name, binding] of Object.entries(apis)) { const re = new RegExp(`\\b${name}\\s*\\(\\s*(["'\`])([^"'\`]+)\\1`, 'g') diff --git a/src/scripts/errorHandlers.mjs b/src/scripts/errorHandlers.mjs new file mode 100644 index 000000000..caaf2af67 --- /dev/null +++ b/src/scripts/errorHandlers.mjs @@ -0,0 +1,31 @@ +/** + * 后端错误处理:只报 fount 自身故障(console + Sentry)。 + * 用户输入/操作过错应返回 4xx / 业务错误给调用方,不要走这里。 + */ +import * as Sentry from 'npm:@sentry/deno' + +import { sentry_enabled } from './sentry_state.mjs' + +/** + * @param {unknown} error 异常或字符串 + * @returns {Error} 规范化 Error + */ +function toError(error) { + if (error instanceof Error) return error + if (Object(error?.message) instanceof String) return new Error(error.message) + return new Error(String(error)) +} + +/** + * fount 故障:console.error;Sentry 启用时再上报。 + * 可直接 `.catch(handleError)`。 + * @param {unknown} error 异常 + * @param {...unknown} extras 额外 console.error 参数 + * @returns {Error} 规范化 Error + */ +export function handleError(error, ...extras) { + const err = toError(error) + console.error(err, ...extras) + if (sentry_enabled) Sentry.captureException(err) + return err +} diff --git a/src/scripts/sentry_state.mjs b/src/scripts/sentry_state.mjs index 89792c737..7561edc70 100644 --- a/src/scripts/sentry_state.mjs +++ b/src/scripts/sentry_state.mjs @@ -1,5 +1,6 @@ /** * Sentry 启用状态与初始化的独立模块。 + * 错误汇报见 `fount/scripts/errorHandlers.mjs`。 */ import * as Sentry from 'npm:@sentry/deno' diff --git a/src/scripts/test/hub/apis/health.mjs b/src/scripts/test/hub/apis/health.mjs index fcb08fc5b..950f99359 100644 --- a/src/scripts/test/hub/apis/health.mjs +++ b/src/scripts/test/hub/apis/health.mjs @@ -8,7 +8,7 @@ import { Router } from 'npm:express' */ export function createHealthRouter() { const router = Router() - router.get('/health', (_req, res) => { + router.get('/health', (req, res) => { res.json({ ok: true }) }) return router diff --git a/src/scripts/test/playwright/pages_server.mjs b/src/scripts/test/playwright/pages_server.mjs index 31f7c24e6..491f3081d 100644 --- a/src/scripts/test/playwright/pages_server.mjs +++ b/src/scripts/test/playwright/pages_server.mjs @@ -73,7 +73,7 @@ export function createPagesApp(projectRoot = REPO_ROOT) { // 测试框架浏览器侧脚本(pages/scripts 未覆盖的路径) app.use('/fount/scripts/test', express.static(path.join(projectRoot, 'src', 'scripts', 'test'))) - app.get('/fount/data/comments.json', async (_req, res) => { + app.get('/fount/data/comments.json', async (req, res) => { try { const response = await fetch(GITHUB_PAGES_COMMENTS_URL, { signal: AbortSignal.timeout(8000), @@ -104,7 +104,7 @@ export function createPagesApp(projectRoot = REPO_ROOT) { }) for (const filePath of hooked_version_files) - app.get(`/fount/${filePath}`, async (_req, res) => { + app.get(`/fount/${filePath}`, async (req, res) => { const body = await getHookedVersionFileContent(filePath) res.type('application/javascript').send(body) }) diff --git a/src/server/test/manifest.json b/src/server/test/manifest.json index 740e10910..bd0ddca09 100644 --- a/src/server/test/manifest.json +++ b/src/server/test/manifest.json @@ -18,7 +18,7 @@ "src/server/registries.mjs", "src/server/no_cors.mjs", "src/scripts/net_listen.mjs", - "src/public/pages/scripts/api/registries.mjs" + "src/public/pages/scripts/endpoints/registries.mjs" ] }, { diff --git a/src/server/web_server/endpoints.mjs b/src/server/web_server/endpoints.mjs index e2a3ca33e..5562147a8 100644 --- a/src/server/web_server/endpoints.mjs +++ b/src/server/web_server/endpoints.mjs @@ -143,7 +143,7 @@ export function registerEndpoints(router) { /** 已认证用户通用 no-CORS 中转:双向流式;见 src/server/no_cors.mjs */ router.all('/api/no-cors', authenticate, handleNoCors) - router.get('/api/notify/vapid-public-key', cors(), async (_req, res) => { + router.get('/api/notify/vapid-public-key', cors(), async (req, res) => { res.status(200).json({ publicKey: await getVapidPublicKey() }) }) router.post('/api/notify/push-subscribe', authenticate, async (req, res) => { diff --git a/src/server/web_server/p2p_endpoints.mjs b/src/server/web_server/p2p_endpoints.mjs index 827371e3d..d1774f403 100644 --- a/src/server/web_server/p2p_endpoints.mjs +++ b/src/server/web_server/p2p_endpoints.mjs @@ -44,17 +44,14 @@ export function registerP2pEndpoints(router) { }) router.get('/api/p2p/network', authenticate, async (req, res) => { - void getUserByReq(req) res.status(200).json(loadNetwork()) }) router.get('/api/p2p/denylist', authenticate, async (req, res) => { - void getUserByReq(req) res.status(200).json(loadDenylist()) }) router.post('/api/p2p/denylist', authenticate, async (req, res) => { - void getUserByReq(req) const body = req.body || {} const scope = String(body.scope || '').trim().toLowerCase() const value = String(body.value || '').trim() @@ -69,7 +66,6 @@ export function registerP2pEndpoints(router) { }) router.get('/api/p2p/mailbox/summary', authenticate, async (req, res) => { - void getUserByReq(req) const { countMailboxPending } = await import('npm:@steve02081504/fount-p2p/mailbox/store') const pendingCount = await countMailboxPending() res.status(200).json({ pendingCount }) From 793347c51549c78a981d3b4ee8de57d83472b8cc Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 16:30:05 +0800 Subject: [PATCH 02/13] Chat shell endpoints module and hub error migration --- src/public/parts/shells/chat/public/AGENTS.md | 3 +- .../shells/chat/public/emoji-packs/index.mjs | 25 +- .../parts/shells/chat/public/hub/AGENTS.md | 4 +- .../parts/shells/chat/public/hub/banners.mjs | 12 +- .../parts/shells/chat/public/hub/call.mjs | 8 +- .../chat/public/hub/channelContextMenu.mjs | 12 +- .../parts/shells/chat/public/hub/charCard.mjs | 5 +- .../shells/chat/public/hub/chatConfig.mjs | 32 +- .../shells/chat/public/hub/core/bindings.mjs | 16 +- .../shells/chat/public/hub/discoveryPanel.mjs | 8 +- .../shells/chat/public/hub/entityProfile.mjs | 4 +- .../public/hub/federation/federationModal.mjs | 10 +- .../public/hub/federation/forkActions.mjs | 18 +- .../parts/shells/chat/public/hub/files.mjs | 25 +- .../shells/chat/public/hub/friendChat.mjs | 71 +-- .../shells/chat/public/hub/friendsList.mjs | 30 +- .../chat/public/hub/gestures/chatGestures.mjs | 4 +- .../chat/public/hub/groupContextMenu.mjs | 17 +- .../parts/shells/chat/public/hub/hashNav.mjs | 4 +- .../shells/chat/public/hub/hubStatus.mjs | 23 +- .../shells/chat/public/hub/inboxClient.mjs | 28 +- .../shells/chat/public/hub/inboxView.mjs | 8 +- .../parts/shells/chat/public/hub/index.mjs | 4 +- .../parts/shells/chat/public/hub/init.mjs | 2 +- .../parts/shells/chat/public/hub/initCore.mjs | 24 +- .../chat/public/hub/memberContextMenu.mjs | 27 +- .../chat/public/hub/memberReadMarkers.mjs | 8 +- .../chat/public/hub/mentionAutocomplete.mjs | 20 +- .../public/hub/messages/actions/bookmark.mjs | 2 +- .../public/hub/messages/actions/branch.mjs | 2 +- .../public/hub/messages/actions/delete.mjs | 2 +- .../chat/public/hub/messages/actions/edit.mjs | 2 +- .../public/hub/messages/actions/feedback.mjs | 2 +- .../public/hub/messages/actions/forward.mjs | 4 +- .../chat/public/hub/messages/actions/pin.mjs | 4 +- .../hub/messages/channelMessageStore.mjs | 2 +- .../public/hub/messages/channelTypeRouter.mjs | 2 +- .../chat/public/hub/messages/exportHtml.mjs | 19 +- .../public/hub/messages/messageRefresh.mjs | 14 +- .../chat/public/hub/messages/messageSend.mjs | 2 +- .../hub/messages/messageVirtualList.mjs | 2 +- .../hub/messages/render/translation.mjs | 9 +- .../parts/shells/chat/public/hub/misc.mjs | 17 +- .../shells/chat/public/hub/personalFilter.mjs | 12 +- .../shells/chat/public/hub/pinsBookmarks.mjs | 8 +- .../parts/shells/chat/public/hub/presence.mjs | 8 +- .../shells/chat/public/hub/privateGroup.mjs | 13 +- .../shells/chat/public/hub/profileEdit.mjs | 23 +- .../shells/chat/public/hub/runHubAction.mjs | 4 +- .../parts/shells/chat/public/hub/search.mjs | 6 +- .../shells/chat/public/hub/sendQueue.mjs | 2 +- .../shells/chat/public/hub/serverBar.mjs | 28 +- .../chat/public/hub/sidebar/createChannel.mjs | 8 +- .../public/hub/sidebar/federationRoom.mjs | 15 +- .../public/hub/sidebar/groupMembership.mjs | 8 +- .../shells/chat/public/hub/sidebar/index.mjs | 6 +- .../chat/public/hub/sidebar/selectChannel.mjs | 27 +- .../public/hub/stream/handlers/dagEvent.mjs | 2 +- .../chat/public/hub/stream/volatileSlots.mjs | 2 +- .../shells/chat/public/hub/threadDrawer.mjs | 4 +- .../public/hub/translationPrefsDialog.mjs | 12 +- .../parts/shells/chat/public/hub/unread.mjs | 19 +- .../chat/public/hub/wiring/bootstrap.mjs | 2 +- .../chat/public/hub/wiring/fileEvents.mjs | 10 +- .../public/hub/wiring/messageBubbleEvents.mjs | 27 +- .../chat/public/hub/wiring/voteEvents.mjs | 6 +- .../shells/chat/public/profile/index.mjs | 44 +- .../public/profile/ownerSettingsPanel.mjs | 36 +- .../chat/public/profile/src/endpoints.mjs | 92 ---- .../shells/chat/public/providers/emoji.mjs | 67 +-- .../shells/chat/public/shared/aliases.mjs | 29 +- .../parts/shells/chat/public/shared/care.mjs | 28 +- .../chat/public/shared/entityProfileCard.mjs | 13 +- .../public/shared/entityProfileHoverCard.mjs | 6 +- .../chat/public/shared/entityProfilePopup.mjs | 6 +- .../shells/chat/public/shared/evfsMedia.mjs | 54 +- .../public/shared/notificationPreferences.mjs | 23 +- .../shells/chat/public/src/achievements.mjs | 4 +- .../chat/public/src/api/channelArchive.mjs | 58 --- .../public/src/api/federationSettings.mjs | 40 -- .../chat/public/src/api/groupClient.mjs | 57 -- .../shells/chat/public/src/auditLogPanel.mjs | 2 +- .../chat/public/src/composerAttachments.mjs | 4 +- .../chat/public/src/deepLinkConsume.mjs | 10 +- .../parts/shells/chat/public/src/dmLink.mjs | 2 +- .../public/src/endpoints/channelArchive.mjs | 76 +++ .../public/src/endpoints/channelPerms.mjs | 33 ++ .../chat/public/src/endpoints/discovery.mjs | 25 + .../chat/public/src/endpoints/emoji.mjs | 80 +++ .../chat/public/src/endpoints/emojiPacks.mjs | 63 +++ .../chat/public/src/endpoints/entities.mjs | 151 ++++++ .../src/endpoints/federationSettings.mjs | 5 + .../chat/public/src/endpoints/folders.mjs | 20 + .../chat/public/src/endpoints/groupBan.mjs | 29 ++ .../public/src/endpoints/groupBookmarks.mjs | 60 +++ .../public/src/endpoints/groupChannel.mjs | 491 ++++++++++++++++++ .../chat/public/src/endpoints/groupClient.mjs | 50 ++ .../chat/public/src/endpoints/groupCore.mjs | 377 ++++++++++++++ .../chat/public/src/endpoints/groupDm.mjs | 26 + .../public/src/endpoints/groupFederation.mjs | 95 ++++ .../chat/public/src/endpoints/groupFiles.mjs | 66 +++ .../src/endpoints/groupFriendBinding.mjs | 48 ++ .../public/src/endpoints/groupGovernance.mjs | 142 +++++ .../chat/public/src/endpoints/inbox.mjs | 26 + .../chat/public/src/endpoints/members.mjs | 16 + .../chat/public/src/endpoints/mentions.mjs | 18 + .../shells/chat/public/src/endpoints/p2p.mjs | 62 +++ .../chat/public/src/endpoints/prefs.mjs | 94 ++++ .../chat/public/src/endpoints/roles.mjs | 41 ++ .../chat/public/src/endpoints/social.mjs | 45 ++ .../chat/public/src/endpoints/viewer.mjs | 14 + .../chat/public/src/entityProfileApi.mjs | 95 ---- .../parts/shells/chat/public/src/files.mjs | 5 +- .../shells/chat/public/src/groupFileBlob.mjs | 15 +- .../public/src/groupSettings/archiveTab.mjs | 19 +- .../src/groupSettings/channelPermsTab.mjs | 48 +- .../public/src/groupSettings/emojisTab.mjs | 94 ++-- .../public/src/groupSettings/generalTab.mjs | 119 ++--- .../public/src/groupSettings/inviteTab.mjs | 2 +- .../chat/public/src/groupSettings/load.mjs | 2 +- .../public/src/groupSettings/membersTab.mjs | 11 +- .../src/groupSettings/permissionsTab.mjs | 39 +- .../chat/public/src/groupSettings/shared.mjs | 15 - .../public/src/groupViewerPermissions.mjs | 8 +- .../public/src/lib/personalFilterClient.mjs | 10 +- .../public/src/saveStickerFromMessage.mjs | 13 +- .../chat/public/src/trustAuthorDialog.mjs | 6 +- .../shells/chat/public/src/trustedAuthors.mjs | 13 +- .../shells/chat/public/src/ui/errors.mjs | 44 -- .../chat/public/src/ui/groupFileUpload.mjs | 101 +--- .../shells/chat/public/src/ui/groupModals.mjs | 2 +- .../chat/public/src/ui/reactionHandlers.mjs | 41 +- .../chat/src/chat/dag/chatLogMirror.mjs | 4 +- .../shells/chat/src/chat/dag/hydration.mjs | 3 +- .../src/chat/federation/bootstrapRelay.mjs | 9 +- .../shells/chat/src/chat/federation/index.mjs | 10 +- .../shells/chat/src/chat/files/groupFiles.mjs | 9 +- .../shells/chat/src/entity/endpoints.mjs | 3 +- .../chat/src/group/routes/groupEmojis.mjs | 7 +- .../parts/shells/chat/test/manifest.json | 6 +- .../test/pure/viewer_log_dispatch.test.mjs | 2 +- 141 files changed, 2788 insertions(+), 1484 deletions(-) delete mode 100644 src/public/parts/shells/chat/public/profile/src/endpoints.mjs delete mode 100644 src/public/parts/shells/chat/public/src/api/channelArchive.mjs delete mode 100644 src/public/parts/shells/chat/public/src/api/federationSettings.mjs delete mode 100644 src/public/parts/shells/chat/public/src/api/groupClient.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/channelArchive.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/channelPerms.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/discovery.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/emoji.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/emojiPacks.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/entities.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/folders.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupChannel.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupDm.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupFederation.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupFiles.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupFriendBinding.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/inbox.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/members.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/mentions.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/p2p.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/prefs.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/roles.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/social.mjs create mode 100644 src/public/parts/shells/chat/public/src/endpoints/viewer.mjs delete mode 100644 src/public/parts/shells/chat/public/src/entityProfileApi.mjs delete mode 100644 src/public/parts/shells/chat/public/src/ui/errors.mjs diff --git a/src/public/parts/shells/chat/public/AGENTS.md b/src/public/parts/shells/chat/public/AGENTS.md index 31096d106..8e88c4717 100644 --- a/src/public/parts/shells/chat/public/AGENTS.md +++ b/src/public/parts/shells/chat/public/AGENTS.md @@ -37,7 +37,8 @@ Root: `{userDict}/shells/chat/entities/{entityHash}/` — bookmarks, folders, al ## HTTP -Thin wrappers: `endpoints/shared.mjs` → `chatClientFromReq` → operator client. Shapes: `public/llms.txt`. +- **Backend**: thin wrappers `src/endpoints/shared.mjs` → `chatClientFromReq` → operator client. Shapes: `public/llms.txt`. +- **Frontend**: named functions only in `public/src/endpoints/*.mjs`. Private `chatFetch` / `groupFetch` stay inside `endpoints/` — UI must not import `groupClient`. UI / shared / providers must not `fetch` shell REST — only `endpoints/**` and Litterbox in `share.mjs` may call raw `fetch`. Global whoami / getdetails / EVFS → `/scripts/endpoints/`. HTML templates → `renderTemplate` / `mountTemplate` / `withTemplates`. `GET …/groups/:id/state` → `{ meta, viewer, federation }`. Frontend flatten must **not** let `viewer.roles` (held role IDs) overwrite `meta.roles` (role definition map) — write held roles into `myRoles`. diff --git a/src/public/parts/shells/chat/public/emoji-packs/index.mjs b/src/public/parts/shells/chat/public/emoji-packs/index.mjs index d9d1537be..527b01db6 100644 --- a/src/public/parts/shells/chat/public/emoji-packs/index.mjs +++ b/src/public/parts/shells/chat/public/emoji-packs/index.mjs @@ -7,6 +7,8 @@ import { discoverEmojiPackOffers } from '/scripts/features/emoji/discover.mjs' import { showEmojiPackPreview } from '/scripts/components/emojiPackPreview.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' +import { joinGroup } from '../src/endpoints/groupCore.mjs' +import { postRelationshipFollow } from '../src/endpoints/social.mjs' applyTheme() await initTranslations() @@ -15,9 +17,6 @@ const statusEl = document.getElementById('emoji-packs-status') const gridEl = document.getElementById('emoji-packs-grid') const emptyEl = document.getElementById('emoji-packs-empty') -const CHAT_API = '/api/parts/shells:chat' -const SOCIAL_API = '/api/parts/shells:social' - /** * @param {HTMLElement} actions 操作区 * @param {{ i18nKey: string, fallback: string, className: string, onClick: () => void | Promise<void> }} options 按钮选项 @@ -91,21 +90,15 @@ function renderOfferCard(offer) { if (sourceKind === 'group' && sourceId) { /** @returns {Promise<void>} 加入来源群 */ - const joinGroup = async () => { - const r = await fetch(`${CHAT_API}/groups/${encodeURIComponent(sourceId)}/join`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }) - if (!r.ok) throw new Error(await r.text() || r.statusText) + const joinSourceGroup = async () => { + await joinGroup(sourceId) window.location.href = `/parts/shells:chat/hub/#group:${encodeURIComponent(sourceId)}:default` } addActionButton(actions, { i18nKey: 'chat.emojiPacks.joinGroup', fallback: 'Join', className: 'btn btn-primary btn-sm', - onClick: joinGroup, + onClick: joinSourceGroup, }) } else if (sourceKind === 'entity' && sourceId) { @@ -113,13 +106,7 @@ function renderOfferCard(offer) { let followBtn /** @returns {Promise<void>} 关注作者 */ const followAuthor = async () => { - const r = await fetch(`${SOCIAL_API}/relationships/follow`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ entityHash: sourceId, follow: true }), - }) - if (!r.ok) throw new Error(await r.text() || r.statusText) + await postRelationshipFollow(sourceId, true) showToastI18n('success', 'chat.emoji.followSuccess') followBtn.disabled = true followBtn.dataset.i18n = 'chat.emoji.alreadyFollowing' diff --git a/src/public/parts/shells/chat/public/hub/AGENTS.md b/src/public/parts/shells/chat/public/hub/AGENTS.md index f4a426345..abfa05f51 100644 --- a/src/public/parts/shells/chat/public/hub/AGENTS.md +++ b/src/public/parts/shells/chat/public/hub/AGENTS.md @@ -27,8 +27,10 @@ Deeper UI (profile card, module layout, unread/inbox/aliases, cabinet bind perms - CSS: page-local, no `hub-` prefix. Ready-gate: `HUB_GATE` / `fount:hub-*`. Layout: `body[data-layout-pane]` / `body[data-surface]`. Mobile (`≤768px`): `body[data-layout-pane=nav|main]` via `hubPane.mjs`. - **`fount.user.send`**: Hub bootstrap registers `globalThis.fount.user.send(string | chatLogEntry)` → current channel. Normalize in `shared/fountUserSend.mjs` (Deno-pure — no `/scripts/*` imports there). -- Errors: `handleUIError` (toast + `console.error` + Sentry). Background: `toError` + console + Sentry, no toast. +- Errors: `handleError('chat.hub.…')` → `.catch` closure (toast + console + Sentry) for fount faults. User mistakes: `showToastI18n`. Impl: `/scripts/features/errorHandlers.mjs`. +- Floating promises: call directly; no need for `void`. Use `return void sideEffect()` only when the side effect's return value is not `undefined`. - Prefer `renderTemplate` / `mountTemplate`. Modals: `openDialogFromTemplate` (`modal-box` only). Cross-shell shared modules: `withTemplates`, never bare `usingTemplates`. Prefer DaisyUI; context menus via `/scripts/components/positionContextMenu.mjs`; prompts via `/scripts/features/promptDialog.mjs`. +- **HTTP**: named functions in `../src/endpoints/*.mjs` only — no UI `fetch` of shell REST (`share.mjs` Litterbox is the sole non-endpoint exception). Global whoami/getdetails/EVFS → `/scripts/endpoints/`. - State: `core/state.mjs` — import exported bindings; heavy modules use call-site `await import()`. - No hardcoded user-visible strings; `data-i18n` / `setElementI18n` + `zh-CN.json`. - **@-mention autocomplete**: on `<textarea>` use only `aria-controls` / `aria-activedescendant`; do not add `role="combobox"` / `aria-expanded`. diff --git a/src/public/parts/shells/chat/public/hub/banners.mjs b/src/public/parts/shells/chat/public/hub/banners.mjs index 4685223a0..83ea7c7a5 100644 --- a/src/public/parts/shells/chat/public/hub/banners.mjs +++ b/src/public/parts/shells/chat/public/hub/banners.mjs @@ -8,6 +8,8 @@ import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' import { renderTemplateAsHtmlString } from '../../../../scripts/features/template.mjs' +import { getDagTips } from '../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { refreshBoundBanners } from './core/bindings.mjs' @@ -55,11 +57,7 @@ export async function refreshDagForkBanner() { store.federation.dagTips = [] return } - const response = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(store.context.currentGroupId)}/dag/tips`, - { credentials: 'include' }, - ) - const data = await response.json() + const data = await getDagTips(store.context.currentGroupId) const tips = Array.isArray(data.tips) ? data.tips : [] store.federation.dagTips = tips const governanceFork = !!data.governanceFork || !!store.context.currentState?.governanceFork @@ -168,6 +166,6 @@ export function updateStatusBanners() { refreshGshBufferBanner() refreshQuarantineBanner() refreshLocalViewBanner() - void refreshChannelPinsBar() - void refreshDagForkBanner().then(() => refreshLocalViewBanner()) + refreshChannelPinsBar().catch(handleError('chat.hub.operationFailed')) + refreshDagForkBanner().then(refreshLocalViewBanner).catch(handleError('chat.hub.operationFailed')) } diff --git a/src/public/parts/shells/chat/public/hub/call.mjs b/src/public/parts/shells/chat/public/hub/call.mjs index 32b98ab55..7fdb50dd4 100644 --- a/src/public/parts/shells/chat/public/hub/call.mjs +++ b/src/public/parts/shells/chat/public/hub/call.mjs @@ -8,6 +8,7 @@ import { geti18n, setElementI18n } from '../../../../scripts/i18n/index.mjs' import { buildChatCallWsUrl } from '../shared/avRelayClient.mjs' import { displayProfileAvatar } from '../shared/hashAvatar.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' +import { getCallStatus } from '../src/endpoints/groupChannel.mjs' import { iconifyImg, iconifyUrl } from '../src/lib/emojiSvg.mjs' import { joinCodecsAvRoom, leaveCodecsAvRoom } from './codecsAv.mjs' @@ -517,12 +518,7 @@ export async function refreshCallStatusBadge() { return } try { - const res = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/call-status`, - { credentials: 'include' }, - ) - if (!res.ok) return - const data = await res.json() + const data = await getCallStatus(groupId, channelId) updateCallBadge(data.active ? data.peerCount || 0 : 0) } catch { /* ignore */ } diff --git a/src/public/parts/shells/chat/public/hub/channelContextMenu.mjs b/src/public/parts/shells/chat/public/hub/channelContextMenu.mjs index f6a0f36cc..0a840c1f2 100644 --- a/src/public/parts/shells/chat/public/hub/channelContextMenu.mjs +++ b/src/public/parts/shells/chat/public/hub/channelContextMenu.mjs @@ -3,7 +3,7 @@ * 【职责】侧栏频道项右键菜单:重命名、删除、类型切换与打开线程等频道级操作入口。 * 【原理】`showChannelContextMenu` 在频道行旁弹出定位菜单并绑定一次性点击处理;删除/切换频道后由 `selectChannel`/`loadMessages` 重建主栏消息视图。 * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 - * 【关联】打开频道时可能触发 `updateHash`(由 `sidebar.selectChannel` 完成);../../../../scripts/i18n、../../../../scripts/template、../../../../scripts/toast、../src/api/groupCore、groupChannel、core/state、sidebar。 + * 【关联】打开频道时可能触发 `updateHash`(由 `sidebar.selectChannel` 完成);../../../../scripts/i18n、../../../../scripts/template、../../../../scripts/toast、../src/endpoints/groupCore、groupChannel、core/state、sidebar。 */ import { renderTemplate } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' @@ -11,14 +11,14 @@ import { confirmI18n } from '../../../../scripts/i18n/index.mjs' import { downloadChannelArchiveJson, exportChannelArchiveJson, -} from '../src/api/channelArchive.mjs' +} from '../src/endpoints/channelArchive.mjs' import { deleteChannel, setDefaultChannel, updateChannel, -} from '../src/api/groupChannel.mjs' -import { getGroupState } from '../src/api/groupCore.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +} from '../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { bindDismissOnDocumentInteraction } from '/scripts/components/contextMenuDismiss.mjs' import { positionContextMenu } from '/scripts/components/positionContextMenu.mjs' @@ -139,7 +139,7 @@ export async function showChannelContextMenu(event, channelId) { showToastI18n('success', 'chat.hub.channel.context.exportOk') } catch (error) { - handleUIError(error, 'chat.hub.channel.context.exportFailed') + handleError('chat.hub.channel.context.exportFailed')(error) } }) diff --git a/src/public/parts/shells/chat/public/hub/charCard.mjs b/src/public/parts/shells/chat/public/hub/charCard.mjs index ddef403eb..068afc8ee 100644 --- a/src/public/parts/shells/chat/public/hub/charCard.mjs +++ b/src/public/parts/shells/chat/public/hub/charCard.mjs @@ -10,6 +10,7 @@ import { renderTemplateAsHtmlString, usingTemplates, } from '../../../../scripts/features/template.mjs' +import { getPartDetails } from '/scripts/endpoints/parts.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { createEntityProfileCardElement } from '../shared/entityProfileCard.mjs' import { displayProfileAvatar } from '../shared/hashAvatar.mjs' @@ -36,9 +37,7 @@ let charInfoCardRenderGeneration = 0 */ export async function getCharDetails(name) { try { - const resp = await fetch(`/api/getdetails/chars/${encodeURIComponent(name)}`, { credentials: 'include' }) - if (!resp.ok) return null - return await resp.json() + return await getPartDetails(`chars/${name}`) } catch { return null diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index 1626c2c61..7e90ce458 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -3,14 +3,22 @@ * 【职责】群组/频道聊天配置面板:挂载到设置浮层或内嵌区,编辑频道与生成相关选项。 * 【原理】`mountChatConfigPanel` 将配置表单模板注入指定容器(常由 `chat.openGroupSettingsModal` 调用);配置变更可能触发重新生成或刷新消息;本模块只负责表单 UI 与保存回调。 * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 - * 【关联】../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/api/groupCore、groupClient、groupChannel、core/domUtils、core/overlayModal、core/state。 + * 【关联】../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/endpoints/groupCore、groupChannel、core/domUtils、core/overlayModal、core/state。 */ -import { getPartList } from '../../../../scripts/api/parts.mjs' +import { getPartList } from '../../../../scripts/endpoints/parts.mjs' import { mountTemplate, renderTemplateAsHtmlString } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' -import { triggerChannelReply } from '../src/api/groupChannel.mjs' -import { groupRequest } from '../src/api/groupClient.mjs' -import { getGroupChatConfig } from '../src/api/groupCore.mjs' +import { triggerChannelReply } from '../src/endpoints/groupChannel.mjs' +import { + addGroupPlugin, + getGroupChatConfig, + listGroupPlugins, + removeGroupChar, + removeGroupPlugin, + setGroupCharFrequency, + setGroupPersona, + setGroupWorld, +} from '../src/endpoints/groupCore.mjs' import { showOverlayNotice } from './core/overlayModal.mjs' import { store } from './core/state.mjs' @@ -57,7 +65,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio getPartList('worlds').catch(() => []), getPartList('personas').catch(() => []), getPartList('plugins').catch(() => []), - groupRequest(groupId, 'plugins', 'GET').catch(() => []), + listGroupPlugins(groupId).catch(() => []), ]) const charlist = Array.isArray(initial?.charlist) ? initial.charlist : [] @@ -80,7 +88,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio document.getElementById('character-chat-persona')?.addEventListener('change', async (changeEvent) => { const v = changeEvent.target.value || null try { - await groupRequest(groupId, 'persona', 'PUT', { personaname: v }) + await setGroupPersona(groupId, v) const { invalidateUserProfileCache } = await import('./presence.mjs') const { refreshViewerHubPresentation } = await import('./init.mjs') const { renderMemberList } = await import('./sidebar/index.mjs') @@ -100,7 +108,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio document.getElementById('character-chat-world')?.addEventListener('change', async (changeEvent) => { const v = changeEvent.target.value || null try { - await groupRequest(groupId, 'world', 'PUT', { worldname: v, channelId }) + await setGroupWorld(groupId, v, channelId) showOverlayNotice('success', '', 'chat.hub.config.saved') } catch (err) { @@ -113,7 +121,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio const pluginname = sel?.value if (!pluginname) return try { - await groupRequest(groupId, 'plugin', 'POST', { pluginname }) + await addGroupPlugin(groupId, pluginname) await mountChatConfigPanel(groupId, channelId, options) showOverlayNotice('success', '', 'chat.hub.config.saved') } @@ -127,7 +135,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio const pluginname = removePluginButton.dataset.plugin if (!pluginname) return try { - await groupRequest(groupId, `plugin/${encodeURIComponent(pluginname)}`, 'DELETE') + await removeGroupPlugin(groupId, pluginname) await mountChatConfigPanel(groupId, channelId, options) showOverlayNotice('success', '', 'chat.hub.config.saved') } @@ -145,7 +153,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio if (!charname) return const frequency = Number(inputEvent.target.value) / 100 try { - await groupRequest(groupId, `char/${encodeURIComponent(charname)}/frequency`, 'PUT', { frequency }) + await setGroupCharFrequency(groupId, charname, frequency) } catch (err) { showToastI18n('error', 'chat.hub.config.saveFailed', { error: err.message }) @@ -172,7 +180,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio const charname = removeCharButton.dataset.char if (!charname) return try { - await groupRequest(groupId, `char/${encodeURIComponent(charname)}`, 'DELETE') + await removeGroupChar(groupId, charname) await mountChatConfigPanel(groupId, channelId, options) showOverlayNotice('success', '', 'chat.hub.config.saved') } diff --git a/src/public/parts/shells/chat/public/hub/core/bindings.mjs b/src/public/parts/shells/chat/public/hub/core/bindings.mjs index 273579358..b1dcfd3b6 100644 --- a/src/public/parts/shells/chat/public/hub/core/bindings.mjs +++ b/src/public/parts/shells/chat/public/hub/core/bindings.mjs @@ -1,7 +1,9 @@ /** * Hub 横幅与固定 DOM 节点的声明式绑定(订阅 store / watchState)。 */ -import { getGroupState } from '../../src/api/groupCore.mjs' +import { syncArchive } from '../../src/endpoints/channelArchive.mjs' +import { getGroupState } from '../../src/endpoints/groupCore.mjs' +import { dismissShunBanner } from '../../src/endpoints/groupFederation.mjs' import { store, setState, watchState } from './state.mjs' @@ -218,10 +220,7 @@ export function wireHubBannerBindings() { document.getElementById('archive-sync-button')?.addEventListener('click', () => { const groupId = store.context.currentGroupId if (!groupId) return - void fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/archive/sync`, { - method: 'POST', - credentials: 'include', - }).then(async () => { + void syncArchive(groupId).then(async () => { setState('context.currentState', await getGroupState(groupId)) refreshBoundBanners() }).catch(console.error) @@ -229,12 +228,7 @@ export function wireHubBannerBindings() { document.getElementById('shun-keep-history-button')?.addEventListener('click', () => { const groupId = store.context.currentGroupId if (!groupId) return - void fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/federation/shun-dismiss`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: '{}', - }).then(async () => { + void dismissShunBanner(groupId).then(async () => { setState('context.currentState', await getGroupState(groupId)) refreshBoundBanners() }).catch(console.error) diff --git a/src/public/parts/shells/chat/public/hub/discoveryPanel.mjs b/src/public/parts/shells/chat/public/hub/discoveryPanel.mjs index 6899f5100..da35bcdf7 100644 --- a/src/public/parts/shells/chat/public/hub/discoveryPanel.mjs +++ b/src/public/parts/shells/chat/public/hub/discoveryPanel.mjs @@ -1,7 +1,7 @@ /** Hub 群发现主内容页。 */ import { mountTemplate, renderTemplate } from '../../../../scripts/features/template.mjs' -import { fetchDiscoveryIndex, refreshDiscoveryGossip } from '../src/api/discoveryApi.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { fetchDiscoveryIndex, refreshDiscoveryGossip } from '../src/endpoints/discovery.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { setPinsBookmarksWrapVisible, updateStatusBanners } from './banners.mjs' @@ -59,7 +59,7 @@ async function loadDiscoveryEntries(root) { } catch (error) { if (generation !== loadGeneration || !root.isConnected) return - handleUIError(error, 'chat.hub.discovery.loadFailed') + handleError('chat.hub.discovery.loadFailed')(error) await mountTemplate(grid, 'hub/empty/error', { i18nKey: 'chat.hub.discovery.loadFailed', errorMessage: error.message, @@ -104,7 +104,7 @@ export async function activateDiscoveryView() { const target = event.target instanceof Element ? event.target.closest('[data-group-id]') : null const groupId = target?.getAttribute('data-group-id') if (!groupId) return - void selectGroup(groupId).catch(error => handleUIError(error, 'chat.hub.load.groupFailed')) + void selectGroup(groupId).catch(handleError('chat.hub.load.groupFailed')) }) const { disableComposer, refreshHubHeaderButtons } = await import('./messages/composerController.mjs') diff --git a/src/public/parts/shells/chat/public/hub/entityProfile.mjs b/src/public/parts/shells/chat/public/hub/entityProfile.mjs index 70b4f64bf..f88ca4a09 100644 --- a/src/public/parts/shells/chat/public/hub/entityProfile.mjs +++ b/src/public/parts/shells/chat/public/hub/entityProfile.mjs @@ -19,7 +19,7 @@ import { profileDescriptionText as sharedProfileDescriptionText, } from '../shared/entityProfileCard.mjs' import { formatSocialProfileHref } from '/parts/shells:social/shared/runUri.mjs' -import { fetchEntityProfileApi, cachedProfileFromApi } from '../src/entityProfileApi.mjs' +import { cachedProfileFromApi, getEntityProfile } from '../src/endpoints/entities.mjs' import { refreshAliasDependentUi } from './aliasUi.mjs' import { store } from './core/state.mjs' @@ -48,7 +48,7 @@ export async function loadEntityProfile(entityHash, options = {}) { const cached = await fetchUserProfile(entityHash, { groupId: options.groupId }) if (cached) return cached } - const data = await fetchEntityProfileApi(entityHash, options.groupId || store.context.currentGroupId) + const data = await getEntityProfile(entityHash, options.groupId || store.context.currentGroupId) if (!data?.profile) return null return cachedProfileFromApi(data.profile, entityHash) } diff --git a/src/public/parts/shells/chat/public/hub/federation/federationModal.mjs b/src/public/parts/shells/chat/public/hub/federation/federationModal.mjs index 9cbd10156..19713745e 100644 --- a/src/public/parts/shells/chat/public/hub/federation/federationModal.mjs +++ b/src/public/parts/shells/chat/public/hub/federation/federationModal.mjs @@ -2,18 +2,18 @@ * 【文件】public/hub/federation/federationModal.mjs * 【职责】Hub 联邦设置面板:节点 relay/省电、群 房间口令轮换、入群快照修复、信誉与 DM 链接。 * 【原理】`mountFederationPrefsPanel` 写入偏好壳 panel/footer;`openFederationSettingsModal` 打开统一偏好壳并切到联邦分区。 - * 【关联】hubPrefs.mjs、core/overlayModal.mjs、src/api/group*.mjs、src/dmLink.mjs。 + * 【关联】hubPrefs.mjs、core/overlayModal.mjs、src/endpoints/group*.mjs、src/dmLink.mjs。 */ import { isHex64, normalizeHex64, HEX_ID_64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' import { renderTemplate, usingTemplates } from '../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../scripts/features/toast.mjs' import { confirmI18n, geti18n } from '../../../../../scripts/i18n/index.mjs' -import { getFederationSettings, putFederationSettings } from '../../src/api/federationSettings.mjs' -import { getGroupState } from '../../src/api/groupCore.mjs' -import { repairJoinSnapshot, rotateFederationRoomSecret } from '../../src/api/groupFederation.mjs' -import { getGroupReputation, postReputationReset, postReputationSlash } from '../../src/api/groupGovernance.mjs' import { createDmLinkAndSync, rotateDmLinkAndSync } from '../../src/dmLink.mjs' +import { getFederationSettings, putFederationSettings } from '../../src/endpoints/federationSettings.mjs' +import { getGroupState } from '../../src/endpoints/groupCore.mjs' +import { repairJoinSnapshot, rotateFederationRoomSecret } from '../../src/endpoints/groupFederation.mjs' +import { getGroupReputation, postReputationReset, postReputationSlash } from '../../src/endpoints/groupGovernance.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { closeOverlayModal } from '../core/overlayModal.mjs' diff --git a/src/public/parts/shells/chat/public/hub/federation/forkActions.mjs b/src/public/parts/shells/chat/public/hub/federation/forkActions.mjs index 3f0c34101..9f8076172 100644 --- a/src/public/parts/shells/chat/public/hub/federation/forkActions.mjs +++ b/src/public/parts/shells/chat/public/hub/federation/forkActions.mjs @@ -3,13 +3,13 @@ * 【职责】DAG 分叉治理 UI:绑定顶栏分叉按钮,执行分支、合并、封锁对立叉与刷新分叉横幅。 * 【原理】监听 `#fork-branch-button` 等控件,配合 `banners.refreshDagForkBanner` 提示当前治理状态;分叉/合并成功后调用 `loadMessages` 重建频道视图以反映新 DAG 尖。 * 【数据结构】store 当前群/频道上下文与 WS 连接状态;见模块内变量 JSDoc。 - * 【关联】../../../../../scripts/i18n、../../../../../scripts/toast、../../src/api/groupGovernance、../banners、../core/state、../messages/messages。 + * 【关联】../../../../../scripts/i18n、../../../../../scripts/toast、../../src/endpoints/groupGovernance、../banners、../core/state、../messages/messages。 */ import { showToastI18n } from '../../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../../scripts/i18n/index.mjs' -import { getGroupState } from '../../src/api/groupCore.mjs' -import { blockOpposingForkBranch, forkGroupAsNew, mergeDagTips, setGovernanceBranch } from '../../src/api/groupGovernance.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { getGroupState } from '../../src/endpoints/groupCore.mjs' +import { blockOpposingForkBranch, forkGroupAsNew, mergeDagTips, setGovernanceBranch } from '../../src/endpoints/groupGovernance.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { refreshDagForkBanner, selectedForkTipId } from '../banners.mjs' import { store, setState } from '../core/state.mjs' import { loadMessages } from '../messages/messages.mjs' @@ -31,7 +31,7 @@ export function wireForkActions() { showToastI18n('success', 'chat.hub.applyBranchOk') } catch (error) { - handleUIError(error, 'chat.hub.applyBranchFailed') + handleError('chat.hub.applyBranchFailed')(error) } finally { if (branchButton) branchButton.disabled = false @@ -50,7 +50,7 @@ export function wireForkActions() { showToastI18n('success', 'chat.hub.autoBranchOk') } catch (error) { - handleUIError(error, 'chat.hub.autoBranchFailed') + handleError('chat.hub.autoBranchFailed')(error) } finally { if (autoBranchButton) autoBranchButton.disabled = false @@ -81,7 +81,7 @@ export function wireForkActions() { location.reload() } catch (error) { - handleUIError(error, 'chat.hub.forkSplit.failed') + handleError('chat.hub.forkSplit.failed')(error) } finally { if (submitButton) submitButton.disabled = false @@ -103,7 +103,7 @@ export function wireForkActions() { showToastI18n('success', 'chat.hub.block.opposingOk', { count: blocked.length }) } catch (error) { - handleUIError(error, 'chat.hub.block.opposingFailed') + handleError('chat.hub.block.opposingFailed')(error) } finally { if (blockOpposingButton) blockOpposingButton.disabled = false @@ -122,7 +122,7 @@ export function wireForkActions() { showToastI18n('success', 'chat.hub.mergeDagOk') } catch (error) { - handleUIError(error, 'chat.hub.mergeDagFailed') + handleError('chat.hub.mergeDagFailed')(error) } finally { if (mergeButton) mergeButton.disabled = false diff --git a/src/public/parts/shells/chat/public/hub/files.mjs b/src/public/parts/shells/chat/public/hub/files.mjs index 0c2a47aac..b5cdea129 100644 --- a/src/public/parts/shells/chat/public/hub/files.mjs +++ b/src/public/parts/shells/chat/public/hub/files.mjs @@ -1,8 +1,9 @@ /** * Hub 群文件面板:列出当前成员 role 可访问的共享柜;管理者可绑定新柜。 */ -import { getGroupState } from '../src/api/groupCore.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { getGroupState } from '../src/endpoints/groupCore.mjs' +import { bindGroupCabinet } from '../src/endpoints/groupFiles.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' @@ -41,11 +42,11 @@ export function wireFilesDrawerToggle() { const open = toggle instanceof HTMLInputElement && toggle.checked setFilesDrawerOpen(open) if (open && store.context.currentGroupId) - void refreshFilesDrawer({ + refreshFilesDrawer({ groupId: store.context.currentGroupId, state: store.context.currentState, viewer: store.context.currentState?.viewer, - }).catch(handleUIError) + }).catch(handleError('chat.hub.files.loadFailed')) }) } @@ -121,7 +122,7 @@ export async function refreshFilesDrawer(drawer) { addBtn.setAttribute('data-i18n', 'chat.hub.files.bindCabinet') addBtn.textContent = '添加文件柜' addBtn.addEventListener('click', () => { - void bindCabinetFlow(drawer.groupId, state).then(() => refreshFilesDrawer(drawer)).catch(handleUIError) + bindCabinetFlow(drawer.groupId, state).then(() => refreshFilesDrawer(drawer)).catch(handleError('chat.hub.files.loadFailed')) }) actions.appendChild(addBtn) } @@ -141,16 +142,10 @@ async function bindCabinetFlow(groupId, state) { if (!roleId) return const access = window.prompt('访问级别 rw / ro', 'rw') if (!access) return - const response = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/cabinets/bind`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - cabinet_id: cabinetId.trim(), - role_access: { [roleId.trim()]: access.trim() === 'ro' ? 'ro' : 'rw' }, - }), + await bindGroupCabinet(groupId, { + cabinet_id: cabinetId.trim(), + role_access: { [roleId.trim()]: access.trim() === 'ro' ? 'ro' : 'rw' }, }) - if (!response.ok) throw new Error(await response.text()) } /** @@ -163,7 +158,7 @@ export function wireFilesDrawer(drawer) { const toggle = document.getElementById('files-drawer-toggle') toggle?.addEventListener('change', () => { if (isFilesDrawerOpen()) - void refreshFilesDrawer(drawer).catch(handleUIError) + refreshFilesDrawer(drawer).catch(handleError('chat.hub.files.loadFailed')) }) } diff --git a/src/public/parts/shells/chat/public/hub/friendChat.mjs b/src/public/parts/shells/chat/public/hub/friendChat.mjs index b933e049d..01168e4ef 100644 --- a/src/public/parts/shells/chat/public/hub/friendChat.mjs +++ b/src/public/parts/shells/chat/public/hub/friendChat.mjs @@ -12,11 +12,11 @@ import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { aliasForEntity } from '../shared/aliases.mjs' import { isEntityHash128 } from '../shared/entityHash.mjs' import { buildCharFriendBinding, buildUserFriendBinding, normalizeFriendBinding } from '../shared/friendBinding.mjs' -import { getFederationSettings } from '../src/api/federationSettings.mjs' -import { getGroupState } from '../src/api/groupCore.mjs' -import { createDirectMessageByPubKeys } from '../src/api/groupDm.mjs' -import { setGroupFriendBinding } from '../src/api/groupFriendBinding.mjs' -import { handleUIError, toError } from '../src/ui/errors.mjs' +import { getFederationSettings } from '../src/endpoints/federationSettings.mjs' +import { addGroupChar, createFriendGroup, getGroupState, listGroupChars } from '../src/endpoints/groupCore.mjs' +import { createDirectMessageByPubKeys } from '../src/endpoints/groupDm.mjs' +import { setGroupFriendBinding } from '../src/endpoints/groupFriendBinding.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { getCharDetails, renderCharInfoCardActive } from './charCard.mjs' import { store } from './core/state.mjs' @@ -34,25 +34,6 @@ let enterFriendChatAbort = null /** @type {Promise<void>} 串行化 resolveFriendGroupId,避免并发重复建群 */ let resolveFriendGroupChain = Promise.resolve() -/** - * @param {string} url fetch URL - * @param {RequestInit} [init] fetch 选项 - * @param {AbortSignal} [signal] 取消信号 - * @returns {Promise<Response>} HTTP 响应 - */ -async function chatRuntimeFetch(url, init = {}, signal) { - if (signal?.aborted) - throw new DOMException('Aborted', 'AbortError') - try { - return await fetch(url, { credentials: 'include', ...init, signal }) - } - catch (error) { - if (signal?.aborted || error?.name === 'AbortError') throw error - const err = toError(error) - throw new Error(`fetch ${url}: ${err.message}`, { cause: err }) - } -} - /** * @param {import('../shared/friendBinding.mjs').FriendBinding | null} a 绑定 A * @param {import('../shared/friendBinding.mjs').FriendBinding | null} b 绑定 B @@ -107,35 +88,25 @@ async function findExistingFriendGroup(binding) { * 确保群上已挂载角色 part。 * @param {string} groupId 群 ID * @param {string} charname 角色名 - * @param {AbortSignal} signal 取消信号 * @returns {Promise<void>} */ -async function ensureCharOnGroup(groupId, charname, signal) { - const base = `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}` - const cr = await chatRuntimeFetch(`${base}/chars`, {}, signal) - if (!cr.ok) throw new Error(`GET chars HTTP ${cr.status}`) - const chars = await cr.json() - if (Array.isArray(chars) && chars.includes(charname)) return - const add = await chatRuntimeFetch(`${base}/char`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ charname, deferGreeting: true }), - }, signal) - if (!add.ok) throw new Error(`POST char HTTP ${add.status}`) +async function ensureCharOnGroup(groupId, charname) { + const chars = await listGroupChars(groupId) + if (chars.includes(charname)) return + await addGroupChar(groupId, { charname, deferGreeting: true }) } /** * 解析或新建好友群 ID(角色需 addchar;用户 DM 由调用方传入 groupId)。 * @param {import('../shared/friendBinding.mjs').FriendBinding} binding 绑定 * @param {{ groupId?: string, forceNew?: boolean }} options 选项 - * @param {AbortSignal} signal 取消信号 * @returns {Promise<string|null>} 群 ID;失败为 null */ -async function resolveFriendGroupId(binding, options, signal) { +async function resolveFriendGroupId(binding, options) { let groupId = options.forceNew ? undefined : options.groupId if (groupId) { if (binding.charname) - await ensureCharOnGroup(groupId, binding.charname, signal) + await ensureCharOnGroup(groupId, binding.charname) return groupId } if (!groupId && !options.forceNew) { @@ -146,21 +117,15 @@ async function resolveFriendGroupId(binding, options, signal) { groupId = await findExistingFriendGroup(binding) if (!groupId) { - const r = await chatRuntimeFetch('/api/parts/shells:chat/groups', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - friendBinding: binding, - ...options.forceNew ? { forceNew: true } : {}, - }), - }, signal) - if (!r.ok) throw new Error(`POST groups HTTP ${r.status}`) - const payload = await r.json() + const payload = await createFriendGroup({ + friendBinding: binding, + ...options.forceNew ? { forceNew: true } : {}, + }) groupId = payload.groupId } if (binding.charname) - await ensureCharOnGroup(groupId, binding.charname, signal) + await ensureCharOnGroup(groupId, binding.charname) return groupId } @@ -288,7 +253,7 @@ export async function enterFriendChat(options = {}) { throwIfAborted(signal) const groupId = await enqueueResolveFriendGroup( - () => resolveFriendGroupId(binding, options, signal), + () => resolveFriendGroupId(binding, options), signal, ) if (!groupId) return @@ -298,7 +263,7 @@ export async function enterFriendChat(options = {}) { } catch (error) { if (signal.aborted) return - const err = handleUIError(error, 'chat.hub.createChatFailed') + const err = handleError('chat.hub.createChatFailed')(error) await mountTemplate(document.getElementById('messages'), 'hub/empty/error', { i18nKey: 'chat.hub.createChatFailed', errorMessage: err.message, diff --git a/src/public/parts/shells/chat/public/hub/friendsList.mjs b/src/public/parts/shells/chat/public/hub/friendsList.mjs index f96ca928d..c8fb699fc 100644 --- a/src/public/parts/shells/chat/public/hub/friendsList.mjs +++ b/src/public/parts/shells/chat/public/hub/friendsList.mjs @@ -7,7 +7,7 @@ */ import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' -import { getAllCachedPartDetails, getPartList } from '../../../../scripts/api/parts.mjs' +import { getAllCachedPartDetails, getPartList } from '../../../../scripts/endpoints/parts.mjs' import { mountTemplate, renderTemplate } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n, geti18n } from '../../../../scripts/i18n/index.mjs' @@ -17,6 +17,9 @@ import { formatEntityAtId, isEntityHash128 } from '../shared/entityHash.mjs' import { bindEntityProfileHoverAnchor } from '../shared/entityProfileHoverCard.mjs' import { displayProfileAvatar, listAvatarTemplateFields } from '../shared/hashAvatar.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' +import { searchEntities } from '../src/endpoints/entities.mjs' +import { deleteSession } from '../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { promptText } from '/scripts/features/promptDialog.mjs' import { getCharDetails } from './charCard.mjs' @@ -203,14 +206,7 @@ async function deleteFriendSession(friend) { const name = friend.charname || friend.displayName || friend.groupId if (!confirmI18n('chat.hub.deleteSessionConfirm', { name })) return try { - const r = await fetch( - `/api/parts/shells:chat/sessions/${encodeURIComponent(friend.groupId)}`, - { method: 'DELETE', credentials: 'include' }, - ) - if (!r.ok) { - const data = await r.json().catch(() => ({})) - throw new Error(data.error || `HTTP ${r.status}`) - } + await deleteSession(friend.groupId) showToastI18n('success', 'chat.hub.session.deleted') if (store.privateGroup.groupId === friend.groupId) { const { clearPrivateGroupState } = await import('./privateGroup.mjs') @@ -453,7 +449,8 @@ async function appendFriendsSearchHit(hit, resultsHost) { return } if (hit.handle || hit.name) - await setEntityAlias(hit.entityHash, hit.alias || hit.handle || hit.name).catch(() => { }) + await setEntityAlias(hit.entityHash, hit.alias || hit.handle || hit.name) + .catch(handleError('chat.hub.operationFailed')) await dispatchFriendChat({ type: 'user', displayName: hit.label, @@ -486,17 +483,14 @@ async function runFriendsEntitySearch(input, resultsHost) { return } - const [localChars, response] = await Promise.all([ + const [localChars, data] = await Promise.all([ searchLocalChars(q), - fetch(`/api/parts/shells:chat/entities/search?q=${encodeURIComponent(q)}`, { - credentials: 'include', + searchEntities(q).catch(error => { + showToastI18n('error', 'chat.hub.createChatFailed', { error: error.message }) + return null }), ]) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - showToastI18n('error', 'chat.hub.createChatFailed', { error: data.error || `HTTP ${response.status}` }) - return - } + if (!data) return const seenChars = new Set(localChars.map(h => h.charname)) /** @type {FriendsSearchHit[]} */ diff --git a/src/public/parts/shells/chat/public/hub/gestures/chatGestures.mjs b/src/public/parts/shells/chat/public/hub/gestures/chatGestures.mjs index 664ce6b19..b2584a861 100644 --- a/src/public/parts/shells/chat/public/hub/gestures/chatGestures.mjs +++ b/src/public/parts/shells/chat/public/hub/gestures/chatGestures.mjs @@ -2,10 +2,10 @@ * 【文件】public/hub/gestures/chatGestures.mjs * 【职责】移动端/触控聊天手势:末条角色消息左右滑动切换时间轴分支,桌面端显示箭头按钮。 * 【原理】在 `#messages` 上事件委托 touch;桌面箭头仍挂末条角色消息。群/频道 ID 直接读 store。 - * 【关联】scripts/template、src/api/groupChannel、core/state + * 【关联】scripts/template、src/endpoints/groupChannel、core/state */ import { renderTemplate } from '../../../../../scripts/features/template.mjs' -import { modifyBranch } from '../../src/api/groupChannel.mjs' +import { modifyBranch } from '../../src/endpoints/groupChannel.mjs' import { store } from '../core/state.mjs' const CHAT_SWIPE_THRESHOLD = 50 diff --git a/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs b/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs index bb95d5daf..eda6b166c 100644 --- a/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs +++ b/src/public/parts/shells/chat/public/hub/groupContextMenu.mjs @@ -3,9 +3,9 @@ * 【职责】群组侧栏项与顶栏群组菜单:离开群、邀请、联邦入口、文件夹操作等上下文动作。 * 【原理】`showGroupContextMenu` / `showGroupHeaderMenu` 弹出单例菜单层并处理 dismiss;离开或删除群后清空消息区;本模块不渲染气泡。 * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 - * 【关联】../../../../scripts/i18n、../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/api/groupCore、groupClient、../src/inviteQr、chat、core/domUtils。 + * 【关联】../../../../scripts/i18n、../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/endpoints/groupCore、../src/inviteQr、chat、core/domUtils。 */ -import { getPartList } from '../../../../scripts/api/parts.mjs' +import { getPartList } from '../../../../scripts/endpoints/parts.mjs' import { openDialogFromTemplate } from '../../../../scripts/features/dialog.mjs' import { renderTemplate, @@ -16,10 +16,9 @@ import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../scripts/i18n/index.mjs' import { aliasForGroup, setGroupAlias } from '../shared/aliases.mjs' import { promptText } from '/scripts/features/promptDialog.mjs' -import { groupRequest } from '../src/api/groupClient.mjs' -import { createGroupInvite, leaveGroups } from '../src/api/groupCore.mjs' +import { addGroupChar, createGroupInvite, leaveGroups } from '../src/endpoints/groupCore.mjs' import { buildInviteJoinShareUrl } from '../src/inviteQr.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { bindDismissOnDocumentInteraction } from '/scripts/components/contextMenuDismiss.mjs' import { groupDisplayName } from './core/domUtils.mjs' @@ -148,7 +147,7 @@ function runLeaveGroupsInBackground(groupIds) { catch (error) { clearGroupsLeaving(ids) await renderServerBar() - handleUIError(error, 'chat.hub.load.groupFailed') + handleError('chat.hub.load.groupFailed')(error) const { loadGroups } = await import('./serverBar.mjs') await loadGroups() } @@ -227,7 +226,7 @@ async function mountGroupActionMenuAt(groupId, left, top, targetGroupIds = null) showToastI18n('success', 'chat.hub.group.context.inviteCopied') } catch (err) { - handleUIError(err, 'chat.hub.shareGroupFailed') + handleError('chat.hub.shareGroupFailed')(err) } }) @@ -334,12 +333,12 @@ async function showAddCharDialog(groupId) { const charname = sel instanceof HTMLSelectElement ? sel.value.trim() : '' if (!charname) return try { - await groupRequest(groupId, 'char', 'POST', { charname }) + await addGroupChar(groupId, { charname }) showToastI18n('success', 'chat.dragAndDrop.charAdded', { partName: charname }) closeModal() } catch (err) { - handleUIError(err, 'chat.hub.operationFailed') + handleError('chat.hub.operationFailed')(err) } }) }, diff --git a/src/public/parts/shells/chat/public/hub/hashNav.mjs b/src/public/parts/shells/chat/public/hub/hashNav.mjs index d9e355f48..142a70270 100644 --- a/src/public/parts/shells/chat/public/hub/hashNav.mjs +++ b/src/public/parts/shells/chat/public/hub/hashNav.mjs @@ -5,7 +5,7 @@ * 【数据结构】hash 片段约定见 core/urlHash(`#group:groupId:channelId`、`#friends`)。 * 【关联】init、core/urlHash、sidebar、friendBindings、friendChat、mode、serverBar。 */ -import { handleUIError } from '../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' import { DISCOVERY_HASH, FRIENDS_HASH, INBOX_HASH, isFriendsHash, parseHash } from './core/urlHash.mjs' @@ -72,7 +72,7 @@ async function navigateFromHashInner() { if (eventId) await scrollToAndHighlightEventId(eventId) } catch (error) { - handleUIError(error, 'chat.hub.load.groupFailed') + handleError('chat.hub.load.groupFailed')(error) } } diff --git a/src/public/parts/shells/chat/public/hub/hubStatus.mjs b/src/public/parts/shells/chat/public/hub/hubStatus.mjs index 38d5335e7..f2af63df4 100644 --- a/src/public/parts/shells/chat/public/hub/hubStatus.mjs +++ b/src/public/parts/shells/chat/public/hub/hubStatus.mjs @@ -7,6 +7,7 @@ */ import { renderTemplate, renderTemplateAsHtmlString, usingTemplates } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' +import { postEntityHeartbeat, setEntityStatus } from '../src/endpoints/entities.mjs' import { bindDismissOnDocumentInteraction } from '/scripts/components/contextMenuDismiss.mjs' import { store } from './core/state.mjs' @@ -59,10 +60,7 @@ export async function applyMyStatusUI(status, customStatus = '') { */ export async function sendHeartbeat(entityHash) { if (!entityHash) return - await fetch(`/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}/heartbeat`, { - method: 'POST', - credentials: 'include', - }) + await postEntityHeartbeat(entityHash) } /** @@ -73,17 +71,12 @@ export async function sendHeartbeat(entityHash) { export async function setMyStatus(status, options = {}) { const entityHash = store.viewer.viewerEntityHash if (!entityHash) return - const resp = await fetch(`/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}/status`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ status }), - }) - if (!resp.ok) { - if (!options.silent) { - const data = await resp.json().catch(() => ({})) - showToastI18n('error', 'chat.hub.operationFailed', { error: data.error || resp.statusText }) - } + try { + await setEntityStatus(entityHash, status) + } + catch (error) { + if (!options.silent) + showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) return } if (MANUAL_STATUSES.includes(status)) diff --git a/src/public/parts/shells/chat/public/hub/inboxClient.mjs b/src/public/parts/shells/chat/public/hub/inboxClient.mjs index 7c5b24d5d..0019cd4d4 100644 --- a/src/public/parts/shells/chat/public/hub/inboxClient.mjs +++ b/src/public/parts/shells/chat/public/hub/inboxClient.mjs @@ -1,13 +1,12 @@ /** - * Hub 跨群 inbox:API、badge 与 WS 增量。 + * Hub 跨群 inbox:badge 与 WS 增量(HTTP 在 endpoints/inbox)。 */ -import { handleUIError } from '../src/ui/errors.mjs' +import { fetchInboxPage as fetchInboxPageApi, markInboxSeen as markInboxSeenApi } from '../src/endpoints/inbox.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' import { formatUnreadLabel } from './unread.mjs' -const INBOX_API = '/api/parts/shells:chat/inbox' - /** @type {number | null} */ let badgeUnreadCount = null @@ -17,15 +16,8 @@ let badgeUnreadCount = null * @param {string} [options.cursor] 游标 * @returns {Promise<{ items: object[], nextCursor: string | null, unreadCount: number }>} 分页结果 */ -export async function fetchInboxPage(options = {}) { - const params = new URLSearchParams() - if (options.limit) params.set('limit', String(options.limit)) - if (options.cursor) params.set('cursor', String(options.cursor)) - if (options.kinds?.length) params.set('kinds', options.kinds.join(',')) - const query = params.toString() - const response = await fetch(`${INBOX_API}${query ? `?${query}` : ''}`, { credentials: 'include' }) - if (!response.ok) throw new Error(`inbox ${response.status}`) - return response.json() +export function fetchInboxPage(options = {}) { + return fetchInboxPageApi(options) } /** @@ -33,13 +25,7 @@ export async function fetchInboxPage(options = {}) { * @returns {Promise<number>} 写入的 seenAt */ export async function markInboxSeen(at = Date.now()) { - const response = await fetch(`${INBOX_API}/seen`, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ at }), - }) - if (!response.ok) throw new Error(`inbox seen ${response.status}`) + await markInboxSeenApi(at) badgeUnreadCount = 0 await updateInboxBadge() return at @@ -54,7 +40,7 @@ export async function updateInboxBadge() { unread = Number((await fetchInboxPage({ limit: 1 })).unreadCount) || 0 } catch (error) { - handleUIError(error, 'chat.hub.inbox.badgeFetchFailed') + handleError('chat.hub.inbox.badgeFetchFailed')(error) return } badgeUnreadCount = null diff --git a/src/public/parts/shells/chat/public/hub/inboxView.mjs b/src/public/parts/shells/chat/public/hub/inboxView.mjs index 5b3d1b4dd..51f222feb 100644 --- a/src/public/parts/shells/chat/public/hub/inboxView.mjs +++ b/src/public/parts/shells/chat/public/hub/inboxView.mjs @@ -6,7 +6,7 @@ import { bindInfiniteScroll, disconnectInfiniteScroll, ensureScrollSentinel, ins import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { aliasForEntity } from '../shared/aliases.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { groupDisplayName } from './core/domUtils.mjs' import { store } from './core/state.mjs' @@ -151,7 +151,7 @@ async function loadInboxPage(generation = loadGeneration) { } catch (error) { if (generation !== loadGeneration) return - handleUIError(error, 'chat.hub.inbox.loadFailed') + handleError('chat.hub.inbox.loadFailed')(error) if (host && !host.querySelector('.inbox-row')) await mountTemplate(host, 'hub/empty/error', { i18nKey: 'chat.hub.inbox.loadFailed', errorMessage: error.message, @@ -181,7 +181,7 @@ function wireInboxRowClicks(host) { setPendingScrollTarget(eventId, groupId, channelId) await selectGroup(groupId, channelId) await scrollToMessageEventId(eventId) - })().catch(error => handleUIError(error, 'chat.hub.inbox.jumpFailed')) + })().catch(handleError('chat.hub.inbox.jumpFailed')) }) } @@ -273,7 +273,7 @@ export async function activateInboxView() { await markInboxSeen() } catch (error) { - handleUIError(error, 'chat.hub.inbox.markSeenFailed') + handleError('chat.hub.inbox.markSeenFailed')(error) } } diff --git a/src/public/parts/shells/chat/public/hub/index.mjs b/src/public/parts/shells/chat/public/hub/index.mjs index 2ed59a113..a91c8170e 100644 --- a/src/public/parts/shells/chat/public/hub/index.mjs +++ b/src/public/parts/shells/chat/public/hub/index.mjs @@ -33,8 +33,8 @@ export async function bootHub() { } catch (error) { hubGate.markFailed(error) - const { handleUIError } = await import('../src/ui/errors.mjs') - handleUIError(error, 'chat.hub.load.groupFailed') + const { handleError } = await import('/scripts/features/errorHandlers.mjs') + handleError('chat.hub.load.groupFailed')(error) throw error } } diff --git a/src/public/parts/shells/chat/public/hub/init.mjs b/src/public/parts/shells/chat/public/hub/init.mjs index 059731d8d..fa7491928 100644 --- a/src/public/parts/shells/chat/public/hub/init.mjs +++ b/src/public/parts/shells/chat/public/hub/init.mjs @@ -11,7 +11,7 @@ import { aliasForEntity } from '../shared/aliases.mjs' import { normalizeChannelMessage } from '../shared/channelContent.mjs' import { displayProfileAvatar } from '../shared/hashAvatar.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' -import { sendGroupMessage } from '../src/api/groupChannel.mjs' +import { sendGroupMessage } from '../src/endpoints/groupChannel.mjs' import { syncTrustedAuthorsFromShell } from '../src/trustedAuthors.mjs' import { applyProfileAvatarToHost } from './core/avatarCover.mjs' diff --git a/src/public/parts/shells/chat/public/hub/initCore.mjs b/src/public/parts/shells/chat/public/hub/initCore.mjs index 8c97ddaf1..9ceb329d7 100644 --- a/src/public/parts/shells/chat/public/hub/initCore.mjs +++ b/src/public/parts/shells/chat/public/hub/initCore.mjs @@ -6,23 +6,21 @@ import { usingTemplates } from '../../../../scripts/features/template.mjs' import { initTranslations } from '../../../../scripts/i18n/index.mjs' import { loadAliases } from '../shared/aliases.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { getViewer } from '../src/endpoints/viewer.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' +import { whoami } from '/scripts/endpoints/base.mjs' import { store } from './core/state.mjs' import { parseHash } from './core/urlHash.mjs' /** @returns {Promise<void>} 拉取 viewer 到 store(顶栏详情由 init.mjs 补全) */ async function loadViewerIdentity() { - const [viewerResp, whoamiResp] = await Promise.all([ - fetch('/api/parts/shells:chat/viewer', { credentials: 'include' }), - fetch('/api/whoami', { credentials: 'include' }), + const [data, who] = await Promise.all([ + getViewer().catch(() => null), + whoami().catch(() => null), ]) - if (whoamiResp.ok) { - const whoami = await whoamiResp.json() - store.viewer.username = whoami.username || null - } - if (!viewerResp.ok) return - const data = await viewerResp.json() + if (who?.username) store.viewer.username = who.username || null + if (!data) return store.viewer.nodeHash = data.nodeHash || null store.viewer.operatorEntityHash = data.viewerEntityHash || null store.viewer.viewerEntityHash = data.viewerEntityHash || null @@ -49,7 +47,7 @@ async function navigateHubFromLocation() { applied = await applyChatRunUri(runUri) } catch (e) { - handleUIError(e, 'chat.hub.load.groupFailed') + handleError('chat.hub.load.groupFailed')(e) } if (applied?.groupId) { groupId = applied.groupId @@ -97,14 +95,14 @@ export async function initCore() { const { setHubPane } = await import('./hubPane.mjs') setHubPane('nav') await loadViewerIdentity() - await loadAliases().catch(() => { }) + await loadAliases().catch(handleError('chat.hub.operationFailed')) try { const { loadGroups } = await import('./serverBar.mjs') await loadGroups() } catch (error) { store.sidebar.groups = [] - handleUIError(error, 'chat.hub.load.groupFailed') + handleError('chat.hub.load.groupFailed')(error) } await navigateHubFromLocation() } diff --git a/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs b/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs index 52bec1058..acdbc1879 100644 --- a/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs +++ b/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs @@ -8,7 +8,8 @@ import { confirmI18n } from '../../../../scripts/i18n/index.mjs' import { aliasForEntity, setEntityAlias } from '../shared/aliases.mjs' import { isCared, setCared } from '../shared/care.mjs' import { promptText } from '/scripts/features/promptDialog.mjs' -import { getGroupState } from '../src/api/groupCore.mjs' +import { getGroupState } from '../src/endpoints/groupCore.mjs' +import { kickMember } from '../src/endpoints/members.mjs' import { fetchViewerChannelPermissions } from '../src/groupViewerPermissions.mjs' import { refreshAliasDependentUi } from './aliasUi.mjs' @@ -134,26 +135,22 @@ export async function showMemberContextMenu(event, memberElement) { if (!confirmI18n('chat.hub.member.context.kickSelfNodeWarning', { name: displayName })) return if (!confirmI18n('chat.group.settings.page.kick.confirm', { name: displayName })) return - const groupId = store.context.currentGroupId - const resp = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/members/${encodeURIComponent(memberKey)}/kick`, - { method: 'POST', credentials: 'include' }, - ) - if (!resp.ok) { - const data = await resp.json().catch(() => ({})) - showToastI18n('error', 'chat.hub.operationFailed', { error: data.error || resp.statusText }) - return + try { + await kickMember(store.context.currentGroupId, memberKey) + showToastI18n('success', 'chat.group.settings.page.kick.success') + store.context.currentState = await getGroupState(store.context.currentGroupId) + void renderMemberList(store.context.currentState) + closeOnce() + } + catch (error) { + showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) } - showToastI18n('success', 'chat.group.settings.page.kick.success') - store.context.currentState = await getGroupState(store.context.currentGroupId) - void renderMemberList(store.context.currentState) - closeOnce() }) menu.querySelector('.member-menu-ban')?.addEventListener('click', async () => { if (!confirmI18n('chat.group.settings.page.banConfirm', { name: displayName })) return const picked = await pickBanScope({ displayName }) if (!picked) return - const { banMemberWithScope } = await import('../src/api/groupBan.mjs') + const { banMemberWithScope } = await import('../src/endpoints/groupBan.mjs') try { await banMemberWithScope(store.context.currentGroupId, memberKey, picked) showToastI18n('success', 'chat.group.settings.page.banSuccess') diff --git a/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs b/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs index 153835438..6e2e80a20 100644 --- a/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs +++ b/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs @@ -4,6 +4,7 @@ * 【API】GET …/groups/:id/channels/:cid/member-read-markers → `{ markers: { [entityHash]: { seq, eventId } } }` */ import { geti18n } from '../../../../scripts/i18n/index.mjs' +import { getMemberReadMarkers } from '../src/endpoints/groupChannel.mjs' import { hubDeliveryReadIcon } from '../src/lib/emojiSvg.mjs' import { store } from './core/state.mjs' @@ -29,12 +30,7 @@ function cacheKey(groupId, channelId) { */ export async function fetchMemberReadMarkers(groupId, channelId) { try { - const response = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/member-read-markers`, - { credentials: 'include' }, - ) - if (!response.ok) return {} - const { markers = {} } = await response.json() + const { markers = {} } = await getMemberReadMarkers(groupId, channelId) markersCache.set(cacheKey(groupId, channelId), markers) paintOwnDeliveryStatuses() return markers diff --git a/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs b/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs index 4ae9556ef..91428e795 100644 --- a/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs +++ b/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs @@ -5,6 +5,8 @@ import { sanitizePermissiveHtml } from '/scripts/lib/sanitizeHtml.mjs' import { formatHashShort, formatEntityAtId } from '../shared/entityHash.mjs' import { formatEntityMentionToken, formatRoleMentionToken } from '../shared/inlineTokenSyntax.mjs' +import { suggestMentions } from '../src/endpoints/mentions.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' @@ -103,16 +105,13 @@ export function attachHubMentionAutocomplete(textarea) { hide() return } - const response = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/mentions/suggest?q=${encodeURIComponent(query)}&limit=12`, - { credentials: 'include' }, - ) - if (!response.ok) { + try { + const data = await suggestMentions(groupId, query, 12) + render(data.suggestions || []) + } + catch { hide() - return } - const data = await response.json() - render(data.suggestions || []) } /** @@ -194,7 +193,10 @@ export function attachHubMentionAutocomplete(textarea) { return } mentionRange = { start: mention.start, end: mention.end } - void fetchSuggestions(mention.query).catch(() => hide()) + fetchSuggestions(mention.query).catch(error => { + handleError('chat.hub.operationFailed')(error) + hide() + }) } panel.addEventListener('mousedown', event => { diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/bookmark.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/bookmark.mjs index ef9a7133c..5a838b791 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/bookmark.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/bookmark.mjs @@ -3,7 +3,7 @@ * 【职责】频道消息书签。 */ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' -import { addChatBookmark } from '../../../src/api/groupBookmarks.mjs' +import { addChatBookmark } from '../../../src/endpoints/groupBookmarks.mjs' import { refreshPinsBookmarks } from '../../pinsBookmarks.mjs' import { getMessageText } from '../render/text.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/branch.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/branch.mjs index 472d70085..de9d4c383 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/branch.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/branch.mjs @@ -3,7 +3,7 @@ * 【职责】时间线步进与重新生成。 */ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' -import { deleteChannelMessage, modifyBranch, triggerChannelReply } from '../../../src/api/groupChannel.mjs' +import { deleteChannelMessage, modifyBranch, triggerChannelReply } from '../../../src/endpoints/groupChannel.mjs' /** * @param {HTMLElement} button 被点击按钮 diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/delete.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/delete.mjs index 842884c81..e03f4e311 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/delete.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/delete.mjs @@ -4,7 +4,7 @@ */ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../../../scripts/i18n/index.mjs' -import { deleteChannelMessage } from '../../../src/api/groupChannel.mjs' +import { deleteChannelMessage } from '../../../src/endpoints/groupChannel.mjs' import { enqueueDeletion } from '../messageActionsState.mjs' import { shouldConfirmDelete } from '../messageActionsUi.mjs' import { getMessageText } from '../render/text.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/edit.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/edit.mjs index f7e60507a..54987c527 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/edit.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/edit.mjs @@ -3,7 +3,7 @@ * 【职责】频道消息内联编辑。 */ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' -import { editChannelMessage } from '../../../src/api/groupChannel.mjs' +import { editChannelMessage } from '../../../src/endpoints/groupChannel.mjs' import { findContextMessage } from '../messageActionsState.mjs' import { appendEditArea, diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/feedback.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/feedback.mjs index edf0e4fcd..dbaa616a4 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/feedback.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/feedback.mjs @@ -3,7 +3,7 @@ * 【职责】消息反馈(赞/踩)及原因提交。 */ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' -import { setChannelMessageFeedback } from '../../../src/api/groupChannel.mjs' +import { setChannelMessageFeedback } from '../../../src/endpoints/groupChannel.mjs' import { activeFeedbackEdits, showFeedbackReasonInput, diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/forward.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/forward.mjs index 446e26cce..2b7b2c43e 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/forward.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/forward.mjs @@ -5,8 +5,8 @@ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { geti18n } from '../../../../../../scripts/i18n/index.mjs' import { chatExtensionOf, normalizeChannelMessage } from '../../../shared/channelContent.mjs' -import { sendGroupMessage } from '../../../src/api/groupChannel.mjs' -import { getGroupState } from '../../../src/api/groupCore.mjs' +import { sendGroupMessage } from '../../../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../../../src/endpoints/groupCore.mjs' import { store } from '../../core/state.mjs' /** diff --git a/src/public/parts/shells/chat/public/hub/messages/actions/pin.mjs b/src/public/parts/shells/chat/public/hub/messages/actions/pin.mjs index 16ef82e2f..31c3bd1eb 100644 --- a/src/public/parts/shells/chat/public/hub/messages/actions/pin.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/actions/pin.mjs @@ -3,8 +3,8 @@ * 【职责】频道消息置顶 / 取消置顶。 */ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' -import { pinMessage, unpinMessage } from '../../../src/api/groupChannel.mjs' -import { getGroupState } from '../../../src/api/groupCore.mjs' +import { pinMessage, unpinMessage } from '../../../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../../../src/endpoints/groupCore.mjs' import { isDagEventId } from '../../../src/lib/eventId.mjs' import { store } from '../../core/state.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/channelMessageStore.mjs b/src/public/parts/shells/chat/public/hub/messages/channelMessageStore.mjs index 7d69c4991..21e901eeb 100644 --- a/src/public/parts/shells/chat/public/hub/messages/channelMessageStore.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/channelMessageStore.mjs @@ -4,7 +4,7 @@ import { compareHex64Asc } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' import { mergeChannelMessagesForDisplay } from '../../shared/messageMerge.mjs' -import { getChannelViewLogByEventIds } from '../../src/api/groupChannel.mjs' +import { getChannelViewLogByEventIds } from '../../src/endpoints/groupChannel.mjs' import { normalizeEventId } from '../../src/lib/eventId.mjs' import { applyChannelDisplayChain } from '../../src/ui/channelDisplay.mjs' import { store } from '../core/state.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/channelTypeRouter.mjs b/src/public/parts/shells/chat/public/hub/messages/channelTypeRouter.mjs index 0bdf3bf90..5ff556f1b 100644 --- a/src/public/parts/shells/chat/public/hub/messages/channelTypeRouter.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/channelTypeRouter.mjs @@ -1,7 +1,7 @@ /** * 非 text 频道(list / streaming)加载路由。 */ -import { getStreamingChannelAuth } from '../../src/api/groupCore.mjs' +import { getStreamingChannelAuth } from '../../src/endpoints/groupCore.mjs' import { refreshChannelPinsBar } from '../banners.mjs' import { renderListChannel, renderStreamingChannel, renderCodecsAvStreamingChannel } from '../channels.mjs' import { store } from '../core/state.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/exportHtml.mjs b/src/public/parts/shells/chat/public/hub/messages/exportHtml.mjs index 9beccc0ef..5ae8f2288 100644 --- a/src/public/parts/shells/chat/public/hub/messages/exportHtml.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/exportHtml.mjs @@ -8,7 +8,7 @@ import { renderMarkdownAsStandaloneDocument, } from '../../../../../scripts/features/markdown/standaloneDocument.mjs' import { arrayBufferToBase64 } from '../../../../../scripts/lib/base64.mjs' -import { entityFileUrl } from '../../shared/evfsMedia.mjs' +import { fetchEvfsFile } from '/scripts/endpoints/p2p/evfsMedia.mjs' import { groupEntityHash } from '../../shared/groupEntityHash.mjs' import { store } from '../core/state.mjs' @@ -26,14 +26,15 @@ async function resolveGroupFileAttachments(groupId, files) { for (const file of files) { const id = String(file?.fileId || '').trim() if (!id) continue - const plainR = await fetch(entityFileUrl(entityHash, `chat/${id}`), { credentials: 'include' }) - if (!plainR.ok) continue - const mime = String(file.mime_type || plainR.headers.get('Content-Type') || 'application/octet-stream') - out.push({ - name: file.name || id, - mime_type: mime, - buffer: arrayBufferToBase64(await plainR.arrayBuffer()), - }) + try { + const { buffer, mimeType } = await fetchEvfsFile(entityHash, `chat/${id}`) + out.push({ + name: file.name || id, + mime_type: String(file.mime_type || mimeType || 'application/octet-stream'), + buffer: arrayBufferToBase64(buffer), + }) + } + catch { /* skip missing attachment */ } } return out } diff --git a/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs b/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs index bc5e334ea..220efc1b6 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs @@ -3,10 +3,10 @@ import { mountTemplate, } from '../../../../../scripts/features/template.mjs' import { applyMessageEditToRow } from '../../shared/messageMerge.mjs' -import { getChannelViewLog } from '../../src/api/groupChannel.mjs' +import { getChannelViewLog } from '../../src/endpoints/groupChannel.mjs' import { hubEmptyWaveIcon } from '../../src/lib/emojiSvg.mjs' import { eventIdsEqual } from '../../src/lib/eventId.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { refreshChannelPinsBar } from '../banners.mjs' import { store } from '../core/state.mjs' import { @@ -347,15 +347,15 @@ export async function loadMessages() { updateLastMessageId() // 有未读时滚到分割线;打开频道即标已读(badge 清零),分割线锚点保留到下次 load if (!softReload && !store.messages.firstUnreadEventId) scrollToBottom() - await markCurrentChannelRead().catch(() => { }) + await markCurrentChannelRead().catch(handleError('chat.hub.operationFailed')) refreshChannelPinsBar() saveChannelViewCache() - void import('../memberReadMarkers.mjs').then(({ fetchMemberReadMarkers }) => { - void fetchMemberReadMarkers(groupId, channelId) - }) + import('../memberReadMarkers.mjs').then(({ fetchMemberReadMarkers }) => { + fetchMemberReadMarkers(groupId, channelId).catch(handleError('chat.hub.operationFailed')) + }).catch(handleError('chat.hub.operationFailed')) } catch (err) { - const error = handleUIError(err, 'chat.hub.load.messagesFailed') + const error = handleError('chat.hub.load.messagesFailed')(err) await mountTemplate(container, 'hub/empty/error', { i18nKey: 'chat.hub.load.messagesFailed', errorMessage: error.message, diff --git a/src/public/parts/shells/chat/public/hub/messages/messageSend.mjs b/src/public/parts/shells/chat/public/hub/messages/messageSend.mjs index 5312ee785..6b1dc6508 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageSend.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageSend.mjs @@ -1,7 +1,7 @@ import { primaryLocale } from '../../../../../scripts/i18n/index.mjs' import { channelMessage } from '../../shared/channelContent.mjs' import { ensureChatExtension } from '../../shared/messageFields.mjs' -import { sendGroupMessage } from '../../src/api/groupChannel.mjs' +import { sendGroupMessage } from '../../src/endpoints/groupChannel.mjs' import { clearComposerExtras, getContentWarning, getSensitiveMedia } from '../composerExtras.mjs' import { clearSelectedFiles, selectedFiles } from '../composerFiles.mjs' import { clearReplyTarget, getReplyTarget } from '../composerReply.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs index ae54ea44b..9edee3582 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs @@ -1,6 +1,6 @@ import { getChannelViewLog, -} from '../../src/api/groupChannel.mjs' +} from '../../src/endpoints/groupChannel.mjs' import { eventIdsEqual, normalizeEventId } from '../../src/lib/eventId.mjs' import { store } from '../core/state.mjs' import { attachLastCharMessageSwipe, updateHideCharNames } from '../gestures/chatGestures.mjs' diff --git a/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs b/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs index dbc8eb537..84a85ec83 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs @@ -2,6 +2,7 @@ * 【文件】public/hub/messages/render/translation.mjs * 【职责】消息列表自动翻译挂载。 */ +import { getTranslationPrefs } from '../../../src/endpoints/prefs.mjs' /** * 自动翻译:拉取偏好后,对需要翻译的消息 mount 译文块。 @@ -11,9 +12,7 @@ export async function autoTranslateMessages(container) { if (!(container instanceof HTMLElement)) return try { - const response = await fetch('/api/parts/shells:chat/translation-prefs', { credentials: 'include' }) - if (!response.ok) return - const data = await response.json() + const data = await getTranslationPrefs() const prefs = data?.prefs || data || {} if (!prefs.autoTranslate) return @@ -40,8 +39,8 @@ export async function autoTranslateMessages(container) { translatedText: translated, }) } - catch { /* 翻译失败静默跳过 */ } + catch { /* skip one row */ } } } - catch { /* 偏好拉取失败或端点未就绪 */ } + catch { /* prefs unavailable */ } } diff --git a/src/public/parts/shells/chat/public/hub/misc.mjs b/src/public/parts/shells/chat/public/hub/misc.mjs index 858d58c28..cba683b52 100644 --- a/src/public/parts/shells/chat/public/hub/misc.mjs +++ b/src/public/parts/shells/chat/public/hub/misc.mjs @@ -3,11 +3,16 @@ * 【职责】Hub 杂项初始化:浏览器通知权限、成就钩子,以及将 char/world part 拖入群组的导入逻辑。 * 【原理】`setupMisc` 在 `init` 末尾注册拖放区与通知按钮;`setupPartDragDrop` 高亮可放置区域;拖入 part 可能触发群状态变更后刷新消息。 * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 - * 【关联】../../../../scripts/toast、../src/achievements、../src/api/groupClient、core/state。 + * 【关联】../../../../scripts/toast、../src/achievements、../src/endpoints/groupCore、core/state。 */ import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { initializeAchievements } from '../src/achievements.mjs' -import { groupRequest } from '../src/api/groupClient.mjs' +import { + addGroupChar, + addGroupPlugin, + setGroupPersona, + setGroupWorld, +} from '../src/endpoints/groupCore.mjs' import { store } from './core/state.mjs' @@ -36,19 +41,19 @@ export function setupPartDragDrop() { try { switch (partType) { case 'chars': - await groupRequest(groupId, 'char', 'POST', { charname: partName }) + await addGroupChar(groupId, { charname: partName }) showToastI18n('success', 'chat.dragAndDrop.charAdded', { partName }) break case 'personas': - await groupRequest(groupId, 'persona', 'PUT', { personaname: partName }) + await setGroupPersona(groupId, partName) showToastI18n('success', 'chat.dragAndDrop.personaSet', { partName }) break case 'worlds': - await groupRequest(groupId, 'world', 'PUT', { worldname: partName, channelId }) + await setGroupWorld(groupId, partName, channelId) showToastI18n('success', 'chat.dragAndDrop.worldSet', { partName }) break case 'plugins': - await groupRequest(groupId, 'plugin', 'POST', { pluginname: partName }) + await addGroupPlugin(groupId, partName) showToastI18n('success', 'chat.dragAndDrop.pluginAdded', { partName }) break default: diff --git a/src/public/parts/shells/chat/public/hub/personalFilter.mjs b/src/public/parts/shells/chat/public/hub/personalFilter.mjs index f776a3464..400460442 100644 --- a/src/public/parts/shells/chat/public/hub/personalFilter.mjs +++ b/src/public/parts/shells/chat/public/hub/personalFilter.mjs @@ -3,6 +3,7 @@ * 列表本体由 Social relationships API 写入;纯转换在 `shared/personalFilter.mjs`。 * Social 前端不引用本模块(走自有 feed/profile 后端过滤)。 */ +import { postRelationshipBlock } from '../src/endpoints/social.mjs' import { fetchPersonalFilterSets, isPersonallyFiltered, @@ -52,16 +53,7 @@ export function invalidateHubPersonalFilter() { */ export async function postPersonalBlock(targetEntityHash, block) { if (!store.viewer.operatorEntityHash) throw new Error('viewer entity required') - const resp = await fetch('/api/parts/shells:social/relationships/block', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ entityHash: targetEntityHash, block }), - }) - if (!resp.ok) { - const data = await resp.json().catch(() => ({})) - throw new Error(data.error || resp.statusText) - } + await postRelationshipBlock(targetEntityHash, block) invalidateHubPersonalFilter() await loadHubPersonalFilter() } diff --git a/src/public/parts/shells/chat/public/hub/pinsBookmarks.mjs b/src/public/parts/shells/chat/public/hub/pinsBookmarks.mjs index 07c282cee..ba7c02df5 100644 --- a/src/public/parts/shells/chat/public/hub/pinsBookmarks.mjs +++ b/src/public/parts/shells/chat/public/hub/pinsBookmarks.mjs @@ -3,12 +3,12 @@ * 【职责】置顶消息与聊天书签:拉取列表、取消置顶/删除书签,渲染到顶栏搜索框左侧的两个弹出面板。 * 【原理】`refreshPinsBookmarks` 更新 `#pins-wrap`/`#bookmarks-wrap` 面板内条目与按钮计数徽标,配合 `banners.setPinsBookmarksWrapVisible` 控制按钮可见性;`wirePinsBookmarksPanels` 负责按钮的展开/收起交互。条目摘要依赖 `pinPreview`;点击可跳转到对应消息事件。 * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 - * 【关联】../../../../scripts/template、../src/api/groupBookmarks、banners、core/domUtils、core/state、sidebar、messages/messages、messages/pinPreview。 + * 【关联】../../../../scripts/template、../src/endpoints/groupBookmarks、banners、core/domUtils、core/state、sidebar、messages/messages、messages/pinPreview。 */ import { mountTemplate, renderTemplate } from '/scripts/features/template.mjs' -import { getChatBookmarks, removeChatBookmark } from '../src/api/groupBookmarks.mjs' -import { unpinMessage } from '../src/api/groupChannel.mjs' -import { getGroupState } from '../src/api/groupCore.mjs' +import { getChatBookmarks, removeChatBookmark } from '../src/endpoints/groupBookmarks.mjs' +import { unpinMessage } from '../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../src/endpoints/groupCore.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { setPinsBookmarksWrapVisible, refreshChannelPinsBar } from './banners.mjs' diff --git a/src/public/parts/shells/chat/public/hub/presence.mjs b/src/public/parts/shells/chat/public/hub/presence.mjs index 3df2e630a..885969d4a 100644 --- a/src/public/parts/shells/chat/public/hub/presence.mjs +++ b/src/public/parts/shells/chat/public/hub/presence.mjs @@ -22,8 +22,8 @@ import { displayProfileAvatar } from '../shared/hashAvatar.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' import { cachedProfileFromApi, - fetchEntityProfileApi, -} from '../src/entityProfileApi.mjs' + getEntityProfile, +} from '../src/endpoints/entities.mjs' import { applyProfileAvatarToHost } from './core/avatarCover.mjs' import { @@ -45,7 +45,7 @@ const loadProfileCached = memoizePromise( const entityHash = sep === -1 ? cacheKey : cacheKey.slice(0, sep) const groupId = sep === -1 ? undefined : cacheKey.slice(sep + 1) try { - const data = await fetchEntityProfileApi(entityHash, groupId) + const data = await getEntityProfile(entityHash, groupId) return cachedProfileFromApi(data?.profile, entityHash) } catch { @@ -109,7 +109,7 @@ export async function fetchUserProfile(entityHash, options = {}) { const cacheKey = options.groupId ? `${key}:${options.groupId}` : key if (options.bypassCache) try { - const data = await fetchEntityProfileApi(key, options.groupId) + const data = await getEntityProfile(key, options.groupId) return cachedProfileFromApi(data?.profile, key) } catch { diff --git a/src/public/parts/shells/chat/public/hub/privateGroup.mjs b/src/public/parts/shells/chat/public/hub/privateGroup.mjs index 70b5c9091..84b000a33 100644 --- a/src/public/parts/shells/chat/public/hub/privateGroup.mjs +++ b/src/public/parts/shells/chat/public/hub/privateGroup.mjs @@ -9,7 +9,8 @@ import { renderTemplate } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../scripts/i18n/index.mjs' import { buildCharFriendBinding } from '../shared/friendBinding.mjs' -import { setGroupFriendBinding, unbindFriendGroup } from '../src/api/groupFriendBinding.mjs' +import { deleteSession } from '../src/endpoints/groupCore.mjs' +import { setGroupFriendBinding, unbindFriendGroup } from '../src/endpoints/groupFriendBinding.mjs' import { mountChatConfigPanel } from './chatConfig.mjs' import { openOverlayModal, closeOverlayModal } from './core/overlayModal.mjs' @@ -132,15 +133,7 @@ export async function openGroupSettingsModal(groupId) { document.getElementById('character-chat-delete')?.addEventListener('click', async () => { if (!confirmI18n('chat.hub.deleteSessionConfirm', { name: charname })) return try { - const response = await fetch( - `/api/parts/shells:chat/sessions/${encodeURIComponent(groupId)}`, - { method: 'DELETE', credentials: 'include' }, - ) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Session delete failed') - } - await response.json() + await deleteSession(groupId) showToastI18n('success', 'chat.hub.session.deleted') setTimeout(async () => { closeOverlayModal() diff --git a/src/public/parts/shells/chat/public/hub/profileEdit.mjs b/src/public/parts/shells/chat/public/hub/profileEdit.mjs index 432c02178..2a676cdd3 100644 --- a/src/public/parts/shells/chat/public/hub/profileEdit.mjs +++ b/src/public/parts/shells/chat/public/hub/profileEdit.mjs @@ -3,19 +3,23 @@ * 【职责】Hub 内资料编辑模态:头像/横幅上传、昵称/简介/标签/链接表单与提交;SFW 双槽编辑。 * 【原理】`openHubProfileEdit` 弹出编辑对话框并绑定保存/取消;编辑模式 toggle 切换基线 / sfw_* 字段槽,打开时按查看者 `user.sfw` 初始化。 * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 - * 【关联】../../../../scripts/i18n、../../../../scripts/template、../../../../scripts/toast、../profile/src/endpoints、../src/entityProfileApi、../src/profileLocaleEditor、core/state、presence。 + * 【关联】../../../../scripts/i18n、../../../../scripts/template、../../../../scripts/toast、../src/endpoints/entities、../src/profileLocaleEditor、core/state、presence。 */ -import { getUserSetting } from '/scripts/api/base.mjs' +import { getUserSetting } from '/scripts/endpoints/base.mjs' import { renderTemplate, usingTemplates } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n, primaryLocale } from '../../../../scripts/i18n/index.mjs' -import { rebuildProfileFromPart, uploadEntityFile } from '../profile/src/endpoints.mjs' import { configureEntityProfileCard, paintEntityProfileCard, } from '../shared/entityProfileCard.mjs' import { ensureLocaleEntry, renameLocaleEntry } from '../shared/profileLocaleState.mjs' -import { updateEntityProfileApi } from '../src/entityProfileApi.mjs' +import { + getEntityProfile, + rebuildProfileFromPart, + updateEntityProfile, + uploadEntityFile, +} from '../src/endpoints/entities.mjs' import { normalizeProfileLinks, normalizeProfileTag, @@ -25,7 +29,7 @@ import { renderLocaleTabs, renderTagsEditor, } from '../src/profileLocaleEditor.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { applyProfileAvatarToHost } from './core/avatarCover.mjs' import { store } from './core/state.mjs' @@ -694,7 +698,7 @@ async function handleResetFromPart() { await onSavedCallback?.() } catch (error) { - handleUIError(error, 'chat.hub.profileEdit.resetFrom.partFailed') + handleError('chat.hub.profileEdit.resetFrom.partFailed')(error) } } @@ -786,7 +790,7 @@ async function handleSaveProfile() { banner, sfw_banner, } - const result = await updateEntityProfileApi(editingEntityHash, updates, groupId) + const result = await updateEntityProfile(editingEntityHash, updates, groupId) const queued = !!(result?.queued || avatarQueued || bannerQueued) if (!queued && !result?.profile) throw new Error(result?.error || 'update failed') editDialog.close() @@ -797,7 +801,7 @@ async function handleSaveProfile() { await onSavedCallback?.() } catch (error) { - handleUIError(error, 'chat.profile.errors.saveFailed') + handleError('chat.profile.errors.saveFailed')(error) } } @@ -807,10 +811,9 @@ async function handleSaveProfile() { * @returns {Promise<void>} */ export async function openHubProfileEdit(entityHash, options = {}) { - const { fetchEntityProfileApi: fetchApi } = await import('../src/entityProfileApi.mjs') const groupId = store.context.currentGroupId || undefined const dialog = await ensureEditDialog() - const data = await fetchApi(entityHash, groupId) + const data = await getEntityProfile(entityHash, groupId) if (!data?.profile) { showToastI18n('error', 'chat.profile.errors.loadFailed') return diff --git a/src/public/parts/shells/chat/public/hub/runHubAction.mjs b/src/public/parts/shells/chat/public/hub/runHubAction.mjs index de2b0f536..95f54609e 100644 --- a/src/public/parts/shells/chat/public/hub/runHubAction.mjs +++ b/src/public/parts/shells/chat/public/hub/runHubAction.mjs @@ -1,5 +1,5 @@ import { showToastI18n } from '../../../../scripts/features/toast.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' /** * 统一 Hub 操作:toast + 可选 reload。 @@ -15,7 +15,7 @@ export async function runHubAction(action, options = {}) { return true } catch (error) { - handleUIError(error, options.errorKey || 'chat.hub.message.action.failed') + handleError(options.errorKey || 'chat.hub.message.action.failed')(error) return false } } diff --git a/src/public/parts/shells/chat/public/hub/search.mjs b/src/public/parts/shells/chat/public/hub/search.mjs index f659878ef..47fc47302 100644 --- a/src/public/parts/shells/chat/public/hub/search.mjs +++ b/src/public/parts/shells/chat/public/hub/search.mjs @@ -4,8 +4,8 @@ import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { setElementI18n } from '../../../../scripts/i18n/index.mjs' -import { searchAllChatGroups, searchGroupChannelMessages } from '../src/api/groupChannel.mjs' -import { handleUIError } from '../src/ui/errors.mjs' +import { searchAllChatGroups, searchGroupChannelMessages } from '../src/endpoints/groupChannel.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { bindDismissOnDocumentInteraction } from '/scripts/components/contextMenuDismiss.mjs' import { store } from './core/state.mjs' @@ -172,7 +172,7 @@ export async function runHubMessageSearch(query) { renderSearchResults(items, 'group') } catch (error) { - handleUIError(error, 'chat.hub.search.failed') + handleError('chat.hub.search.failed')(error) hideSearchResults() } } diff --git a/src/public/parts/shells/chat/public/hub/sendQueue.mjs b/src/public/parts/shells/chat/public/hub/sendQueue.mjs index 1be37ab16..3014dd1d4 100644 --- a/src/public/parts/shells/chat/public/hub/sendQueue.mjs +++ b/src/public/parts/shells/chat/public/hub/sendQueue.mjs @@ -5,7 +5,7 @@ * online / WS open 时遍历队列依次调用 sendGroupMessage。 * 气泡复用 pending 样式显示「排队中」状态。 */ -import { sendGroupMessage } from '../src/api/groupChannel.mjs' +import { sendGroupMessage } from '../src/endpoints/groupChannel.mjs' import { store } from './core/state.mjs' diff --git a/src/public/parts/shells/chat/public/hub/serverBar.mjs b/src/public/parts/shells/chat/public/hub/serverBar.mjs index c0f0a0f38..b296f851c 100644 --- a/src/public/parts/shells/chat/public/hub/serverBar.mjs +++ b/src/public/parts/shells/chat/public/hub/serverBar.mjs @@ -3,13 +3,15 @@ * 【职责】左侧服务器栏:渲染用户群组列表、文件夹分组,并从 API 加载/缓存 `store.sidebar.groups`。 * 【原理】`renderServerBar` 填充 `#server-list`;支持拖拽排序与群组入口点击(委托给 `sidebar.selectGroup`)。 * 【数据结构】store 及模块内 Map/Set 字段;见 core/state 与各函数 JSDoc。 - * 【关联】../../../../scripts/template、../src/api/groupBookmarks、groupCore、core/domUtils、core/state、friendBindings、groupContextMenu、sidebar + * 【关联】../../../../scripts/template、../src/endpoints/groupBookmarks、groupCore、core/domUtils、core/state、friendBindings、groupContextMenu、sidebar */ import { renderTemplate } from '../../../../scripts/features/template.mjs' import { aliasForGroup } from '../shared/aliases.mjs' import { isGroupMutedInSidebar, loadNotificationPreferences } from '../shared/notificationPreferences.mjs' -import { getChatBookmarks, saveChatBookmarks } from '../src/api/groupBookmarks.mjs' -import { getGroupList } from '../src/api/groupCore.mjs' +import { getGroupFolders, putGroupFolders } from '../src/endpoints/folders.mjs' +import { getChatBookmarks, saveChatBookmarks } from '../src/endpoints/groupBookmarks.mjs' +import { getGroupList } from '../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { avatarColor, avatarInitial, groupDisplayName } from './core/domUtils.mjs' @@ -33,12 +35,8 @@ import { formatUnreadBadgeHtml } from './unread.mjs' * @returns {Promise<void>} 无 */ export async function persistGroupFolders() { - await fetch('/api/parts/shells:chat/group-folders', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folders: store.sidebar.groupFoldersState.folders }), - }).catch(() => { }) + await putGroupFolders({ folders: store.sidebar.groupFoldersState.folders }) + .catch(handleError('chat.hub.operationFailed')) } /** @@ -218,9 +216,10 @@ export async function renderServerBar() { /** 拉取群组列表与文件夹布局并刷新服务器栏。 @returns {Promise<void>} */ export async function loadGroups() { - const groupListPromise = getGroupList() - const foldersResponse = await fetch('/api/parts/shells:chat/group-folders', { credentials: 'include' }) - const groupList = await groupListPromise + const [groupList, foldersPayload] = await Promise.all([ + getGroupList(), + getGroupFolders().catch(() => null), + ]) store.sidebar.groups = groupList.sort( (left, right) => new Date(right.lastMessageTime || 0) - new Date(left.lastMessageTime || 0), ) @@ -232,9 +231,8 @@ export async function loadGroups() { ) if (liveBookmarks.length !== bookmarks.length) await saveChatBookmarks(liveBookmarks) } - if (foldersResponse.ok) { - const payload = await foldersResponse.json() - const rawFolders = Array.isArray(payload.folders) ? payload.folders : [] + if (foldersPayload) { + const rawFolders = Array.isArray(foldersPayload.folders) ? foldersPayload.folders : [] store.sidebar.groupFoldersState = { folders: rawFolders.map((folder, folderIndex) => ({ id: String(folder.id || '').trim() || `folder-${folderIndex}`, diff --git a/src/public/parts/shells/chat/public/hub/sidebar/createChannel.mjs b/src/public/parts/shells/chat/public/hub/sidebar/createChannel.mjs index d2cf38f4a..f29cce05d 100644 --- a/src/public/parts/shells/chat/public/hub/sidebar/createChannel.mjs +++ b/src/public/parts/shells/chat/public/hub/sidebar/createChannel.mjs @@ -5,9 +5,9 @@ import { openDialogFromTemplate } from '../../../../../scripts/features/dialog.mjs' import { usingTemplates } from '../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../scripts/features/toast.mjs' -import { createChannel } from '../../src/api/groupChannel.mjs' -import { getGroupState } from '../../src/api/groupCore.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { createChannel } from '../../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store, setState } from '../core/state.mjs' import { selectChannel } from './selectChannel.mjs' @@ -44,7 +44,7 @@ export async function showCreateChannelModal() { showToastI18n('success', 'chat.hub.newChannel.success') } catch (error) { - handleUIError(error, 'chat.hub.newChannel.failed') + handleError('chat.hub.newChannel.failed')(error) } }) }, diff --git a/src/public/parts/shells/chat/public/hub/sidebar/federationRoom.mjs b/src/public/parts/shells/chat/public/hub/sidebar/federationRoom.mjs index c0b1c3e4a..735be8b60 100644 --- a/src/public/parts/shells/chat/public/hub/sidebar/federationRoom.mjs +++ b/src/public/parts/shells/chat/public/hub/sidebar/federationRoom.mjs @@ -2,11 +2,11 @@ * 【文件】public/hub/sidebar/federationRoom.mjs * 【职责】切群/切频道时安静重绑联邦分区房间。 */ -import { rebindFederationRoom } from '../../src/api/groupFederation.mjs' -import { toError } from '../../src/ui/errors.mjs' +import { rebindFederationRoom } from '../../src/endpoints/groupFederation.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' /** - * 后台重绑联邦分区房间;失败写入 debug 日志,不打扰切频道 UX。 + * 后台重绑联邦分区房间;失败经 handleError 上报。 * @param {string} groupId 群 ID * @param {{ channelId?: string | null }} [options] 活跃频道 * @returns {Promise<void>} @@ -17,14 +17,9 @@ export async function rebindFederationRoomQuiet(groupId, options = {}) { await rebindFederationRoom(groupId, options) } catch (error) { - const err = toError(error) - import('https://esm.sh/@sentry/browser') - .then(Sentry => Sentry.captureException(err)) - .catch(() => { }) - console.error('hub_federation_rebind', { + handleError('chat.hub.federation.rebindFailed', { groupId, channelId: options.channelId ?? null, - error: err.message, - }) + }, error) } } diff --git a/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs b/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs index 41360d8cb..c3c9cc3fc 100644 --- a/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs +++ b/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs @@ -2,11 +2,11 @@ * 【文件】public/hub/sidebar/groupMembership.mjs * 【职责】选群时的入群判定、自动 join、联邦 catch-up。 */ -import { getGroupState, joinGroup } from '../../src/api/groupCore.mjs' -import { federationCatchUp } from '../../src/api/groupFederation.mjs' +import { getGroupState, joinGroup } from '../../src/endpoints/groupCore.mjs' +import { federationCatchUp } from '../../src/endpoints/groupFederation.mjs' import { broadcastHubGroupJoined } from '../../src/hubBroadcast.mjs' import { resolvePowForJoin } from '../../src/powJoin.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { setPinsBookmarksWrapVisible, setSyncBanner, @@ -36,7 +36,7 @@ export async function syncGroupFromNetwork(groupId, options = {}) { catchup = await federationCatchUp(groupId, { waitMs: options.waitMs ?? 1400 }) } catch (error) { - const catchupError = handleUIError(error, 'chat.hub.sync.failed').message + const catchupError = handleError('chat.hub.sync.failed')(error).message setSyncBanner(true, { i18nKey: 'chat.hub.sync.failed', params: { error: catchupError } }) return } diff --git a/src/public/parts/shells/chat/public/hub/sidebar/index.mjs b/src/public/parts/shells/chat/public/hub/sidebar/index.mjs index 8dccc9fc8..94c5e317d 100644 --- a/src/public/parts/shells/chat/public/hub/sidebar/index.mjs +++ b/src/public/parts/shells/chat/public/hub/sidebar/index.mjs @@ -3,8 +3,8 @@ * 【职责】群侧栏协调入口:组装频道树 / 成员 / 信息卡,驱动 selectGroup / selectChannel。 */ import { mountTemplate } from '../../../../../scripts/features/template.mjs' -import { getGroupState } from '../../src/api/groupCore.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { getGroupState } from '../../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { setPinsBookmarksWrapVisible, updateStatusBanners, @@ -141,7 +141,7 @@ export async function selectGroup(groupId, presetChannelId = null) { catch (error) { setPinsBookmarksWrapVisible(false) updateStatusBanners() - const err = handleUIError(error, 'chat.hub.load.groupFailed') + const err = handleError('chat.hub.load.groupFailed')(error) await mountTemplate(document.getElementById('messages'), 'hub/empty/error', { i18nKey: 'chat.hub.load.groupFailed', errorMessage: err.message, diff --git a/src/public/parts/shells/chat/public/hub/sidebar/selectChannel.mjs b/src/public/parts/shells/chat/public/hub/sidebar/selectChannel.mjs index 46f02bd0c..8ba689455 100644 --- a/src/public/parts/shells/chat/public/hub/sidebar/selectChannel.mjs +++ b/src/public/parts/shells/chat/public/hub/sidebar/selectChannel.mjs @@ -3,11 +3,18 @@ * 【职责】切换频道:composer、草稿、消息加载、群 WS。 */ import { showToastI18n } from '../../../../../scripts/features/toast.mjs' -import { updateChannelListItems } from '../../src/api/groupChannel.mjs' -import { getGroupState } from '../../src/api/groupCore.mjs' +import { updateChannelListItems } from '../../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../../src/endpoints/groupCore.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { createFileHandlers } from '../../src/ui/groupFileUpload.mjs' import { updateStatusBanners } from '../banners.mjs' +import { + refreshCallButtonActiveForCurrentChannel, + refreshCallStatusBadge, +} from '../call.mjs' import { channelTypeIconHtml } from '../channels.mjs' +import { loadDraft } from '../composerDraft.mjs' +import { clearReplyTarget } from '../composerReply.mjs' import { warmCharEntityHashCache } from '../core/domUtils.mjs' import { store, setState } from '../core/state.mjs' import { updateHash } from '../core/urlHash.mjs' @@ -42,10 +49,10 @@ export async function selectChannel(channelId) { if (isPrivateChatActive()) store.privateGroup.channelId = channelId updateHash(store.context.currentGroupId, channelId) - void import('../composerReply.mjs').then(({ clearReplyTarget }) => clearReplyTarget()) + clearReplyTarget() const { showHubMainPane } = await import('../hubPane.mjs') showHubMainPane() - void warmCharEntityHashCache() + warmCharEntityHashCache().catch(handleError('chat.hub.warmCharCacheFailed')) const { renderHubChannelSidebar } = await import('./index.mjs') await renderHubChannelSidebar(store.context.currentState) if (store.context.currentGroupId) @@ -75,18 +82,14 @@ export async function selectChannel(channelId) { /** @returns {object | null} 当前群 state(读取文件加密模式) */ getCurrentState: () => store.context.currentState, }) - void import('../composerDraft.mjs').then(({ loadDraft }) => { - loadDraft(store.context.currentGroupId, channelId) - }) + loadDraft(store.context.currentGroupId, channelId) await loadMessages() if (store.context.currentGroupId && store.context.currentChannelId && channelType === 'text') connectGroupWebSocket(store.context.currentGroupId, store.context.currentChannelId) updateStatusBanners() - void refreshPinsBookmarks() - void import('../call.mjs').then(m => { - m.refreshCallButtonActiveForCurrentChannel() - void m.refreshCallStatusBadge() - }) + refreshPinsBookmarks().catch(handleError('chat.hub.operationFailed')) + refreshCallButtonActiveForCurrentChannel() + refreshCallStatusBadge().catch(handleError('chat.hub.operationFailed')) } /** diff --git a/src/public/parts/shells/chat/public/hub/stream/handlers/dagEvent.mjs b/src/public/parts/shells/chat/public/hub/stream/handlers/dagEvent.mjs index a37b7dfb5..8a30dbd43 100644 --- a/src/public/parts/shells/chat/public/hub/stream/handlers/dagEvent.mjs +++ b/src/public/parts/shells/chat/public/hub/stream/handlers/dagEvent.mjs @@ -2,7 +2,7 @@ * 【文件】public/hub/stream/handlers/dagEvent.mjs * 【职责】WS `dag_event`(频道结构 / 编辑删除 / overlay)。 */ -import { getGroupState } from '../../../src/api/groupCore.mjs' +import { getGroupState } from '../../../src/endpoints/groupCore.mjs' import { store } from '../../core/state.mjs' import { dispatchChannelMessageDelete, diff --git a/src/public/parts/shells/chat/public/hub/stream/volatileSlots.mjs b/src/public/parts/shells/chat/public/hub/stream/volatileSlots.mjs index ebaaf4366..5044ff165 100644 --- a/src/public/parts/shells/chat/public/hub/stream/volatileSlots.mjs +++ b/src/public/parts/shells/chat/public/hub/stream/volatileSlots.mjs @@ -251,7 +251,7 @@ export function resumeActiveStreamBuffers() { for (const streamId of [...volatileStreams.keys()]) void (async () => { try { - const { getStreamBufferChunks } = await import('../../src/api/groupChannel.mjs') + const { getStreamBufferChunks } = await import('../../src/endpoints/groupChannel.mjs') const chunks = await getStreamBufferChunks(groupId, channelId, streamId) for (const chunk of chunks) await appendStreamSlices(streamId, Number(chunk.chunkSeq ?? 0), chunk.slices || [], channelId) diff --git a/src/public/parts/shells/chat/public/hub/threadDrawer.mjs b/src/public/parts/shells/chat/public/hub/threadDrawer.mjs index 70bfe0c6b..fe9f1f304 100644 --- a/src/public/parts/shells/chat/public/hub/threadDrawer.mjs +++ b/src/public/parts/shells/chat/public/hub/threadDrawer.mjs @@ -8,8 +8,8 @@ import { usingTemplates, } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' -import { createChannelThread, getChannelViewLog, sendGroupMessage } from '../src/api/groupChannel.mjs' -import { getGroupState } from '../src/api/groupCore.mjs' +import { createChannelThread, getChannelViewLog, sendGroupMessage } from '../src/endpoints/groupChannel.mjs' +import { getGroupState } from '../src/endpoints/groupCore.mjs' import { hubEmptyWaveIcon } from '../src/lib/emojiSvg.mjs' import { applyChannelDisplayChain } from '../src/ui/channelDisplay.mjs' diff --git a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs index 5c74960c7..f86f469fb 100644 --- a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs +++ b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs @@ -3,6 +3,7 @@ */ import { renderTemplate, usingTemplates } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' +import { getTranslationPrefs, putTranslationPrefs } from '../src/endpoints/prefs.mjs' import { closeOverlayModal } from './core/overlayModal.mjs' @@ -14,8 +15,7 @@ import { closeOverlayModal } from './core/overlayModal.mjs' */ export async function mountTranslationPrefsPanel(panel, footer) { usingTemplates('/parts/shells:chat/src/templates') - const response = await fetch('/api/parts/shells:chat/translation-prefs', { credentials: 'include' }) - const data = response.ok ? await response.json() : { prefs: { autoTranslate: false } } + const data = await getTranslationPrefs().catch(() => ({ prefs: { autoTranslate: false } })) const prefs = data.prefs || { autoTranslate: false } const root = await renderTemplate('hub/prefs/translation', { autoTranslateChecked: prefs.autoTranslate ? 'checked' : '', @@ -29,13 +29,7 @@ export async function mountTranslationPrefsPanel(panel, footer) { footer.querySelector('[data-action="save"]')?.addEventListener('click', () => { const checked = panel.querySelector('#auto-translate') instanceof HTMLInputElement && /** @type {HTMLInputElement} */ panel.querySelector('#auto-translate').checked - void fetch('/api/parts/shells:chat/translation-prefs', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prefs: { ...prefs, autoTranslate: checked } }), - }).then(res => { - if (!res.ok) throw new Error(String(res.status)) + void putTranslationPrefs({ prefs: { ...prefs, autoTranslate: checked } }).then(() => { showToastI18n('success', 'chat.hub.translationPrefs.saved') closeOverlayModal() }).catch(error => { diff --git a/src/public/parts/shells/chat/public/hub/unread.mjs b/src/public/parts/shells/chat/public/hub/unread.mjs index efe19f45a..c80b3d106 100644 --- a/src/public/parts/shells/chat/public/hub/unread.mjs +++ b/src/public/parts/shells/chat/public/hub/unread.mjs @@ -1,10 +1,15 @@ /** * 【文件】hub/unread.mjs — 未读 badge 与 read-marker 同步。 */ -import { putChannelReadMarker } from '../src/api/groupChannel.mjs' +import { putChannelReadMarker } from '../src/endpoints/groupChannel.mjs' import { store } from './core/state.mjs' +/** serverBar 静态 import 会与 unread 成环;惰性刷新 chrome。 @returns {void} */ +const refreshServerBar = () => { + import('./serverBar.mjs').then(({ renderServerBar }) => renderServerBar()) +} + /** * @param {number} count 未读数 * @returns {string} 封顶展示(99+) @@ -96,9 +101,9 @@ export async function markCurrentChannelRead() { delete group.channelUnread[channelId] group.unreadCount = sumChannelUnread(group.channelUnread) } - void import('./serverBar.mjs').then(({ renderServerBar }) => renderServerBar()) - void import('./sidebar/index.mjs').then(({ renderHubChannelSidebar }) => { - if (store.context.currentState) void renderHubChannelSidebar(store.context.currentState) + refreshServerBar() + import('./sidebar/index.mjs').then(({ renderHubChannelSidebar }) => { + if (store.context.currentState) renderHubChannelSidebar(store.context.currentState) }) } @@ -108,7 +113,7 @@ export async function markCurrentChannelRead() { * @returns {void} */ export function handleReadMarkerWire(wireMessage) { - void import('./memberReadMarkers.mjs').then(({ applyMemberReadMarkerWire }) => { + import('./memberReadMarkers.mjs').then(({ applyMemberReadMarkerWire }) => { applyMemberReadMarkerWire(wireMessage) }) const viewerName = store.viewer.username @@ -128,7 +133,7 @@ export function handleReadMarkerWire(wireMessage) { store.messages.readMarker = readMarker store.messages.firstUnreadEventId = null } - void import('./serverBar.mjs').then(({ renderServerBar }) => renderServerBar()) + refreshServerBar() } /** @@ -144,5 +149,5 @@ export function bumpChannelUnread(groupId, channelId) { group.channelUnread ??= {} group.channelUnread[channelId] = (Number(group.channelUnread[channelId]) || 0) + 1 group.unreadCount = (Number(group.unreadCount) || 0) + 1 - void import('./serverBar.mjs').then(({ renderServerBar }) => renderServerBar()) + refreshServerBar() } diff --git a/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs b/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs index 71e7a95ed..cf954e373 100644 --- a/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs +++ b/src/public/parts/shells/chat/public/hub/wiring/bootstrap.mjs @@ -3,7 +3,7 @@ * 【职责】Hub 轻量事件绑定:不依赖 messages/init 重模块图,保证建群、composer、hash 导航等壳层交互尽快可用。 * 【关联】groupModals、dialog、wireEvents(其余绑定延后加载) */ -import { onServerEvent } from '../../../../../scripts/api/server_events.mjs' +import { onServerEvent } from '../../../../../scripts/endpoints/server_events.mjs' import { openDialogFromTemplate } from '../../../../../scripts/features/dialog.mjs' import { withTemplates } from '../../../../../scripts/features/template.mjs' import { iconifyImg } from '../../src/lib/emojiSvg.mjs' diff --git a/src/public/parts/shells/chat/public/hub/wiring/fileEvents.mjs b/src/public/parts/shells/chat/public/hub/wiring/fileEvents.mjs index 7ddc3ed98..5b7b59d4f 100644 --- a/src/public/parts/shells/chat/public/hub/wiring/fileEvents.mjs +++ b/src/public/parts/shells/chat/public/hub/wiring/fileEvents.mjs @@ -1,4 +1,5 @@ import { showToastI18n } from '../../../../../scripts/features/toast.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from '../core/state.mjs' import { isFilesDrawerOpen, refreshFilesDrawer, setFilesDrawerOpen, wireFilesDrawerToggle } from '../files.mjs' @@ -18,8 +19,7 @@ export function wireFileEvents() { await addFilesFromEvent({ target: { files } }) } catch (err) { - const { handleUIError } = await import('../../src/ui/errors.mjs') - handleUIError(err, 'chat.hub.send.imageFailed') + handleError('chat.hub.send.imageFailed')(err) } }) @@ -31,13 +31,11 @@ export function wireFileEvents() { const open = !isFilesDrawerOpen() setFilesDrawerOpen(open) if (open) - void refreshFilesDrawer({ + refreshFilesDrawer({ groupId: store.context.currentGroupId, state: store.context.currentState, viewer: store.context.currentState?.viewer, - }).catch(err => { - void import('../../src/ui/errors.mjs').then(({ handleUIError }) => handleUIError(err)) - }) + }).catch(handleError('chat.hub.files.loadFailed')) }) wireFilesDrawerToggle() diff --git a/src/public/parts/shells/chat/public/hub/wiring/messageBubbleEvents.mjs b/src/public/parts/shells/chat/public/hub/wiring/messageBubbleEvents.mjs index 0725f2a0f..671eada41 100644 --- a/src/public/parts/shells/chat/public/hub/wiring/messageBubbleEvents.mjs +++ b/src/public/parts/shells/chat/public/hub/wiring/messageBubbleEvents.mjs @@ -1,9 +1,10 @@ import { showToastI18n } from '../../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../../scripts/i18n/index.mjs' import { parseEmojiToken } from '../../shared/inlineTokenSyntax.mjs' +import { addDenylistEntry } from '../../src/endpoints/p2p.mjs' import { addPackToCollection, saveStickerFromMessage } from '../../src/saveStickerFromMessage.mjs' import { showTrustAuthorDialog } from '../../src/trustAuthorDialog.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from '../core/state.mjs' /** @@ -38,7 +39,7 @@ export async function handleMessageBubbleClick(event) { showToastI18n('success', 'chat.hub.save.emojiOk') } catch (error) { - handleUIError(error, 'chat.hub.save.emojiFailed') + handleError('chat.hub.save.emojiFailed')(error) } return true } @@ -54,7 +55,7 @@ export async function handleMessageBubbleClick(event) { showToastI18n('success', 'chat.hub.save.stickerOk') } catch (error) { - handleUIError(error, 'chat.hub.save.stickerFailed') + handleError('chat.hub.save.stickerFailed')(error) } return true } @@ -62,25 +63,15 @@ export async function handleMessageBubbleClick(event) { if (blockAuthorButton?.dataset?.blockPub && store.context.currentGroupId) { if (!confirmI18n('chat.hub.block.confirm')) return true try { - const response = await fetch('/api/p2p/denylist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - scope: 'subject', - value: blockAuthorButton.dataset.blockPub, - groupId: store.context.currentGroupId, - }), + await addDenylistEntry({ + scope: 'subject', + value: blockAuthorButton.dataset.blockPub, + groupId: store.context.currentGroupId, }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - handleUIError(new Error(data.error || response.statusText), 'chat.hub.operationFailed') - return true - } showToastI18n('success', 'chat.hub.block.ok') } catch (error) { - handleUIError(error, 'chat.hub.operationFailed') + handleError('chat.hub.operationFailed')(error) } return true } diff --git a/src/public/parts/shells/chat/public/hub/wiring/voteEvents.mjs b/src/public/parts/shells/chat/public/hub/wiring/voteEvents.mjs index 8b9571d90..b12cdb1a1 100644 --- a/src/public/parts/shells/chat/public/hub/wiring/voteEvents.mjs +++ b/src/public/parts/shells/chat/public/hub/wiring/voteEvents.mjs @@ -1,6 +1,6 @@ import { showToastI18n } from '../../../../../scripts/features/toast.mjs' -import { castChannelVote, createChannelVote } from '../../src/api/groupChannel.mjs' -import { handleUIError } from '../../src/ui/errors.mjs' +import { castChannelVote, createChannelVote } from '../../src/endpoints/groupChannel.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from '../core/state.mjs' import { loadMessages } from '../messages/messages.mjs' import { getActiveThreadChannelId } from '../threadDrawer.mjs' @@ -42,7 +42,7 @@ export function wireVoteEvents() { await loadMessages() } catch (err) { - handleUIError(err, 'chat.hub.vote.createFailed') + handleError('chat.hub.vote.createFailed')(err) } }) } diff --git a/src/public/parts/shells/chat/public/profile/index.mjs b/src/public/parts/shells/chat/public/profile/index.mjs index ba727ae57..55cc72390 100644 --- a/src/public/parts/shells/chat/public/profile/index.mjs +++ b/src/public/parts/shells/chat/public/profile/index.mjs @@ -1,11 +1,11 @@ /** * 【文件】public/profile/index.mjs * 【职责】实体资料独立页:按 URL 中 entityHash 拉取并渲染多语言简介、链接与编辑入口。 - * 【原理】getProfile + 模板 profile/*;onLanguageChange 刷新;可跳转 Hub profileEdit;挂载主人设置面板。 + * 【原理】getEntityProfile + 模板 profile/*;onLanguageChange 刷新;可跳转 Hub profileEdit;挂载主人设置面板。 * 【数据结构】currentEntityHash、currentProfile;localized 各 locale 字段。 - * 【关联】profile/src/endpoints.mjs、ownerSettingsPanel.mjs;hub/entityProfile.mjs、profileEdit.mjs。 + * 【关联】src/endpoints/entities.mjs、ownerSettingsPanel.mjs;hub/entityProfile.mjs、profileEdit.mjs。 */ -import { onServerEvent } from '../../../scripts/api/server_events.mjs' +import { onServerEvent } from '../../../scripts/endpoints/server_events.mjs' import { renderTemplate, usingTemplates, @@ -20,10 +20,12 @@ import { paintEntityProfileCard, } from '../shared/entityProfileCard.mjs' import { avatarInitial } from '../shared/hashAvatar.mjs' +import { getEntityProfile } from '../src/endpoints/entities.mjs' +import { getGroupList, getGroupState } from '../src/endpoints/groupCore.mjs' +import { getViewer } from '../src/endpoints/viewer.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { initProfileOwnerSettings } from './ownerSettingsPanel.mjs' -import { getProfile } from './src/endpoints.mjs' let currentEntityHash = null let currentProfile = null @@ -91,9 +93,7 @@ async function init() { }) try { - const resp = await fetch('/api/parts/shells:chat/viewer', { credentials: 'include' }) - if (!resp.ok) throw new Error(`viewer ${resp.status}`) - const data = await resp.json() + const data = await getViewer() if (!data.viewerEntityHash) { showToastI18n( 'error', @@ -133,7 +133,7 @@ async function init() { */ async function loadProfile(entityHash) { try { - const response = await getProfile(entityHash) + const response = await getEntityProfile(entityHash) if (response.profile) { currentProfile = response.profile await renderProfile(currentProfile) @@ -181,14 +181,7 @@ async function renderProfile(profile) { */ async function loadUserGroups() { try { - const response = await fetch('/api/parts/shells:chat/groups', { - credentials: 'include', - }) - if (!response.ok) return - const data = await response.json() - if (!Array.isArray(data)) return - - const groups = data + const groups = await getGroupList() const container = document.getElementById('profile-groups') const noGroups = document.getElementById('no-groups') @@ -224,26 +217,15 @@ async function loadUserGroups() { */ async function loadUserChannels() { try { - const response = await fetch('/api/parts/shells:chat/groups', { - credentials: 'include', - }) - if (!response.ok) return - const data = await response.json() - if (!Array.isArray(data)) return - - const groups = data + const groups = await getGroupList() const allChannels = [] for (const group of groups) try { - const stateRes = await fetch(`/api/parts/shells:chat/groups/${group.groupId}/state`, { - credentials: 'include', - }) - if (!stateRes.ok) continue - const stateData = await stateRes.json() - if (!stateData.meta?.channels) continue + const state = await getGroupState(group.groupId) + if (!state.channels) continue - for (const [channelId, channel] of Object.entries(stateData.meta.channels)) + for (const [channelId, channel] of Object.entries(state.channels)) allChannels.push({ channelId, name: channel.name || channelId, diff --git a/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs b/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs index c49c15512..268304134 100644 --- a/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs +++ b/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs @@ -5,6 +5,8 @@ */ import { mountTemplate } from '../../../scripts/features/template.mjs' import { showToastI18n } from '../../../scripts/features/toast.mjs' +import { getEntityProfile, setEntityOwner } from '../src/endpoints/entities.mjs' +import { getViewer } from '../src/endpoints/viewer.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { showOwnerConfirmDialog } from './ownerConfirmDialog.mjs' @@ -20,23 +22,6 @@ function normalizeOwnerInput(raw) { return value } -/** - * @param {string | null} ownerEntityHash 主人 hash;null 清除 - * @returns {Promise<void>} - */ -async function putOwner(ownerEntityHash) { - const res = await fetch('/api/parts/shells:chat/entities/owner', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ownerEntityHash }), - }) - if (!res.ok) { - const data = await res.json().catch(() => ({})) - throw new Error(data.error || res.statusText) - } -} - /** * 在个人资料页挂载「我的主人」面板。 * @returns {Promise<void>} @@ -50,20 +35,13 @@ export async function initProfileOwnerSettings() { let agents = [] let ownerEntityHash = '' try { - const viewerRes = await fetch('/api/parts/shells:chat/viewer', { credentials: 'include' }) - if (!viewerRes.ok) throw new Error(`viewer ${viewerRes.status}`) - const viewer = await viewerRes.json() + const viewer = await getViewer() viewerEntityHash = viewer.viewerEntityHash || null agents = Array.isArray(viewer.agents) ? viewer.agents : [] ownerEntityHash = String(viewer.profile?.ownerEntityHash || '').trim().toLowerCase() if (viewerEntityHash && !ownerEntityHash) { - const profileRes = await fetch(`/api/parts/shells:chat/entities/${encodeURIComponent(viewerEntityHash)}`, { - credentials: 'include', - }) - if (profileRes.ok) { - const data = await profileRes.json() - ownerEntityHash = String(data.profile?.ownerEntityHash || '').trim().toLowerCase() - } + const data = await getEntityProfile(viewerEntityHash) + ownerEntityHash = String(data.profile?.ownerEntityHash || '').trim().toLowerCase() } } catch (error) { @@ -103,7 +81,7 @@ export async function initProfileOwnerSettings() { } const confirmed = await showOwnerConfirmDialog(next) if (!confirmed) return - await putOwner(next) + await setEntityOwner(next) showToastI18n('success', 'chat.profile.owner.saved') await initProfileOwnerSettings() } @@ -114,7 +92,7 @@ export async function initProfileOwnerSettings() { document.getElementById('profile-owner-clear')?.addEventListener('click', async () => { try { - await putOwner(null) + await setEntityOwner(null) showToastI18n('success', 'chat.profile.owner.cleared') await initProfileOwnerSettings() } diff --git a/src/public/parts/shells/chat/public/profile/src/endpoints.mjs b/src/public/parts/shells/chat/public/profile/src/endpoints.mjs deleted file mode 100644 index 57795d0eb..000000000 --- a/src/public/parts/shells/chat/public/profile/src/endpoints.mjs +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 【文件】public/profile/src/endpoints.mjs - * 【职责】实体资料 REST 薄封装:GET/PUT /entities/:entityHash、rebuild、EVFS multipart 上传。 - * 【原理】localeQueryString 附加 groupId;credentials include;错误时附带 response 的 Error。 - * 【数据结构】entityHash(128 hex)、updates 对象、File。 - * 【关联】entityProfileApi.mjs;profile/index.mjs、Hub 资料编辑。 - */ -import { localeQueryString } from '../../src/entityProfileApi.mjs' - -/** - * 实体资料 API(128 位 entityHash) - */ - -/** - * @param {string} entityHash 128 位 entityHash - * @param {string} [groupId] 群 ID - * @returns {Promise<object>} 资料 JSON - */ -export async function getProfile(entityHash, groupId) { - const qs = localeQueryString(groupId) - const response = await fetch( - `/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`, - { credentials: 'include' }, - ) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw Object.assign(new Error(data.error || response.statusText), data, { response }) - } - return response.json() -} - -/** - * @param {string} entityHash 128 位 entityHash - * @param {object} updates 更新内容 - * @param {string} [groupId] 群 ID - * @returns {Promise<object>} 更新后的资料 JSON - */ -export async function updateProfile(entityHash, updates, groupId) { - const qs = localeQueryString(groupId) - const response = await fetch(`/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - ...updates, - ...groupId ? { groupId } : {}, - }), - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) - throw Object.assign(new Error(data.error || response.statusText), data, { response }) - - return data -} - -/** - * @param {string} entityHash 128 位 entityHash - * @param {string} [groupId] 群 ID - * @returns {Promise<object>} 重建后的资料 JSON - */ -export async function rebuildProfileFromPart(entityHash, groupId) { - const qs = localeQueryString(groupId) - const response = await fetch( - `/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}/rebuild-from-part${qs ? `?${qs}` : ''}`, - { method: 'POST', credentials: 'include' }, - ) - const data = await response.json().catch(() => ({})) - if (!response.ok) - throw Object.assign(new Error(data.error || response.statusText), data, { response }) - return data -} - -/** - * multipart 写任意实体 EVFS 路径。 - * @param {string} entityHash 128 hex - * @param {string} logicalPath EVFS 逻辑路径 - * @param {File|Blob} file 文件 - * @returns {Promise<object>} `{ url, manifest? }` - */ -export async function uploadEntityFile(entityHash, logicalPath, file) { - const formData = new FormData() - formData.append('file', file) - const response = await fetch( - `/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}/files/${String(logicalPath || '').replace(/^\/+/, '')}`, - { method: 'POST', credentials: 'include', body: formData }, - ) - const data = await response.json().catch(() => ({})) - if (!response.ok) - throw Object.assign(new Error(data.error || response.statusText), data, { response }) - return data -} diff --git a/src/public/parts/shells/chat/public/providers/emoji.mjs b/src/public/parts/shells/chat/public/providers/emoji.mjs index 0826ffb1f..4ea0e4661 100644 --- a/src/public/parts/shells/chat/public/providers/emoji.mjs +++ b/src/public/parts/shells/chat/public/providers/emoji.mjs @@ -4,9 +4,19 @@ import { primaryLocale, loadPreferredLangs } from '/scripts/i18n/index.mjs' import { resolveEmojiItemLabels, resolvePackPresentation } from '/scripts/features/emoji/packPresentation.mjs' +import { CHAT_API_CLIENT_PREFIX } from '../shared/apiPaths.mjs' import { formatEmojiToken, tokenForSelection } from '../shared/inlineTokenSyntax.mjs' - -const CHAT_API = '/api/parts/shells:chat' +import { + addEmojiCollectionPack, + discoverEmojiPacks, + getEmojiUsage, + getGroupPreview, + listEmojiPacks, + recordEmojiUsage, + removeEmojiCollectionPack, +} from '../src/endpoints/emoji.mjs' + +const CHAT_API = CHAT_API_CLIENT_PREFIX /** * 包表情内容 URL(经 chat API 代理)。 @@ -23,9 +33,7 @@ export function packEmojiContentUrl(packId, emojiId) { * @returns {Promise<object>} emoji-usage 载荷 */ async function fetchEmojiUsage() { - const r = await fetch(`${CHAT_API}/emoji-usage`, { credentials: 'include' }) - if (!r.ok) return { log: [], lastUsedAtByPack: {}, collection: { packIds: [], emojiIds: [] } } - return r.json() + return getEmojiUsage() } /** @@ -35,11 +43,7 @@ async function fetchEmojiUsage() { * @returns {Promise<object[]>} 原始包清单 */ async function fetchAvailablePacks(context = {}) { - const q = context.groupId ? `?groupId=${encodeURIComponent(context.groupId)}` : '' - const r = await fetch(`${CHAT_API}/emoji-packs${q}`, { credentials: 'include' }) - if (!r.ok) return [] - const data = await r.json() - return Array.isArray(data.packs) ? data.packs : [] + return listEmojiPacks(context.groupId) } /** @@ -126,9 +130,13 @@ export default { async packSourcePreview(pack) { const groupId = pack?.source?.kind === 'group' ? pack.source.id : pack?.groupId if (!groupId) return null - const r = await fetch(`${CHAT_API}/groups/${encodeURIComponent(groupId)}/preview`, { credentials: 'include' }) - if (!r.ok) return { kind: 'group', groupId, pack } - return { kind: 'group', groupId, pack, preview: await r.json() } + try { + const preview = await getGroupPreview(groupId) + return { kind: 'group', groupId, pack, preview } + } + catch { + return { kind: 'group', groupId, pack } + } }, /** @@ -138,12 +146,8 @@ export default { */ async discoverPacks(options = {}) { const locales = loadPreferredLangs().length ? loadPreferredLangs() : [primaryLocale()] - const r = await fetch(`${CHAT_API}/emoji-packs/discover?limit=${encodeURIComponent(options.limit || 48)}`, { - credentials: 'include', - }) - if (!r.ok) return [] - const data = await r.json() - return (data.offers || []).map(offer => { + const offers = await discoverEmojiPacks(options.limit || 48) + return offers.map(offer => { const presentation = resolvePackPresentation(offer, locales, offer.infoDefaults || {}) return { packId: offer.packId, @@ -177,12 +181,7 @@ export default { * @returns {Promise<void>} */ async record(item) { - await fetch(`${CHAT_API}/emoji-usage/record`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(item), - }) + await recordEmojiUsage(item) }, }, @@ -201,15 +200,7 @@ export default { * @returns {Promise<{ packIds: string[], emojiIds: string[] }>} 更新后收藏 */ async add(packId) { - const r = await fetch(`${CHAT_API}/emoji-usage/collection/packs`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ packId }), - }) - if (!r.ok) throw new Error(await r.text() || r.statusText) - const data = await r.json().catch(() => ({})) - return data.collection || data + return addEmojiCollectionPack(packId) }, /** * 从收藏移除包。 @@ -217,13 +208,7 @@ export default { * @returns {Promise<{ packIds: string[], emojiIds: string[] }>} 更新后收藏 */ async remove(packId) { - const r = await fetch(`${CHAT_API}/emoji-usage/collection/packs/${encodeURIComponent(packId)}`, { - method: 'DELETE', - credentials: 'include', - }) - if (!r.ok) throw new Error(await r.text() || r.statusText) - const data = await r.json().catch(() => ({})) - return data.collection || data + return removeEmojiCollectionPack(packId) }, }, diff --git a/src/public/parts/shells/chat/public/shared/aliases.mjs b/src/public/parts/shells/chat/public/shared/aliases.mjs index 57aee485f..b87da1528 100644 --- a/src/public/parts/shells/chat/public/shared/aliases.mjs +++ b/src/public/parts/shells/chat/public/shared/aliases.mjs @@ -1,6 +1,4 @@ -import { CHAT_API_CLIENT_PREFIX } from './apiPaths.mjs' - -const ALIASES_API = `${CHAT_API_CLIENT_PREFIX}/aliases` +import { getAliases, putAliases as putAliasesApi } from '../src/endpoints/prefs.mjs' /** @type {{ entities: Record<string, string>, groups: Record<string, string> } | null} */ let cache = null @@ -14,22 +12,6 @@ function normEntity(entityHash) { return String(entityHash || '').trim().toLowerCase() } -/** - * @param {object} doc 别名档 - * @returns {Promise<{ entities: Record<string, string>, groups: Record<string, string> }>} 写入后的别名档 - */ -async function putAliases(doc) { - const response = await fetch(ALIASES_API, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(doc), - }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'save aliases failed') - return { entities: data.entities || {}, groups: data.groups || {} } -} - /** * 拉取整档别名并填充内存缓存(幂等,并发共享同一请求)。 * @returns {Promise<{ entities: Record<string, string>, groups: Record<string, string> }>} 别名档 @@ -37,10 +19,7 @@ async function putAliases(doc) { export async function loadAliases() { if (cache) return cache loadPromise ??= (async () => { - const response = await fetch(ALIASES_API, { credentials: 'include' }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'load aliases failed') - cache = { entities: data.entities || {}, groups: data.groups || {} } + cache = await getAliases() return cache })() try { @@ -95,7 +74,7 @@ export async function setEntityAlias(entityHash, name) { const value = String(name || '').trim() if (value) entities[key] = value else delete entities[key] - cache = await putAliases({ entities, groups: current.groups }) + cache = await putAliasesApi({ entities, groups: current.groups }) } /** @@ -111,5 +90,5 @@ export async function setGroupAlias(groupId, name) { const value = String(name || '').trim() if (value) groups[key] = value else delete groups[key] - cache = await putAliases({ entities: current.entities, groups }) + cache = await putAliasesApi({ entities: current.entities, groups }) } diff --git a/src/public/parts/shells/chat/public/shared/care.mjs b/src/public/parts/shells/chat/public/shared/care.mjs index bcf7006de..d5bd2984c 100644 --- a/src/public/parts/shells/chat/public/shared/care.mjs +++ b/src/public/parts/shells/chat/public/shared/care.mjs @@ -1,6 +1,4 @@ -import { CHAT_API_CLIENT_PREFIX } from './apiPaths.mjs' - -const CARE_API = `${CHAT_API_CLIENT_PREFIX}/care` +import { listCaredEntities, setCaredEntity } from '../src/endpoints/prefs.mjs' /** @type {Promise<string[]> | null} */ let caredCache = null @@ -9,16 +7,10 @@ let caredCache = null * @returns {Promise<string[]>} cared entityHashes(恒为 operator) */ export function listCared() { - caredCache ??= fetch(CARE_API, { credentials: 'include' }) - .then(async (response) => { - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'load care failed') - return Array.isArray(data.cared) ? data.cared : [] - }) - .catch((error) => { - caredCache = null - throw error - }) + caredCache ??= listCaredEntities().catch((error) => { + caredCache = null + throw error + }) return caredCache } @@ -28,15 +20,7 @@ export function listCared() { * @returns {Promise<string[]>} 更新后的列表 */ export async function setCared(targetEntityHash, cared) { - const response = await fetch(CARE_API, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetEntityHash, cared }), - }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'set care failed') - const next = Array.isArray(data.cared) ? data.cared : [] + const next = await setCaredEntity(targetEntityHash, cared) caredCache = Promise.resolve(next) return next } diff --git a/src/public/parts/shells/chat/public/shared/entityProfileCard.mjs b/src/public/parts/shells/chat/public/shared/entityProfileCard.mjs index c42d9edc1..04c14700b 100644 --- a/src/public/parts/shells/chat/public/shared/entityProfileCard.mjs +++ b/src/public/parts/shells/chat/public/shared/entityProfileCard.mjs @@ -5,7 +5,7 @@ * bio 只吃 markdown 源,本机安全/可信两档渲染后挂载,不信任对端 HTML、也不对源做 escapeHtml。 * 悬停 / 点击弹层 / 嵌入页共用 `hub/profile_popup` 模板与 `paintEntityProfileCard`,勿另起视觉壳。 */ -import { createDOMFromHtmlString } from '/scripts/features/template.mjs' +import { withTemplates, renderTemplate } from '/scripts/features/template.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { geti18n } from '/scripts/i18n/index.mjs' import { formatSocialProfileHref } from '/parts/shells:social/shared/runUri.mjs' @@ -17,10 +17,6 @@ import { displayProfileAvatar, entityProfilePattern, isAvatarImageUrl } from './ import { safeProfileLink } from './safeProfileLink.mjs' import { mountTrustedMarkdown } from './trustedMarkdown.mjs' -const PROFILE_POPUP_TEMPLATE_URL = '/parts/shells:chat/src/templates/hub/profile_popup.html' -/** @type {string | null} */ -let cachedProfilePopupTemplateHtml = null - const ENTITY_PROFILE_CARD_STYLESHEET = '/parts/shells:chat/shared/entityProfileCard.css' const ENTITY_PROFILE_BANNER_STYLESHEET = '/parts/shells:chat/shared/entityProfileBanner.css' @@ -108,12 +104,7 @@ export function normalizeEntityProfile(profile, entityHash) { */ export async function createEntityProfileCardElement(mode = 'popup') { ensureEntityProfileCardStyles() - if (!cachedProfilePopupTemplateHtml) { - const response = await fetch(PROFILE_POPUP_TEMPLATE_URL) - if (!response.ok) throw new Error(`profile_popup template HTTP ${response.status}`) - cachedProfilePopupTemplateHtml = await response.text() - } - const root = createDOMFromHtmlString(cachedProfilePopupTemplateHtml) + const root = await withTemplates('/parts/shells:chat/src/templates', () => renderTemplate('hub/profile_popup', {})) if (!(root instanceof HTMLElement)) throw new Error('profile_popup template root missing') configureEntityProfileCard(root, mode) return root diff --git a/src/public/parts/shells/chat/public/shared/entityProfileHoverCard.mjs b/src/public/parts/shells/chat/public/shared/entityProfileHoverCard.mjs index 750b7b185..42396d6fd 100644 --- a/src/public/parts/shells/chat/public/shared/entityProfileHoverCard.mjs +++ b/src/public/parts/shells/chat/public/shared/entityProfileHoverCard.mjs @@ -3,7 +3,7 @@ * 【职责】跨壳人物卡悬浮层:与点击弹层共用 profile_popup + paintEntityProfileCard。 * 【原理】单例卡 + 单队列串行绘制;与点击弹层共用 `profile_popup` 全量模板。Hub 操作按钮经 `options.wireActions` 可选挂载。 */ -import { cachedProfileFromApi, fetchEntityProfileApi } from '../src/entityProfileApi.mjs' +import { cachedProfileFromApi, getEntityProfile } from '../src/endpoints/entities.mjs' import { aliasForEntity } from './aliases.mjs' import { isEntityHash128 } from './entityHash.mjs' @@ -162,7 +162,7 @@ async function paintHoverCard(generation, anchor, options) { : options.loadProfile ? await options.loadProfile() : isEntityHash128(entityHash) - ? await fetchEntityProfileApi(entityHash, options.groupId) + ? await getEntityProfile(entityHash, options.groupId) .then(data => cachedProfileFromApi(data?.profile, entityHash)) .catch(() => null) : null @@ -188,7 +188,7 @@ async function paintHoverCard(generation, anchor, options) { ownerName = aliasForEntity(ownerEntityHash) if (!ownerName) try { - const ownerData = await fetchEntityProfileApi(ownerEntityHash) + const ownerData = await getEntityProfile(ownerEntityHash) if (!isCurrentShow(generation)) return ownerName = ownerData?.profile?.name || null } diff --git a/src/public/parts/shells/chat/public/shared/entityProfilePopup.mjs b/src/public/parts/shells/chat/public/shared/entityProfilePopup.mjs index 623b68b93..d4a64f106 100644 --- a/src/public/parts/shells/chat/public/shared/entityProfilePopup.mjs +++ b/src/public/parts/shells/chat/public/shared/entityProfilePopup.mjs @@ -5,7 +5,7 @@ * 模板经 `createEntityProfileCardElement` 加载,不污染全局 `usingTemplates`。 */ import { formatSocialProfileHref } from '/parts/shells:social/shared/runUri.mjs' -import { fetchEntityProfileApi, cachedProfileFromApi } from '../src/entityProfileApi.mjs' +import { cachedProfileFromApi, getEntityProfile } from '../src/endpoints/entities.mjs' import { aliasForEntity } from './aliases.mjs' import { isEntityHash128 } from './entityHash.mjs' @@ -59,7 +59,7 @@ export function dismissEntityProfilePopup() { */ async function paintSharedPopup(popup, entity) { const entityHash = entity.entityHash - const data = entityHash ? await fetchEntityProfileApi(entityHash).catch(() => null) : null + const data = entityHash ? await getEntityProfile(entityHash).catch(() => null) : null const profile = data?.profile ? cachedProfileFromApi(data.profile, entityHash) : null const name = aliasForEntity(entityHash) || profile?.name || entity.displayName || '?' await paintEntityProfileCard(popup, profile || { name }, { @@ -74,7 +74,7 @@ async function paintSharedPopup(popup, entity) { ownerName = aliasForEntity(ownerEntityHash) if (!ownerName) try { - const ownerData = await fetchEntityProfileApi(ownerEntityHash) + const ownerData = await getEntityProfile(ownerEntityHash) ownerName = ownerData?.profile?.name || null } catch { /* miss */ } diff --git a/src/public/parts/shells/chat/public/shared/evfsMedia.mjs b/src/public/parts/shells/chat/public/shared/evfsMedia.mjs index 4634fc1be..256155ab9 100644 --- a/src/public/parts/shells/chat/public/shared/evfsMedia.mjs +++ b/src/public/parts/shells/chat/public/shared/evfsMedia.mjs @@ -1,19 +1,10 @@ /** - * 浏览器端 EVFS 媒体(Chat / Social 共用)。 + * EVFS 媒体 URL 纯函数(Deno-pure;无 fetch)。 + * HTTP 上传/下载见 `/scripts/endpoints/p2p/evfsMedia.mjs`。 */ const CHAT_SHELL_API_PREFIX = '/api/parts/shells:chat' -/** - * @returns {Promise<string | null>} viewer entityHash - */ -export async function getViewerEntityHash() { - const resp = await fetch(`${CHAT_SHELL_API_PREFIX}/viewer`, { credentials: 'include' }) - if (!resp.ok) throw new Error(`viewer ${resp.status}`) - const data = await resp.json() - return data.viewerEntityHash -} - /** * @param {string} entityHash owner * @param {string} logicalPath EVFS 路径 @@ -24,47 +15,6 @@ export function entityFileUrl(entityHash, logicalPath) { return `${CHAT_SHELL_API_PREFIX}/entities/${encodeURIComponent(entityHash)}/files/${path.split('/').map(encodeURIComponent).join('/')}` } -/** - * @param {string} entityHash owner - * @param {string} logicalPath 路径 - * @param {File | Blob} file 文件 - * @returns {Promise<{ entityHash: string, path: string, url: string }>} 上传结果 - */ -export async function uploadEvfsFile(entityHash, logicalPath, file) { - const url = entityFileUrl(entityHash, logicalPath) - const res = await fetch(url, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/octet-stream' }, - body: file, - }) - if (!res.ok) throw new Error((await res.json()).error || `evfs upload failed: ${res.status}`) - const { url: resolvedUrl } = await res.json() - return { entityHash, path: logicalPath, url: resolvedUrl } -} - -/** - * @param {string} entityHash owner - * @param {string} logicalPath 路径 - * @returns {Promise<ArrayBuffer>} 文件字节 - */ -export async function fetchEvfsFile(entityHash, logicalPath) { - const res = await fetch(entityFileUrl(entityHash, logicalPath), { credentials: 'include' }) - if (!res.ok) throw new Error(`evfs fetch failed: ${res.status}`) - return res.arrayBuffer() -} - -/** - * @param {File | Blob} file 文件 - * @param {string} logicalPathPrefix 逻辑路径前缀(如 shells/chat/attachments) - * @returns {Promise<{ entityHash: string, path: string, url: string }>} 上传结果 - */ -export async function uploadEvfsAttachment(file, logicalPathPrefix) { - const entityHash = await getViewerEntityHash() - if (!entityHash) throw new Error('identity required for attachments') - return uploadEvfsFile(entityHash, `${logicalPathPrefix}/${crypto.randomUUID()}`, file) -} - /** 与 `sanitizeHtml.isSafeHtmlUrl` 对齐(本模块保持 Deno-pure,不 import `/scripts`)。 */ const SAFE_MEDIA_URL = /^(https?:|mailto:|tel:|#|\/|about:blank#|fount:)/i diff --git a/src/public/parts/shells/chat/public/shared/notificationPreferences.mjs b/src/public/parts/shells/chat/public/shared/notificationPreferences.mjs index 0844f89ed..86b7a2756 100644 --- a/src/public/parts/shells/chat/public/shared/notificationPreferences.mjs +++ b/src/public/parts/shells/chat/public/shared/notificationPreferences.mjs @@ -1,31 +1,18 @@ -import { CHAT_API_CLIENT_PREFIX } from './apiPaths.mjs' - -const NOTIFY_PREFS_API = `${CHAT_API_CLIENT_PREFIX}/notify-prefs` +import { getNotificationPreferences, putNotificationPreferences } from '../src/endpoints/prefs.mjs' /** * @returns {Promise<Record<string, object>>} 整档通知偏好 */ -export async function loadNotificationPreferences() { - const response = await fetch(NOTIFY_PREFS_API, { credentials: 'include' }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'load notification preferences failed') - return data.prefs || {} +export function loadNotificationPreferences() { + return getNotificationPreferences() } /** * @param {Record<string, object>} prefs 整档通知偏好 * @returns {Promise<Record<string, object>>} 写入后的整档偏好 */ -export async function saveNotificationPreferences(prefs) { - const response = await fetch(NOTIFY_PREFS_API, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prefs }), - }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'save notification preferences failed') - return data.prefs || {} +export function saveNotificationPreferences(prefs) { + return putNotificationPreferences(prefs) } /** diff --git a/src/public/parts/shells/chat/public/src/achievements.mjs b/src/public/parts/shells/chat/public/src/achievements.mjs index 2d1ba4a36..fdc7b4f72 100644 --- a/src/public/parts/shells/chat/public/src/achievements.mjs +++ b/src/public/parts/shells/chat/public/src/achievements.mjs @@ -3,9 +3,9 @@ * 【职责】chat shell 前端成就钩子:监听 Markdown 代码块执行等并解锁对应成就。 * 【原理】initializeAchievements 注册 window 事件监听,满足条件时 unlockAchievement(parts API)。 * 【数据结构】无持久模块状态;事件 detail 由 markdown 渲染器发出。 - * 【关联】@pages/scripts/api/parts.mjs;chatMarkdown 代码块执行。 + * 【关联】@pages/scripts/endpoints/parts.mjs;chatMarkdown 代码块执行。 */ -import { unlockAchievement } from '../../../scripts/api/parts.mjs' +import { unlockAchievement } from '../../../scripts/endpoints/parts.mjs' /** 初始化聊天 shell 的成就系统,注册相关事件监听。 */ export async function initializeAchievements() { diff --git a/src/public/parts/shells/chat/public/src/api/channelArchive.mjs b/src/public/parts/shells/chat/public/src/api/channelArchive.mjs deleted file mode 100644 index f30a34427..000000000 --- a/src/public/parts/shells/chat/public/src/api/channelArchive.mjs +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 【文件】public/src/api/channelArchive.mjs - * 【职责】频道归档 REST:导出下载、multipart 导入。 - * 【关联】channelContextMenu、groupSettings/generalTab;后端 channelArchive 路由。 - */ - -/** - * @param {string} groupId 群 ID - * @param {string} channelId 频道 ID - * @returns {Promise<object>} 归档 JSON - */ -export async function exportChannelArchiveJson(groupId, channelId) { - const response = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/channels/${encodeURIComponent(channelId)}/export`, - { credentials: 'include' }, - ) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw Object.assign(new Error(data.error || `HTTP ${response.status}`), data) - } - return response.json() -} - -/** - * @param {object} archive 归档对象 - * @param {string} fileName 下载文件名 - * @returns {void} - */ -export function downloadChannelArchiveJson(archive, fileName) { - const blob = new Blob([JSON.stringify(archive, null, '\t')], { type: 'application/json' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = fileName - a.click() - URL.revokeObjectURL(url) -} - -/** - * @param {string} groupId 群 ID - * @param {File} file JSON 文件 - * @param {{ name?: string }} [options] 可选频道名 - * @returns {Promise<{ channelId: string, messageCount: number }>} 导入结果 - */ -export async function importChannelArchiveFile(groupId, file, options = {}) { - const form = new FormData() - form.append('archive', file, file.name || 'channel-archive.json') - if (options.name) form.append('name', options.name) - const response = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/channels/import`, - { method: 'POST', credentials: 'include', body: form }, - ) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw Object.assign(new Error(data.error || `HTTP ${response.status}`), data) - } - return response.json() -} diff --git a/src/public/parts/shells/chat/public/src/api/federationSettings.mjs b/src/public/parts/shells/chat/public/src/api/federationSettings.mjs deleted file mode 100644 index 00bf0c537..000000000 --- a/src/public/parts/shells/chat/public/src/api/federationSettings.mjs +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 【文件】public/src/api/federationSettings.mjs - * 【职责】本节点联邦设置 REST:读取与更新 relay、省电、identity 等。 - * 【原理】GET/PUT /api/p2p/federation,credentials include。 - * 【数据结构】enabled、relayUrls[]、batterySaver、activePubKeyHex 等 JSON。 - * 【关联】hub/federation/federationModal.mjs、dmLink.mjs、friendChat.mjs。 - */ -const FEDERATION_SETTINGS_URL = '/api/p2p/federation' - -/** - * 读取本节点联邦设置。 - * @returns {Promise<object>} 设置 JSON - */ -export async function getFederationSettings() { - const response = await fetch(FEDERATION_SETTINGS_URL, { credentials: 'include' }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to load federation settings') - } - return response.json() -} - -/** - * 更新本节点联邦设置。 - * @param {object} body 请求体 - * @returns {Promise<object>} 服务端响应 - */ -export async function putFederationSettings(body) { - const response = await fetch(FEDERATION_SETTINGS_URL, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'Failed to save federation settings') - } - return response.json() -} diff --git a/src/public/parts/shells/chat/public/src/api/groupClient.mjs b/src/public/parts/shells/chat/public/src/api/groupClient.mjs deleted file mode 100644 index c3f93c5f5..000000000 --- a/src/public/parts/shells/chat/public/src/api/groupClient.mjs +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 【文件】public/src/api/groupClient.mjs - * 【职责】联邦群 HTTP 客户端底座:统一 BASE、路径编码与 JSON fetch,供各 api/*.mjs 复用。 - * 【原理】groupPath 对各段 encodeURIComponent;groupFetch 拼接 /api/parts/shells:chat/groups/ 并 credentials:include,json 选项自动设 Content-Type;非 2xx 抛 Error(data.error)。groupRequest 为 Hub 常用的 groupId+endpoint 快捷封装。 - * 【数据结构】GROUPS_BASE 常量;groupFetch(path, RequestInit&{json?})、groupPath(groupId,...segments)、groupRequest(groupId, endpoint, method, body)。 - * 【关联】各 api/*.mjs(groupCore/Channel/…)与后端 src/group/routes。 - */ -import { GROUPS_CLIENT_PREFIX } from '../../shared/apiPaths.mjs' - -const GROUPS_BASE = GROUPS_CLIENT_PREFIX - -/** - * 构建 `groups/:groupId/...` 相对路径(各段均 URL 编码)。 - * @param {string} groupId 群 ID - * @param {...string} segments 后续路径段 - * @returns {string} 相对 `groups/` 的路径 - */ -export function groupPath(groupId, ...segments) { - return [encodeURIComponent(groupId), ...segments.map(s => encodeURIComponent(String(s)))].join('/') -} - -/** - * 对 `/api/parts/shells:chat/groups/` 发起请求并解析 JSON。 - * @param {string} path 相对 `groups/` 的路径(空串表示群集合根) - * @param {RequestInit & { json?: object }} [options] 额外 fetch 选项;`json` 会序列化为请求体 - * @returns {Promise<any>} 成功时的响应 JSON - */ -export async function groupFetch(path, options = {}) { - const { json, ...init } = options - const suffix = path ? `/${path}` : '' - const response = await fetch(`${GROUPS_BASE}${suffix}`, { - credentials: 'include', - headers: json ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, - body: json ? JSON.stringify(json) : init.body, - ...init, - }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `HTTP ${response.status}`) - } - return response.json() -} - -/** - * Hub / 设置面板用:对指定群的 REST 子路径发起请求。 - * @param {string} groupId 群 ID - * @param {string} endpoint `groups/:id/` 之后的子路径 - * @param {'GET'|'POST'|'PUT'|'DELETE'} [method] HTTP 方法 - * @param {object} [body] JSON 请求体 - * @returns {Promise<any>} 响应 JSON - */ -export function groupRequest(groupId, endpoint, method = 'GET', body) { - const path = endpoint ? `${groupPath(groupId)}/${endpoint}` : groupPath(groupId) - const options = { method } - if (body != null && method !== 'GET' && method !== 'HEAD') options.json = body - return groupFetch(path, options) -} diff --git a/src/public/parts/shells/chat/public/src/auditLogPanel.mjs b/src/public/parts/shells/chat/public/src/auditLogPanel.mjs index 765225213..b169f36cd 100644 --- a/src/public/parts/shells/chat/public/src/auditLogPanel.mjs +++ b/src/public/parts/shells/chat/public/src/auditLogPanel.mjs @@ -13,7 +13,7 @@ import { import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { createVirtualList } from '../../../../scripts/lib/virtualList.mjs' -import { fetchGroupAuditLog } from './api/groupCore.mjs' +import { fetchGroupAuditLog } from './endpoints/groupCore.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' diff --git a/src/public/parts/shells/chat/public/src/composerAttachments.mjs b/src/public/parts/shells/chat/public/src/composerAttachments.mjs index fd29d3a27..a77f2cab6 100644 --- a/src/public/parts/shells/chat/public/src/composerAttachments.mjs +++ b/src/public/parts/shells/chat/public/src/composerAttachments.mjs @@ -7,7 +7,7 @@ */ import { svgInliner } from '/scripts/lib/svgInliner.mjs' import { renderTemplate } from '/scripts/features/template.mjs' -import { entityFileUrl, fetchEvfsFile } from '/parts/shells:chat/shared/evfsMedia.mjs' +import { entityFileUrl, fetchEvfsFile } from '/scripts/endpoints/p2p/evfsMedia.mjs' import { parseEvfsRef } from './lib/evfsRef.mjs' import { arrayBufferToBase64 } from './lib/federationUpload.mjs' import { processTimeStampForId } from './lib/timestampId.mjs' @@ -111,7 +111,7 @@ export async function renderAttachmentPreview(file, index, selectedFiles) { const evfsRef = typeof file.buffer === 'string' ? parseEvfsRef(file.buffer) : null if (evfsRef && isPreviewable) { file = { ...file } - file.buffer = arrayBufferToBase64(await fetchEvfsFile(evfsRef.entityHash, evfsRef.logicalPath)) + file.buffer = arrayBufferToBase64((await fetchEvfsFile(evfsRef.entityHash, evfsRef.logicalPath)).buffer) } const previewContainer = attachmentElement.querySelector('.preview-container') diff --git a/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs b/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs index 6a635fd72..e03b3cf6b 100644 --- a/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs +++ b/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs @@ -10,9 +10,10 @@ import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' import { parseDmRunUri, parseJoinRunUri, parseMessageRunUri } from '../shared/runUri.mjs' -import { getFederationSettings } from './api/federationSettings.mjs' -import { getGroupState, joinGroup } from './api/groupCore.mjs' -import { createDirectMessageByPubKeys } from './api/groupDm.mjs' +import { getFederationSettings } from './endpoints/federationSettings.mjs' +import { getGroupState, joinGroup } from './endpoints/groupCore.mjs' +import { createDirectMessageByPubKeys } from './endpoints/groupDm.mjs' +import { getViewer } from './endpoints/viewer.mjs' import { broadcastHubGroupJoined } from './hubBroadcast.mjs' import { PENDING_INVITE_STORAGE_KEY } from './pendingInviteStorage.mjs' import { resolvePowForJoin } from './powJoin.mjs' @@ -73,8 +74,7 @@ export async function applyChatRunUri(raw) { const join = parseJoinRunUri(raw) if (join) { const groupState = await getGroupState(join.groupId).catch(() => null) - const viewerResp = await fetch('/api/parts/shells:chat/viewer', { credentials: 'include' }).catch(() => null) - const viewer = viewerResp?.ok ? await viewerResp.json() : {} + const viewer = await getViewer().catch(() => ({})) const pow = await resolvePowForJoin(join.groupId, groupState, viewer.nodeHash || '') await joinGroup(join.groupId, join.inviteCode, null, pow, join.roomSecret || join.introducerPubKeyHash || join.introducerNodeHash diff --git a/src/public/parts/shells/chat/public/src/dmLink.mjs b/src/public/parts/shells/chat/public/src/dmLink.mjs index 7a046426a..8223f2fba 100644 --- a/src/public/parts/shells/chat/public/src/dmLink.mjs +++ b/src/public/parts/shells/chat/public/src/dmLink.mjs @@ -11,7 +11,7 @@ import { normalizeHex64, HEX_ID_64 } from 'https://esm.sh/@steve02081504/fount-p import { bytesToHex } from '../shared/digest.mjs' import { formatDmRunUri } from '../shared/runUri.mjs' -import { putFederationSettings } from './api/federationSettings.mjs' +import { putFederationSettings } from './endpoints/federationSettings.mjs' import { dmLinkSignableBytes } from '/parts/shells:chat/shared/dmLinkSignature.mjs' import { sign } from './lib/signer.mjs' diff --git a/src/public/parts/shells/chat/public/src/endpoints/channelArchive.mjs b/src/public/parts/shells/chat/public/src/endpoints/channelArchive.mjs new file mode 100644 index 000000000..8662583ad --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/channelArchive.mjs @@ -0,0 +1,76 @@ +/** + * 【文件】public/src/endpoints/channelArchive.mjs + * 【职责】频道归档 REST:导出下载、multipart 导入。 + * 【关联】channelContextMenu、groupSettings/generalTab;后端 channelArchive 路由。 + */ +import { chatFetch, groupPath } from './groupClient.mjs' + +/** + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<object>} 归档 JSON + */ +export async function exportChannelArchiveJson(groupId, channelId) { + return chatFetch(`/groups/${groupPath(groupId, 'channels', channelId, 'export')}`) +} + +/** + * @param {object} archive 归档对象 + * @param {string} fileName 下载文件名 + * @returns {void} + */ +export function downloadChannelArchiveJson(archive, fileName) { + const blob = new Blob([JSON.stringify(archive, null, '\t')], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = fileName + a.click() + URL.revokeObjectURL(url) +} + +/** + * @param {string} groupId 群 ID + * @param {File} file JSON 文件 + * @param {{ name?: string }} [options] 可选频道名 + * @returns {Promise<{ channelId: string, messageCount: number }>} 导入结果 + */ +export async function importChannelArchiveFile(groupId, file, options = {}) { + const form = new FormData() + form.append('archive', file, file.name || 'channel-archive.json') + if (options.name) form.append('name', options.name) + return chatFetch(`/groups/${groupPath(groupId, 'channels', 'import')}`, { + method: 'POST', + body: form, + }) +} + +/** + * 拉取群冷归档存储摘要(按频道/月份分布)。 + * @param {string} groupId 群 ID + * @returns {Promise<{ files: { channelId: string, month: string, bytes: number }[] }>} 归档摘要 + */ +export async function getArchiveSummary(groupId) { + return chatFetch(`/groups/${groupPath(groupId, 'archive', 'summary')}`) +} + +/** + * 删除指定月份之前的冷归档文件。 + * @param {string} groupId 群 ID + * @param {string} beforeMonth `YYYY-MM` + * @returns {Promise<{ deletedFiles: number }>} 删除结果 + */ +export async function deleteArchiveBefore(groupId, beforeMonth) { + return chatFetch(`/groups/${groupPath(groupId, 'archive')}?before=${encodeURIComponent(beforeMonth)}`, { + method: 'DELETE', + }) +} + +/** + * 触发一次冷归档补齐同步(向对等节点请求缺失月份)。 + * @param {string} groupId 群 ID + * @returns {Promise<void>} + */ +export async function syncArchive(groupId) { + await chatFetch(`/groups/${groupPath(groupId, 'archive', 'sync')}`, { method: 'POST' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/channelPerms.mjs b/src/public/parts/shells/chat/public/src/endpoints/channelPerms.mjs new file mode 100644 index 000000000..199318876 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/channelPerms.mjs @@ -0,0 +1,33 @@ +/** + * 【文件】public/src/endpoints/channelPerms.mjs + * 【职责】频道级角色权限覆盖:查询与更新。 + * 【关联】groupSettings/channelPermsTab.mjs;后端 group/channels/:id/permissions 路由。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 拉取频道各角色的权限覆盖。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<Record<string, { allow?: Record<string, boolean>, deny?: Record<string, boolean> }>>} 各角色频道权限 + */ +export async function getChannelPermissions(groupId, channelId) { + const data = await groupFetch(groupPath(groupId, 'channels', channelId, 'permissions'), { method: 'GET' }) + return data.permissions || {} +} + +/** + * 更新单个角色在该频道的权限覆盖。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} roleId 角色 ID + * @param {Record<string, boolean>} allow 允许位图 + * @param {Record<string, boolean>} deny 拒绝位图 + * @returns {Promise<void>} + */ +export async function putChannelPermissions(groupId, channelId, roleId, allow, deny) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'permissions'), { + method: 'PUT', + json: { roleId, allow, deny }, + }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/discovery.mjs b/src/public/parts/shells/chat/public/src/endpoints/discovery.mjs new file mode 100644 index 000000000..0c11660a3 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/discovery.mjs @@ -0,0 +1,25 @@ +/** + * 【文件】public/src/endpoints/discovery.mjs + * 【职责】用户级群发现索引 API 客户端。 + * 【关联】hub/discoveryPanel.mjs;后端 endpoints/discovery.mjs。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * @param {{ limit?: number }} [options] 分页 + * @returns {Promise<{ entries: object[] }>} 发现索引条目 + */ +export async function fetchDiscoveryIndex(options = {}) { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + const qs = params.toString() + return chatFetch(`/discovery${qs ? `?${qs}` : ''}`) +} + +/** + * 触发全网发现 gossip(本机已加入群)。 + * @returns {Promise<void>} + */ +export async function refreshDiscoveryGossip() { + await chatFetch('/discovery/refresh', { method: 'POST' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs b/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs new file mode 100644 index 000000000..de04d9732 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs @@ -0,0 +1,80 @@ +/** + * 【文件】public/src/endpoints/emoji.mjs + * 【职责】emoji-usage / emoji-packs / group preview REST。 + */ +import { chatFetch, groupFetch, groupPath } from './groupClient.mjs' + +/** + * @returns {Promise<object>} emoji-usage 载荷 + */ +export async function getEmojiUsage() { + try { + return await chatFetch('/emoji-usage') + } + catch { + return { log: [], lastUsedAtByPack: {}, collection: { packIds: [], emojiIds: [] } } + } +} + +/** + * @param {string} [groupId] 群 ID + * @returns {Promise<object[]>} packs + */ +export async function listEmojiPacks(groupId) { + const q = groupId ? `?groupId=${encodeURIComponent(groupId)}` : '' + try { + const data = await chatFetch(`/emoji-packs${q}`) + return Array.isArray(data.packs) ? data.packs : [] + } + catch { + return [] + } +} + +/** + * @param {string} groupId 群 ID + * @returns {Promise<object>} preview + */ +export function getGroupPreview(groupId) { + return groupFetch(groupPath(groupId, 'preview')) +} + +/** + * @param {number} [limit=48] 数量 + * @returns {Promise<object[]>} offers + */ +export async function discoverEmojiPacks(limit = 48) { + try { + const data = await chatFetch(`/emoji-packs/discover?limit=${encodeURIComponent(limit)}`) + return data.offers || [] + } + catch { + return [] + } +} + +/** + * @param {object} item 选中项 + * @returns {Promise<void>} + */ +export function recordEmojiUsage(item) { + return chatFetch('/emoji-usage/record', { method: 'POST', json: item }) +} + +/** + * @param {string} packId 包 ID + * @returns {Promise<object>} collection + */ +export async function addEmojiCollectionPack(packId) { + const data = await chatFetch('/emoji-usage/collection/packs', { method: 'POST', json: { packId } }) + return data.collection || data +} + +/** + * @param {string} packId 包 ID + * @returns {Promise<object>} collection + */ +export async function removeEmojiCollectionPack(packId) { + const data = await chatFetch(`/emoji-usage/collection/packs/${encodeURIComponent(packId)}`, { method: 'DELETE' }) + return data.collection || data +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/emojiPacks.mjs b/src/public/parts/shells/chat/public/src/endpoints/emojiPacks.mjs new file mode 100644 index 000000000..5002eb57e --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/emojiPacks.mjs @@ -0,0 +1,63 @@ +/** + * 【文件】public/src/endpoints/emojiPacks.mjs + * 【职责】群表情包 CRUD:包列表/详情、建包、上传/删除表情。 + * 【关联】groupSettings/emojisTab.mjs;后端 group/emoji-packs 路由。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 拉取群下所有表情包摘要。 + * @param {string} groupId 群 ID + * @returns {Promise<object[]>} 表情包列表 + */ +export async function listGroupEmojiPacks(groupId) { + const data = await groupFetch(groupPath(groupId, 'emoji-packs'), { method: 'GET' }) + return Array.isArray(data.packs) ? data.packs : [] +} + +/** + * 拉取单个表情包详情(含表情条目)。 + * @param {string} groupId 群 ID + * @param {string} packId 包 ID + * @returns {Promise<object|null>} 表情包详情 + */ +export async function getGroupEmojiPack(groupId, packId) { + const data = await groupFetch(groupPath(groupId, 'emoji-packs', packId), { method: 'GET' }) + return data.pack || null +} + +/** + * 创建新表情包。 + * @param {string} groupId 群 ID + * @param {string} packId 包 ID + * @returns {Promise<object>} `{ pack }` + */ +export function createGroupEmojiPack(groupId, packId) { + return groupFetch(groupPath(groupId, 'emoji-packs'), { method: 'POST', json: { packId } }) +} + +/** + * 上传表情到指定包。 + * @param {string} groupId 群 ID + * @param {string} packId 包 ID + * @param {File} file 表情图片 + * @param {string} name 表情名 + * @returns {Promise<object>} 服务端响应 + */ +export function uploadGroupEmoji(groupId, packId, file, name) { + const form = new FormData() + form.append('emoji', file) + form.append('name', name) + return groupFetch(groupPath(groupId, 'emoji-packs', packId, 'emojis'), { method: 'POST', body: form }) +} + +/** + * 从指定包删除表情。 + * @param {string} groupId 群 ID + * @param {string} packId 包 ID + * @param {string} emojiId 表情 ID + * @returns {Promise<void>} + */ +export async function deleteGroupEmoji(groupId, packId, emojiId) { + await groupFetch(groupPath(groupId, 'emoji-packs', packId, 'emojis', emojiId), { method: 'DELETE' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/entities.mjs b/src/public/parts/shells/chat/public/src/endpoints/entities.mjs new file mode 100644 index 000000000..d142ae99f --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/entities.mjs @@ -0,0 +1,151 @@ +/** + * 【文件】public/src/endpoints/entities.mjs + * 【职责】实体资料 REST:查询/更新/重建/EVFS 文件上传/主人绑定/网络搜索/心跳/在线状态。 + * 【原理】localeQueryString 仅附加 groupId(locales 由服务端从登录用户解析);chatFetch 统一错误处理;uploadEntityFile 走 multipart(无 `json` 选项,body 为 FormData)。 + * 【数据结构】entityHash(128 hex)、groupId、profile JSON(localized 多语言)。 + * 【关联】entityProfileHoverCard、entityProfile、profileEdit、hubStatus、ownerSettingsPanel、friendsList;后端 entity/endpoints.mjs。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * 实体资料 API 查询串。不传 `locales`:服务端 `localesFromRequest` 用登录用户的 `user.locales`。 + * @param {string} [groupId] 群 ID(persona 解析) + * @returns {string} 查询串 + */ +export function localeQueryString(groupId) { + const params = new URLSearchParams() + if (groupId) params.set('groupId', groupId) + return params.toString() +} + +/** + * @param {string} entityHash 128 位 entityHash + * @param {string} [groupId] 群 ID + * @returns {Promise<{ profile: object }>} 资料 JSON + */ +export async function getEntityProfile(entityHash, groupId) { + const qs = localeQueryString(groupId) + return chatFetch(`/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`) +} + +/** + * @param {string} entityHash 128 位 entityHash + * @param {object} updates 更新内容 + * @param {string} [groupId] 群 ID + * @returns {Promise<object>} 更新后的资料 JSON(或代理写入时的 `{ queued: true, ... }`) + */ +export async function updateEntityProfile(entityHash, updates, groupId) { + const qs = localeQueryString(groupId) + return chatFetch(`/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`, { + method: 'PUT', + json: { ...updates, ...groupId ? { groupId } : {} }, + }) +} + +/** + * 从关联的角色 part 重建本地 agent 资料。 + * @param {string} entityHash 128 位 entityHash + * @param {string} [groupId] 群 ID + * @returns {Promise<{ profile: object }>} 重建后的资料 JSON + */ +export async function rebuildProfileFromPart(entityHash, groupId) { + const qs = localeQueryString(groupId) + return chatFetch(`/entities/${encodeURIComponent(entityHash)}/rebuild-from-part${qs ? `?${qs}` : ''}`, { + method: 'POST', + }) +} + +/** + * multipart 写任意实体 EVFS 路径。 + * @param {string} entityHash 128 hex + * @param {string} logicalPath EVFS 逻辑路径 + * @param {File|Blob} file 文件 + * @returns {Promise<{ url: string, manifest?: object }>} 上传结果 + */ +export async function uploadEntityFile(entityHash, logicalPath, file) { + const formData = new FormData() + formData.append('file', file) + const path = String(logicalPath || '').replace(/^\/+/, '') + return chatFetch(`/entities/${encodeURIComponent(entityHash)}/files/${path}`, { + method: 'POST', + body: formData, + }) +} + +/** + * 为当前 operator 实体声明 / 清除 ownerEntityHash。 + * @param {string|null} ownerEntityHash 主人 128 hex;`null` 清除 + * @returns {Promise<{ entityHash: string, ownerEntityHash: string|null }>} 更新后的绑定 + */ +export function setEntityOwner(ownerEntityHash) { + return chatFetch('/entities/owner', { method: 'PUT', json: { ownerEntityHash } }) +} + +/** + * 网络实体搜索(handle / 展示名)。 + * @param {string} q 查询词 + * @param {{ limit?: number }} [opts] 选项 + * @returns {Promise<{ entities: object[] }>} 命中列表 + */ +export function searchEntities(q, opts = {}) { + const params = new URLSearchParams({ q }) + if (opts.limit) params.set('limit', String(opts.limit)) + return chatFetch(`/entities/search?${params}`) +} + +/** + * 发送在线心跳。 + * @param {string} entityHash 128 位 entityHash + * @returns {Promise<{ lastSeenAt: number, effectiveStatus: string }>} 心跳结果 + */ +export function postEntityHeartbeat(entityHash) { + return chatFetch(`/entities/${encodeURIComponent(entityHash)}/heartbeat`, { method: 'POST' }) +} + +/** + * 设置在线状态(online / idle / dnd / invisible)与自定义状态文案。 + * @param {string} entityHash 128 位 entityHash + * @param {string} status 状态键 + * @param {string} [customStatus] 自定义状态文案 + * @returns {Promise<{ status: string, customStatus: string, lastSeenAt: number, effectiveStatus: string }>} 更新后的状态 + */ +export function setEntityStatus(entityHash, status, customStatus) { + return chatFetch(`/entities/${encodeURIComponent(entityHash)}/status`, { + method: 'POST', + json: { status, customStatus }, + }) +} + +/** + * 将 API profile 转为 Hub 缓存结构。 + * @param {object|null|undefined} profile API profile + * @param {string} entityHash 128 位 entityHash + * @returns {object|null} Hub 缓存结构或 `null` + */ +export function cachedProfileFromApi(profile, entityHash) { + if (!profile) return null + const key = String(entityHash || '').toLowerCase() + return { + entityHash: key, + avatar: profile.avatar || null, + infoDefaults: profile.infoDefaults || null, + name: profile.name || key.slice(64, 72), + handle: profile.handle || null, + themeColor: profile.themeColor || '', + banner: profile.displayBanner || profile.banner || '', + sfw_banner: profile.sfw_banner || '', + displayBanner: profile.displayBanner || profile.banner || '', + description: profile.description || '', + description_markdown: profile.description_markdown || '', + localized: profile.localized || {}, + tags: Array.isArray(profile.tags) ? profile.tags : [], + links: Array.isArray(profile.links) ? profile.links : [], + status: profile.effectiveStatus || profile.status || 'offline', + customStatus: profile.customStatus || '', + ownerEntityHash: profile.ownerEntityHash + ? String(profile.ownerEntityHash).toLowerCase() + : null, + activePubKeyHex: profile.activePubKeyHex || null, + keyGeneration: profile.keyGeneration ?? null, + } +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs b/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs new file mode 100644 index 000000000..16ecaba17 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs @@ -0,0 +1,5 @@ +/** + * 【文件】public/src/endpoints/federationSettings.mjs + * 【职责】本节点联邦设置 REST(re-export from p2p.mjs)。 + */ +export { getFederationSettings, putFederationSettings } from './p2p.mjs' diff --git a/src/public/parts/shells/chat/public/src/endpoints/folders.mjs b/src/public/parts/shells/chat/public/src/endpoints/folders.mjs new file mode 100644 index 000000000..1e4dd4248 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/folders.mjs @@ -0,0 +1,20 @@ +/** + * 【文件】public/src/endpoints/folders.mjs + * 【职责】群文件夹(侧栏分组)REST。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * @returns {Promise<any>} folders 载荷 + */ +export function getGroupFolders() { + return chatFetch('/group-folders') +} + +/** + * @param {object} body 全量 folders 状态 + * @returns {Promise<any>} 响应 + */ +export function putGroupFolders(body) { + return chatFetch('/group-folders', { method: 'PUT', json: body }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs new file mode 100644 index 000000000..3f2bec2b0 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs @@ -0,0 +1,29 @@ +/** + * 【文件】public/src/endpoints/groupBan.mjs + * 【职责】按范围封禁成员:DAG 声誉 + 服务端 blocklist/peers 同步。 + * 【原理】校验 targetPubKeyHash 为 hex64 后调用 ban 端点,可选 postReputationSlash。 + * 【数据结构】groupId、targetPubKeyHash、scope 选项。 + * 【关联】groupClient.mjs、groupGovernance.mjs、fount-p2p/core/hexIds。 + */ +import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' + +import { groupFetch, groupPath } from './groupClient.mjs' +import { postReputationSlash } from './groupGovernance.mjs' + +/** + * 按范围封禁成员(群内 DAG + 声誉 + 服务端同步 blocklist/peers)。 + * @param {string} groupId 群 ID + * @param {string} targetPubKeyHash 目标成员 pubKeyHash + * @param {{ banScope: 'entity'|'node' }} options 封禁范围 + * @returns {Promise<void>} + */ +export async function banMemberWithScope(groupId, targetPubKeyHash, options) { + const target = String(targetPubKeyHash || '').trim().toLowerCase() + if (!isHex64(target)) throw new Error('invalid target') + const banScope = String(options?.banScope || '').trim().toLowerCase() + await groupFetch(groupPath(groupId, 'members', target, 'ban'), { + method: 'POST', + json: { banScope }, + }) + await postReputationSlash(groupId, { targetPubKeyHash: target, claim: 1, verified: false }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs new file mode 100644 index 000000000..9f8d326eb --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs @@ -0,0 +1,60 @@ +/** + * 【文件】public/src/endpoints/groupBookmarks.mjs + * 【职责】Hub 侧栏书签 CRUD:读写用户级 chat bookmarks 列表。 + * 【原理】GET/PUT /bookmarks;add/remove 在客户端合并数组后 saveChatBookmarks。 + * 【数据结构】书签条目 { groupId, channelId?, label? } 数组。 + * 【关联】Hub 侧栏导航;独立 sessions API。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * 读取 Hub 侧栏书签列表。 + * @returns {Promise<object[]>} 书签条目数组 + */ +export async function getChatBookmarks() { + const data = await chatFetch('/bookmarks') + return Array.isArray(data.entries) ? data.entries : [] +} + +/** + * 全量覆盖保存书签。 + * @param {object[]} entries 书签条目 + * @returns {Promise<void>} + */ +export async function saveChatBookmarks(entries) { + await chatFetch('/bookmarks', { method: 'PUT', json: { entries } }) +} + +/** + * 追加一条书签(同群同事件去重)。 + * @param {object} entry 书签条目 + * @returns {Promise<boolean>} 是否新增成功 + */ +export async function addChatBookmark(entry) { + const entries = await getChatBookmarks() + const groupId = String(entry.groupId || '') + const eventId = String(entry.eventId) + if (groupId && eventId && entries.some(bookmark => bookmark?.groupId === groupId && bookmark?.eventId === eventId)) + return false + entries.push(entry) + await saveChatBookmarks(entries) + return true +} + +/** + * 删除一条书签(按 groupId + eventId 匹配,回落 href 匹配)。 + * @param {{ groupId?: string, eventId?: string, href?: string }} entry 书签条目 + * @returns {Promise<void>} + */ +export async function removeChatBookmark(entry) { + const entries = await getChatBookmarks() + const groupId = String(entry.groupId || '') + const eventId = String(entry.eventId || '') + const href = String(entry.href || '') + const next = entries.filter(bookmark => { + if (eventId) return !(String(bookmark?.groupId || '') === groupId && String(bookmark?.eventId || '') === eventId) + if (href) return String(bookmark?.href || '') !== href + return true + }) + if (next.length !== entries.length) await saveChatBookmarks(next) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupChannel.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupChannel.mjs new file mode 100644 index 000000000..9dd10a54c --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupChannel.mjs @@ -0,0 +1,491 @@ +/** + * 【文件】public/src/endpoints/groupChannel.mjs + * 【职责】频道与消息 API:发消息、编辑/删除、时间线、投票、置顶、反馈、触发 AI 回复、频道 CRUD。 + * 【原理】content 经 channelContent.mjs 规范为对象;groupFetch POST/PUT 到 channels/:channelId/...;getChatTimeline 供 MessagePipeline 分页。 + * 【数据结构】channelMessage、normalizeChannelMessage;eventId、ballotId、timeline 游标。 + * 【关联】groupClient.mjs、lib/channelContent.mjs;MessagePipeline、Hub composer。 + */ +import { channelMessage, normalizeChannelMessage } from '../../shared/channelContent.mjs' + +import { chatFetch, groupFetch, groupPath } from './groupClient.mjs' + +/** + * 对频道内投票单投一票。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} ballotId 投票单 ID + * @param {string} choice 选项标识 + * @returns {Promise<object>} 产生的链上事件 + */ +export async function castChannelVote(groupId, channelId, ballotId, choice) { + const data = await groupFetch( + groupPath(groupId, 'channels', channelId, 'votes', ballotId, 'cast'), + { method: 'POST', json: { choice } }, + ) + return data.event +} + +/** + * 置顶频道消息。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} targetEventId 目标消息事件 ID + * @returns {Promise<void>} 无 + */ +export async function pinMessage(groupId, channelId, targetEventId) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'pins'), { + method: 'POST', + json: { targetEventId }, + }) +} + +/** + * 取消置顶频道消息。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} targetEventId 目标消息事件 ID + * @returns {Promise<void>} 无 + */ +export async function unpinMessage(groupId, channelId, targetEventId) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'pins', targetEventId), { + method: 'DELETE', + }) +} + +/** + * 更新列表频道条目。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {object[]} items 列表项 + * @returns {Promise<void>} 无 + */ +export async function updateChannelListItems(groupId, channelId, items) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'list-items'), { + method: 'POST', + json: { items }, + }) +} + +/** + * 在频道内发起投票。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {object} body 投票单定义 + * @returns {Promise<{ event: object, ballotId: string }>} 事件与投票单 ID + */ +export async function createChannelVote(groupId, channelId, body) { + const data = await groupFetch(groupPath(groupId, 'channels', channelId, 'votes'), { + method: 'POST', + json: body, + }) + return { event: data.event, ballotId: data.ballotId } +} + +/** + * 向频道发送消息。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string|object} content 纯文本或富内容对象 + * @param {object[]} [files] 附件(name、mime_type、buffer base64) + * @returns {Promise<object>} 落盘后的 DAG `message` 事件 + */ +export async function sendGroupMessage(groupId, channelId, content, files = []) { + const body = { + content: typeof content === 'string' + ? channelMessage(content) + : normalizeChannelMessage(content), + } + if (files.length) body.files = files + const data = await groupFetch(groupPath(groupId, 'channels', channelId, 'messages'), { + method: 'POST', + json: body, + }) + return data.event +} + +/** + * 向联邦邻居问询更早的频道消息并读回本地。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {{ before?: string, limit?: number }} [options] 游标与条数 + * @returns {Promise<object[]>} 合并写入本机后的消息行 + */ +export async function requestChannelHistoryFromPeers(groupId, channelId, options = {}) { + const data = await groupFetch(groupPath(groupId, 'channels', channelId, 'history-want'), { + method: 'POST', + json: { + before: options.before || null, + limit: options.limit, + }, + }) + return Array.isArray(data.messages) ? data.messages : [] +} + +/** + * 分页拉取频道消息与反应事件(raw;治理/审计用,Hub 主视图请用 getChannelViewLog)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {{ since?: string, before?: string, limit?: number, eventIds?: string[] }} [options] 游标与条数限制 + * @returns {Promise<{ messages: object[], reactions: Record<string, Record<string, { voters: string[] } >> }>} 消息与反应 + */ +export async function getChannelMessages(groupId, channelId, options = {}) { + if (Array.isArray(options.eventIds) && options.eventIds.length) { + const data = await groupFetch( + groupPath(groupId, 'channels', channelId, 'messages', 'batch-get'), + { method: 'POST', json: { eventIds: options.eventIds } }, + ) + return { + messages: data.messages || [], + reactions: data.reactions || {}, + } + } + const params = new URLSearchParams() + if (options.since) params.append('since', options.since) + if (options.before) params.append('before', options.before) + if (options.limit) params.append('limit', String(options.limit)) + const query = params.toString() + const data = await groupFetch( + `${groupPath(groupId, 'channels', channelId, 'messages')}${query ? `?${query}` : ''}`, + { method: 'GET' }, + ) + return { + messages: data.messages || [], + reactions: data.reactions || {}, + } +} + +/** + * 拉取 viewer 过滤后的频道消息(Hub 主视图;与 getChannelMessages 同形 DTO)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {{ since?: string, before?: string, limit?: number }} [options] 游标与条数限制 + * @returns {Promise<{ messages: object[], reactions: Record<string, Record<string, { voters: string[] } >>, readMarker: object | null, hasMore: boolean, oldestRawEventId: string | null }>} 消息与反应 + */ +export async function getChannelViewLog(groupId, channelId, options = {}) { + const params = new URLSearchParams() + if (options.since) params.append('since', options.since) + if (options.before) params.append('before', options.before) + if (options.limit) params.append('limit', String(options.limit)) + const query = params.toString() + const data = await groupFetch( + `${groupPath(groupId, 'channels', channelId, 'view-log')}${query ? `?${query}` : ''}`, + { method: 'GET' }, + ) + return { + messages: data.messages || [], + reactions: data.reactions || {}, + readMarker: data.readMarker || null, + hasMore: !!data.hasMore, + oldestRawEventId: data.oldestRawEventId ? String(data.oldestRawEventId) : null, + } +} + +/** + * 按 eventId 批量拉取 viewer 投影行(导航/编辑补拉;被滤消息不返回)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string[]} eventIds 目标 eventId 列表(≤500) + * @returns {Promise<{ messages: object[], reactions: Record<string, Record<string, { voters: string[] } >> }>} 可见行与反应 + */ +export async function getChannelViewLogByEventIds(groupId, channelId, eventIds) { + const data = await groupFetch( + groupPath(groupId, 'channels', channelId, 'view-log', 'batch-get'), + { method: 'POST', json: { eventIds } }, + ) + return { + messages: data.messages || [], + reactions: data.reactions || {}, + } +} + +/** + * 更新频道已读水位。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {{ eventId: string, seq: number }} marker 已读水位 + * @returns {Promise<{ readMarker: { eventId: string, seq: number }}>} 服务端确认后的已读水位 + */ +export async function putChannelReadMarker(groupId, channelId, marker) { + return groupFetch(groupPath(groupId, 'channels', channelId, 'read-marker'), { + method: 'PUT', + json: marker, + }) +} + +/** + * 拉取置顶消息 ±N 邻域(冷归档 + 热区;raw,非 viewer 投影)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} pinEventId 置顶 eventId + * @returns {Promise<{ messages: object[] }>} 邻域消息 + */ +export async function getPinContextMessages(groupId, channelId, pinEventId) { + const data = await groupFetch( + groupPath(groupId, 'channels', channelId, 'pin-context', pinEventId), + { method: 'GET' }, + ) + return { messages: data.messages || [] } +} + +/** + * 跨频道搜索群消息。 + * @param {string} groupId 群 ID + * @param {string} query 查询(至少 2 字符) + * @param {{ channelId?: string, limit?: number }} [options] 选项 + * @returns {Promise<{ query: string, items: object[] }>} 规范化查询串与命中列表 + */ +export async function searchGroupChannelMessages(groupId, query, options = {}) { + const params = new URLSearchParams({ q: query }) + if (options.channelId) params.set('channelId', options.channelId) + if (options.limit) params.set('limit', String(options.limit)) + const data = await groupFetch(`${encodeURIComponent(groupId)}/search?${params}`, { method: 'GET' }) + return { query: data.query || query, items: data.items || [] } +} + +/** + * 跨群搜索消息。 + * @param {string} query 查询(至少 2 字符) + * @param {{ limit?: number, cursor?: string }} [options] 选项 + * @returns {Promise<{ query: string, items: object[], nextCursor: string | null }>} 跨群搜索结果 + */ +export async function searchAllChatGroups(query, options = {}) { + const params = new URLSearchParams({ q: query }) + if (options.limit) params.set('limit', String(options.limit)) + if (options.cursor) params.set('cursor', options.cursor) + const data = await chatFetch(`/search?${params}`) + return { + query: data.query || query, + items: data.items || [], + nextCursor: data.nextCursor || null, + } +} + +/** + * 拉取进行中流的已缓冲分片(WS 晚加入时补流式显示)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} pendingStreamId DAG 占位 message eventId + * @returns {Promise<{ chunkSeq: number, slices: object[] }[]}> 流式 diff 块列表 + */ +export async function getStreamBufferChunks(groupId, channelId, pendingStreamId) { + const data = await groupFetch( + groupPath(groupId, 'channels', channelId, 'stream-buffer', pendingStreamId), + { method: 'GET' }, + ) + return data.chunks || [] +} + +/** + * 读取群 chatLog 分支时间线游标(角色扮演分支导航,与频道 DAG 无关)。 + * @param {string} groupId 群 ID + * @returns {Promise<{ current: number, total: number }>} 当前索引与分支总数 + */ +export async function getChatBranch(groupId) { + const data = await groupFetch(groupPath(groupId, 'branch'), { + method: 'GET', + }) + return { + current: Number(data.current) || 0, + total: Number(data.total) || 1, + } +} + +/** + * 调整 RPG 分支(前移/回退/跳到最新)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID(生成上下文) + * @param {number} [delta] 分支偏移步数 + * @param {{ latest?: boolean }} [options] `latest: true` 跳到最新分支 + * @returns {Promise<void>} 无 + */ +export async function modifyBranch(groupId, channelId, delta, options = {}) { + const body = { channelId } + if (options.latest) body.latest = true + else body.delta = delta + await groupFetch(groupPath(groupId, 'branch'), { + method: 'PUT', + json: body, + }) +} + +/** + * 触发频道内角色回复。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} [charname] 指定角色名;省略则由服务端选择 + * @returns {Promise<void>} 无 + */ +export async function triggerChannelReply(groupId, channelId, charname) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'trigger-reply'), { + method: 'POST', + json: charname ? { charname } : {}, + }) +} + +/** + * 编辑频道消息正文。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} eventId 消息事件 ID + * @param {string} content 新正文 + * @returns {Promise<void>} 无 + */ +export async function editChannelMessage(groupId, channelId, eventId, content) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'messages', eventId), { + method: 'PUT', + json: { content: channelMessage(content) }, + }) +} + +/** + * 删除频道消息。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} eventId 消息事件 ID + * @returns {Promise<void>} 无 + */ +export async function deleteChannelMessage(groupId, channelId, eventId) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'messages', eventId), { + method: 'DELETE', + }) +} + +/** + * 设置消息反馈(点赞/点踩等)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} eventId 消息事件 ID + * @param {string} type 反馈类型 + * @param {string} [reason] 可选说明 + * @returns {Promise<void>} 无 + */ +export async function setChannelMessageFeedback(groupId, channelId, eventId, type, reason) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'messages', eventId, 'feedback'), { + method: 'PUT', + json: { type, content: reason || '' }, + }) +} + +/** + * 在频道下创建子线程频道。 + * @param {string} groupId 群 ID + * @param {string} channelId 父频道 ID + * @param {string} parentEventId 父消息事件 ID + * @returns {Promise<string>} 新子线程频道 ID + */ +export async function createChannelThread(groupId, channelId, parentEventId) { + const data = await groupFetch(groupPath(groupId, 'channels', channelId, 'threads'), { + method: 'POST', + json: { parentEventId }, + }) + return data.channelId +} + +/** + * 创建群频道。 + * @param {string} groupId 群 ID + * @param {string} name 频道名称 + * @param {string} [type] 频道类型 text | list | streaming + * @returns {Promise<string>} 新频道 ID + */ +export async function createChannel(groupId, name, type = 'text') { + const data = await groupFetch(groupPath(groupId, 'channels'), { + method: 'POST', + json: { name, type }, + }) + return data.channelId +} + +/** + * 更新群频道元数据。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {object} updates name / description / type 等 + * @returns {Promise<void>} 无 + */ +export async function updateChannel(groupId, channelId, updates) { + await groupFetch(groupPath(groupId, 'channels', channelId), { + method: 'PUT', + json: updates, + }) +} + +/** + * 删除群频道。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<void>} 无 + */ +export async function deleteChannel(groupId, channelId) { + await groupFetch(groupPath(groupId, 'channels', channelId), { + method: 'DELETE', + }) +} + +/** + * 将频道设为群默认频道。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<void>} 无 + */ +export async function setDefaultChannel(groupId, channelId) { + await groupFetch(groupPath(groupId, 'default-channel'), { + method: 'PUT', + json: { channelId }, + }) +} + +/** + * 对消息添加一条表情回应。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} targetEventId 目标消息事件 ID + * @param {string} emoji 表情字符 + * @returns {Promise<void>} 无 + */ +export async function putReaction(groupId, channelId, targetEventId, emoji) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'reactions'), { + method: 'POST', + json: { targetEventId, emoji }, + }) +} + +/** + * 撤销一条表情回应(管理员可代撤 `targetPubKeyHash`)。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @param {string} targetEventId 目标消息事件 ID + * @param {string} emoji 表情字符 + * @param {string} [targetPubKeyHash] 被代签移除的成员 pubKeyHash + * @returns {Promise<void>} 无 + */ +export async function deleteReaction(groupId, channelId, targetEventId, emoji, targetPubKeyHash) { + await groupFetch(groupPath(groupId, 'channels', channelId, 'reactions'), { + method: 'DELETE', + json: { + targetEventId, + emoji, + ...targetPubKeyHash ? { targetPubKeyHash } : {}, + }, + }) +} + +/** + * 拉取频道成员已读水位。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<{ markers: Record<string, { seq: number, eventId: string }> }>} 成员已读水位映射 + */ +export async function getMemberReadMarkers(groupId, channelId) { + return groupFetch(groupPath(groupId, 'channels', channelId, 'member-read-markers'), { method: 'GET' }) +} + +/** + * 查询频道通话状态。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<{ active: boolean, peerCount?: number }>} 通话状态 + */ +export async function getCallStatus(groupId, channelId) { + return groupFetch(groupPath(groupId, 'channels', channelId, 'call-status'), { method: 'GET' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs new file mode 100644 index 000000000..011a99b02 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs @@ -0,0 +1,50 @@ +/** + * 【文件】public/src/endpoints/groupClient.mjs + * 【职责】Chat shell HTTP 客户端底座:CHAT_API 前缀下的 JSON fetch,以及 groups 子路径封装。 + * 【原理】chatFetch 拼 `${CHAT_API_CLIENT_PREFIX}${path}`(path 以 / 开头);groupFetch 为 groups/ 内部包装;groupPath 对各段 encodeURIComponent;非 2xx 抛 Error(data.error)。仅供 endpoints/* 使用,不对 UI 导出 path 客户端。 + * 【数据结构】chatFetch(path, RequestInit&{json?})、groupFetch、groupPath。 + * 【关联】各 endpoints/*.mjs(groupCore/Channel/…)与后端 src/group/routes。 + */ +import { CHAT_API_CLIENT_PREFIX } from '../../shared/apiPaths.mjs' + +/** + * 构建 `groups/:groupId/...` 相对路径(各段均 URL 编码)。 + * @param {string} groupId 群 ID + * @param {...string} segments 后续路径段 + * @returns {string} 相对 `groups/` 的路径 + */ +export function groupPath(groupId, ...segments) { + return [encodeURIComponent(groupId), ...segments.map(s => encodeURIComponent(String(s)))].join('/') +} + +/** + * 对 `/api/parts/shells:chat` 下任意子路径发起请求并解析 JSON。 + * @param {string} path 以 `/` 开头的路径(相对 CHAT_API 前缀) + * @param {RequestInit & { json?: object }} [options] 额外 fetch 选项;`json` 会序列化为请求体 + * @returns {Promise<any>} 成功时的响应 JSON + */ +export async function chatFetch(path, options = {}) { + const { json, ...init } = options + const response = await fetch(`${CHAT_API_CLIENT_PREFIX}${path}`, { + credentials: 'include', + headers: json ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, + body: json ? JSON.stringify(json) : init.body, + ...init, + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || `HTTP ${response.status}`) + } + return response.json() +} + +/** + * 对 `/api/parts/shells:chat/groups/` 发起请求并解析 JSON。 + * @param {string} path 相对 `groups/` 的路径(空串表示群集合根) + * @param {RequestInit & { json?: object }} [options] 额外 fetch 选项;`json` 会序列化为请求体 + * @returns {Promise<any>} 成功时的响应 JSON + */ +export function groupFetch(path, options = {}) { + const suffix = path ? `/${path}` : '' + return chatFetch(`/groups${suffix}`, options) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs new file mode 100644 index 000000000..1745890ce --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs @@ -0,0 +1,377 @@ +/** + * 【文件】public/src/endpoints/groupCore.mjs + * 【职责】群生命周期与元数据 API:建群、列表、入退群、initial-data、成员分页、邀请、文件系统、审计日志。 + * 【原理】经 groupFetch / chatFetch 访问 groups/:id 与 sessions 子路径。 + * 【数据结构】groupId、分页游标、invite 载荷、文件 folder 更新体。 + * 【关联】groupClient.mjs;groupModals、groupSettings、Hub 群切换。 + */ +import { CHAT_LEAVE_BATCH_MAX } from '../lib/batchLimits.mjs' + +import { chatFetch, groupFetch, groupPath } from './groupClient.mjs' + +/** + * @param {string} groupId 群 ID + * @returns {Promise<object>} initial-data 载荷 + */ +export async function getGroupChatConfig(groupId) { + return groupFetch(groupPath(groupId, 'initial-data'), { method: 'GET' }) +} + +/** + * 创建新群组。 + * @param {string} name 群组名称 + * @param {string} [description] 描述 + * @param {{ joinPolicy?: string }} [options] 建群选项 + * @returns {Promise<{ groupId: string, defaultChannelId: string }>} 新群 ID 与默认频道 + */ +export async function createGroup(name, description, options = {}) { + const data = await groupFetch('', { + method: 'POST', + json: { + name, + description, + joinPolicy: options.joinPolicy, + }, + }) + return { groupId: data.groupId, defaultChannelId: data.defaultChannelId || 'default' } +} + +/** + * 拉取当前用户已加入的联邦群 / DM 列表。 + * @returns {Promise<object[]>} 群组摘要列表 + */ +export async function getGroupList() { + const data = await groupFetch('', { method: 'GET' }) + return data.map(row => ({ + groupId: row.groupId, + name: row.name, + description: row.description ?? '', + avatar: row.avatar, + defaultChannelId: row.defaultChannelId, + memberCount: row.memberCount, + channelCount: row.channelCount, + lastMessageTime: row.lastMessageTime, + friendBinding: row.friendBinding ?? null, + unreadCount: Number(row.unreadCount) || 0, + channelUnread: row.channelUnread || {}, + })) +} + +/** + * 加入群组(可选邀请码或 DM 引荐证明)。 + * @param {string} groupId 群 ID + * @param {string | null} [inviteCode] 邀请码 + * @param {{ dmIntroNonce?: string, dmIntroSignatureHex?: string, introducerPubKeyHash?: string }} [dmLinkProof] DM 深链引荐字段 + * @param {{ challenge: string, nonce: string } | null} [pow] PoW 入群证明 + * @param {{ signalingAppId?: string, roomSecret?: string, introducerPubKeyHash?: string, introducerNodeHash?: string } | null} [fedBootstrap] 首次联邦房间凭证 口令与邀请人 + * @returns {Promise<void>} + */ +export async function joinGroup(groupId, inviteCode = null, dmLinkProof = null, pow = null, fedBootstrap = null) { + const json = { + inviteCode: inviteCode || undefined, + pow: pow || undefined, + ...dmLinkProof || {}, + } + if (fedBootstrap?.roomSecret) { + json.roomSecret = fedBootstrap.roomSecret + if (fedBootstrap.signalingAppId) json.signalingAppId = fedBootstrap.signalingAppId + } + if (fedBootstrap?.introducerPubKeyHash) + json.introducerPubKeyHash = fedBootstrap.introducerPubKeyHash + if (fedBootstrap?.introducerNodeHash) + json.introducerNodeHash = fedBootstrap.introducerNodeHash + await groupFetch(groupPath(groupId, 'join'), { method: 'POST', json }) +} + +/** + * 退出一个或多个群(`member_leave` + 移除本机群数据;单群也传长度为 1 的数组)。 + * @param {string | string[]} groupIds 群 ID 或列表 + * @returns {Promise<{ ok: string[], failed: { groupId: string, error: string }[] }>} 成功与失败列表 + */ +export async function leaveGroups(groupIds) { + const ids = [...new Set( + (Array.isArray(groupIds) ? groupIds : [groupIds]).map(id => String(id ?? '').trim()).filter(Boolean), + )] + /** @type {string[]} */ + const ok = [] + /** @type {{ groupId: string, error: string }[]} */ + const failed = [] + for (let i = 0; i < ids.length; i += CHAT_LEAVE_BATCH_MAX) { + const chunk = ids.slice(i, i + CHAT_LEAVE_BATCH_MAX) + const part = await groupFetch('leave', { method: 'POST', json: { groupIds: chunk } }) + ok.push(...part.ok || []) + failed.push(...part.failed || []) + } + return { ok, failed } +} + +/** + * 签发群组邀请票据。 + * @param {string} groupId 群 ID + * @param {{ ttlMs?: number }} [options] 票据有效期等选项 + * @returns {Promise<{ code: string, expiresAt: number, clipboardText?: string }>} 邀请码、过期时间与剪贴板全文 + */ +export async function createGroupInvite(groupId, options = {}) { + const data = await groupFetch(groupPath(groupId, 'invite-ticket'), { method: 'POST', json: { ttlMs: options.ttlMs } }) + return { code: data.code, expiresAt: data.expiresAt, clipboardText: data.clipboardText } +} + +/** + * 拉取群完整状态快照。 + * @param {string} groupId 群 ID + * @returns {Promise<object>} 群状态对象 + */ +export async function getGroupState(groupId) { + const data = await groupFetch(groupPath(groupId, 'state'), { method: 'GET' }) + const { meta = {}, viewer = {}, federation = {} } = data + const { roles: myRoles, ...viewerRest } = viewer + return { + ...meta, + ...viewerRest, + ...federation, + viewerMemberPubKeyHash: viewer.memberKey ?? null, + viewerEntityHash: viewer.entityHash ?? null, + myRoles: myRoles ?? [], + } +} + +/** + * 分页拉取群审计日志(需 ADMIN)。 + * @param {string} groupId 群 ID + * @param {{ before?: string, offset?: number, limit?: number, types?: string[] }} [options] 游标/偏移与类型过滤 + * @returns {Promise<{ entries: object[], hasMore: boolean, total: number, types: string[] }>} 审计条目、分页标记、总数与可用类型列表 + */ +export async function fetchGroupAuditLog(groupId, options = {}) { + const params = new URLSearchParams() + if (options.before) params.set('before', options.before) + if (options.offset !== undefined) params.set('offset', String(options.offset)) + if (options.limit !== undefined) params.set('limit', String(options.limit)) + if (options.types?.length) params.set('types', options.types.join(',')) + const query = params.toString() + const data = await groupFetch( + `${groupPath(groupId, 'audit-log')}${query ? `?${query}` : ''}`, + { method: 'GET' }, + ) + return { + entries: Array.isArray(data.entries) ? data.entries : [], + hasMore: !!data.hasMore, + total: Number(data.total) || 0, + types: Array.isArray(data.types) ? data.types : [], + } +} + +/** + * 分页拉取成员列表。 + * @param {string} groupId 群 ID + * @param {number} pageIdx 页码(从 0 起) + * @returns {Promise<{ members: object[], membersRoot: string|null, membersPagesCount: number }>} 成员页数据 + */ +export async function getMembersPage(groupId, pageIdx) { + const data = await groupFetch(groupPath(groupId, 'members', 'page', Math.max(0, pageIdx)), { method: 'GET' }) + return { + members: data.members, + membersRoot: data.membersRoot ?? null, + membersPagesCount: Number(data.membersPagesCount) || 1, + } +} + +/** + * 删除群内文件。 + * @param {string} groupId 群 ID + * @param {string} fileId 文件 ID + * @returns {Promise<void>} + */ +export async function deleteGroupFile(groupId, fileId) { + await groupFetch(groupPath(groupId, 'files', fileId), { method: 'DELETE' }) +} + +/** + * 创建好友绑定群(角色私聊 / 强制新建)。 + * @param {object} body POST body(含 friendBinding、可选 forceNew) + * @returns {Promise<{ groupId: string }>} 新群 + */ +export async function createFriendGroup(body) { + return groupFetch('', { method: 'POST', json: body }) +} + +/** + * 列出群上已挂载的角色 part 名。 + * @param {string} groupId 群 ID + * @returns {Promise<string[]>} charname 列表 + */ +export async function listGroupChars(groupId) { + const chars = await groupFetch(groupPath(groupId, 'chars'), { method: 'GET' }) + return Array.isArray(chars) ? chars : [] +} + +/** + * 向群添加角色 part。 + * @param {string} groupId 群 ID + * @param {{ charname: string, deferGreeting?: boolean }} body 请求体 + * @returns {Promise<any>} 响应 + */ +export async function addGroupChar(groupId, body) { + return groupFetch(groupPath(groupId, 'char'), { method: 'POST', json: body }) +} + +/** + * 从群移除角色 part。 + * @param {string} groupId 群 ID + * @param {string} charname 角色名 + * @returns {Promise<void>} + */ +export async function removeGroupChar(groupId, charname) { + await groupFetch(groupPath(groupId, 'char', charname), { method: 'DELETE' }) +} + +/** + * 设置角色在群上的回复频率。 + * @param {string} groupId 群 ID + * @param {string} charname 角色名 + * @param {number} frequency 0–1 + * @returns {Promise<void>} + */ +export async function setGroupCharFrequency(groupId, charname, frequency) { + await groupFetch(groupPath(groupId, 'char', charname, 'frequency'), { + method: 'PUT', + json: { frequency }, + }) +} + +/** + * 设置群 persona part。 + * @param {string} groupId 群 ID + * @param {string|null} personaname persona 名;空串/`null` 清除 + * @returns {Promise<any>} 响应 + */ +export async function setGroupPersona(groupId, personaname) { + return groupFetch(groupPath(groupId, 'persona'), { + method: 'PUT', + json: { personaname }, + }) +} + +/** + * 设置频道绑定的 world part。 + * @param {string} groupId 群 ID + * @param {string|null} worldname world 名;空串/`null` 清除 + * @param {string} [channelId] 频道 ID + * @returns {Promise<any>} 响应 + */ +export async function setGroupWorld(groupId, worldname, channelId = 'default') { + return groupFetch(groupPath(groupId, 'world'), { + method: 'PUT', + json: { worldname, channelId }, + }) +} + +/** + * 列出群上已挂载的插件 part 名。 + * @param {string} groupId 群 ID + * @returns {Promise<string[]>} pluginname 列表 + */ +export async function listGroupPlugins(groupId) { + const plugins = await groupFetch(groupPath(groupId, 'plugins'), { method: 'GET' }) + return Array.isArray(plugins) ? plugins : [] +} + +/** + * 向群添加插件 part。 + * @param {string} groupId 群 ID + * @param {string} pluginname 插件名 + * @returns {Promise<any>} 响应 + */ +export async function addGroupPlugin(groupId, pluginname) { + return groupFetch(groupPath(groupId, 'plugin'), { + method: 'POST', + json: { pluginname }, + }) +} + +/** + * 从群移除插件 part。 + * @param {string} groupId 群 ID + * @param {string} pluginname 插件名 + * @returns {Promise<void>} + */ +export async function removeGroupPlugin(groupId, pluginname) { + await groupFetch(groupPath(groupId, 'plugin', pluginname), { method: 'DELETE' }) +} + +/** + * 更新群元数据(名称、描述)。 + * @param {string} groupId 群 ID + * @param {{ name: string, description: string }} body 元数据 + * @returns {Promise<void>} + */ +export async function putGroupMeta(groupId, body) { + await groupFetch(groupPath(groupId, 'meta'), { method: 'PUT', json: body }) +} + +/** + * 更新群设置(联邦调优、限流、存储等参数)。 + * @param {string} groupId 群 ID + * @param {object} body 设置对象 + * @returns {Promise<void>} + */ +export async function putGroupSettings(groupId, body) { + await groupFetch(groupPath(groupId, 'settings'), { method: 'PUT', json: body }) +} + +/** + * 删除整个群组。 + * @param {string} groupId 群 ID + * @returns {Promise<void>} + */ +export async function removeGroup(groupId) { + await groupFetch(groupPath(groupId), { method: 'DELETE' }) +} + +/** + * 删除一个会话(私聊群的消息记录,永久移除)。 + * @param {string} groupId 群 ID + * @returns {Promise<void>} + */ +export async function deleteSession(groupId) { + await chatFetch(`/sessions/${encodeURIComponent(groupId)}`, { method: 'DELETE' }) +} + +/** + * 获取流媒体频道嵌入鉴权。 + * @param {string} groupId 群 ID + * @param {string} channelId 频道 ID + * @returns {Promise<{ token: string, embedUrl: string, expiresAt: number, sessionId: string }>} 流媒体会话凭证 + */ +export async function getStreamingChannelAuth(groupId, channelId) { + const data = await groupFetch(groupPath(groupId, 'channels', channelId, 'streaming-auth'), { + method: 'POST', + json: {}, + }) + return { + token: data.token, + embedUrl: data.embedUrl, + expiresAt: data.expiresAt, + sessionId: data.sessionId, + } +} + +/** + * 查询当前观众在群/频道上的权限位。 + * @param {string} groupId 群 ID + * @param {string} pubKeyHash 观众成员公钥哈希 + * @param {string} channelId 频道 ID + * @returns {Promise<Record<string, boolean>>} 权限表 + */ +export async function getViewerPermissions(groupId, pubKeyHash, channelId) { + const params = new URLSearchParams({ pubKeyHash, channelId }) + return groupFetch(`${groupPath(groupId, 'permissions')}?${params}`, { method: 'GET' }) +} + +/** + * 拉取当前 DAG 分叉 tips(治理/横幅用)。 + * @param {string} groupId 群 ID + * @returns {Promise<{ tips: string[], governanceFork: boolean, consensusBranchTip?: string, tipConsensusScores?: Record<string, number> }>} tips 与治理分叉标记 + */ +export async function getDagTips(groupId) { + return groupFetch(groupPath(groupId, 'dag', 'tips'), { method: 'GET' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupDm.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupDm.mjs new file mode 100644 index 000000000..e56223270 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupDm.mjs @@ -0,0 +1,26 @@ +/** + * 【文件】public/src/endpoints/groupDm.mjs + * 【职责】用双方 Ed25519 公钥创建 DM 群(含可选 dmLink 引荐证明)。 + * 【原理】POST groups/ 建群端点,body 含 myPubKeyHex、peerPubKeyHex 与 dmIntro 签名字段。 + * 【数据结构】hex 公钥对、dmLinkProof { dmIntroNonce, dmIntroSignatureHex }。 + * 【关联】groupClient.mjs;deepLinkConsume、dmLink.mjs。 + */ +import { groupFetch } from './groupClient.mjs' + +/** + * 用双方公钥创建 DM 群。 + * @param {string} myPubKeyHex 本端公钥(hex) + * @param {string} peerPubKeyHex 对端公钥(hex) + * @param {{ dmIntroNonce?: string, dmIntroSignatureHex?: string }} [dmLinkProof] 可选引荐签名 + * @returns {Promise<any>} 建群 API 响应 + */ +export async function createDirectMessageByPubKeys(myPubKeyHex, peerPubKeyHex, dmLinkProof) { + const body = { template: 'dm', myPubKeyHex, peerPubKeyHex } + const nonce = String(dmLinkProof?.dmIntroNonce || '').trim() + const signatureHex = String(dmLinkProof?.dmIntroSignatureHex || '').trim().replace(/^0x/iu, '') + if (nonce.length > 0) { + body.dmIntroNonce = nonce + body.dmIntroSignatureHex = signatureHex + } + return groupFetch('', { method: 'POST', json: body }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupFederation.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupFederation.mjs new file mode 100644 index 000000000..0bd0c5bc9 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupFederation.mjs @@ -0,0 +1,95 @@ +/** + * 【文件】public/src/endpoints/groupFederation.mjs + * 【职责】群联邦同步 API:向对等节点 catch-up、拉取缺失 DAG 事件。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 向联邦对等节点拉取缺失事件。 + * @param {string} groupId 群 ID + * @param {object} [options] catch-up 请求体 + * @returns {Promise<object>} 同步统计 + */ +export async function federationCatchUp(groupId, options = {}) { + return groupFetch(groupPath(groupId, 'federation', 'catchup'), { + method: 'POST', + json: options, + }) +} + +/** + * 重绑联邦分区(按当前活跃频道确保对应 ch-XX 房间已加入)。 + * @param {string} groupId 群 ID + * @param {{ channelId?: string }} [options] 活跃频道 + * @returns {Promise<{ ok: boolean, channelId: string | null }>} 重绑结果 + */ +export async function rebindFederationRoom(groupId, options = {}) { + return groupFetch(groupPath(groupId, 'federation', 'rebind'), { + method: 'POST', + json: { + channelId: options.channelId || null, + }, + }) +} + +/** + * 更新联邦调优参数(分区数、RTC 连接预算、加入速率)。 + * @param {string} groupId 群 ID + * @param {{ federationPartitionCount?: number, rtcConnectionBudgetMax?: number, rtcJoinRatePerMin?: number }} patch 调优字段 + * @returns {Promise<object>} API 响应 + */ +export async function postFederationTuning(groupId, patch = {}) { + return groupFetch(groupPath(groupId, 'federation', 'tuning'), { + method: 'POST', + json: patch, + }) +} + +/** + * 轮换群 房间口令(需 ADMIN / MANAGE_ADMINS)。 + * @param {string} groupId 群 ID + * @returns {Promise<{ roomSecret: string }>} 新口令 + */ +export async function rotateFederationRoomSecret(groupId) { + return groupFetch(groupPath(groupId, 'federation', 'rotate-room-secret'), { method: 'POST', json: {} }) +} + +/** + * 向联邦邻居请求入群快照并本地应用(GSH + 频道历史)。 + * @param {string} groupId 群 ID + * @returns {Promise<{ applied: boolean, channels: number, skipped?: boolean }>} 应用统计 + */ +export async function repairJoinSnapshot(groupId) { + return groupFetch(groupPath(groupId, 'federation', 'join-snapshot'), { method: 'POST', json: {} }) +} + +/** + * 驳回「疑似被移出」横幅(保留历史,不退群)。 + * @param {string} groupId 群 ID + * @returns {Promise<void>} + */ +export async function dismissShunBanner(groupId) { + await groupFetch(groupPath(groupId, 'federation', 'shun-dismiss'), { method: 'POST', json: {} }) +} + +/** + * 增量拉取群事件。 + * @param {string} groupId 群 ID + * @param {{ since?: string, channelId?: string, limit?: number }} [options] 分页与过滤 + * @returns {Promise<{ events: object[], truncated: boolean }>} 事件列表及是否截断 + */ +export async function pullGroupEvents(groupId, options = {}) { + const params = new URLSearchParams() + if (options.since) params.set('since', options.since) + if (options.channelId) params.set('channelId', options.channelId) + if (options.limit) params.set('limit', String(options.limit)) + const query = params.toString() + const data = await groupFetch( + `${groupPath(groupId, 'events')}${query ? `?${query}` : ''}`, + { method: 'GET' }, + ) + return { + events: Array.isArray(data.events) ? data.events : [], + truncated: !!data.truncated, + } +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupFiles.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupFiles.mjs new file mode 100644 index 000000000..408d70f3e --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupFiles.mjs @@ -0,0 +1,66 @@ +/** + * 【文件】public/src/endpoints/groupFiles.mjs + * 【职责】群文件分块上传 REST:chunk 预检/注册/上传、文件事件、meta、断点续传、共享柜绑定。 + * 【关联】groupClient.mjs;src/ui/groupFileUpload.mjs、hub/files.mjs。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 预检收敛加密块是否已存在。 + * @param {string} groupId 群 ID + * @param {object} body `{ ciphertextHash?, size, channelId?, ceMode }` + * @returns {Promise<{ have: boolean, storageLocator?: string }>} 预检结果 + */ +export async function probeChunkHave(groupId, body) { + return groupFetch(groupPath(groupId, 'chunks', 'have'), { method: 'POST', json: body }) +} + +/** + * 注册或上传一个明文块(`registerOnly` 命中去重时跳过重复写入)。 + * @param {string} groupId 群 ID + * @param {object} body `{ fileId, data, registerOnly?, channelId?, ceMode }` + * @returns {Promise<object>} 块 manifest 字段 + */ +export async function uploadOrRegisterChunk(groupId, body) { + return groupFetch(groupPath(groupId, 'chunks'), { method: 'POST', json: body }) +} + +/** + * 落盘文件事件(单块或多块 manifest)。 + * @param {string} groupId 群 ID + * @param {object} manifestBody 文件 manifest + * @returns {Promise<object>} 文件事件 + */ +export async function createGroupFileEvent(groupId, manifestBody) { + return groupFetch(groupPath(groupId, 'files'), { method: 'POST', json: manifestBody }) +} + +/** + * 拉取文件元信息(含 parts / storageLocator)。 + * @param {string} groupId 群 ID + * @param {string} fileId 文件 ID + * @returns {Promise<object>} 文件元信息 + */ +export async function getGroupFileMeta(groupId, fileId) { + return groupFetch(groupPath(groupId, 'files', fileId, 'meta'), { method: 'GET' }) +} + +/** + * 触发多块文件的断点续传(联邦拉取缺失块)。 + * @param {string} groupId 群 ID + * @param {string} fileId 文件 ID + * @returns {Promise<void>} + */ +export async function resumeGroupFileDownload(groupId, fileId) { + await groupFetch(groupPath(groupId, 'files', fileId, 'download-resume'), { method: 'POST', json: {} }) +} + +/** + * 绑定共享文件柜到群角色访问表。 + * @param {string} groupId 群 ID + * @param {{ cabinet_id: string, role_access: Record<string, 'ro'|'rw'> }} body 绑定体 + * @returns {Promise<void>} + */ +export async function bindGroupCabinet(groupId, body) { + await groupFetch(groupPath(groupId, 'cabinets', 'bind'), { method: 'POST', json: body }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupFriendBinding.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupFriendBinding.mjs new file mode 100644 index 000000000..f397417c9 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupFriendBinding.mjs @@ -0,0 +1,48 @@ +/** + * 【文件】public/src/endpoints/groupFriendBinding.mjs + * 【职责】群元数据上的好友私聊绑定:写入/清除 friendBinding(group_meta_update)。 + * 【原理】normalizeFriendBinding 后 POST;unbind 变体清除 char 或 user 绑定。 + * 【数据结构】FriendBinding { entityHash, displayName?, charname? }。 + * 【关联】friendBinding.mjs、groupClient.mjs;Hub 好友频道。 + */ +import { normalizeFriendBinding } from '../../shared/friendBinding.mjs' + +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 写入或清除群上的好友私聊绑定元数据(`group_meta_update`)。 + * @param {string} groupId 群 ID + * @param {import('../../shared/friendBinding.mjs').FriendBinding | null} friendBinding 绑定;`null` 表示解绑 + * @returns {Promise<void>} + */ +export async function setGroupFriendBinding(groupId, friendBinding) { + const normalized = friendBinding === null ? null : normalizeFriendBinding(friendBinding) + if (friendBinding !== null && !normalized) throw new Error('invalid friendBinding') + await groupFetch(groupPath(groupId, 'meta'), { + method: 'PUT', + json: { friendBinding: normalized }, + }) +} + +/** + * 解除好友私聊绑定:群回到侧栏;有角色绑定时一并 session unbind。 + * @param {string} groupId 群 ID + * @param {{ charname?: string | null }} [options] 选项 + * @returns {Promise<void>} + */ +export async function unbindFriendGroup(groupId, { charname } = {}) { + const name = charname?.trim() + if (name) + await groupFetch(groupPath(groupId, 'char', name), { method: 'DELETE' }) + await setGroupFriendBinding(groupId, null) +} + +/** + * 解除角色好友绑定:会话 unbind + 清除元数据(群保留并回到左侧群列表)。 + * @param {string} groupId 群 ID + * @param {string} charname 角色名 + * @returns {Promise<void>} + */ +export async function unbindCharFriendChat(groupId, charname) { + await unbindFriendGroup(groupId, { charname }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs new file mode 100644 index 000000000..51421c1d3 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs @@ -0,0 +1,142 @@ +/** + * 【文件】public/src/endpoints/groupGovernance.mjs + * 【职责】群治理 API:fork、封对立分支、声誉、群主继任、轮换群钥、合并 DAG tips。 + * 【关联】groupClient.mjs;groupBan、审计与 Hub 管理 UI。 + */ +import { chatFetch, groupFetch, groupPath } from './groupClient.mjs' +import { addDenylistEntry } from './p2p.mjs' + +/** + * 将现有群 fork 为新群。 + * @param {string} sourceGroupId 源群 ID + * @param {object} [options] fork 请求体 + * @returns {Promise<any>} fork API 响应 + */ +export async function forkGroupAsNew(sourceGroupId, options = {}) { + return groupFetch(groupPath(sourceGroupId, 'fork'), { method: 'POST', json: options }) +} + +/** + * 拉黑对立治理分支上的签发者(采纳叶 = 当前选支)。 + * @param {string} groupId 群 ID + * @param {string} acceptedTipId 64 hex 叶 id + * @returns {Promise<{ blocked: string[] }>} 被拉黑公钥哈希列表 + */ +export async function blockOpposingForkBranch(groupId, acceptedTipId) { + return groupFetch(groupPath(groupId, 'fork', 'block-opposing'), { + method: 'POST', + json: { acceptedTipId }, + }) +} + +/** + * 追加用户级拉黑(`denylist.json`)。 + * @param {string|{ scope: string, value: string, groupId?: string }} entry 主体或 `{ scope, value }` + * @param {string} [groupId] 来源群 ID(`entry` 为字符串时使用) + * @returns {Promise<void>} + */ +export async function blockUser(entry, groupId) { + const body = entry?.scope + ? { scope: entry.scope, value: entry.value, groupId: entry.groupId || groupId } + : { scope: 'subject', value: entry, groupId } + await addDenylistEntry(body) +} + +/** + * 设置当前采纳的治理分支 tip。 + * @param {string} groupId 群 ID + * @param {string} tipId 分支 tip 事件 ID + * @returns {Promise<{ consensusBranchTip: string|null, localViewBranchTip: string|null, governanceFork: boolean }>} 更新后的分支状态 + */ +export async function setGovernanceBranch(groupId, tipId) { + const data = await groupFetch(groupPath(groupId, 'governance-branch'), { + method: 'PUT', + json: { tipId }, + }) + return { + consensusBranchTip: data.consensusBranchTip ?? null, + localViewBranchTip: data.localViewBranchTip ?? null, + governanceFork: !!data.governanceFork, + } +} + +/** + * 读取群主观信誉表(节点级 `/reputation`,不在 `groups/` 下)。 + * @returns {Promise<object>} `{ reputation }` + */ +export async function getGroupReputation() { + return chatFetch('/reputation') +} + +/** + * 发布 reputation_reset 事件。 + * @param {string} groupId 群 ID + * @param {string} targetPubKeyHash 目标 64 hex + * @returns {Promise<{ applied: number }>} 应用计数 + */ +export async function postReputationReset(groupId, targetPubKeyHash) { + return groupFetch(groupPath(groupId, 'reputation', 'reset'), { + method: 'POST', + json: { targetPubKeyHash: String(targetPubKeyHash || '').trim().toLowerCase() }, + }) +} + +/** + * 发布声誉扣减事件。 + * @param {string} groupId 群 ID + * @param {object} body 扣减参数(`targetPubKeyHash`、`claim`、`verified`、`proof` 等) + * @returns {Promise<{ applied: number }>} 实际应用的事件数 + */ +export async function postReputationSlash(groupId, body) { + const payload = { + targetPubKeyHash: String(body.targetPubKeyHash || '').trim().toLowerCase(), + claim: Number(body.claim ?? 0.25), + } + if (body.verified) { + payload.verified = true + if (body.proof?.eventId) payload.proof = { eventId: String(body.proof.eventId).trim().toLowerCase() } + } + const data = await groupFetch(groupPath(groupId, 'reputation', 'slash'), { + method: 'POST', + json: payload, + }) + return { applied: Number(data.applied) || 0 } +} + +/** + * 合并 DAG 分叉 tip(§8 治理)。 + * @param {string} groupId 群 ID + * @returns {Promise<object>} merge API 响应 + */ +export async function mergeDagTips(groupId) { + return groupFetch(groupPath(groupId, 'dag', 'merge-tips'), { method: 'POST', json: {} }) +} + +/** + * 手动轮换群 GSH 密钥。 + * @param {string} groupId 群 ID + * @returns {Promise<object>} file-key-rotate 响应 + */ +export async function rotateGroupKey(groupId) { + return groupFetch(groupPath(groupId, 'file-key-rotate'), { method: 'POST', json: {} }) +} + +/** + * 群主继任联署提交。 + * @param {string} groupId 群 ID + * @param {object} body `{ proposedOwnerPubKeyHash, ballotId, adminSignatures?, thresholdRatio? }` + * @returns {Promise<object>} 服务端 JSON 响应 + */ +export async function submitOwnerSuccession(groupId, body) { + return groupFetch(groupPath(groupId, 'owner-succession'), { method: 'POST', json: body }) +} + +/** + * 解封成员。 + * @param {string} groupId 群 ID + * @param {string} pubKeyHash 成员公钥哈希(用户名键) + * @returns {Promise<void>} + */ +export async function unbanMember(groupId, pubKeyHash) { + await groupFetch(groupPath(groupId, 'members', pubKeyHash, 'unban'), { method: 'POST', json: {} }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/inbox.mjs b/src/public/parts/shells/chat/public/src/endpoints/inbox.mjs new file mode 100644 index 000000000..bf8693450 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/inbox.mjs @@ -0,0 +1,26 @@ +/** + * 【文件】public/src/endpoints/inbox.mjs + * 【职责】跨群 inbox REST。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * @param {{ limit?: number, cursor?: string, kinds?: string[] }} [options] 分页 + * @returns {Promise<{ items: object[], nextCursor: string | null, unreadCount: number }>} 分页结果 + */ +export function fetchInboxPage(options = {}) { + const params = new URLSearchParams() + if (options.limit) params.set('limit', String(options.limit)) + if (options.cursor) params.set('cursor', String(options.cursor)) + if (options.kinds?.length) params.set('kinds', options.kinds.join(',')) + const query = params.toString() + return chatFetch(`/inbox${query ? `?${query}` : ''}`) +} + +/** + * @param {number} [at] 已读水位毫秒 + * @returns {Promise<any>} 响应 + */ +export function markInboxSeen(at = Date.now()) { + return chatFetch('/inbox/seen', { method: 'PUT', json: { at } }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/members.mjs b/src/public/parts/shells/chat/public/src/endpoints/members.mjs new file mode 100644 index 000000000..ea59ec011 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/members.mjs @@ -0,0 +1,16 @@ +/** + * 【文件】public/src/endpoints/members.mjs + * 【职责】成员管理 REST:踢出成员。封禁/解封见 groupBan.mjs / groupGovernance.mjs。 + * 【关联】groupSettings/membersTab.mjs、memberContextMenu.mjs。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 踢出成员(不封禁,可重新加入)。 + * @param {string} groupId 群 ID + * @param {string} pubKeyHash 成员公钥哈希 + * @returns {Promise<void>} + */ +export async function kickMember(groupId, pubKeyHash) { + await groupFetch(groupPath(groupId, 'members', pubKeyHash, 'kick'), { method: 'POST' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/mentions.mjs b/src/public/parts/shells/chat/public/src/endpoints/mentions.mjs new file mode 100644 index 000000000..9ce7f7350 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/mentions.mjs @@ -0,0 +1,18 @@ +/** + * 【文件】public/src/endpoints/mentions.mjs + * 【职责】群 @ 提及 autocomplete 候选查询。 + * 【关联】groupClient.mjs;hub/mentionAutocomplete.mjs。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 查询群内 @ 提及候选(成员 / 角色 / @everyone / @here)。 + * @param {string} groupId 群 ID + * @param {string} query 过滤词 + * @param {number} [limit] 返回条数上限 + * @returns {Promise<{ suggestions: object[] }>} 候选列表 + */ +export async function suggestMentions(groupId, query, limit = 12) { + const params = new URLSearchParams({ q: query, limit: String(limit) }) + return groupFetch(`${groupPath(groupId, 'mentions', 'suggest')}?${params}`, { method: 'GET' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs b/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs new file mode 100644 index 000000000..28262fb3c --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs @@ -0,0 +1,62 @@ +/** + * 【文件】public/src/endpoints/p2p.mjs + * 【职责】浏览器侧 P2P / 联邦节点 REST(非 chat shell 前缀,仍归 chat Hub 使用面)。 + */ + +/** + * @param {string} path 以 / 开头,相对 /api/p2p + * @param {RequestInit & { json?: object }} [options] fetch 选项 + * @returns {Promise<any>} JSON + */ +async function p2pFetch(path, options = {}) { + const { json, ...init } = options + const response = await fetch(`/api/p2p${path}`, { + credentials: 'include', + headers: json ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, + body: json ? JSON.stringify(json) : init.body, + ...init, + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || `HTTP ${response.status}`) + } + if (response.status === 204) return null + const text = await response.text() + if (!text) return null + return JSON.parse(text) +} + +/** + * 读取本节点联邦设置。 + * @returns {Promise<object>} 设置 JSON + */ +export function getFederationSettings() { + return p2pFetch('/federation') +} + +/** + * 更新本节点联邦设置。 + * @param {object} body 请求体 + * @returns {Promise<object>} 服务端响应 + */ +export function putFederationSettings(body) { + return p2pFetch('/federation', { method: 'PUT', json: body }) +} + +/** + * 写入节点 denylist。 + * @param {object} entry denylist 条目 + * @returns {Promise<any>} 响应 + */ +export function addDenylistEntry(entry) { + return p2pFetch('/denylist', { method: 'POST', json: entry }) +} + +/** + * 连接联邦节点。 + * @param {object} body 连接参数 + * @returns {Promise<any>} 响应 + */ +export function connectFederationNode(body) { + return p2pFetch('/federation/connect-node', { method: 'POST', json: body }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs b/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs new file mode 100644 index 000000000..ae87fea8f --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs @@ -0,0 +1,94 @@ +/** + * 【文件】public/src/endpoints/prefs.mjs + * 【职责】用户级偏好 REST:aliases / care / notify-prefs / translation-prefs / trusted-authors / personal-lists。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * @returns {Promise<{ entities: Record<string, string>, groups: Record<string, string> }>} 别名档 + */ +export async function getAliases() { + const data = await chatFetch('/aliases') + return { entities: data.entities || {}, groups: data.groups || {} } +} + +/** + * @param {{ entities: Record<string, string>, groups: Record<string, string> }} doc 别名档 + * @returns {Promise<{ entities: Record<string, string>, groups: Record<string, string> }>} 写入后 + */ +export async function putAliases(doc) { + const data = await chatFetch('/aliases', { method: 'PUT', json: doc }) + return { entities: data.entities || {}, groups: data.groups || {} } +} + +/** + * @returns {Promise<string[]>} cared entityHashes + */ +export async function listCaredEntities() { + const data = await chatFetch('/care') + return Array.isArray(data.cared) ? data.cared : [] +} + +/** + * @param {string} targetEntityHash 目标 + * @param {boolean} cared 是否关心 + * @returns {Promise<string[]>} 更新后列表 + */ +export async function setCaredEntity(targetEntityHash, cared) { + const data = await chatFetch('/care', { method: 'PUT', json: { targetEntityHash, cared } }) + return Array.isArray(data.cared) ? data.cared : [] +} + +/** + * @returns {Promise<Record<string, object>>} 通知偏好 + */ +export async function getNotificationPreferences() { + const data = await chatFetch('/notify-prefs') + return data.prefs || {} +} + +/** + * @param {Record<string, object>} prefs 整档 + * @returns {Promise<Record<string, object>>} 写入后 + */ +export async function putNotificationPreferences(prefs) { + const data = await chatFetch('/notify-prefs', { method: 'PUT', json: { prefs } }) + return data.prefs || {} +} + +/** + * @returns {Promise<object>} translation prefs + */ +export function getTranslationPrefs() { + return chatFetch('/translation-prefs') +} + +/** + * @param {object} body prefs body + * @returns {Promise<object>} 响应 + */ +export function putTranslationPrefs(body) { + return chatFetch('/translation-prefs', { method: 'PUT', json: body }) +} + +/** + * @returns {Promise<any>} trusted authors + */ +export function getTrustedAuthors() { + return chatFetch('/trusted-authors') +} + +/** + * @param {object} body 请求体 + * @returns {Promise<any>} 响应 + */ +export function putTrustedAuthors(body) { + return chatFetch('/trusted-authors', { method: 'PUT', json: body }) +} + +/** + * @returns {Promise<any>} personal lists + */ +export function getPersonalLists() { + return chatFetch('/personal-lists') +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/roles.mjs b/src/public/parts/shells/chat/public/src/endpoints/roles.mjs new file mode 100644 index 000000000..cc9868e77 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/roles.mjs @@ -0,0 +1,41 @@ +/** + * 【文件】public/src/endpoints/roles.mjs + * 【职责】群角色 CRUD:创建角色、更新角色权限位、删除角色。 + * 【关联】groupSettings/permissionsTab.mjs;后端 group/roles 路由。 + */ +import { groupFetch, groupPath } from './groupClient.mjs' + +/** + * 创建群角色。 + * @param {string} groupId 群 ID + * @param {string} name 角色名 + * @returns {Promise<object>} 服务端响应 + */ +export function createRole(groupId, name) { + return groupFetch(groupPath(groupId, 'roles'), { method: 'POST', json: { name } }) +} + +/** + * 更新角色单条权限位。 + * @param {string} groupId 群 ID + * @param {string} roleId 角色 ID + * @param {string} permission 权限键 + * @param {boolean} enabled 是否启用 + * @returns {Promise<void>} + */ +export async function updateRolePermission(groupId, roleId, permission, enabled) { + await groupFetch(groupPath(groupId, 'roles', roleId, 'permissions'), { + method: 'PUT', + json: { permission, enabled }, + }) +} + +/** + * 删除群角色。 + * @param {string} groupId 群 ID + * @param {string} roleId 角色 ID + * @returns {Promise<void>} + */ +export async function deleteRole(groupId, roleId) { + await groupFetch(groupPath(groupId, 'roles', roleId), { method: 'DELETE' }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/social.mjs b/src/public/parts/shells/chat/public/src/endpoints/social.mjs new file mode 100644 index 000000000..633419958 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/social.mjs @@ -0,0 +1,45 @@ +/** + * 【文件】public/src/endpoints/social.mjs + * 【职责】浏览器侧 Social shell REST(非 chat shell 前缀,仍归 chat Hub 使用面:个人拉黑、关注作者)。 + * 【关联】hub/personalFilter.mjs、emoji-packs/index.mjs。 + */ + +/** + * @param {string} path 以 / 开头,相对 /api/parts/shells:social + * @param {RequestInit & { json?: object }} [options] fetch 选项 + * @returns {Promise<any>} JSON + */ +async function socialFetch(path, options = {}) { + const { json, ...init } = options + const response = await fetch(`/api/parts/shells:social${path}`, { + credentials: 'include', + headers: json ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, + body: json ? JSON.stringify(json) : init.body, + ...init, + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || `HTTP ${response.status}`) + } + return response.json() +} + +/** + * 拉黑 / 取消拉黑指定实体(个人级,写入 Social relationships)。 + * @param {string} entityHash 目标实体 + * @param {boolean} block true=拉黑 + * @returns {Promise<any>} 响应 + */ +export function postRelationshipBlock(entityHash, block) { + return socialFetch('/relationships/block', { method: 'POST', json: { entityHash, block } }) +} + +/** + * 关注 / 取关指定实体。 + * @param {string} entityHash 目标实体 + * @param {boolean} follow true=关注 + * @returns {Promise<any>} 响应 + */ +export function postRelationshipFollow(entityHash, follow) { + return socialFetch('/relationships/follow', { method: 'POST', json: { entityHash, follow } }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/viewer.mjs b/src/public/parts/shells/chat/public/src/endpoints/viewer.mjs new file mode 100644 index 000000000..986f0e677 --- /dev/null +++ b/src/public/parts/shells/chat/public/src/endpoints/viewer.mjs @@ -0,0 +1,14 @@ +/** + * 【文件】public/src/endpoints/viewer.mjs + * 【职责】viewer 身份 REST:`GET /viewer`。 + * 【关联】initCore、deepLinkConsume、profile/index、ownerSettingsPanel。 + */ +import { chatFetch } from './groupClient.mjs' + +/** + * 拉取当前 viewer 身份与资料。 + * @returns {Promise<{ nodeHash: string, viewerEntityHash: string|null, profile: object|null, agents: object[], identityRequired?: boolean }>} viewer 载荷 + */ +export function getViewer() { + return chatFetch('/viewer') +} diff --git a/src/public/parts/shells/chat/public/src/entityProfileApi.mjs b/src/public/parts/shells/chat/public/src/entityProfileApi.mjs deleted file mode 100644 index 00d7e0c2c..000000000 --- a/src/public/parts/shells/chat/public/src/entityProfileApi.mjs +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 【文件】public/src/entityProfileApi.mjs - * 【职责】实体资料 API 辅助:locale 查询串、缓存规范化、fetchEntityProfileApi。 - * 【原理】localeQueryString 仅附加 groupId(locales 由服务端从登录用户解析);cachedProfileFromApi 合并展示字段。 - * 【数据结构】entityHash(128)、groupId、profile JSON(localized 多语言)。 - * 【关联】profile/src/endpoints.mjs;Hub entityProfile。 - */ -/** - * 实体资料 API 查询串。不传 `locales`:服务端 `localesFromRequest` 用登录用户的 `user.locales`。 - * @param {string} [groupId] 群 ID(persona 解析) - * @returns {string} 查询串 - */ -export function localeQueryString(groupId) { - const params = new URLSearchParams() - if (groupId) params.set('groupId', groupId) - return params.toString() -} - -/** - * 将 API profile 转为 Hub 缓存结构。 - * @param {object|null|undefined} profile API profile - * @param {string} entityHash 128 位 entityHash - * @returns {object|null} Hub 缓存结构或 null - */ -export function cachedProfileFromApi(profile, entityHash) { - if (!profile) return null - const key = String(entityHash || '').toLowerCase() - return { - entityHash: key, - avatar: profile.avatar || null, - infoDefaults: profile.infoDefaults || null, - name: profile.name || key.slice(64, 72), - handle: profile.handle || null, - themeColor: profile.themeColor || '', - banner: profile.displayBanner || profile.banner || '', - sfw_banner: profile.sfw_banner || '', - displayBanner: profile.displayBanner || profile.banner || '', - description: profile.description || '', - description_markdown: profile.description_markdown || '', - localized: profile.localized || {}, - tags: Array.isArray(profile.tags) ? profile.tags : [], - links: Array.isArray(profile.links) ? profile.links : [], - status: profile.effectiveStatus || profile.status || 'offline', - customStatus: profile.customStatus || '', - ownerEntityHash: profile.ownerEntityHash - ? String(profile.ownerEntityHash).toLowerCase() - : null, - activePubKeyHex: profile.activePubKeyHex || null, - keyGeneration: profile.keyGeneration ?? null, - } -} - -/** - * @param {string} entityHash 128 位 entityHash - * @param {string} [groupId] 群 ID - * @returns {Promise<object>} API JSON - */ -export async function fetchEntityProfileApi(entityHash, groupId) { - const qs = localeQueryString(groupId) - const response = await fetch( - `/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`, - { credentials: 'include' }, - ) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw Object.assign(new Error(data.error || response.statusText), data, { response }) - } - return response.json() -} - -/** - * @param {string} entityHash 128 位 entityHash - * @param {object} updates 更新体 - * @param {string} [groupId] 群 ID - * @returns {Promise<object>} API JSON - */ -export async function updateEntityProfileApi(entityHash, updates, groupId) { - const qs = localeQueryString(groupId) - const response = await fetch( - `/api/parts/shells:chat/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`, - { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...updates, - ...groupId ? { groupId } : {}, - }), - }, - ) - const data = await response.json().catch(() => ({})) - if (!response.ok) - throw Object.assign(new Error(data.error || response.statusText), data, { response }) - - return data -} diff --git a/src/public/parts/shells/chat/public/src/files.mjs b/src/public/parts/shells/chat/public/src/files.mjs index 33ce1c319..60dbd6d2b 100644 --- a/src/public/parts/shells/chat/public/src/files.mjs +++ b/src/public/parts/shells/chat/public/src/files.mjs @@ -1,7 +1,7 @@ /** * 聊天附件经 EVFS 上传与下载。 */ -import { fetchEvfsFile, uploadEvfsAttachment } from '/parts/shells:chat/shared/evfsMedia.mjs' +import { fetchEvfsFile, uploadEvfsAttachment } from '/scripts/endpoints/p2p/evfsMedia.mjs' const CHAT_ATTACHMENT_PREFIX = 'shells/chat/attachments' @@ -20,5 +20,6 @@ export async function uploadChatAttachment(file) { * @returns {Promise<ArrayBuffer>} 文件字节 */ export async function getFile(ref) { - return fetchEvfsFile(ref.entityHash, ref.path) + const { buffer } = await fetchEvfsFile(ref.entityHash, ref.path) + return buffer } diff --git a/src/public/parts/shells/chat/public/src/groupFileBlob.mjs b/src/public/parts/shells/chat/public/src/groupFileBlob.mjs index 99a0e470f..a88d75641 100644 --- a/src/public/parts/shells/chat/public/src/groupFileBlob.mjs +++ b/src/public/parts/shells/chat/public/src/groupFileBlob.mjs @@ -2,7 +2,7 @@ * 【文件】public/src/groupFileBlob.mjs * 【职责】群加密文件经 groupEntityHash EVFS 解密下载为 Blob URL。 */ -import { entityFileUrl } from '/parts/shells:chat/shared/evfsMedia.mjs' +import { fetchEvfsFile } from '/scripts/endpoints/p2p/evfsMedia.mjs' import { groupEntityHash } from '../shared/groupEntityHash.mjs' @@ -15,10 +15,11 @@ import { groupEntityHash } from '../shared/groupEntityHash.mjs' export async function fetchGroupFileAsBlobUrl(groupId, fileId) { const entityHash = groupEntityHash(groupId) const logicalPath = `chat/${fileId}` - const response = await fetch(entityFileUrl(entityHash, logicalPath), { credentials: 'include' }) - if (!response.ok) return null - const mimeType = response.headers.get('Content-Type') || 'application/octet-stream' - const plainBytes = new Uint8Array(await response.arrayBuffer()) - return URL.createObjectURL(new Blob([plainBytes], { type: mimeType })) + try { + const { buffer, mimeType } = await fetchEvfsFile(entityHash, logicalPath) + return URL.createObjectURL(new Blob([buffer], { type: mimeType })) + } + catch { + return null + } } - diff --git a/src/public/parts/shells/chat/public/src/groupSettings/archiveTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/archiveTab.mjs index 956ac8d40..e43fd24d1 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/archiveTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/archiveTab.mjs @@ -2,8 +2,8 @@ import { appendTemplate, mountTemplate } from '../../../../../../scripts/feature import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../../../scripts/i18n/index.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' -import { importChannelArchiveFile } from '../api/channelArchive.mjs' -import { handleUIError } from '../ui/errors.mjs' +import { deleteArchiveBefore, getArchiveSummary, importChannelArchiveFile } from '../endpoints/channelArchive.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { formatArchiveBytes } from './shared.mjs' @@ -15,11 +15,7 @@ export async function renderArchiveStoragePanel(context) { let archiveRowsHtml = '' if (canManageArchive) try { - const resp = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/archive/summary`, - { credentials: 'include' }, - ) - const data = await resp.json() + const data = await getArchiveSummary(context.groupId) const files = Array.isArray(data.files) ? data.files : [] if (files.length) archiveRowsHtml = `<div class="overflow-x-auto"><table class="table table-sm"> @@ -53,7 +49,7 @@ export async function renderArchiveStoragePanel(context) { window.location.href = `/parts/shells:chat/hub/#group:${encodeURIComponent(context.groupId)}:${encodeURIComponent(result.channelId)}` } catch (error) { - handleUIError(error, 'chat.group.settings.page.channelArchive.importFailed') + handleError('chat.group.settings.page.channelArchive.importFailed')(error) } }) } @@ -67,12 +63,7 @@ export async function renderArchiveStoragePanel(context) { const deleteArchiveButton = document.getElementById('archive-delete-button') if (deleteArchiveButton instanceof HTMLButtonElement) deleteArchiveButton.disabled = true try { - const resp = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/archive?before=${encodeURIComponent(raw)}`, - { method: 'DELETE', credentials: 'include' }, - ) - const data = await resp.json() - if (!resp.ok) throw new Error(data.error || resp.statusText) + const data = await deleteArchiveBefore(context.groupId, raw) showToastI18n('success', 'chat.group.settings.archive.delete.ok', { files: String(data.deletedFiles ?? 0), }) diff --git a/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs index fc4c6b7ad..8522956ec 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs @@ -1,5 +1,6 @@ import { mountTemplate } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' +import { getChannelPermissions, putChannelPermissions } from '../endpoints/channelPerms.mjs' import { ALL_PERMISSIONS } from './constants.mjs' @@ -15,43 +16,6 @@ function channelPermTriState(allow, deny, perm) { return 'neutral' } -/** - * @param {import('./state.mjs').GroupSettingsContext} context 群设置上下文 - * @param {string} channelId 频道 ID - * @returns {Promise<Record<string, { allow?: Record<string, boolean>, deny?: Record<string, boolean> }>>} 各角色频道权限 - */ -async function fetchChannelPermissions(context, channelId) { - const resp = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/channels/${encodeURIComponent(channelId)}/permissions`, - { credentials: 'include' }, - ) - const data = await resp.json() - if (!resp.ok) throw new Error(data.error || resp.statusText) - return data.permissions || {} -} - -/** - * @param {import('./state.mjs').GroupSettingsContext} context 群设置上下文 - * @param {string} channelId 频道 ID - * @param {string} roleId 角色 ID - * @param {Record<string, boolean>} allow 允许位图 - * @param {Record<string, boolean>} deny 拒绝位图 - * @returns {Promise<void>} - */ -async function putChannelPermissions(context, channelId, roleId, allow, deny) { - const resp = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/channels/${encodeURIComponent(channelId)}/permissions`, - { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ roleId, allow, deny }), - }, - ) - const data = await resp.json() - if (!resp.ok) throw new Error(data.error || resp.statusText) -} - /** @param {import('./state.mjs').GroupSettingsContext} context @returns {Promise<void>} */ export async function renderChannelPermissionsPanel(context) { const container = document.getElementById('channel-perms-container') @@ -79,7 +43,7 @@ export async function renderChannelPermissionsPanel(context) { let permissions = {} try { - permissions = await fetchChannelPermissions(context, context.selectedChannelPermsId) + permissions = await getChannelPermissions(context.groupId, context.selectedChannelPermsId) } catch (error) { showToastI18n('error', 'chat.group.settings.page.channelPerms.updateFailed', { error: error.message }) @@ -123,7 +87,7 @@ export async function renderChannelPermissionsPanel(context) { const roleId = sel instanceof HTMLSelectElement ? sel.value : '' if (!roleId || !context.selectedChannelPermsId) return try { - await putChannelPermissions(context, context.selectedChannelPermsId, roleId, {}, {}) + await putChannelPermissions(context.groupId, context.selectedChannelPermsId, roleId, {}, {}) showToastI18n('success', 'chat.group.settings.page.channelPerms.updated') await renderChannelPermissionsPanel(context) } @@ -135,7 +99,7 @@ export async function renderChannelPermissionsPanel(context) { const removeRoleOverrideButton = event.target.closest('[data-action="remove-role-override"]') if (removeRoleOverrideButton?.dataset.roleId && context.selectedChannelPermsId) { try { - await putChannelPermissions(context, context.selectedChannelPermsId, removeRoleOverrideButton.dataset.roleId, {}, {}) + await putChannelPermissions(context.groupId, context.selectedChannelPermsId, removeRoleOverrideButton.dataset.roleId, {}, {}) showToastI18n('success', 'chat.group.settings.page.channelPerms.updated') await renderChannelPermissionsPanel(context) } @@ -152,7 +116,7 @@ export async function renderChannelPermissionsPanel(context) { const perm = group.getAttribute('data-perm') const nextState = channelPermStateButton.getAttribute('data-state') if (!roleId || !perm || !nextState) return - const current = await fetchChannelPermissions(context, context.selectedChannelPermsId) + const current = await getChannelPermissions(context.groupId, context.selectedChannelPermsId) const allow = { ...current[roleId]?.allow } const deny = { ...current[roleId]?.deny } delete allow[perm] @@ -160,7 +124,7 @@ export async function renderChannelPermissionsPanel(context) { if (nextState === 'allow') allow[perm] = true else if (nextState === 'deny') deny[perm] = true try { - await putChannelPermissions(context, context.selectedChannelPermsId, roleId, allow, deny) + await putChannelPermissions(context.groupId, context.selectedChannelPermsId, roleId, allow, deny) showToastI18n('success', 'chat.group.settings.page.channelPerms.updated') await renderChannelPermissionsPanel(context) } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs index ef208d638..82fc3696d 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs @@ -4,6 +4,14 @@ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n, geti18n } from '../../../../../../scripts/i18n/index.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { packEmojiContentUrl, resolveActivePackId } from '../../providers/emoji.mjs' +import { + createGroupEmojiPack, + deleteGroupEmoji, + getGroupEmojiPack, + listGroupEmojiPacks, + uploadGroupEmoji, +} from '../endpoints/emojiPacks.mjs' +import { putGroupSettings } from '../endpoints/groupCore.mjs' import { viewerCanManageMessages } from '../groupViewerPermissions.mjs' /** @@ -36,10 +44,7 @@ async function renderGroupEmojis(context) { const container = document.getElementById('group-emojis-container') if (!container || !context.groupId) return const channelId = context.state?.groupSettings?.defaultChannelId || 'default' - const packsPayload = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/emoji-packs`, { credentials: 'include' }) - .then(r => r.ok ? r.json() : {}) - .then(d => Array.isArray(d.packs) ? d.packs : []) - .catch(() => []) + const packsPayload = await listGroupEmojiPacks(context.groupId).catch(() => []) const packIds = packsPayload.map(p => p.packId).filter(Boolean) if (!packIds.includes(context.groupId)) packIds.unshift(context.groupId) @@ -48,10 +53,7 @@ async function renderGroupEmojis(context) { const [canManage, packDetail] = await Promise.all([ viewerCanManageMessages(context.state, context.groupId, channelId).catch(() => false), - fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/emoji-packs/${encodeURIComponent(activePackId)}`, { credentials: 'include' }) - .then(r => r.ok ? r.json() : {}) - .then(d => d.pack || null) - .catch(() => null), + getGroupEmojiPack(context.groupId, activePackId).catch(() => null), ]) const entries = Array.isArray(packDetail?.items) ? packDetail.items : [] @@ -96,17 +98,7 @@ ${del} const previousValue = String(context.state?.groupSettings?.defaultEmojiPackId || '').trim() || context.groupId const packId = String(defaultSelect.value || '').trim() try { - const resp = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/settings`, { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ defaultEmojiPackId: packId || null }), - }) - if (!resp.ok) { - showToastI18n('error', 'chat.group.settings.page.defaultEmojiPack.failed') - defaultSelect.value = previousValue - return - } + await putGroupSettings(context.groupId, { defaultEmojiPackId: packId || null }) showToastI18n('success', 'chat.group.settings.page.defaultEmojiPack.ok') if (context.state?.groupSettings) context.state.groupSettings.defaultEmojiPackId = packId || null @@ -128,21 +120,16 @@ ${del} ) if (packId == null) return const id = String(packId || '').trim() || suggested - const resp = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/emoji-packs`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ packId: id }), - }) - const data = await resp.json().catch(() => ({})) - if (!resp.ok) { - showToastI18n('error', 'chat.group.settings.page.emojis.create.packFailed', { error: data.error || resp.statusText }) - return + try { + const data = await createGroupEmojiPack(context.groupId, id) + showToastI18n('success', 'chat.group.settings.page.emojis.create.packOk') + context.activeEmojiPackId = data.pack?.packId || id + context.emojisPanelReady = false + await ensureGroupEmojisPanel(context) + } + catch (error) { + showToastI18n('error', 'chat.group.settings.page.emojis.create.packFailed', { error: error.message }) } - showToastI18n('success', 'chat.group.settings.page.emojis.create.packOk') - context.activeEmojiPackId = data.pack?.packId || id - context.emojisPanelReady = false - await ensureGroupEmojisPanel(context) }) const upload = document.getElementById('group-emoji-upload') @@ -150,22 +137,16 @@ ${del} upload.addEventListener('change', async () => { const file = upload.files?.[0] if (!file) return - const form = new FormData() - form.append('emoji', file) - form.append('name', file.name.replace(/\.[^.]+$/, '')) const packId = context.activeEmojiPackId || context.groupId - const up = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/emoji-packs/${encodeURIComponent(packId)}/emojis`, - { method: 'POST', credentials: 'include', body: form }, - ) - const upData = await up.json() - if (!up.ok) { - showToastI18n('error', 'chat.group.settings.page.emojis.uploadFailed', { error: upData.error || up.statusText }) - return + try { + await uploadGroupEmoji(context.groupId, packId, file, file.name.replace(/\.[^.]+$/, '')) + showToastI18n('success', 'chat.group.settings.page.emojis.uploadOk') + context.emojisPanelReady = false + await ensureGroupEmojisPanel(context) + } + catch (error) { + showToastI18n('error', 'chat.group.settings.page.emojis.uploadFailed', { error: error.message }) } - showToastI18n('success', 'chat.group.settings.page.emojis.uploadOk') - context.emojisPanelReady = false - await ensureGroupEmojisPanel(context) }) container.querySelectorAll('[data-delete-emoji]').forEach(deleteEmojiButton => { @@ -173,18 +154,15 @@ ${del} const emojiId = deleteEmojiButton.getAttribute('data-delete-emoji') if (!emojiId || !confirmI18n('chat.group.settings.page.emojis.deleteConfirm')) return const packId = context.activeEmojiPackId || context.groupId - const del = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/emoji-packs/${encodeURIComponent(packId)}/emojis/${encodeURIComponent(emojiId)}`, - { method: 'DELETE', credentials: 'include' }, - ) - const delData = await del.json() - if (!del.ok) { - showToastI18n('error', 'chat.group.settings.page.emojis.deleteFailed', { error: delData.error || '' }) - return + try { + await deleteGroupEmoji(context.groupId, packId, emojiId) + showToastI18n('success', 'chat.group.settings.page.emojis.deleteOk') + context.emojisPanelReady = false + await ensureGroupEmojisPanel(context) + } + catch (error) { + showToastI18n('error', 'chat.group.settings.page.emojis.deleteFailed', { error: error.message }) } - showToastI18n('success', 'chat.group.settings.page.emojis.deleteOk') - context.emojisPanelReady = false - await ensureGroupEmojisPanel(context) }) }) } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/generalTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/generalTab.mjs index 4487b9790..1bfa5559a 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/generalTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/generalTab.mjs @@ -3,13 +3,13 @@ import { usingTemplates } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../../../scripts/i18n/index.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' -import { postFederationTuning } from '../api/groupFederation.mjs' -import { rotateGroupKey, submitOwnerSuccession } from '../api/groupGovernance.mjs' +import { putGroupMeta, putGroupSettings, removeGroup } from '../endpoints/groupCore.mjs' +import { postFederationTuning } from '../endpoints/groupFederation.mjs' +import { rotateGroupKey, submitOwnerSuccession } from '../endpoints/groupGovernance.mjs' import { collectFederationTuningPatch } from './federationTab.mjs' import { collectIceServersFromDom, wireIceServersEditor } from './iceTab.mjs' import { wireInvitePanel } from './inviteTab.mjs' -import { readApiError } from './shared.mjs' /** @param {import('./state.mjs').GroupSettingsContext} context @returns {Promise<void>} */ export async function showOwnerSuccessionModal(context) { @@ -67,69 +67,57 @@ export async function saveGroupSettings(context) { showToastI18n('error', 'chat.group.settings.page.governanceDenied') return } - const metaResponse = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/meta`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - name: document.getElementById('group-name').value.trim(), - description: document.getElementById('group-description').value.trim(), - }) + await putGroupMeta(context.groupId, { + name: document.getElementById('group-name').value.trim(), + description: document.getElementById('group-description').value.trim(), }) - if (!metaResponse.ok) throw new Error(await readApiError(metaResponse)) const gossipTtl = Number.parseInt(document.getElementById('gossip-ttl').value, 10) - const settingsResponse = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/settings`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - joinPolicy: document.getElementById('join-policy').value, - powDifficulty: Number.parseInt(document.getElementById('pow-difficulty').value, 10) || 4, - streamGeneratingIdleMs: Number.parseInt(document.getElementById('stream-generating-idle-ms').value, 10) || 150000, - autoReplyFrequency: Math.max(0, Number.parseInt(document.getElementById('auto-reply-frequency')?.value, 10) || 0), - maxDagPayloadBytes: Number.parseInt(document.getElementById('max-dag-payload-bytes').value, 10) || 262144, - batterySaver: !!document.getElementById('battery-saver')?.checked, - trustedPeerSlots: Number.parseInt(document.getElementById('trusted-peer-slots')?.value, 10) || 8, - explorePeerSlots: Number.parseInt(document.getElementById('explore-peer-slots')?.value, 10) || 4, - maxPeers: Number.parseInt(document.getElementById('max-peers')?.value, 10) || 24, - gossipTtl: Number.isFinite(gossipTtl) ? gossipTtl : 2, - wantIdsBudget: Number.parseInt(document.getElementById('want-ids-budget')?.value, 10) || 16, - hlcMaxSkewMs: Number.parseInt(document.getElementById('hlc-max-skew-ms')?.value, 10) || 3_600_000, - streamingSfuWss: document.getElementById('streaming-sfu-wss')?.value?.trim() || null, - messageContentRetentionMs: Number.parseInt( - document.getElementById('message-content-retention-ms')?.value, - 10, - ) || 0, - eventRetentionDepth: Number.parseInt(document.getElementById('event-retention-depth')?.value, 10) || 200_000, - eventRetentionMs: Number.parseInt(document.getElementById('event-retention-ms')?.value, 10) || 0, - compactTriggerEventDepth: Number.parseInt(document.getElementById('compact-trigger-event-depth')?.value, 10) || 100_000, - messageRateLimitPerMin: Math.max(1, Math.min(120, - Number.parseInt(document.getElementById('message-rate-limit-per-min')?.value, 10) || 10)), - autoReplyTokenBucketEnabled: !!document.getElementById('auto-reply-token-bucket-enabled')?.checked, - autoReplyTokenBurst: Math.max(1, Math.min(12, - Number.parseInt(document.getElementById('auto-reply-token-burst')?.value, 10) || 2)), - autoReplyTokenRefillPerMessage: Math.max(0.1, Math.min(5, - Number.parseFloat(document.getElementById('auto-reply-token-refill')?.value) || 0.5)), - fileCeMode: String(document.getElementById('file-ce-mode')?.value || 'convergent') === 'random' - ? 'random' - : 'convergent', - iceServers: collectIceServersFromDom(), - discoveryPublic: !!document.getElementById('discovery-public')?.checked, - discoveryTitle: document.getElementById('discovery-title')?.value?.trim() || null, - discoveryBlurb: document.getElementById('discovery-blurb')?.value?.trim() || null, - autoChannelGc: !!document.getElementById('auto-channel-gc')?.checked, - hotLatestMessageCount: Math.max(0, Number.parseInt( - document.getElementById('hot-latest-message-count')?.value, - 10, - ) || 50), - pinContextMessageCount: Math.max(0, Number.parseInt( - document.getElementById('pin-context-message-count')?.value, - 10, - ) || 30), - }) + await putGroupSettings(context.groupId, { + joinPolicy: document.getElementById('join-policy').value, + powDifficulty: Number.parseInt(document.getElementById('pow-difficulty').value, 10) || 4, + streamGeneratingIdleMs: Number.parseInt(document.getElementById('stream-generating-idle-ms').value, 10) || 150000, + autoReplyFrequency: Math.max(0, Number.parseInt(document.getElementById('auto-reply-frequency')?.value, 10) || 0), + maxDagPayloadBytes: Number.parseInt(document.getElementById('max-dag-payload-bytes').value, 10) || 262144, + batterySaver: !!document.getElementById('battery-saver')?.checked, + trustedPeerSlots: Number.parseInt(document.getElementById('trusted-peer-slots')?.value, 10) || 8, + explorePeerSlots: Number.parseInt(document.getElementById('explore-peer-slots')?.value, 10) || 4, + maxPeers: Number.parseInt(document.getElementById('max-peers')?.value, 10) || 24, + gossipTtl: Number.isFinite(gossipTtl) ? gossipTtl : 2, + wantIdsBudget: Number.parseInt(document.getElementById('want-ids-budget')?.value, 10) || 16, + hlcMaxSkewMs: Number.parseInt(document.getElementById('hlc-max-skew-ms')?.value, 10) || 3_600_000, + streamingSfuWss: document.getElementById('streaming-sfu-wss')?.value?.trim() || null, + messageContentRetentionMs: Number.parseInt( + document.getElementById('message-content-retention-ms')?.value, + 10, + ) || 0, + eventRetentionDepth: Number.parseInt(document.getElementById('event-retention-depth')?.value, 10) || 200_000, + eventRetentionMs: Number.parseInt(document.getElementById('event-retention-ms')?.value, 10) || 0, + compactTriggerEventDepth: Number.parseInt(document.getElementById('compact-trigger-event-depth')?.value, 10) || 100_000, + messageRateLimitPerMin: Math.max(1, Math.min(120, + Number.parseInt(document.getElementById('message-rate-limit-per-min')?.value, 10) || 10)), + autoReplyTokenBucketEnabled: !!document.getElementById('auto-reply-token-bucket-enabled')?.checked, + autoReplyTokenBurst: Math.max(1, Math.min(12, + Number.parseInt(document.getElementById('auto-reply-token-burst')?.value, 10) || 2)), + autoReplyTokenRefillPerMessage: Math.max(0.1, Math.min(5, + Number.parseFloat(document.getElementById('auto-reply-token-refill')?.value) || 0.5)), + fileCeMode: String(document.getElementById('file-ce-mode')?.value || 'convergent') === 'random' + ? 'random' + : 'convergent', + iceServers: collectIceServersFromDom(), + discoveryPublic: !!document.getElementById('discovery-public')?.checked, + discoveryTitle: document.getElementById('discovery-title')?.value?.trim() || null, + discoveryBlurb: document.getElementById('discovery-blurb')?.value?.trim() || null, + autoChannelGc: !!document.getElementById('auto-channel-gc')?.checked, + hotLatestMessageCount: Math.max(0, Number.parseInt( + document.getElementById('hot-latest-message-count')?.value, + 10, + ) || 50), + pinContextMessageCount: Math.max(0, Number.parseInt( + document.getElementById('pin-context-message-count')?.value, + 10, + ) || 30), }) - if (!settingsResponse.ok) throw new Error(await readApiError(settingsResponse)) const partitionElement = document.getElementById('federation-partition-count') if (partitionElement) { @@ -149,12 +137,7 @@ export async function deleteGroup(context) { return } if (!confirmI18n('chat.group.settings.page.delete.confirm')) return - const resp = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}`, { - method: 'DELETE', - credentials: 'include' - }) - const data = await resp.json() - if (!resp.ok) throw new Error(data.error) + await removeGroup(context.groupId) showToastI18n('success', 'chat.group.settings.page.delete.success') window.location.href = '/parts/shells:chat/hub/' } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/inviteTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/inviteTab.mjs index b2b5f3170..098e71c16 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/inviteTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/inviteTab.mjs @@ -1,5 +1,5 @@ import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' -import { createGroupInvite } from '../api/groupCore.mjs' +import { createGroupInvite } from '../endpoints/groupCore.mjs' /** @param {import('./state.mjs').GroupSettingsContext} context @returns {void} */ export function wireInvitePanel(context) { document.getElementById('group-settings-mint-invite-button')?.addEventListener('click', async () => { diff --git a/src/public/parts/shells/chat/public/src/groupSettings/load.mjs b/src/public/parts/shells/chat/public/src/groupSettings/load.mjs index d66d3b9a1..45e7d5141 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/load.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/load.mjs @@ -1,6 +1,6 @@ import { activateSection } from '../../settings/nav.mjs' -import { getGroupState } from '../api/groupCore.mjs' import { initAuditLogPanel } from '../auditLogPanel.mjs' +import { getGroupState } from '../endpoints/groupCore.mjs' import { resolveViewerSettingsCapabilities } from '../groupViewerPermissions.mjs' import { renderArchiveStoragePanel } from './archiveTab.mjs' diff --git a/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs index bf8639724..f23ca5b2d 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs @@ -6,7 +6,8 @@ import { authorDisplayLabel } from '../../hub/core/domUtils.mjs' import { aliasForEntity } from '../../shared/aliases.mjs' import { avatarInitial } from '../../shared/hashAvatar.mjs' import { disambiguateLabels, resolveDisplayName } from '../../shared/nameResolve.mjs' -import { unbanMember } from '../api/groupGovernance.mjs' +import { unbanMember } from '../endpoints/groupGovernance.mjs' +import { kickMember as kickMemberRequest } from '../endpoints/members.mjs' import { memberDisplaysAsAdmin } from '../memberDisplay.mjs' /** @@ -20,11 +21,7 @@ async function kickMember(context, username) { if (!confirmI18n('chat.group.settings.page.kick.selfNodeWarning', { name: username })) return if (!confirmI18n('chat.group.settings.page.kick.confirm', { name: username })) return - const resp = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/members/${encodeURIComponent(username)}/kick`, { - method: 'POST', - credentials: 'include' - }) - if (!resp.ok) throw new Error(resp.statusText) + await kickMemberRequest(context.groupId, username) showToastI18n('success', 'chat.group.settings.page.kick.success') await context.reload(context.groupId) } @@ -39,7 +36,7 @@ async function banMember(context, username) { const picked = await pickBanScope({ displayName: username }) if (!picked) return try { - const { banMemberWithScope } = await import('../api/groupBan.mjs') + const { banMemberWithScope } = await import('../endpoints/groupBan.mjs') await banMemberWithScope(context.groupId, username, picked) showToastI18n('success', 'chat.group.settings.page.banSuccess') await context.reload(context.groupId) diff --git a/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs index 7beaf61b5..fcfcad769 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs @@ -1,6 +1,7 @@ import { mountTemplate, renderTemplateAsHtmlString } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n, promptI18n } from '../../../../../../scripts/i18n/index.mjs' +import { createRole, deleteRole as deleteRoleRequest, updateRolePermission as updateRolePermissionRequest } from '../endpoints/roles.mjs' import { ALL_PERMISSIONS } from './constants.mjs' @@ -63,18 +64,14 @@ export async function renderPermissionSettings(context) { * @returns {Promise<void>} */ async function updateRolePermission(context, roleId, permission, enabled) { - const resp = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/roles/${encodeURIComponent(roleId)}/permissions`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ permission, enabled }) - }) - if (!resp.ok) { - showToastI18n('error', 'chat.group.settings.page.permissionUpdateFailed', { error: resp.statusText }) + try { + await updateRolePermissionRequest(context.groupId, roleId, permission, enabled) + showToastI18n('success', 'chat.group.settings.page.permissionUpdated') + } + catch (error) { + showToastI18n('error', 'chat.group.settings.page.permissionUpdateFailed', { error: error.message }) await context.reload(context.groupId) - return } - showToastI18n('success', 'chat.group.settings.page.permissionUpdated') } /** @@ -84,11 +81,7 @@ async function updateRolePermission(context, roleId, permission, enabled) { */ async function deleteRole(context, roleId) { if (!confirmI18n('chat.group.settings.page.delete.roleConfirm')) return - const resp = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/roles/${encodeURIComponent(roleId)}`, { - method: 'DELETE', - credentials: 'include' - }) - if (!resp.ok) throw new Error(resp.statusText) + await deleteRoleRequest(context.groupId, roleId) showToastI18n('success', 'chat.group.settings.page.delete.roleSuccess') await context.reload(context.groupId) } @@ -98,16 +91,8 @@ function showCreateRoleModal(context) { const name = promptI18n('chat.group.settings.page.create.rolePrompt') if (!name?.trim()) return - fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(context.groupId)}/roles`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ name: name.trim() }) - }).then(r => r.json().then(async data => { - if (r.ok) { - showToastI18n('success', 'chat.group.settings.page.create.roleSuccess') - await context.reload(context.groupId) - } else - showToastI18n('error', 'chat.group.settings.page.create.roleFailed', { error: data.error || '' }) - })).catch(error => showToastI18n('error', 'chat.group.settings.page.create.roleFailed', { error: error.message })) + createRole(context.groupId, name.trim()).then(async () => { + showToastI18n('success', 'chat.group.settings.page.create.roleSuccess') + await context.reload(context.groupId) + }).catch(error => showToastI18n('error', 'chat.group.settings.page.create.roleFailed', { error: error.message })) } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/shared.mjs b/src/public/parts/shells/chat/public/src/groupSettings/shared.mjs index e72c1d388..6bfc9ea84 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/shared.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/shared.mjs @@ -1,18 +1,3 @@ -/** - * @param {Response} response HTTP 响应 - * @returns {Promise<string>} 错误文案 - */ -export async function readApiError(response) { - const text = await response.text() - try { - const data = JSON.parse(text) - return String(data.error || text) - } - catch { - return text || `HTTP ${response.status}` - } -} - /** * 从 `#settings:<groupId>` 解析群组 ID(与 hub `urlHash` 一致支持 encode)。 * @returns {string | null} 群组 ID;hash 不匹配时为 null diff --git a/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs b/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs index 7f2189cdc..ddc819b54 100644 --- a/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs +++ b/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs @@ -5,6 +5,7 @@ * 【数据结构】Record<string, boolean> 权限表;stateJson.viewerMemberPubKeyHash。 * 【关联】Hub composer、reactionHandlers;后端 groups/:id/state。 */ +import { getViewerPermissions } from './endpoints/groupCore.mjs' /** * @param {object} stateJson `/groups/:id/state` 的 JSON @@ -26,12 +27,7 @@ export async function fetchViewerChannelPermissions(stateJson, groupId, channelI const pubKeyHash = stateJson?.viewerMemberPubKeyHash if (!pubKeyHash) return {} const ch = channelId || governanceChannelIdFromState(stateJson) - const response = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/permissions?pubKeyHash=${encodeURIComponent(pubKeyHash)}&channelId=${encodeURIComponent(ch)}`, - { credentials: 'include' }, - ) - if (!response.ok) return {} - return response.json() + return getViewerPermissions(groupId, pubKeyHash, ch).catch(() => ({})) } /** diff --git a/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs b/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs index afeb3fd46..e984deb60 100644 --- a/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs +++ b/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs @@ -1,4 +1,5 @@ import { filterSetsFromPersonalListEntries, isAuthorFilteredByPersonalSets } from '../../shared/personalFilter.mjs' +import { getPersonalLists } from '../endpoints/prefs.mjs' const EMPTY = filterSetsFromPersonalListEntries([]) @@ -14,9 +15,12 @@ export function normalizePersonalFilterResponse(raw = { entries: [] }) { * @returns {Promise<ReturnType<typeof filterSetsFromPersonalListEntries>>} 过滤集 */ export async function fetchPersonalFilterSets() { - const resp = await fetch('/api/parts/shells:chat/personal-lists', { credentials: 'include' }) - if (!resp.ok) return EMPTY - return normalizePersonalFilterResponse(await resp.json()) + try { + return normalizePersonalFilterResponse(await getPersonalLists()) + } + catch { + return EMPTY + } } /** diff --git a/src/public/parts/shells/chat/public/src/saveStickerFromMessage.mjs b/src/public/parts/shells/chat/public/src/saveStickerFromMessage.mjs index f8137039d..364ba6fac 100644 --- a/src/public/parts/shells/chat/public/src/saveStickerFromMessage.mjs +++ b/src/public/parts/shells/chat/public/src/saveStickerFromMessage.mjs @@ -3,6 +3,8 @@ */ import { parseEmojiToken } from '../shared/inlineTokenSyntax.mjs' +import { addEmojiCollectionPack } from './endpoints/emoji.mjs' + /** * 将 pack 加入收藏。 * @param {string} packId 表情包 ID @@ -11,16 +13,7 @@ import { parseEmojiToken } from '../shared/inlineTokenSyntax.mjs' export async function addPackToCollection(packId) { const id = String(packId || '').trim() if (!id) throw new Error('packId required') - const response = await fetch('/api/parts/shells:chat/emoji-usage/collection/packs', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ packId: id }), - }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || 'collection failed') - } + await addEmojiCollectionPack(id) return { packId: id } } diff --git a/src/public/parts/shells/chat/public/src/trustAuthorDialog.mjs b/src/public/parts/shells/chat/public/src/trustAuthorDialog.mjs index 9337dffaf..e34dfdd9a 100644 --- a/src/public/parts/shells/chat/public/src/trustAuthorDialog.mjs +++ b/src/public/parts/shells/chat/public/src/trustAuthorDialog.mjs @@ -9,7 +9,7 @@ import { renderTemplate, usingTemplates } from '../../../scripts/features/templa import { closeOverlayModal, openOverlayModal } from '../hub/core/overlayModal.mjs' import { addTrustedAuthor, TRUST_EXPIRES_NEVER } from './trustedAuthors.mjs' -import { handleUIError } from './ui/errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' const COOLDOWN_SECONDS = 5 const SECOND_CONFIRM_TIMEOUT_MS = 3000 @@ -159,7 +159,7 @@ export function showTrustAuthorDialog(authorPubKeyHash, authorDisplayName = '') } catch (error) { cleanup() - handleUIError(error, 'chat.hub.operationFailed') + handleError('chat.hub.operationFailed')(error) resolve(false) } }) @@ -178,7 +178,7 @@ export function showTrustAuthorDialog(authorPubKeyHash, authorDisplayName = '') } catch (error) { cleanup() - handleUIError(error, 'chat.hub.operationFailed') + handleError('chat.hub.operationFailed')(error) resolve(false) } })() diff --git a/src/public/parts/shells/chat/public/src/trustedAuthors.mjs b/src/public/parts/shells/chat/public/src/trustedAuthors.mjs index a4fe1a52e..cd140a49b 100644 --- a/src/public/parts/shells/chat/public/src/trustedAuthors.mjs +++ b/src/public/parts/shells/chat/public/src/trustedAuthors.mjs @@ -5,6 +5,8 @@ * 【数据结构】TRUST_EXPIRES_NEVER、{ pubKeyHash, expiresAt } 记录。 * 【关联】trustAuthorDialog.mjs、hub/social Markdown 两档渲染;默认可信 = 本人 / 本机 char 实体(nodeHash 前缀)/ 观看者声明的主人。 */ +import { getTrustedAuthors, putTrustedAuthors } from './endpoints/prefs.mjs' + const TRUSTED_AUTHORS_DB_NAME = 'fount_chat_security' const TRUSTED_AUTHORS_STORE = 'trustedAuthors' @@ -36,9 +38,7 @@ function normalizePubKeyHash(pubKeyHash) { */ export async function syncTrustedAuthorsFromShell() { try { - const response = await fetch('/api/parts/shells:chat/trusted-authors', { credentials: 'include' }) - if (!response.ok) return - const data = await response.json() + const data = await getTrustedAuthors() const hashes = Array.isArray(data.hashes) ? data.hashes : [] shellTrustedPubKeyHashes = new Set(hashes.map(normalizePubKeyHash).filter(Boolean)) const database = await openTrustedAuthorsDatabase() @@ -95,12 +95,7 @@ async function pushTrustedAuthorsToShell() { /** @returns {void} */ request.onerror = () => reject(request.error) }) - await fetch('/api/parts/shells:chat/trusted-authors', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hashes: activePubKeyHashes }), - }) + await putTrustedAuthors({ hashes: activePubKeyHashes }) shellTrustedPubKeyHashes = new Set(activePubKeyHashes) } diff --git a/src/public/parts/shells/chat/public/src/ui/errors.mjs b/src/public/parts/shells/chat/public/src/ui/errors.mjs deleted file mode 100644 index 8f710a155..000000000 --- a/src/public/parts/shells/chat/public/src/ui/errors.mjs +++ /dev/null @@ -1,44 +0,0 @@ -/** - * 【文件】public/src/ui/errors.mjs - * 【职责】前端用户可见错误统一处理:Sentry 上报 → console.error → i18n toast。 - * 【原理】handleUIError 将 unknown 规范为 Error 后三路汇报,避免 catch 仅改 UI 吞掉调试信息。 - * 【数据结构】error、i18nKey、toastParams。 - * 【关联】@sentry/browser、toast.mjs;Hub、groupFileUpload、reactionHandlers。 - */ -import { showToastI18n } from '/scripts/features/toast.mjs' - -/** - * @param {Error} err 上报目标 - * @returns {void} - */ -function reportToSentry(err) { - import('https://esm.sh/@sentry/browser') - .then(Sentry => Sentry.captureException(err)) - .catch(() => { }) -} - -/** - * @param {unknown} error 异常或字符串 - * @returns {Error} 规范化 Error 实例 - */ -export function toError(error) { - if (error instanceof Error) return error - if (error && typeof error === 'object' && typeof error.message === 'string') - return new Error(error.message) - return new Error(typeof error === 'string' ? error : String(error)) -} - -/** - * 标准 fount 前端错误处理(toast + console.error + Sentry),三者缺一不可。 - * @param {unknown} error 异常 - * @param {string} i18nKey toast 文案键 - * @param {Record<string, string>} [toastParams] 额外 i18n 插值(`error` 由本函数注入) - * @returns {Error} 规范化后的 Error - */ -export function handleUIError(error, i18nKey, toastParams = {}) { - const err = toError(error) - reportToSentry(err) - console.error(`[fount-ui] ${i18nKey}`, err) - showToastI18n('error', i18nKey, { ...toastParams, error: err.message }) - return err -} diff --git a/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs b/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs index 97ec1ca7a..210495ec1 100644 --- a/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs +++ b/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs @@ -3,18 +3,25 @@ * 【职责】群文件分块上传与解密预览处理器工厂(挂到 Hub 实例)。 * 【原理】createFileHandlers(hub) 返回选文件、进度、chunk POST;CHUNK_UPLOAD_MAX_BYTES 对齐联邦上限。 * 【数据结构】hub { groupId, state }、上传进度、file meta。 - * 【关联】federationUpload.mjs、groupFileBlob.mjs、errors.mjs。 + * 【关联】federationUpload.mjs、groupFileBlob.mjs、errorHandlers.mjs。 */ import { renderTemplate, usingTemplates } from '../../../../scripts/features/template.mjs' import { sha256HexFromBlob } from '../../shared/digest.mjs' -import { entityFileUrl } from '/parts/shells:chat/shared/evfsMedia.mjs' +import { fetchEvfsFile } from '/scripts/endpoints/p2p/evfsMedia.mjs' import { groupEntityHash } from '../../shared/groupEntityHash.mjs' +import { + createGroupFileEvent, + getGroupFileMeta, + probeChunkHave, + resumeGroupFileDownload, + uploadOrRegisterChunk, +} from '../endpoints/groupFiles.mjs' import { fetchGroupFileAsBlobUrl } from '../groupFileBlob.mjs' import { convergentChunkHashes } from '../lib/convergentChunk.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { arrayBufferToBase64, FEDERATION_CHUNK_MAX_BYTES } from '../lib/federationUpload.mjs' -import { handleUIError } from './errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' /** * 单块上传明文上限(与联邦 chunk 上限对齐)。 @@ -97,42 +104,11 @@ async function uploadEncryptedChunk(groupId, partFileId, plainB64, byteLength, c ...channelField, ...modeField, } - const haveResponse = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/chunks/have`, - { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(haveBody), - }, - ) - if (!haveResponse.ok) throw new Error(`chunk have HTTP ${haveResponse.status}`) - const probe = await haveResponse.json() - if (ceMode !== 'random' && probe?.have && probe.storageLocator) { - const registerResponse = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/chunks`, - { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileId: partFileId, data: plainB64, registerOnly: true, ...channelField, ...modeField }), - }, - ) - if (!registerResponse.ok) throw new Error(`chunk register HTTP ${registerResponse.status}`) - return await registerResponse.json() - } + const probe = await probeChunkHave(groupId, haveBody) + if (ceMode !== 'random' && probe?.have && probe.storageLocator) + return uploadOrRegisterChunk(groupId, { fileId: partFileId, data: plainB64, registerOnly: true, ...channelField, ...modeField }) - const uploadResponse = await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/chunks`, - { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fileId: partFileId, data: plainB64, ...channelField, ...modeField }), - }, - ) - if (!uploadResponse.ok) throw new Error(`chunk HTTP ${uploadResponse.status}`) - return await uploadResponse.json() + return uploadOrRegisterChunk(groupId, { fileId: partFileId, data: plainB64, ...channelField, ...modeField }) } /** @@ -217,17 +193,7 @@ export function createFileHandlers(hub) { manifestBody.key_generation = parts[0]?.key_generation } - const fileEventResponse = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/files`, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(manifestBody), - }) - if (!fileEventResponse.ok) { - progress.fail() - handleUIError(new Error(`uploadGroupFile files HTTP ${fileEventResponse.status}`), 'chat.hub.file.uploadFailed') - return - } + await createGroupFileEvent(groupId, manifestBody) progress.set(100, 'chat.hub.file.uploaded') showToastI18n('success', skippedAny ? 'chat.hub.file.skippedDedup' : 'chat.hub.file.uploaded') progress.done() @@ -235,7 +201,7 @@ export function createFileHandlers(hub) { } catch (error) { progress.fail() - handleUIError(error, 'chat.hub.file.uploadFailed') + handleError('chat.hub.file.uploadFailed')(error) } } @@ -286,36 +252,25 @@ export function createFileHandlers(hub) { */ const downloadGroupFile = async (fileId, fileName) => { try { - const metaResponse = await fetch(`/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/files/${encodeURIComponent(fileId)}/meta`) - if (!metaResponse.ok) { - handleUIError(new Error(`downloadGroupFile meta HTTP ${metaResponse.status}`), 'chat.hub.file.downloadFailed') - return - } - const meta = await metaResponse.json() + const meta = await getGroupFileMeta(groupId, fileId) const hasParts = Array.isArray(meta.parts) && meta.parts.length if (!meta.contentHash || (!hasParts && !meta.storageLocator)) { - handleUIError(new Error('downloadGroupFile: missing blob meta'), 'chat.hub.file.noKey') + handleError('chat.hub.file.noKey')(new Error('downloadGroupFile: missing blob meta')) return } - if (hasParts) - await fetch( - `/api/parts/shells:chat/groups/${encodeURIComponent(groupId)}/files/${encodeURIComponent(fileId)}/download-resume`, - { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }, - ).catch(() => { }) + if (hasParts) await resumeGroupFileDownload(groupId, fileId).catch(() => { }) const fileIdForEvfs = String(meta?.fileId || '').trim() const entityHash = groupEntityHash(groupId) - const plainResponse = await fetch(entityFileUrl(entityHash, `chat/${fileIdForEvfs}`), { credentials: 'include' }) - if (!plainResponse.ok) { - handleUIError(new Error('downloadGroupFile decrypt failed'), 'chat.hub.file.downloadFailed') + let plain + try { + const { buffer } = await fetchEvfsFile(entityHash, `chat/${fileIdForEvfs}`) + plain = new Uint8Array(buffer) + } + catch (error) { + handleError('chat.hub.file.downloadFailed')(error) return } - const plain = new Uint8Array(await plainResponse.arrayBuffer()) const { createWriteStream } = await import('https://esm.sh/streamsaver@2.0.6') const fileStream = createWriteStream( @@ -338,7 +293,7 @@ export function createFileHandlers(hub) { } } catch (error) { - handleUIError(error, 'chat.hub.file.downloadFailed') + handleError('chat.hub.file.downloadFailed')(error) } } @@ -350,7 +305,7 @@ export function createFileHandlers(hub) { */ const fetchGroupFileAsBlob = (fileId, mimeType) => fetchGroupFileAsBlobUrl(groupId, fileId).catch(error => { - handleUIError(error, 'chat.hub.file.loadFailed') + handleError('chat.hub.file.loadFailed')(error) return null }) diff --git a/src/public/parts/shells/chat/public/src/ui/groupModals.mjs b/src/public/parts/shells/chat/public/src/ui/groupModals.mjs index a1182107c..7414ce92f 100644 --- a/src/public/parts/shells/chat/public/src/ui/groupModals.mjs +++ b/src/public/parts/shells/chat/public/src/ui/groupModals.mjs @@ -11,7 +11,7 @@ import { usingTemplates, } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' -import { createGroup, getGroupList } from '../api/groupCore.mjs' +import { createGroup, getGroupList } from '../endpoints/groupCore.mjs' import { PENDING_INVITE_STORAGE_KEY } from '../pendingInviteStorage.mjs' /** 按需注入群组 UI 样式表(幂等)。 */ diff --git a/src/public/parts/shells/chat/public/src/ui/reactionHandlers.mjs b/src/public/parts/shells/chat/public/src/ui/reactionHandlers.mjs index dd065c865..9b7e9e827 100644 --- a/src/public/parts/shells/chat/public/src/ui/reactionHandlers.mjs +++ b/src/public/parts/shells/chat/public/src/ui/reactionHandlers.mjs @@ -1,13 +1,13 @@ /** * 【文件】public/src/ui/reactionHandlers.mjs - * 【职责】群消息表情回应 toggle:POST/DELETE 频道 reaction API 并 handleUIError。 - * 【原理】createReactionHandlers({ groupId, channelId }) 返回 toggleReaction;groupPath 拼 REST。 + * 【职责】群消息表情回应 toggle:POST/DELETE 频道 reaction API 并 handleError。 + * 【原理】createReactionHandlers({ groupId, channelId }) 返回 toggleReaction。 * 【数据结构】targetEventId、emoji、remove 布尔、targetPubKeyHash 可选。 - * 【关联】groupClient.mjs、channelDisplay.mjs、errors.mjs。 + * 【关联】groupChannel.mjs、channelDisplay.mjs、errorHandlers.mjs。 */ -import { groupPath } from '../api/groupClient.mjs' +import { deleteReaction, putReaction } from '../endpoints/groupChannel.mjs' -import { handleUIError } from './errors.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' /** * 创建群消息表情回应处理函数集。 @@ -27,34 +27,13 @@ export function createReactionHandlers(channelScope) { */ const toggleReaction = async (targetEventId, emoji, remove, targetPubKeyHash) => { try { - const url = `/api/parts/shells:chat/groups/${groupPath(groupId, 'channels', channelId, 'reactions')}` - if (remove) { - const response = await fetch(url, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ - targetEventId, - emoji, - ...targetPubKeyHash ? { targetPubKeyHash } : {}, - }), - }) - if (!response.ok) - return handleUIError(new Error(`toggleReaction HTTP ${response.status}`), 'chat.hub.reactionFailed') - } - else { - const response = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify({ targetEventId, emoji }), - }) - if (!response.ok) - handleUIError(new Error(`toggleReaction HTTP ${response.status}`), 'chat.hub.reactionFailed') - } + if (remove) + await deleteReaction(groupId, channelId, targetEventId, emoji, targetPubKeyHash) + else + await putReaction(groupId, channelId, targetEventId, emoji) } catch (error) { - handleUIError(error, 'chat.hub.reactionFailed') + handleError('chat.hub.reactionFailed')(error) } } diff --git a/src/public/parts/shells/chat/src/chat/dag/chatLogMirror.mjs b/src/public/parts/shells/chat/src/chat/dag/chatLogMirror.mjs index 09312f264..aa04f89d8 100644 --- a/src/public/parts/shells/chat/src/chat/dag/chatLogMirror.mjs +++ b/src/public/parts/shells/chat/src/chat/dag/chatLogMirror.mjs @@ -8,6 +8,8 @@ import { Buffer } from 'node:buffer' import { createHash } from 'node:crypto' +import { handleError } from 'fount/scripts/errorHandlers.mjs' + import { channelMessage, normalizeChannelMessage } from '../../../public/shared/channelContent.mjs' import { commitChannelMessageEvent } from '../channel/messageCommit.mjs' import { replicateChunkToFederation } from '../federation/chunks.mjs' @@ -34,7 +36,7 @@ async function storeContentRef(username, groupId, text) { const storage = getStorageForGroup(username, state.groupSettings, { groupId }) const { storageLocator } = await storage.putChunk(groupId, hash, buffer) if (storage.storagePeerId === 'federation_swarm') - void replicateChunkToFederation(username, groupId, hash, buffer).catch(() => { }) + replicateChunkToFederation(username, groupId, hash, buffer).catch(handleError) return { contentHash: hash, alg: 'sha256', diff --git a/src/public/parts/shells/chat/src/chat/dag/hydration.mjs b/src/public/parts/shells/chat/src/chat/dag/hydration.mjs index f4e8e27bb..78ed1227c 100644 --- a/src/public/parts/shells/chat/src/chat/dag/hydration.mjs +++ b/src/public/parts/shells/chat/src/chat/dag/hydration.mjs @@ -15,6 +15,7 @@ import { Buffer } from 'node:buffer' import { existsSync, readFileSync } from 'node:fs' import { join } from 'node:path' +import { handleError } from 'fount/scripts/errorHandlers.mjs' import { isHex64 } from 'npm:@steve02081504/fount-p2p/core/hexIds' import { geti18nForUser } from '../../../../../../../scripts/i18n/index.mjs' @@ -221,7 +222,7 @@ export function hydrateWireFiles(username, groupId, state, wireFiles) { */ get() { if (bufferCache) return bufferCache - void ensureBuffer().catch(() => { }) + ensureBuffer().catch(handleError) return bufferCache ?? Buffer.alloc(0) }, }) diff --git a/src/public/parts/shells/chat/src/chat/federation/bootstrapRelay.mjs b/src/public/parts/shells/chat/src/chat/federation/bootstrapRelay.mjs index 44b4a35db..5924a00a3 100644 --- a/src/public/parts/shells/chat/src/chat/federation/bootstrapRelay.mjs +++ b/src/public/parts/shells/chat/src/chat/federation/bootstrapRelay.mjs @@ -3,6 +3,7 @@ */ import { randomUUID } from 'node:crypto' +import { handleError } from 'fount/scripts/errorHandlers.mjs' import { isHex64 } from 'npm:@steve02081504/fount-p2p/core/hexIds' import { decryptUtf8ForMember, encryptUtf8ForMember } from 'npm:@steve02081504/fount-p2p/crypto/key' @@ -142,18 +143,18 @@ export async function applyFedBootstrapResponse(username, groupId, response) { const { clearFederationBootstrap } = await import('./bootstrapStore.mjs') clearFederationBootstrap(username, groupId) } - void catchUpGroupFromPeers(username, groupId, { + catchUpGroupFromPeers(username, groupId, { waitMs: 2000, extraWantIds: creds.settingsEventId ? [creds.settingsEventId] : undefined, - }) + }).catch(handleError) return true } invalidateFederationRoomCache(username, groupId) - void catchUpGroupFromPeers(username, groupId, { + catchUpGroupFromPeers(username, groupId, { waitMs: 2000, extraWantIds: creds.settingsEventId ? [creds.settingsEventId] : undefined, - }) + }).catch(handleError) return true } diff --git a/src/public/parts/shells/chat/src/chat/federation/index.mjs b/src/public/parts/shells/chat/src/chat/federation/index.mjs index c03318799..44aa18dd8 100644 --- a/src/public/parts/shells/chat/src/chat/federation/index.mjs +++ b/src/public/parts/shells/chat/src/chat/federation/index.mjs @@ -5,6 +5,7 @@ * 【数据结构】signPayload 为已验签 DAG 行;catchUp 返回 tipsCollected、wantIds、eventsFilled 等统计;listFederationPeers 返回 selfNodeHash、peers 名册。 * 【关联】room.mjs、acl.mjs、pendingRelay.mjs、gossip.mjs、archiveHandshake.mjs、peerPool.mjs、dagDependencies.mjs、registry.mjs;DAG 读写在 scripts/p2p 与 dag/ 层。 */ +import { handleError } from 'fount/scripts/errorHandlers.mjs' import { sortedPrevEventIds } from 'npm:@steve02081504/fount-p2p/dag/index' import { readJsonlStream } from 'npm:@steve02081504/fount-p2p/dag/storage' import { stripDagEventLocalExtensions } from 'npm:@steve02081504/fount-p2p/dag/strip_extensions' @@ -143,8 +144,7 @@ export async function publishSignedEventToFederation(username, groupId, signPayl // 单飞 ensureFederationRoom 把房间建起来(按 (username,groupId,partitionId) 经 inflight 去重)。 slot = getFederationPartitionSlot(username, groupId, outboundPartition) ?? null if (!slot) - void ensureFederationRoom(username, groupId, { channelId }) - .catch(error => console.error('federation: background room ensure failed', error)) + ensureFederationRoom(username, groupId, { channelId }).catch(handleError) } if (!slot) return @@ -241,7 +241,7 @@ async function catchUpGroupFromPeersImpl(username, groupId, options = {}) { pickTargetPeerIds, }) - void syncMissingArchiveMonths(username, groupId, slot).catch(console.error) + syncMissingArchiveMonths(username, groupId, slot).catch(handleError) // 补齐要把 DAG 缺口补到“无悬挂父引用”为止:远端 tip 本地缺失 ∪ 本地事件 prev_event_ids 指向的本地缺失父(有叶无链)。 // 关键:延迟桶(pending_ingest / quarantine)里的事件同样引用尚缺的父,但它们并不在 events.jsonl 中, @@ -331,9 +331,9 @@ async function catchUpGroupFromPeersImpl(username, groupId, options = {}) { wantIdsRateLimited, stalePeersPruned: getStalePeerPruneCount(groupId) - stalePeersAtStart, } - void maybeRequestBootstrapAfterCatchup(username, groupId, stats, slot) + maybeRequestBootstrapAfterCatchup(username, groupId, stats, slot).catch(handleError) if (localArchive.checkpoint?.local_tips_hash) - void markGroupOnlineSynced(username, groupId, localArchive.checkpoint.local_tips_hash).catch(console.error) + markGroupOnlineSynced(username, groupId, localArchive.checkpoint.local_tips_hash).catch(handleError) try { const { releasePendingIngestEvents, releaseQuarantinedEvents } = await import('../dag/remoteIngest.mjs') await releaseQuarantinedEvents(username, groupId) diff --git a/src/public/parts/shells/chat/src/chat/files/groupFiles.mjs b/src/public/parts/shells/chat/src/chat/files/groupFiles.mjs index 7856c114e..22cf5d36d 100644 --- a/src/public/parts/shells/chat/src/chat/files/groupFiles.mjs +++ b/src/public/parts/shells/chat/src/chat/files/groupFiles.mjs @@ -7,6 +7,7 @@ */ import { Buffer } from 'node:buffer' +import { handleError } from 'fount/scripts/errorHandlers.mjs' import { base64ToBytes } from 'npm:@steve02081504/fount-p2p/core/bytes_codec' import { BLOB_STORAGE_LOCATOR_RE, isHex64 } from 'npm:@steve02081504/fount-p2p/core/hexIds' import { @@ -364,7 +365,7 @@ export async function getDecryptedFile(username, groupId, meta, blamePeerKey) { for (const [hash, bytes] of Object.entries(fetched)) await putCiphertextBlob(username, hash, bytes).catch(() => { }) for (const [hash, bytes] of Object.entries(fetched)) - void replicateChunkToFederation(username, groupId, hash, bytes, { requiredAcks: 0 }).catch(() => { }) + replicateChunkToFederation(username, groupId, hash, bytes, { requiredAcks: 0 }).catch(handleError) for (const hash of Object.keys(fetched)) if (fileId) await updateDownloadChunkState(username, groupId, fileId, hash, 'done').catch(() => { }) for (const hash of missing) @@ -486,7 +487,7 @@ export async function getDecryptedChunk(username, groupId, storageLocator, conte raw = await resolveCiphertextRaw(username, groupId, storageLocator) } catch (e) { - if (blamePeerKey) void penalizeChunkStorageFailure(blamePeerKey).catch(() => { }) + if (blamePeerKey) penalizeChunkStorageFailure(blamePeerKey).catch(handleError) throw e } let plain = null @@ -508,10 +509,10 @@ export async function getDecryptedChunk(username, groupId, storageLocator, conte else plain = decryptConvergentCiphertext(raw, contentHash) if (!plain) { - if (blamePeerKey) void penalizeChunkStorageFailure(blamePeerKey).catch(() => { }) + if (blamePeerKey) penalizeChunkStorageFailure(blamePeerKey).catch(handleError) throw new Error('convergent blob decrypt failed') } - void cachePlaintextFile(username, contentHash, plain).catch(() => { }) + cachePlaintextFile(username, contentHash, plain).catch(handleError) return new Uint8Array(plain) } diff --git a/src/public/parts/shells/chat/src/entity/endpoints.mjs b/src/public/parts/shells/chat/src/entity/endpoints.mjs index ba9982df0..b7e5db615 100644 --- a/src/public/parts/shells/chat/src/entity/endpoints.mjs +++ b/src/public/parts/shells/chat/src/entity/endpoints.mjs @@ -1,3 +1,4 @@ +import { handleError } from 'fount/scripts/errorHandlers.mjs' import { isEntityHash128 } from 'npm:@steve02081504/fount-p2p/core/entity_id' import { loadPersonalBlockEntries, @@ -126,7 +127,7 @@ export function registerEntityEndpoints(router) { if (!await isWritableLocalEntityForUser(replicaUsername, entityHash)) return res.status(403).json({ error: 'Permission denied' }) const { lastSeenAt } = await recordHeartbeat(replicaUsername, entityHash) - void pollOwnedEntityProfileUpdates(replicaUsername).catch(() => { }) + pollOwnedEntityProfileUpdates(replicaUsername).catch(handleError) const profile = await getProfile(entityHash, replicaUsername, { skipPresentation: true }) res.status(200).json({ lastSeenAt, diff --git a/src/public/parts/shells/chat/src/group/routes/groupEmojis.mjs b/src/public/parts/shells/chat/src/group/routes/groupEmojis.mjs index b14e7eeb0..59d991371 100644 --- a/src/public/parts/shells/chat/src/group/routes/groupEmojis.mjs +++ b/src/public/parts/shells/chat/src/group/routes/groupEmojis.mjs @@ -2,6 +2,7 @@ * group/routes/groupEmojis.mjs — 群表情包 REST + 内容端点。 */ import { PERMISSIONS } from 'fount/public/parts/shells/chat/src/permissions/chat.mjs' +import { handleError } from 'fount/scripts/errorHandlers.mjs' import { applySafeContentHeaders } from '../../../../../../../scripts/http_content.mjs' import { httpError } from '../../../../../../../scripts/http_error.mjs' @@ -78,11 +79,11 @@ async function sendEmojiContentResponse(req, res, username, groupId, emojiId, pa */ async function replicateAfterUpload(username, groupId, entry) { const slot = await ensureFederationRoom(username, groupId) - void replicateGroupEmojiManifestToUserRoom(username, groupId, entry).catch(() => { }) + replicateGroupEmojiManifestToUserRoom(username, groupId, entry).catch(handleError) if (slot?.replicateGroupEmojiManifest) - void slot.replicateGroupEmojiManifest(entry).catch(() => { }) + slot.replicateGroupEmojiManifest(entry).catch(handleError) if (slot?.replicateGroupEmoji) - void slot.replicateGroupEmoji(entry.emojiId, entry.packId).catch(() => { }) + slot.replicateGroupEmoji(entry.emojiId, entry.packId).catch(handleError) } /** diff --git a/src/public/parts/shells/chat/test/manifest.json b/src/public/parts/shells/chat/test/manifest.json index ef92de5aa..f7d0284f0 100644 --- a/src/public/parts/shells/chat/test/manifest.json +++ b/src/public/parts/shells/chat/test/manifest.json @@ -12,7 +12,7 @@ ], "shellLiveCommon": [ "src/server/{registries.mjs,parts_loader.mjs}", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/features/markdown/extensions.mjs", "src/public/pages/scripts/components/emojiPicker.mjs" ], @@ -21,7 +21,7 @@ "deno.json", "src/server/p2p_server/**", "src/server/{registries.mjs,parts_loader.mjs}", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/features/markdown/**", "src/public/pages/scripts/lib/sanitizeHtml.mjs", "src/public/parts/shells/chat/public/src/trustedAuthors.mjs", @@ -32,7 +32,7 @@ "src/scripts/search/**", "src/server/p2p_server/**", "src/server/{registries.mjs,parts_loader.mjs}", - "src/public/pages/scripts/api/registries.mjs", + "src/public/pages/scripts/endpoints/registries.mjs", "src/public/pages/scripts/features/markdown/extensions.mjs", "src/public/pages/scripts/components/emojiPicker.mjs" ], diff --git a/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs b/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs index 54d803e11..65773e635 100644 --- a/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs +++ b/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs @@ -19,7 +19,7 @@ const baseLog = [ Deno.test('applyWorldChatLogView uses GetChatLogForViewer', async () => { const filtered = [baseLog[0]] /** @type {(req: object, viewer: object) => Promise<object[]>} */ - const getForViewer = async (_req, viewer) => { + const getForViewer = async (req, viewer) => { assertEquals(viewer.kind, 'char') return filtered } From 4a1020534458e268aed53f80f56a3cb2216653b7 Mon Sep 17 00:00:00 2001 From: codefactor-io <support@codefactor.io> Date: Fri, 7 Aug 2026 09:52:54 +0000 Subject: [PATCH 03/13] [CodeFactor] Apply fixes --- src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs index 1745890ce..c7ebfc91e 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs @@ -70,7 +70,7 @@ export async function joinGroup(groupId, inviteCode = null, dmLinkProof = null, const json = { inviteCode: inviteCode || undefined, pow: pow || undefined, - ...dmLinkProof || {}, + ...dmLinkProof, } if (fedBootstrap?.roomSecret) { json.roomSecret = fedBootstrap.roomSecret From 2d89cec6db6608224f21e56725942aa4f60c138d Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 18:41:36 +0800 Subject: [PATCH 04/13] 1 --- .../shells/chat/public/emoji-packs/index.mjs | 5 +- .../parts/shells/chat/public/hub/call.mjs | 5 +- .../shells/chat/public/hub/chatConfig.mjs | 30 ++-- .../shells/chat/public/hub/composerDraft.mjs | 17 ++- .../shells/chat/public/hub/core/bindings.mjs | 33 +++-- .../parts/shells/chat/public/hub/files.mjs | 10 +- .../shells/chat/public/hub/friendChat.mjs | 25 ++-- .../shells/chat/public/hub/friendsList.mjs | 2 +- .../shells/chat/public/hub/hubStatus.mjs | 11 +- .../shells/chat/public/hub/inboxClient.mjs | 12 +- .../shells/chat/public/hub/inboxView.mjs | 3 +- .../parts/shells/chat/public/hub/initCore.mjs | 20 +-- .../chat/public/hub/memberContextMenu.mjs | 29 ++-- .../chat/public/hub/memberReadMarkers.mjs | 6 +- .../chat/public/hub/mentionAutocomplete.mjs | 9 +- .../public/hub/messages/messageRefresh.mjs | 12 +- .../chat/public/hub/messages/render/file.mjs | 22 ++- .../hub/messages/render/translation.mjs | 9 +- .../parts/shells/chat/public/hub/misc.mjs | 3 +- .../shells/chat/public/hub/personalFilter.mjs | 12 +- .../shells/chat/public/hub/profileEdit.mjs | 9 +- .../shells/chat/public/hub/serverBar.mjs | 4 +- .../public/hub/sidebar/groupMembership.mjs | 3 +- .../public/hub/translationPrefsDialog.mjs | 18 ++- .../parts/shells/chat/public/hub/unread.mjs | 22 +-- .../shells/chat/public/profile/index.mjs | 16 +-- .../public/profile/ownerSettingsPanel.mjs | 15 +- .../shells/chat/public/providers/emoji.mjs | 133 +++++++++++------- .../shells/chat/public/src/api/groupBan.mjs | 29 ---- .../chat/public/src/api/groupBookmarks.mjs | 67 --------- .../chat/public/src/api/groupGovernance.mjs | 10 +- .../chat/public/src/deepLinkConsume.mjs | 3 +- .../chat/public/src/endpoints/emoji.mjs | 27 +--- .../chat/public/src/endpoints/entities.mjs | 20 +-- .../src/endpoints/federationSettings.mjs | 46 +++++- .../chat/public/src/endpoints/groupBan.mjs | 20 +-- .../public/src/endpoints/groupBookmarks.mjs | 34 ++--- .../chat/public/src/endpoints/groupClient.mjs | 7 +- .../chat/public/src/endpoints/groupCore.mjs | 30 ++-- .../public/src/endpoints/groupGovernance.mjs | 10 +- .../shells/chat/public/src/endpoints/p2p.mjs | 26 +--- .../chat/public/src/endpoints/prefs.mjs | 4 + .../shells/chat/public/src/groupFileBlob.mjs | 12 +- .../src/groupSettings/channelPermsTab.mjs | 23 +-- .../public/src/groupSettings/emojisTab.mjs | 21 ++- .../public/src/groupSettings/membersTab.mjs | 20 ++- .../src/groupSettings/permissionsTab.mjs | 26 ++-- .../public/src/groupViewerPermissions.mjs | 6 +- .../public/src/lib/personalFilterClient.mjs | 9 +- .../chat/public/src/ui/groupFileUpload.mjs | 2 +- .../chat/src/api/client/privateState.mjs | 34 +++++ .../shells/chat/src/endpoints/preferences.mjs | 8 ++ .../chat/src/group/routes/governance.mjs | 84 ++++++++--- .../integration/entity_private_state.test.mjs | 19 +++ .../test/pure/viewer_log_dispatch.test.mjs | 4 +- .../parts/shells/social/public/src/media.mjs | 2 +- 56 files changed, 607 insertions(+), 491 deletions(-) delete mode 100644 src/public/parts/shells/chat/public/src/api/groupBan.mjs delete mode 100644 src/public/parts/shells/chat/public/src/api/groupBookmarks.mjs diff --git a/src/public/parts/shells/chat/public/emoji-packs/index.mjs b/src/public/parts/shells/chat/public/emoji-packs/index.mjs index 527b01db6..a3695274d 100644 --- a/src/public/parts/shells/chat/public/emoji-packs/index.mjs +++ b/src/public/parts/shells/chat/public/emoji-packs/index.mjs @@ -6,6 +6,7 @@ import { initTranslations, geti18n } from '/scripts/i18n/index.mjs' import { discoverEmojiPackOffers } from '/scripts/features/emoji/discover.mjs' import { showEmojiPackPreview } from '/scripts/components/emojiPackPreview.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { showToastI18n } from '/scripts/features/toast.mjs' import { joinGroup } from '../src/endpoints/groupCore.mjs' import { postRelationshipFollow } from '../src/endpoints/social.mjs' @@ -29,9 +30,7 @@ function addActionButton(actions, { i18nKey, fallback, className, onClick }) { button.dataset.i18n = i18nKey button.textContent = geti18n(i18nKey) || fallback button.addEventListener('click', () => { - void Promise.resolve(onClick()).catch(error => { - showToastI18n('error', 'chat.emoji.previewActionFailed', { error: error.message || String(error) }) - }) + void Promise.resolve(onClick()).catch(handleError('chat.emoji.previewActionFailed')) }) actions.appendChild(button) return button diff --git a/src/public/parts/shells/chat/public/hub/call.mjs b/src/public/parts/shells/chat/public/hub/call.mjs index 7fdb50dd4..05d0148b2 100644 --- a/src/public/parts/shells/chat/public/hub/call.mjs +++ b/src/public/parts/shells/chat/public/hub/call.mjs @@ -10,6 +10,7 @@ import { displayProfileAvatar } from '../shared/hashAvatar.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' import { getCallStatus } from '../src/endpoints/groupChannel.mjs' import { iconifyImg, iconifyUrl } from '../src/lib/emojiSvg.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { joinCodecsAvRoom, leaveCodecsAvRoom } from './codecsAv.mjs' import { store } from './core/state.mjs' @@ -521,7 +522,9 @@ export async function refreshCallStatusBadge() { const data = await getCallStatus(groupId, channelId) updateCallBadge(data.active ? data.peerCount || 0 : 0) } - catch { /* ignore */ } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } } /** diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index 7e90ce458..5b45ca283 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -6,8 +6,8 @@ * 【关联】../../../../scripts/parts、../../../../scripts/template、../../../../scripts/toast、../src/endpoints/groupCore、groupChannel、core/domUtils、core/overlayModal、core/state。 */ import { getPartList } from '../../../../scripts/endpoints/parts.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { mountTemplate, renderTemplateAsHtmlString } from '../../../../scripts/features/template.mjs' -import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { triggerChannelReply } from '../src/endpoints/groupChannel.mjs' import { addGroupPlugin, @@ -99,8 +99,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio await renderMemberList(store.context.currentState) showOverlayNotice('success', '', 'chat.hub.config.saved') } - catch (err) { - showOverlayNotice('error', err.message) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) @@ -111,8 +111,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio await setGroupWorld(groupId, v, channelId) showOverlayNotice('success', '', 'chat.hub.config.saved') } - catch (err) { - showOverlayNotice('error', err.message) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) @@ -125,8 +125,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio await mountChatConfigPanel(groupId, channelId, options) showOverlayNotice('success', '', 'chat.hub.config.saved') } - catch (err) { - showOverlayNotice('error', err.message) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) @@ -139,8 +139,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio await mountChatConfigPanel(groupId, channelId, options) showOverlayNotice('success', '', 'chat.hub.config.saved') } - catch (err) { - showOverlayNotice('error', err.message) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) }) @@ -155,8 +155,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio try { await setGroupCharFrequency(groupId, charname, frequency) } - catch (err) { - showToastI18n('error', 'chat.hub.config.saveFailed', { error: err.message }) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) }) @@ -169,8 +169,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio await triggerChannelReply(groupId, channelId, charname) showOverlayNotice('success', '', 'chat.hub.config.saved') } - catch (err) { - showOverlayNotice('error', err.message) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) }) @@ -184,8 +184,8 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio await mountChatConfigPanel(groupId, channelId, options) showOverlayNotice('success', '', 'chat.hub.config.saved') } - catch (err) { - showOverlayNotice('error', err.message) + catch (error) { + handleError('chat.hub.config.saveFailed')(error) } }) }) diff --git a/src/public/parts/shells/chat/public/hub/composerDraft.mjs b/src/public/parts/shells/chat/public/hub/composerDraft.mjs index b3260d898..e880b37fe 100644 --- a/src/public/parts/shells/chat/public/hub/composerDraft.mjs +++ b/src/public/parts/shells/chat/public/hub/composerDraft.mjs @@ -54,24 +54,29 @@ export function saveDraft(groupId, channelId, draft) { export function loadDraft(groupId, channelId) { if (!groupId || !channelId) return try { + const input = document.getElementById('message-input') + if (input instanceof HTMLTextAreaElement) input.value = '' + const cw = document.getElementById('content-warning') + if (cw instanceof HTMLInputElement) cw.value = '' + const sm = document.getElementById('sensitive-media') + if (sm instanceof HTMLInputElement) sm.checked = false + const extras = document.getElementById('composer-extras') + if (extras) extras.hidden = true + const raw = localStorage.getItem(draftKey(groupId, channelId)) if (!raw) return const draft = JSON.parse(raw) - const input = document.getElementById('message-input') if (input instanceof HTMLTextAreaElement && draft.text) { input.value = draft.text input.dispatchEvent(new Event('input', { bubbles: true })) } - const cw = document.getElementById('content-warning') if (cw instanceof HTMLInputElement && draft.content_warning) cw.value = draft.content_warning - const sm = document.getElementById('sensitive-media') if (sm instanceof HTMLInputElement && draft.sensitive_media) sm.checked = true - if (draft.content_warning || draft.sensitive_media) { - const extras = document.getElementById('composer-extras') + if (draft.content_warning || draft.sensitive_media) if (extras) extras.hidden = false - } + } catch { /* JSON 解析失败忽略 */ } } diff --git a/src/public/parts/shells/chat/public/hub/core/bindings.mjs b/src/public/parts/shells/chat/public/hub/core/bindings.mjs index b1dcfd3b6..554ea9154 100644 --- a/src/public/parts/shells/chat/public/hub/core/bindings.mjs +++ b/src/public/parts/shells/chat/public/hub/core/bindings.mjs @@ -1,6 +1,7 @@ /** * Hub 横幅与固定 DOM 节点的声明式绑定(订阅 store / watchState)。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { syncArchive } from '../../src/endpoints/channelArchive.mjs' import { getGroupState } from '../../src/endpoints/groupCore.mjs' import { dismissShunBanner } from '../../src/endpoints/groupFederation.mjs' @@ -217,28 +218,40 @@ export function wireHubBannerBindings() { watchState('context.currentGroupId', refreshBoundBanners) watchState('context.currentChannelId', refreshBoundBanners) watchState('context.currentState', refreshBoundBanners) - document.getElementById('archive-sync-button')?.addEventListener('click', () => { + document.getElementById('archive-sync-button')?.addEventListener('click', async () => { const groupId = store.context.currentGroupId if (!groupId) return - void syncArchive(groupId).then(async () => { + try { + await syncArchive(groupId) setState('context.currentState', await getGroupState(groupId)) refreshBoundBanners() - }).catch(console.error) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } }) - document.getElementById('shun-keep-history-button')?.addEventListener('click', () => { + document.getElementById('shun-keep-history-button')?.addEventListener('click', async () => { const groupId = store.context.currentGroupId if (!groupId) return - void dismissShunBanner(groupId).then(async () => { + try { + await dismissShunBanner(groupId) setState('context.currentState', await getGroupState(groupId)) refreshBoundBanners() - }).catch(console.error) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } }) - document.getElementById('shun-leave-button')?.addEventListener('click', () => { + document.getElementById('shun-leave-button')?.addEventListener('click', async () => { const groupId = store.context.currentGroupId if (!groupId) return - void import('../groupContextMenu.mjs').then(({ leaveGroupsOptimistic }) => - leaveGroupsOptimistic([groupId]), - ).catch(console.error) + try { + const { leaveGroupsOptimistic } = await import('../groupContextMenu.mjs') + await leaveGroupsOptimistic([groupId]) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } }) refreshBoundBanners() } diff --git a/src/public/parts/shells/chat/public/hub/files.mjs b/src/public/parts/shells/chat/public/hub/files.mjs index b5cdea129..6a0185166 100644 --- a/src/public/parts/shells/chat/public/hub/files.mjs +++ b/src/public/parts/shells/chat/public/hub/files.mjs @@ -121,8 +121,14 @@ export async function refreshFilesDrawer(drawer) { addBtn.className = 'btn btn-primary btn-sm' addBtn.setAttribute('data-i18n', 'chat.hub.files.bindCabinet') addBtn.textContent = '添加文件柜' - addBtn.addEventListener('click', () => { - bindCabinetFlow(drawer.groupId, state).then(() => refreshFilesDrawer(drawer)).catch(handleError('chat.hub.files.loadFailed')) + addBtn.addEventListener('click', async () => { + try { + await bindCabinetFlow(drawer.groupId, state) + await refreshFilesDrawer(drawer) + } + catch (error) { + handleError('chat.hub.files.loadFailed')(error) + } }) actions.appendChild(addBtn) } diff --git a/src/public/parts/shells/chat/public/hub/friendChat.mjs b/src/public/parts/shells/chat/public/hub/friendChat.mjs index 01168e4ef..bbf798cca 100644 --- a/src/public/parts/shells/chat/public/hub/friendChat.mjs +++ b/src/public/parts/shells/chat/public/hub/friendChat.mjs @@ -52,7 +52,7 @@ function friendBindingsEqual(a, b) { * @returns {void} */ function throwIfAborted(signal) { - if (signal.aborted) + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') } @@ -88,25 +88,30 @@ async function findExistingFriendGroup(binding) { * 确保群上已挂载角色 part。 * @param {string} groupId 群 ID * @param {string} charname 角色名 + * @param {AbortSignal} [signal] 取消信号 * @returns {Promise<void>} */ -async function ensureCharOnGroup(groupId, charname) { - const chars = await listGroupChars(groupId) +async function ensureCharOnGroup(groupId, charname, signal) { + throwIfAborted(signal) + const chars = await listGroupChars(groupId, signal) + throwIfAborted(signal) if (chars.includes(charname)) return - await addGroupChar(groupId, { charname, deferGreeting: true }) + await addGroupChar(groupId, { charname, deferGreeting: true }, signal) + throwIfAborted(signal) } /** * 解析或新建好友群 ID(角色需 addchar;用户 DM 由调用方传入 groupId)。 * @param {import('../shared/friendBinding.mjs').FriendBinding} binding 绑定 - * @param {{ groupId?: string, forceNew?: boolean }} options 选项 + * @param {{ groupId?: string, forceNew?: boolean, signal?: AbortSignal }} options 选项 * @returns {Promise<string|null>} 群 ID;失败为 null */ async function resolveFriendGroupId(binding, options) { + const { signal } = options let groupId = options.forceNew ? undefined : options.groupId if (groupId) { if (binding.charname) - await ensureCharOnGroup(groupId, binding.charname) + await ensureCharOnGroup(groupId, binding.charname, signal) return groupId } if (!groupId && !options.forceNew) { @@ -116,16 +121,18 @@ async function resolveFriendGroupId(binding, options) { if (!groupId && !options.forceNew) groupId = await findExistingFriendGroup(binding) + throwIfAborted(signal) if (!groupId) { const payload = await createFriendGroup({ friendBinding: binding, ...options.forceNew ? { forceNew: true } : {}, - }) + }, signal) + throwIfAborted(signal) groupId = payload.groupId } if (binding.charname) - await ensureCharOnGroup(groupId, binding.charname) + await ensureCharOnGroup(groupId, binding.charname, signal) return groupId } @@ -253,7 +260,7 @@ export async function enterFriendChat(options = {}) { throwIfAborted(signal) const groupId = await enqueueResolveFriendGroup( - () => resolveFriendGroupId(binding, options), + () => resolveFriendGroupId(binding, { ...options, signal }), signal, ) if (!groupId) return diff --git a/src/public/parts/shells/chat/public/hub/friendsList.mjs b/src/public/parts/shells/chat/public/hub/friendsList.mjs index c8fb699fc..603f6db35 100644 --- a/src/public/parts/shells/chat/public/hub/friendsList.mjs +++ b/src/public/parts/shells/chat/public/hub/friendsList.mjs @@ -486,7 +486,7 @@ async function runFriendsEntitySearch(input, resultsHost) { const [localChars, data] = await Promise.all([ searchLocalChars(q), searchEntities(q).catch(error => { - showToastI18n('error', 'chat.hub.createChatFailed', { error: error.message }) + handleError('chat.hub.createChatFailed')(error) return null }), ]) diff --git a/src/public/parts/shells/chat/public/hub/hubStatus.mjs b/src/public/parts/shells/chat/public/hub/hubStatus.mjs index f2af63df4..dd49a9765 100644 --- a/src/public/parts/shells/chat/public/hub/hubStatus.mjs +++ b/src/public/parts/shells/chat/public/hub/hubStatus.mjs @@ -6,8 +6,8 @@ * 【关联】../../../../scripts/i18n、../../../../scripts/toast、core/state、presence */ import { renderTemplate, renderTemplateAsHtmlString, usingTemplates } from '../../../../scripts/features/template.mjs' -import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { postEntityHeartbeat, setEntityStatus } from '../src/endpoints/entities.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { bindDismissOnDocumentInteraction } from '/scripts/components/contextMenuDismiss.mjs' import { store } from './core/state.mjs' @@ -60,7 +60,12 @@ export async function applyMyStatusUI(status, customStatus = '') { */ export async function sendHeartbeat(entityHash) { if (!entityHash) return - await postEntityHeartbeat(entityHash) + try { + await postEntityHeartbeat(entityHash) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } } /** @@ -76,7 +81,7 @@ export async function setMyStatus(status, options = {}) { } catch (error) { if (!options.silent) - showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) + handleError('chat.hub.operationFailed')(error) return } if (MANUAL_STATUSES.includes(status)) diff --git a/src/public/parts/shells/chat/public/hub/inboxClient.mjs b/src/public/parts/shells/chat/public/hub/inboxClient.mjs index 0019cd4d4..2e9686299 100644 --- a/src/public/parts/shells/chat/public/hub/inboxClient.mjs +++ b/src/public/parts/shells/chat/public/hub/inboxClient.mjs @@ -1,7 +1,7 @@ /** * Hub 跨群 inbox:badge 与 WS 增量(HTTP 在 endpoints/inbox)。 */ -import { fetchInboxPage as fetchInboxPageApi, markInboxSeen as markInboxSeenApi } from '../src/endpoints/inbox.mjs' +import { fetchInboxPage, markInboxSeen as markInboxSeenApi } from '../src/endpoints/inbox.mjs' import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' @@ -10,16 +10,6 @@ import { formatUnreadLabel } from './unread.mjs' /** @type {number | null} */ let badgeUnreadCount = null -/** - * @param {object} options 分页参数 - * @param {number} [options.limit] 条数 - * @param {string} [options.cursor] 游标 - * @returns {Promise<{ items: object[], nextCursor: string | null, unreadCount: number }>} 分页结果 - */ -export function fetchInboxPage(options = {}) { - return fetchInboxPageApi(options) -} - /** * @param {number} [at] 已读水位毫秒 * @returns {Promise<number>} 写入的 seenAt diff --git a/src/public/parts/shells/chat/public/hub/inboxView.mjs b/src/public/parts/shells/chat/public/hub/inboxView.mjs index 51f222feb..e881bb61a 100644 --- a/src/public/parts/shells/chat/public/hub/inboxView.mjs +++ b/src/public/parts/shells/chat/public/hub/inboxView.mjs @@ -6,12 +6,13 @@ import { bindInfiniteScroll, disconnectInfiniteScroll, ensureScrollSentinel, ins import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { aliasForEntity } from '../shared/aliases.mjs' import { resolveDisplayName } from '../shared/nameResolve.mjs' +import { fetchInboxPage } from '../src/endpoints/inbox.mjs' import { handleError } from '/scripts/features/errorHandlers.mjs' import { groupDisplayName } from './core/domUtils.mjs' import { store } from './core/state.mjs' import { INBOX_HASH, updateInboxHash } from './core/urlHash.mjs' -import { fetchInboxPage, markInboxSeen } from './inboxClient.mjs' +import { markInboxSeen } from './inboxClient.mjs' import { cancelScheduledChannelRefresh } from './messages/channelRefreshScheduler.mjs' import { scrollToMessageEventId } from './messages/messages.mjs' import { clearPrivateGroupState } from './privateGroup.mjs' diff --git a/src/public/parts/shells/chat/public/hub/initCore.mjs b/src/public/parts/shells/chat/public/hub/initCore.mjs index 9ceb329d7..588c33fa9 100644 --- a/src/public/parts/shells/chat/public/hub/initCore.mjs +++ b/src/public/parts/shells/chat/public/hub/initCore.mjs @@ -15,17 +15,17 @@ import { parseHash } from './core/urlHash.mjs' /** @returns {Promise<void>} 拉取 viewer 到 store(顶栏详情由 init.mjs 补全) */ async function loadViewerIdentity() { - const [data, who] = await Promise.all([ - getViewer().catch(() => null), - whoami().catch(() => null), + const [viewer, identity] = await Promise.all([ + getViewer().catch(error => { handleError('chat.hub.operationFailed')(error); return null }), + whoami().catch(error => { handleError('chat.hub.operationFailed')(error); return null }), ]) - if (who?.username) store.viewer.username = who.username || null - if (!data) return - store.viewer.nodeHash = data.nodeHash || null - store.viewer.operatorEntityHash = data.viewerEntityHash || null - store.viewer.viewerEntityHash = data.viewerEntityHash || null - store.viewer.ownerEntityHash = String(data.profile?.ownerEntityHash || '').trim().toLowerCase() || null - store.viewer.agents = data.agents || [] + if (identity?.username) store.viewer.username = identity.username + if (!viewer) return + store.viewer.nodeHash = viewer.nodeHash || null + store.viewer.operatorEntityHash = viewer.viewerEntityHash || null + store.viewer.viewerEntityHash = viewer.viewerEntityHash || null + store.viewer.ownerEntityHash = String(viewer.profile?.ownerEntityHash || '').trim().toLowerCase() || null + store.viewer.agents = viewer.agents || [] const { ingestAgentEntityHashList } = await import('./core/domUtils.mjs') ingestAgentEntityHashList(store.viewer.agents) } diff --git a/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs b/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs index acdbc1879..eb3907851 100644 --- a/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs +++ b/src/public/parts/shells/chat/public/hub/memberContextMenu.mjs @@ -2,6 +2,7 @@ * 【文件】public/hub/memberContextMenu.mjs * 【职责】成员列表项右键菜单:查看资料、私信、踢出、封禁(含 `banScopePicker`)等成员操作。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { renderTemplate } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../scripts/i18n/index.mjs' @@ -95,9 +96,7 @@ export async function showMemberContextMenu(event, memberElement) { const cared = await isCared(entityHash) await setCared(entityHash, !cared) showToastI18n('success', cared ? 'chat.hub.member.context.careRemoved' : 'chat.hub.member.context.careAdded') - })().catch(error => { - showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) - }) + })().catch(handleError('chat.hub.operationFailed')) closeOnce() }) menu.querySelector('.member-menu-alias')?.addEventListener('click', () => { @@ -113,9 +112,7 @@ export async function showMemberContextMenu(event, memberElement) { showToastI18n('success', 'chat.hub.member.context.aliasSaved') store.context.currentState = await getGroupState(store.context.currentGroupId) await refreshAliasDependentUi() - })().catch(error => { - showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) - }) + })().catch(handleError('chat.hub.operationFailed')) closeOnce() }) menu.querySelector('.member-menu-dm')?.addEventListener('click', () => { @@ -125,9 +122,7 @@ export async function showMemberContextMenu(event, memberElement) { dismissMemberContextMenu() await dispatchFriendChat(entity) } - })().catch(error => { - showToastI18n('error', 'chat.hub.profilePopup.dm.failed', { error: error.message }) - }) + })().catch(handleError('chat.hub.profilePopup.dm.failed')) closeOnce() }) menu.querySelector('.member-menu-kick')?.addEventListener('click', async () => { @@ -139,11 +134,11 @@ export async function showMemberContextMenu(event, memberElement) { await kickMember(store.context.currentGroupId, memberKey) showToastI18n('success', 'chat.group.settings.page.kick.success') store.context.currentState = await getGroupState(store.context.currentGroupId) - void renderMemberList(store.context.currentState) + await renderMemberList(store.context.currentState) closeOnce() } catch (error) { - showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) + handleError('chat.group.settings.page.kick.failed')(error) } }) menu.querySelector('.member-menu-ban')?.addEventListener('click', async () => { @@ -152,13 +147,15 @@ export async function showMemberContextMenu(event, memberElement) { if (!picked) return const { banMemberWithScope } = await import('../src/endpoints/groupBan.mjs') try { - await banMemberWithScope(store.context.currentGroupId, memberKey, picked) + const result = await banMemberWithScope(store.context.currentGroupId, memberKey, picked) showToastI18n('success', 'chat.group.settings.page.banSuccess') + if (result.reputationSlash && result.reputationSlash.ok === false) + handleError('chat.group.settings.page.banFailed')(new Error(result.reputationSlash.error || 'reputation slash failed')) store.context.currentState = await getGroupState(store.context.currentGroupId) - void renderMemberList(store.context.currentState) + await renderMemberList(store.context.currentState) } catch (error) { - showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) + handleError('chat.group.settings.page.banFailed')(error) } closeOnce() }) @@ -169,10 +166,10 @@ export async function showMemberContextMenu(event, memberElement) { await postPersonalBlock(entityHash, true) showToastI18n('success', 'chat.hub.member.context.personalBlockSuccess') store.context.currentState = await getGroupState(store.context.currentGroupId) - void renderMemberList(store.context.currentState) + await renderMemberList(store.context.currentState) } catch (error) { - showToastI18n('error', 'chat.hub.operationFailed', { error: error.message }) + handleError('chat.hub.operationFailed')(error) } closeOnce() }) diff --git a/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs b/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs index 6e2e80a20..1999afe20 100644 --- a/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs +++ b/src/public/parts/shells/chat/public/hub/memberReadMarkers.mjs @@ -6,6 +6,7 @@ import { geti18n } from '../../../../scripts/i18n/index.mjs' import { getMemberReadMarkers } from '../src/endpoints/groupChannel.mjs' import { hubDeliveryReadIcon } from '../src/lib/emojiSvg.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' @@ -35,7 +36,10 @@ export async function fetchMemberReadMarkers(groupId, channelId) { paintOwnDeliveryStatuses() return markers } - catch { return {} } + catch (error) { + handleError('chat.hub.operationFailed')(error) + return {} + } } /** diff --git a/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs b/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs index 91428e795..f5a5f154d 100644 --- a/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs +++ b/src/public/parts/shells/chat/public/hub/mentionAutocomplete.mjs @@ -105,13 +105,8 @@ export function attachHubMentionAutocomplete(textarea) { hide() return } - try { - const data = await suggestMentions(groupId, query, 12) - render(data.suggestions || []) - } - catch { - hide() - } + const data = await suggestMentions(groupId, query, 12) + render(data.suggestions || []) } /** diff --git a/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs b/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs index 220efc1b6..dd9c7266e 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs @@ -348,11 +348,15 @@ export async function loadMessages() { // 有未读时滚到分割线;打开频道即标已读(badge 清零),分割线锚点保留到下次 load if (!softReload && !store.messages.firstUnreadEventId) scrollToBottom() await markCurrentChannelRead().catch(handleError('chat.hub.operationFailed')) - refreshChannelPinsBar() + refreshChannelPinsBar().catch(handleError('chat.hub.operationFailed')) saveChannelViewCache() - import('../memberReadMarkers.mjs').then(({ fetchMemberReadMarkers }) => { - fetchMemberReadMarkers(groupId, channelId).catch(handleError('chat.hub.operationFailed')) - }).catch(handleError('chat.hub.operationFailed')) + try { + const { fetchMemberReadMarkers } = await import('../memberReadMarkers.mjs') + await fetchMemberReadMarkers(groupId, channelId) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } } catch (err) { const error = handleError('chat.hub.load.messagesFailed')(err) diff --git a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs index 82a3f1f29..13340803e 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs @@ -7,6 +7,7 @@ import { renderTemplateAsHtmlString, } from '../../../../../../scripts/features/template.mjs' import { fetchGroupFileAsBlobUrl } from '../../../src/groupFileBlob.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' import { store } from '../../core/state.mjs' @@ -14,6 +15,21 @@ import { getMessageText } from './text.mjs' const LAZY_MEDIA_BYTES = 2 * 1024 * 1024 +/** + * @param {string} groupId 群 ID + * @param {string} fileId 文件 ID + * @returns {Promise<string | null>} Blob URL;失败已 toast 时为 null + */ +async function loadGroupFileBlobUrl(groupId, fileId) { + try { + return await fetchGroupFileAsBlobUrl(groupId, fileId) + } + catch (error) { + handleError('chat.hub.file.loadFailed')(error) + return null + } +} + /** * @param {string} groupId 群 ID * @param {string} id 文件 ID @@ -25,7 +41,7 @@ const LAZY_MEDIA_BYTES = 2 * 1024 * 1024 async function renderSingleFileAttachmentHtml(groupId, id, meta, mime, alt) { const fileName = escapeHtml(meta.name || id) if (mime.startsWith('image/')) { - const blobUrl = await fetchGroupFileAsBlobUrl(groupId, id) + const blobUrl = await loadGroupFileBlobUrl(groupId, id) if (!blobUrl) return renderTemplateAsHtmlString('hub/messages/media_error', {}) return renderTemplateAsHtmlString('hub/messages/inline_image', { @@ -43,7 +59,7 @@ async function renderSingleFileAttachmentHtml(groupId, id, meta, mime, alt) { fileName, mimeType: escapeHtml(mime), }) - const blobUrl = await fetchGroupFileAsBlobUrl(groupId, id) + const blobUrl = await loadGroupFileBlobUrl(groupId, id) if (!blobUrl) return renderTemplateAsHtmlString('hub/messages/media_error', {}) if (mime.startsWith('video/')) @@ -104,7 +120,7 @@ export function wireMessageMediaPlaceholders(container) { event.preventDefault() event.stopPropagation() const mime = String(placeholder.getAttribute('data-mime') || '') - const blobUrl = await fetchGroupFileAsBlobUrl(groupId, fileId) + const blobUrl = await loadGroupFileBlobUrl(groupId, fileId) if (!blobUrl) { placeholder.replaceWith( await createDocumentFragmentFromHtmlStringNoScriptActivation( diff --git a/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs b/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs index 84a85ec83..1e8cf1b18 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/translation.mjs @@ -2,6 +2,7 @@ * 【文件】public/hub/messages/render/translation.mjs * 【职责】消息列表自动翻译挂载。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { getTranslationPrefs } from '../../../src/endpoints/prefs.mjs' /** @@ -39,8 +40,12 @@ export async function autoTranslateMessages(container) { translatedText: translated, }) } - catch { /* skip one row */ } + catch (error) { + handleError('chat.hub.translateFailed')(error) + } } } - catch { /* prefs unavailable */ } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } } diff --git a/src/public/parts/shells/chat/public/hub/misc.mjs b/src/public/parts/shells/chat/public/hub/misc.mjs index cba683b52..7db6b6bad 100644 --- a/src/public/parts/shells/chat/public/hub/misc.mjs +++ b/src/public/parts/shells/chat/public/hub/misc.mjs @@ -5,6 +5,7 @@ * 【数据结构】store(core/state)及本模块函数入参/返回值;详见 JSDoc。 * 【关联】../../../../scripts/toast、../src/achievements、../src/endpoints/groupCore、core/state。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { initializeAchievements } from '../src/achievements.mjs' import { @@ -61,7 +62,7 @@ export function setupPartDragDrop() { } } catch (error) { - showToastI18n('error', 'chat.dragAndDrop.errorAddingPart', { partName, error: error.message }) + handleError('chat.dragAndDrop.errorAddingPart', { partName })(error) } }) } diff --git a/src/public/parts/shells/chat/public/hub/personalFilter.mjs b/src/public/parts/shells/chat/public/hub/personalFilter.mjs index 400460442..d55a2fcbf 100644 --- a/src/public/parts/shells/chat/public/hub/personalFilter.mjs +++ b/src/public/parts/shells/chat/public/hub/personalFilter.mjs @@ -3,6 +3,7 @@ * 列表本体由 Social relationships API 写入;纯转换在 `shared/personalFilter.mjs`。 * Social 前端不引用本模块(走自有 feed/profile 后端过滤)。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { postRelationshipBlock } from '../src/endpoints/social.mjs' import { fetchPersonalFilterSets, @@ -19,8 +20,15 @@ let cachedFilter = null * @returns {Promise<ReturnType<typeof normalizePersonalFilterResponse>>} 过滤集 */ export async function loadHubPersonalFilter() { - cachedFilter = await fetchPersonalFilterSets() - return cachedFilter + try { + cachedFilter = await fetchPersonalFilterSets() + return cachedFilter + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + cachedFilter = normalizePersonalFilterResponse() + return cachedFilter + } } /** diff --git a/src/public/parts/shells/chat/public/hub/profileEdit.mjs b/src/public/parts/shells/chat/public/hub/profileEdit.mjs index 2a676cdd3..721dadd3b 100644 --- a/src/public/parts/shells/chat/public/hub/profileEdit.mjs +++ b/src/public/parts/shells/chat/public/hub/profileEdit.mjs @@ -813,7 +813,14 @@ async function handleSaveProfile() { export async function openHubProfileEdit(entityHash, options = {}) { const groupId = store.context.currentGroupId || undefined const dialog = await ensureEditDialog() - const data = await getEntityProfile(entityHash, groupId) + let data + try { + data = await getEntityProfile(entityHash, groupId) + } + catch (error) { + handleError('chat.profile.errors.loadFailed')(error) + return + } if (!data?.profile) { showToastI18n('error', 'chat.profile.errors.loadFailed') return diff --git a/src/public/parts/shells/chat/public/hub/serverBar.mjs b/src/public/parts/shells/chat/public/hub/serverBar.mjs index b296f851c..6b777d556 100644 --- a/src/public/parts/shells/chat/public/hub/serverBar.mjs +++ b/src/public/parts/shells/chat/public/hub/serverBar.mjs @@ -218,7 +218,7 @@ export async function renderServerBar() { export async function loadGroups() { const [groupList, foldersPayload] = await Promise.all([ getGroupList(), - getGroupFolders().catch(() => null), + getGroupFolders().catch(error => { handleError('chat.hub.operationFailed')(error); return null }), ]) store.sidebar.groups = groupList.sort( (left, right) => new Date(right.lastMessageTime || 0) - new Date(left.lastMessageTime || 0), @@ -232,7 +232,7 @@ export async function loadGroups() { if (liveBookmarks.length !== bookmarks.length) await saveChatBookmarks(liveBookmarks) } if (foldersPayload) { - const rawFolders = Array.isArray(foldersPayload.folders) ? foldersPayload.folders : [] + const rawFolders = foldersPayload.folders store.sidebar.groupFoldersState = { folders: rawFolders.map((folder, folderIndex) => ({ id: String(folder.id || '').trim() || `folder-${folderIndex}`, diff --git a/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs b/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs index c3c9cc3fc..6caf5eab4 100644 --- a/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs +++ b/src/public/parts/shells/chat/public/hub/sidebar/groupMembership.mjs @@ -36,8 +36,7 @@ export async function syncGroupFromNetwork(groupId, options = {}) { catchup = await federationCatchUp(groupId, { waitMs: options.waitMs ?? 1400 }) } catch (error) { - const catchupError = handleError('chat.hub.sync.failed')(error).message - setSyncBanner(true, { i18nKey: 'chat.hub.sync.failed', params: { error: catchupError } }) + setSyncBanner(true, { i18nKey: 'chat.hub.sync.failed', params: { error: handleError('chat.hub.sync.failed')(error).message } }) return } diff --git a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs index f86f469fb..efb7115e2 100644 --- a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs +++ b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs @@ -1,6 +1,7 @@ /** * Chat Hub 用户级翻译偏好面板(挂入偏好壳内容区)。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { renderTemplate, usingTemplates } from '../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../scripts/features/toast.mjs' import { getTranslationPrefs, putTranslationPrefs } from '../src/endpoints/prefs.mjs' @@ -15,7 +16,10 @@ import { closeOverlayModal } from './core/overlayModal.mjs' */ export async function mountTranslationPrefsPanel(panel, footer) { usingTemplates('/parts/shells:chat/src/templates') - const data = await getTranslationPrefs().catch(() => ({ prefs: { autoTranslate: false } })) + const data = await getTranslationPrefs().catch(error => { + handleError('chat.hub.operationFailed')(error) + return { prefs: { autoTranslate: false } } + }) const prefs = data.prefs || { autoTranslate: false } const root = await renderTemplate('hub/prefs/translation', { autoTranslateChecked: prefs.autoTranslate ? 'checked' : '', @@ -26,15 +30,17 @@ export async function mountTranslationPrefsPanel(panel, footer) { footer.replaceChildren(...foot ? [...foot.childNodes] : []) footer.querySelector('[data-action="close"]')?.addEventListener('click', () => closeOverlayModal()) - footer.querySelector('[data-action="save"]')?.addEventListener('click', () => { + footer.querySelector('[data-action="save"]')?.addEventListener('click', async () => { const checked = panel.querySelector('#auto-translate') instanceof HTMLInputElement && /** @type {HTMLInputElement} */ panel.querySelector('#auto-translate').checked - void putTranslationPrefs({ prefs: { ...prefs, autoTranslate: checked } }).then(() => { + try { + await putTranslationPrefs({ prefs: { ...prefs, autoTranslate: checked } }) showToastI18n('success', 'chat.hub.translationPrefs.saved') closeOverlayModal() - }).catch(error => { - showToastI18n('error', 'chat.hub.translationPrefs.saveFailed', { error: error?.message || String(error) }) - }) + } + catch (error) { + handleError('chat.hub.translationPrefs.saveFailed')(error) + } }) } diff --git a/src/public/parts/shells/chat/public/hub/unread.mjs b/src/public/parts/shells/chat/public/hub/unread.mjs index c80b3d106..e9318968e 100644 --- a/src/public/parts/shells/chat/public/hub/unread.mjs +++ b/src/public/parts/shells/chat/public/hub/unread.mjs @@ -2,12 +2,14 @@ * 【文件】hub/unread.mjs — 未读 badge 与 read-marker 同步。 */ import { putChannelReadMarker } from '../src/endpoints/groupChannel.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { store } from './core/state.mjs' -/** serverBar 静态 import 会与 unread 成环;惰性刷新 chrome。 @returns {void} */ -const refreshServerBar = () => { - import('./serverBar.mjs').then(({ renderServerBar }) => renderServerBar()) +/** serverBar 静态 import 会与 unread 成环;惰性刷新 chrome。 @returns {Promise<void>} */ +async function refreshServerBar() { + const { renderServerBar } = await import('./serverBar.mjs') + await renderServerBar() } /** @@ -101,10 +103,10 @@ export async function markCurrentChannelRead() { delete group.channelUnread[channelId] group.unreadCount = sumChannelUnread(group.channelUnread) } - refreshServerBar() - import('./sidebar/index.mjs').then(({ renderHubChannelSidebar }) => { - if (store.context.currentState) renderHubChannelSidebar(store.context.currentState) - }) + refreshServerBar().catch(handleError('chat.hub.operationFailed')) + import('./sidebar/index.mjs').then(async ({ renderHubChannelSidebar }) => { + if (store.context.currentState) await renderHubChannelSidebar(store.context.currentState) + }).catch(handleError('chat.hub.operationFailed')) } /** @@ -115,7 +117,7 @@ export async function markCurrentChannelRead() { export function handleReadMarkerWire(wireMessage) { import('./memberReadMarkers.mjs').then(({ applyMemberReadMarkerWire }) => { applyMemberReadMarkerWire(wireMessage) - }) + }).catch(handleError('chat.hub.operationFailed')) const viewerName = store.viewer.username if (!wireMessage?.readMarker || wireMessage.username !== viewerName) return const { groupId, channelId, readMarker } = wireMessage @@ -133,7 +135,7 @@ export function handleReadMarkerWire(wireMessage) { store.messages.readMarker = readMarker store.messages.firstUnreadEventId = null } - refreshServerBar() + refreshServerBar().catch(handleError('chat.hub.operationFailed')) } /** @@ -149,5 +151,5 @@ export function bumpChannelUnread(groupId, channelId) { group.channelUnread ??= {} group.channelUnread[channelId] = (Number(group.channelUnread[channelId]) || 0) + 1 group.unreadCount = (Number(group.unreadCount) || 0) + 1 - refreshServerBar() + refreshServerBar().catch(handleError('chat.hub.operationFailed')) } diff --git a/src/public/parts/shells/chat/public/profile/index.mjs b/src/public/parts/shells/chat/public/profile/index.mjs index 55cc72390..bd90bb584 100644 --- a/src/public/parts/shells/chat/public/profile/index.mjs +++ b/src/public/parts/shells/chat/public/profile/index.mjs @@ -10,6 +10,7 @@ import { renderTemplate, usingTemplates, } from '../../../scripts/features/template.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' import { showToastI18n } from '../../../scripts/features/toast.mjs' import { initTranslations, onLanguageChange } from '../../../scripts/i18n/index.mjs' import { applyTheme } from '../../../scripts/theme/index.mjs' @@ -105,8 +106,7 @@ async function init() { await loadProfile(currentEntityHash) } catch (error) { - console.error('Failed to get current user:', error) - showToastI18n('error', 'chat.profile.errors.fetchUserFailed') + handleError('chat.profile.errors.fetchUserFailed')(error) } document.getElementById('profile-edit-button')?.addEventListener('click', () => { @@ -140,8 +140,7 @@ async function loadProfile(entityHash) { } } catch (error) { - console.error('Failed to load profile:', error) - showToastI18n('error', 'chat.profile.errors.loadFailed') + handleError('chat.profile.errors.loadFailed')(error) } } @@ -208,7 +207,7 @@ async function loadUserGroups() { } catch (error) { - console.error('Failed to load groups:', error) + handleError('chat.profile.errors.operationFailed')(error) } } @@ -236,8 +235,9 @@ async function loadUserChannels() { defaultChannelId: group.defaultChannelId, }) } - catch { /* skip group */ } - + catch (error) { + handleError('chat.profile.errors.operationFailed')(error) + } const container = document.getElementById('profile-channels') const noChannels = document.getElementById('no-channels') @@ -262,7 +262,7 @@ async function loadUserChannels() { } catch (error) { - console.error('Failed to load channels:', error) + handleError('chat.profile.errors.operationFailed')(error) } } diff --git a/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs b/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs index 268304134..c11455431 100644 --- a/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs +++ b/src/public/parts/shells/chat/public/profile/ownerSettingsPanel.mjs @@ -3,6 +3,7 @@ * 【职责】资料页「我的主人」设置:为当前 operator 实体声明 / 清除 ownerEntityHash。 * 【原理】读 viewer + profile;PUT /entities/owner;本地 agent 列表作快捷选择;保存前高风险确认。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { mountTemplate } from '../../../scripts/features/template.mjs' import { showToastI18n } from '../../../scripts/features/toast.mjs' import { getEntityProfile, setEntityOwner } from '../src/endpoints/entities.mjs' @@ -45,7 +46,7 @@ export async function initProfileOwnerSettings() { } } catch (error) { - showToastI18n('error', 'chat.profile.owner.saveFailed', { error: error?.message || String(error) }) + handleError('chat.profile.errors.fetchUserFailed')(error) return } @@ -85,8 +86,12 @@ export async function initProfileOwnerSettings() { showToastI18n('success', 'chat.profile.owner.saved') await initProfileOwnerSettings() } - catch (e) { - showToastI18n('error', 'chat.profile.owner.saveFailed', { error: e?.message || String(e) }) + catch (error) { + if (error?.message === 'invalid ownerEntityHash') { + showToastI18n('error', 'chat.profile.owner.saveFailed', { error: error.message }) + return + } + handleError('chat.profile.owner.saveFailed')(error) } }) @@ -96,8 +101,8 @@ export async function initProfileOwnerSettings() { showToastI18n('success', 'chat.profile.owner.cleared') await initProfileOwnerSettings() } - catch (e) { - showToastI18n('error', 'chat.profile.owner.saveFailed', { error: e?.message || String(e) }) + catch (error) { + handleError('chat.profile.owner.saveFailed')(error) } }) } diff --git a/src/public/parts/shells/chat/public/providers/emoji.mjs b/src/public/parts/shells/chat/public/providers/emoji.mjs index 4ea0e4661..3f5a446da 100644 --- a/src/public/parts/shells/chat/public/providers/emoji.mjs +++ b/src/public/parts/shells/chat/public/providers/emoji.mjs @@ -1,6 +1,7 @@ /** * Chat shell 表情包容器(`registries.emoji`)。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { primaryLocale, loadPreferredLangs } from '/scripts/i18n/index.mjs' import { resolveEmojiItemLabels, resolvePackPresentation } from '/scripts/features/emoji/packPresentation.mjs' @@ -84,40 +85,46 @@ export default { * @returns {Promise<object[]>} 带展示字段与条目的包列表 */ async listPacks(context = {}) { - const locales = loadPreferredLangs().length ? loadPreferredLangs() : [primaryLocale()] - const packs = await fetchAvailablePacks(context) - return packs.map(pack => { - const presentation = resolvePackPresentation(pack, locales, pack.infoDefaults || {}) - const items = (pack.items || pack.entries || []).map(entry => { - const labels = resolveEmojiItemLabels(entry, locales) - const packId = pack.packId - const emojiId = entry.emojiId + try { + const locales = loadPreferredLangs().length ? loadPreferredLangs() : [primaryLocale()] + const packs = await fetchAvailablePacks(context) + return packs.map(pack => { + const presentation = resolvePackPresentation(pack, locales, pack.infoDefaults || {}) + const items = (pack.items || pack.entries || []).map(entry => { + const labels = resolveEmojiItemLabels(entry, locales) + const packId = pack.packId + const emojiId = entry.emojiId + return { + kind: 'pack', + packId, + emojiId, + emojiRef: formatEmojiToken(packId, emojiId), + name: labels.name, + alt: labels.alt, + label: labels.name, + previewUrl: packEmojiContentUrl(packId, emojiId), + animated: !!entry.animated, + } + }) return { - kind: 'pack', - packId, - emojiId, - emojiRef: formatEmojiToken(packId, emojiId), - name: labels.name, - alt: labels.alt, - label: labels.name, - previewUrl: packEmojiContentUrl(packId, emojiId), - animated: !!entry.animated, + packId: pack.packId, + source: pack.source || { kind: 'group', id: pack.groupId || pack.packId }, + groupId: pack.groupId, + joinedAt: pack.joinedAt, + defaultEmojiPackId: pack.defaultEmojiPackId, + isDefault: isDefaultGroupPack(pack), + localized: pack.localized, + infoDefaults: pack.infoDefaults, + name: presentation.name, + avatar: presentation.avatar, + items, } }) - return { - packId: pack.packId, - source: pack.source || { kind: 'group', id: pack.groupId || pack.packId }, - groupId: pack.groupId, - joinedAt: pack.joinedAt, - defaultEmojiPackId: pack.defaultEmojiPackId, - isDefault: isDefaultGroupPack(pack), - localized: pack.localized, - infoDefaults: pack.infoDefaults, - name: presentation.name, - avatar: presentation.avatar, - items, - } - }) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + return [] + } }, packContentUrl: packEmojiContentUrl, @@ -145,25 +152,31 @@ export default { * @returns {Promise<object[]>} 公开群包 offers */ async discoverPacks(options = {}) { - const locales = loadPreferredLangs().length ? loadPreferredLangs() : [primaryLocale()] - const offers = await discoverEmojiPacks(options.limit || 48) - return offers.map(offer => { - const presentation = resolvePackPresentation(offer, locales, offer.infoDefaults || {}) - return { - packId: offer.packId, - source: { kind: 'group', id: offer.sourceId }, - groupId: offer.sourceId, - localized: offer.localized, - infoDefaults: offer.infoDefaults, - itemCount: offer.itemCount, - joinPolicy: offer.joinPolicy, - name: presentation.name, - avatar: presentation.avatar, - description: presentation.description, - tags: presentation.tags, - links: presentation.links, - } - }) + try { + const locales = loadPreferredLangs().length ? loadPreferredLangs() : [primaryLocale()] + const offers = await discoverEmojiPacks(options.limit || 48) + return offers.map(offer => { + const presentation = resolvePackPresentation(offer, locales, offer.infoDefaults || {}) + return { + packId: offer.packId, + source: { kind: 'group', id: offer.sourceId }, + groupId: offer.sourceId, + localized: offer.localized, + infoDefaults: offer.infoDefaults, + itemCount: offer.itemCount, + joinPolicy: offer.joinPolicy, + name: presentation.name, + avatar: presentation.avatar, + description: presentation.description, + tags: presentation.tags, + links: presentation.links, + } + }) + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + return [] + } }, usage: { @@ -172,8 +185,14 @@ export default { * @returns {Promise<{ log: object[], lastUsedAtByPack: object }>} 最近使用日志与包级时间戳 */ async load() { - const state = await fetchEmojiUsage() - return { log: state.log || [], lastUsedAtByPack: state.lastUsedAtByPack || {} } + try { + const state = await fetchEmojiUsage() + return { log: state.log || [], lastUsedAtByPack: state.lastUsedAtByPack || {} } + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + return { log: [], lastUsedAtByPack: {} } + } }, /** * 记录一次 emoji 使用。 @@ -191,8 +210,14 @@ export default { * @returns {Promise<{ packIds: string[], emojiIds: string[] }>} 用户收藏的包与表情 */ async list() { - const state = await fetchEmojiUsage() - return state.collection || { packIds: [], emojiIds: [] } + try { + const state = await fetchEmojiUsage() + return state.collection || { packIds: [], emojiIds: [] } + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + return { packIds: [], emojiIds: [] } + } }, /** * 将包加入收藏。 diff --git a/src/public/parts/shells/chat/public/src/api/groupBan.mjs b/src/public/parts/shells/chat/public/src/api/groupBan.mjs deleted file mode 100644 index a50fb5b5d..000000000 --- a/src/public/parts/shells/chat/public/src/api/groupBan.mjs +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 【文件】public/src/api/groupBan.mjs - * 【职责】按范围封禁成员:DAG 声誉 + 服务端 blocklist/peers 同步。 - * 【原理】校验 targetPubKeyHash 为 hex64 后调用 ban 端点,可选 postReputationSlash。 - * 【数据结构】groupId、targetPubKeyHash、scope 选项。 - * 【关联】groupClient.mjs、groupGovernance.mjs、fount-p2p/core/hexIds。 - */ -import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' - -import { groupFetch, groupPath } from './groupClient.mjs' -import { postReputationSlash } from './groupGovernance.mjs' - -/** - * 按范围封禁成员(群内 DAG + 声誉 + 服务端同步 blocklist/peers)。 - * @param {string} groupId 群 ID - * @param {string} targetPubKeyHash 目标成员 pubKeyHash - * @param {{ banScope: 'entity'|'node' }} options 封禁范围 - * @returns {Promise<void>} - */ -export async function banMemberWithScope(groupId, targetPubKeyHash, options) { - const target = String(targetPubKeyHash || '').trim().toLowerCase() - if (!isHex64(target)) throw new Error('invalid target') - const banScope = String(options?.banScope || '').trim().toLowerCase() - await groupFetch(groupPath(groupId, 'members', target, 'ban'), { - method: 'POST', - json: { banScope }, - }) - await postReputationSlash(groupId, { targetPubKeyHash: target, claim: 1, verified: false }) -} diff --git a/src/public/parts/shells/chat/public/src/api/groupBookmarks.mjs b/src/public/parts/shells/chat/public/src/api/groupBookmarks.mjs deleted file mode 100644 index 18cd2c4f8..000000000 --- a/src/public/parts/shells/chat/public/src/api/groupBookmarks.mjs +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 【文件】public/src/api/groupBookmarks.mjs - * 【职责】Hub 侧栏书签 CRUD:读写用户级 chat bookmarks 列表。 - * 【原理】GET/PUT /api/parts/shells:chat/bookmarks;add/remove 在客户端合并数组后 saveChatBookmarks。 - * 【数据结构】书签条目 { groupId, channelId?, label? } 数组。 - * 【关联】Hub 侧栏导航;独立 sessions API。 - */ -/** - * 读取 Hub 侧栏书签列表。 - * @returns {Promise<object[]>} 书签条目数组 - */ -export async function getChatBookmarks() { - const response = await fetch('/api/parts/shells:chat/bookmarks', { credentials: 'include' }) - const data = await response.json() - if (!response.ok) throw new Error('Failed to fetch bookmarks') - return Array.isArray(data.entries) ? data.entries : [] -} - -/** - * 全量覆盖保存书签。 - * @param {object[]} entries 书签条目 - * @returns {Promise<void>} - */ -export async function saveChatBookmarks(entries) { - const response = await fetch('/api/parts/shells:chat/bookmarks', { - method: 'PUT', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ entries }), - }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'Failed to save bookmarks') -} - -/** - * 追加一条书签(同群同事件去重)。 - * @param {object} entry 书签条目 - * @returns {Promise<boolean>} 是否新增成功 - */ -export async function addChatBookmark(entry) { - const entries = await getChatBookmarks() - const groupId = String(entry.groupId || '') - const eventId = String(entry.eventId) - if (groupId && eventId && entries.some(bookmark => bookmark?.groupId === groupId && bookmark?.eventId === eventId)) - return false - entries.push(entry) - await saveChatBookmarks(entries) - return true -} - -/** - * 删除一条书签(按 groupId + eventId 匹配,回落 href 匹配)。 - * @param {{ groupId?: string, eventId?: string, href?: string }} entry 书签条目 - * @returns {Promise<void>} - */ -export async function removeChatBookmark(entry) { - const entries = await getChatBookmarks() - const groupId = String(entry.groupId || '') - const eventId = String(entry.eventId || '') - const href = String(entry.href || '') - const next = entries.filter(bookmark => { - if (eventId) return !(String(bookmark?.groupId || '') === groupId && String(bookmark?.eventId || '') === eventId) - if (href) return String(bookmark?.href || '') !== href - return true - }) - if (next.length !== entries.length) await saveChatBookmarks(next) -} diff --git a/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs b/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs index 9c9779a30..1e0cdc43f 100644 --- a/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs +++ b/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs @@ -30,19 +30,15 @@ export async function blockOpposingForkBranch(groupId, acceptedTipId) { /** * 追加用户级拉黑(`denylist.json`)。 - * @param {string|{ scope: string, value: string, groupId?: string }} entry 主体或 `{ scope, value }` - * @param {string} [groupId] 来源群 ID(`entry` 为字符串时使用) + * @param {{ scope: string, value: string, groupId?: string }} entry 拉黑条目 * @returns {Promise<void>} */ -export async function blockUser(entry, groupId) { - const body = entry?.scope - ? { scope: entry.scope, value: entry.value, groupId: entry.groupId || groupId } - : { scope: 'subject', value: entry, groupId } +export async function blockUser(entry) { const response = await fetch('/api/p2p/denylist', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: JSON.stringify(entry), }) const data = await response.json() if (!response.ok) throw new Error(data.error || 'denylist failed') diff --git a/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs b/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs index e03b3cf6b..bd30e024e 100644 --- a/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs +++ b/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs @@ -17,6 +17,7 @@ import { getViewer } from './endpoints/viewer.mjs' import { broadcastHubGroupJoined } from './hubBroadcast.mjs' import { PENDING_INVITE_STORAGE_KEY } from './pendingInviteStorage.mjs' import { resolvePowForJoin } from './powJoin.mjs' +import { handleError } from '/scripts/features/errorHandlers.mjs' /** * 从当前页 query 解析 `fount://run/…` 深链(`run` 参数)。 @@ -74,7 +75,7 @@ export async function applyChatRunUri(raw) { const join = parseJoinRunUri(raw) if (join) { const groupState = await getGroupState(join.groupId).catch(() => null) - const viewer = await getViewer().catch(() => ({})) + const viewer = await getViewer().catch(error => { handleError('chat.hub.operationFailed')(error); return {} }) const pow = await resolvePowForJoin(join.groupId, groupState, viewer.nodeHash || '') await joinGroup(join.groupId, join.inviteCode, null, pow, join.roomSecret || join.introducerPubKeyHash || join.introducerNodeHash diff --git a/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs b/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs index de04d9732..8aa58d304 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/emoji.mjs @@ -7,13 +7,8 @@ import { chatFetch, groupFetch, groupPath } from './groupClient.mjs' /** * @returns {Promise<object>} emoji-usage 载荷 */ -export async function getEmojiUsage() { - try { - return await chatFetch('/emoji-usage') - } - catch { - return { log: [], lastUsedAtByPack: {}, collection: { packIds: [], emojiIds: [] } } - } +export function getEmojiUsage() { + return chatFetch('/emoji-usage') } /** @@ -22,13 +17,8 @@ export async function getEmojiUsage() { */ export async function listEmojiPacks(groupId) { const q = groupId ? `?groupId=${encodeURIComponent(groupId)}` : '' - try { - const data = await chatFetch(`/emoji-packs${q}`) - return Array.isArray(data.packs) ? data.packs : [] - } - catch { - return [] - } + const data = await chatFetch(`/emoji-packs${q}`) + return Array.isArray(data.packs) ? data.packs : [] } /** @@ -44,13 +34,8 @@ export function getGroupPreview(groupId) { * @returns {Promise<object[]>} offers */ export async function discoverEmojiPacks(limit = 48) { - try { - const data = await chatFetch(`/emoji-packs/discover?limit=${encodeURIComponent(limit)}`) - return data.offers || [] - } - catch { - return [] - } + const data = await chatFetch(`/emoji-packs/discover?limit=${encodeURIComponent(limit)}`) + return data.offers || [] } /** diff --git a/src/public/parts/shells/chat/public/src/endpoints/entities.mjs b/src/public/parts/shells/chat/public/src/endpoints/entities.mjs index d142ae99f..3bb0b94af 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/entities.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/entities.mjs @@ -19,24 +19,26 @@ export function localeQueryString(groupId) { } /** + * 读取实体资料。 * @param {string} entityHash 128 位 entityHash * @param {string} [groupId] 群 ID * @returns {Promise<{ profile: object }>} 资料 JSON */ export async function getEntityProfile(entityHash, groupId) { - const qs = localeQueryString(groupId) - return chatFetch(`/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`) + const queryString = localeQueryString(groupId) + return chatFetch(`/entities/${encodeURIComponent(entityHash)}${queryString ? `?${queryString}` : ''}`) } /** + * 更新实体资料。 * @param {string} entityHash 128 位 entityHash * @param {object} updates 更新内容 * @param {string} [groupId] 群 ID * @returns {Promise<object>} 更新后的资料 JSON(或代理写入时的 `{ queued: true, ... }`) */ export async function updateEntityProfile(entityHash, updates, groupId) { - const qs = localeQueryString(groupId) - return chatFetch(`/entities/${encodeURIComponent(entityHash)}${qs ? `?${qs}` : ''}`, { + const queryString = localeQueryString(groupId) + return chatFetch(`/entities/${encodeURIComponent(entityHash)}${queryString ? `?${queryString}` : ''}`, { method: 'PUT', json: { ...updates, ...groupId ? { groupId } : {} }, }) @@ -83,13 +85,13 @@ export function setEntityOwner(ownerEntityHash) { /** * 网络实体搜索(handle / 展示名)。 - * @param {string} q 查询词 - * @param {{ limit?: number }} [opts] 选项 + * @param {string} query 查询词 + * @param {{ limit?: number }} [options] 选项 * @returns {Promise<{ entities: object[] }>} 命中列表 */ -export function searchEntities(q, opts = {}) { - const params = new URLSearchParams({ q }) - if (opts.limit) params.set('limit', String(opts.limit)) +export function searchEntities(query, options = {}) { + const params = new URLSearchParams({ q: query }) + if (options.limit) params.set('limit', String(options.limit)) return chatFetch(`/entities/search?${params}`) } diff --git a/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs b/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs index 16ecaba17..d6326d5b8 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/federationSettings.mjs @@ -1,5 +1,47 @@ /** * 【文件】public/src/endpoints/federationSettings.mjs - * 【职责】本节点联邦设置 REST(re-export from p2p.mjs)。 + * 【职责】本节点联邦设置 REST(/api/p2p/federation)。 */ -export { getFederationSettings, putFederationSettings } from './p2p.mjs' + +/** + * @param {string} [path=''] 相对 /federation 的子路径 + * @param {RequestInit & { json?: object }} [options] fetch 选项 + * @returns {Promise<any>} JSON + */ +async function federationFetch(path = '', options = {}) { + const { json, ...init } = options + const headers = json + ? { 'Content-Type': 'application/json', ...init.headers } + : init.headers + const response = await fetch(`/api/p2p/federation${path}`, { + ...init, + credentials: 'include', + headers, + body: json ? JSON.stringify(json) : init.body, + }) + if (!response.ok) { + const data = await response.json().catch(() => ({})) + throw new Error(data.error || `HTTP ${response.status}`) + } + if (response.status === 204) return null + const text = await response.text() + if (!text) return null + return JSON.parse(text) +} + +/** + * 读取本节点联邦设置。 + * @returns {Promise<object>} 设置 JSON + */ +export function getFederationSettings() { + return federationFetch() +} + +/** + * 更新本节点联邦设置。 + * @param {object} body 请求体 + * @returns {Promise<object>} 服务端响应 + */ +export function putFederationSettings(body) { + return federationFetch('', { method: 'PUT', json: body }) +} diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs index 3f2bec2b0..486a5e346 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupBan.mjs @@ -1,29 +1,21 @@ /** * 【文件】public/src/endpoints/groupBan.mjs - * 【职责】按范围封禁成员:DAG 声誉 + 服务端 blocklist/peers 同步。 - * 【原理】校验 targetPubKeyHash 为 hex64 后调用 ban 端点,可选 postReputationSlash。 - * 【数据结构】groupId、targetPubKeyHash、scope 选项。 - * 【关联】groupClient.mjs、groupGovernance.mjs、fount-p2p/core/hexIds。 + * 【职责】按范围封禁成员:服务端原子合并 member_ban + 声誉扣减。 + * 【原理】信任本机 Hub 传入的 targetPubKeyHash / banScope;一次 POST ban,服务端返回部分成功状态。 + * 【关联】groupClient.mjs;后端 group/routes/governance.mjs。 */ -import { isHex64 } from 'https://esm.sh/@steve02081504/fount-p2p/core/hexIds' - import { groupFetch, groupPath } from './groupClient.mjs' -import { postReputationSlash } from './groupGovernance.mjs' /** * 按范围封禁成员(群内 DAG + 声誉 + 服务端同步 blocklist/peers)。 * @param {string} groupId 群 ID * @param {string} targetPubKeyHash 目标成员 pubKeyHash * @param {{ banScope: 'entity'|'node' }} options 封禁范围 - * @returns {Promise<void>} + * @returns {Promise<{ banned: true, reputationSlash: { ok: boolean, error?: string, alreadyBanned?: boolean, banEventId?: string } }>} 封禁结果;声誉失败时 banned 仍为 true */ export async function banMemberWithScope(groupId, targetPubKeyHash, options) { - const target = String(targetPubKeyHash || '').trim().toLowerCase() - if (!isHex64(target)) throw new Error('invalid target') - const banScope = String(options?.banScope || '').trim().toLowerCase() - await groupFetch(groupPath(groupId, 'members', target, 'ban'), { + return groupFetch(groupPath(groupId, 'members', targetPubKeyHash, 'ban'), { method: 'POST', - json: { banScope }, + json: { banScope: options.banScope }, }) - await postReputationSlash(groupId, { targetPubKeyHash: target, claim: 1, verified: false }) } diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs index 9f8d326eb..99c9682ff 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupBookmarks.mjs @@ -1,15 +1,14 @@ /** * 【文件】public/src/endpoints/groupBookmarks.mjs * 【职责】Hub 侧栏书签 CRUD:读写用户级 chat bookmarks 列表。 - * 【原理】GET/PUT /bookmarks;add/remove 在客户端合并数组后 saveChatBookmarks。 - * 【数据结构】书签条目 { groupId, channelId?, label? } 数组。 - * 【关联】Hub 侧栏导航;独立 sessions API。 + * 【原理】GET/PUT /bookmarks 全量;POST/DELETE 由服务端原子 add/remove,避免客户端 RMW 竞态。 + * 【关联】Hub 侧栏导航、messages/actions/bookmark.mjs。 */ import { chatFetch } from './groupClient.mjs' /** * 读取 Hub 侧栏书签列表。 - * @returns {Promise<object[]>} 书签条目数组 + * @returns {Promise<object[]>} 书签条目数组(含 eventId / title / href 等) */ export async function getChatBookmarks() { const data = await chatFetch('/bookmarks') @@ -26,35 +25,20 @@ export async function saveChatBookmarks(entries) { } /** - * 追加一条书签(同群同事件去重)。 - * @param {object} entry 书签条目 + * 追加一条书签(服务端同群同事件去重)。 + * @param {object} entry 书签条目(eventId、title、href 等) * @returns {Promise<boolean>} 是否新增成功 */ export async function addChatBookmark(entry) { - const entries = await getChatBookmarks() - const groupId = String(entry.groupId || '') - const eventId = String(entry.eventId) - if (groupId && eventId && entries.some(bookmark => bookmark?.groupId === groupId && bookmark?.eventId === eventId)) - return false - entries.push(entry) - await saveChatBookmarks(entries) - return true + const data = await chatFetch('/bookmarks', { method: 'POST', json: { entry } }) + return data.added !== false } /** - * 删除一条书签(按 groupId + eventId 匹配,回落 href 匹配)。 + * 删除一条书签(按 groupId + eventId,回落 href)。 * @param {{ groupId?: string, eventId?: string, href?: string }} entry 书签条目 * @returns {Promise<void>} */ export async function removeChatBookmark(entry) { - const entries = await getChatBookmarks() - const groupId = String(entry.groupId || '') - const eventId = String(entry.eventId || '') - const href = String(entry.href || '') - const next = entries.filter(bookmark => { - if (eventId) return !(String(bookmark?.groupId || '') === groupId && String(bookmark?.eventId || '') === eventId) - if (href) return String(bookmark?.href || '') !== href - return true - }) - if (next.length !== entries.length) await saveChatBookmarks(next) + await chatFetch('/bookmarks', { method: 'DELETE', json: { entry } }) } diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs index 011a99b02..0e09f53ca 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs @@ -25,11 +25,14 @@ export function groupPath(groupId, ...segments) { */ export async function chatFetch(path, options = {}) { const { json, ...init } = options + const headers = json + ? { 'Content-Type': 'application/json', ...init.headers } + : init.headers const response = await fetch(`${CHAT_API_CLIENT_PREFIX}${path}`, { + ...init, credentials: 'include', - headers: json ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, + headers, body: json ? JSON.stringify(json) : init.body, - ...init, }) if (!response.ok) { const data = await response.json().catch(() => ({})) diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs index c7ebfc91e..3561bbe38 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs @@ -10,6 +10,7 @@ import { CHAT_LEAVE_BATCH_MAX } from '../lib/batchLimits.mjs' import { chatFetch, groupFetch, groupPath } from './groupClient.mjs' /** + * 拉取群 initial-data(聊天配置快照)。 * @param {string} groupId 群 ID * @returns {Promise<object>} initial-data 载荷 */ @@ -89,15 +90,13 @@ export async function joinGroup(groupId, inviteCode = null, dmLinkProof = null, * @returns {Promise<{ ok: string[], failed: { groupId: string, error: string }[] }>} 成功与失败列表 */ export async function leaveGroups(groupIds) { - const ids = [...new Set( - (Array.isArray(groupIds) ? groupIds : [groupIds]).map(id => String(id ?? '').trim()).filter(Boolean), - )] + const ids = Array.isArray(groupIds) ? groupIds : [groupIds] /** @type {string[]} */ const ok = [] /** @type {{ groupId: string, error: string }[]} */ const failed = [] - for (let i = 0; i < ids.length; i += CHAT_LEAVE_BATCH_MAX) { - const chunk = ids.slice(i, i + CHAT_LEAVE_BATCH_MAX) + for (let offset = 0; offset < ids.length; offset += CHAT_LEAVE_BATCH_MAX) { + const chunk = ids.slice(offset, offset + CHAT_LEAVE_BATCH_MAX) const part = await groupFetch('leave', { method: 'POST', json: { groupIds: chunk } }) ok.push(...part.ok || []) failed.push(...part.failed || []) @@ -163,11 +162,11 @@ export async function fetchGroupAuditLog(groupId, options = {}) { /** * 分页拉取成员列表。 * @param {string} groupId 群 ID - * @param {number} pageIdx 页码(从 0 起) + * @param {number} pageIndex 页码(从 0 起) * @returns {Promise<{ members: object[], membersRoot: string|null, membersPagesCount: number }>} 成员页数据 */ -export async function getMembersPage(groupId, pageIdx) { - const data = await groupFetch(groupPath(groupId, 'members', 'page', Math.max(0, pageIdx)), { method: 'GET' }) +export async function getMembersPage(groupId, pageIndex) { + const data = await groupFetch(groupPath(groupId, 'members', 'page', pageIndex), { method: 'GET' }) return { members: data.members, membersRoot: data.membersRoot ?? null, @@ -188,19 +187,21 @@ export async function deleteGroupFile(groupId, fileId) { /** * 创建好友绑定群(角色私聊 / 强制新建)。 * @param {object} body POST body(含 friendBinding、可选 forceNew) + * @param {AbortSignal} [signal] 取消信号 * @returns {Promise<{ groupId: string }>} 新群 */ -export async function createFriendGroup(body) { - return groupFetch('', { method: 'POST', json: body }) +export async function createFriendGroup(body, signal) { + return groupFetch('', { method: 'POST', json: body, signal }) } /** * 列出群上已挂载的角色 part 名。 * @param {string} groupId 群 ID + * @param {AbortSignal} [signal] 取消信号 * @returns {Promise<string[]>} charname 列表 */ -export async function listGroupChars(groupId) { - const chars = await groupFetch(groupPath(groupId, 'chars'), { method: 'GET' }) +export async function listGroupChars(groupId, signal) { + const chars = await groupFetch(groupPath(groupId, 'chars'), { method: 'GET', signal }) return Array.isArray(chars) ? chars : [] } @@ -208,10 +209,11 @@ export async function listGroupChars(groupId) { * 向群添加角色 part。 * @param {string} groupId 群 ID * @param {{ charname: string, deferGreeting?: boolean }} body 请求体 + * @param {AbortSignal} [signal] 取消信号 * @returns {Promise<any>} 响应 */ -export async function addGroupChar(groupId, body) { - return groupFetch(groupPath(groupId, 'char'), { method: 'POST', json: body }) +export async function addGroupChar(groupId, body, signal) { + return groupFetch(groupPath(groupId, 'char'), { method: 'POST', json: body, signal }) } /** diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs index 51421c1d3..df14fc919 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupGovernance.mjs @@ -31,15 +31,11 @@ export async function blockOpposingForkBranch(groupId, acceptedTipId) { /** * 追加用户级拉黑(`denylist.json`)。 - * @param {string|{ scope: string, value: string, groupId?: string }} entry 主体或 `{ scope, value }` - * @param {string} [groupId] 来源群 ID(`entry` 为字符串时使用) + * @param {{ scope: string, value: string, groupId?: string }} entry 拉黑条目 * @returns {Promise<void>} */ -export async function blockUser(entry, groupId) { - const body = entry?.scope - ? { scope: entry.scope, value: entry.value, groupId: entry.groupId || groupId } - : { scope: 'subject', value: entry, groupId } - await addDenylistEntry(body) +export async function blockUser(entry) { + await addDenylistEntry(entry) } /** diff --git a/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs b/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs index 28262fb3c..ff3cc5175 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/p2p.mjs @@ -1,6 +1,6 @@ /** * 【文件】public/src/endpoints/p2p.mjs - * 【职责】浏览器侧 P2P / 联邦节点 REST(非 chat shell 前缀,仍归 chat Hub 使用面)。 + * 【职责】浏览器侧 P2P REST(denylist / 联邦连接;联邦设置见 federationSettings.mjs)。 */ /** @@ -10,11 +10,14 @@ */ async function p2pFetch(path, options = {}) { const { json, ...init } = options + const headers = json + ? { 'Content-Type': 'application/json', ...init.headers } + : init.headers const response = await fetch(`/api/p2p${path}`, { + ...init, credentials: 'include', - headers: json ? { 'Content-Type': 'application/json', ...init.headers } : init.headers, + headers, body: json ? JSON.stringify(json) : init.body, - ...init, }) if (!response.ok) { const data = await response.json().catch(() => ({})) @@ -26,23 +29,6 @@ async function p2pFetch(path, options = {}) { return JSON.parse(text) } -/** - * 读取本节点联邦设置。 - * @returns {Promise<object>} 设置 JSON - */ -export function getFederationSettings() { - return p2pFetch('/federation') -} - -/** - * 更新本节点联邦设置。 - * @param {object} body 请求体 - * @returns {Promise<object>} 服务端响应 - */ -export function putFederationSettings(body) { - return p2pFetch('/federation', { method: 'PUT', json: body }) -} - /** * 写入节点 denylist。 * @param {object} entry denylist 条目 diff --git a/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs b/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs index ae87fea8f..9c200b788 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/prefs.mjs @@ -22,6 +22,7 @@ export async function putAliases(doc) { } /** + * 列出关心的实体。 * @returns {Promise<string[]>} cared entityHashes */ export async function listCaredEntities() { @@ -57,6 +58,7 @@ export async function putNotificationPreferences(prefs) { } /** + * 读取翻译偏好。 * @returns {Promise<object>} translation prefs */ export function getTranslationPrefs() { @@ -72,6 +74,7 @@ export function putTranslationPrefs(body) { } /** + * 读取信任作者列表。 * @returns {Promise<any>} trusted authors */ export function getTrustedAuthors() { @@ -87,6 +90,7 @@ export function putTrustedAuthors(body) { } /** + * 读取个人名单(拉黑/隐藏等)。 * @returns {Promise<any>} personal lists */ export function getPersonalLists() { diff --git a/src/public/parts/shells/chat/public/src/groupFileBlob.mjs b/src/public/parts/shells/chat/public/src/groupFileBlob.mjs index a88d75641..fa7bcd38f 100644 --- a/src/public/parts/shells/chat/public/src/groupFileBlob.mjs +++ b/src/public/parts/shells/chat/public/src/groupFileBlob.mjs @@ -10,16 +10,10 @@ import { groupEntityHash } from '../shared/groupEntityHash.mjs' * 获取并解密群文件,返回 Blob URL(供 Hub 内联渲染)。 * @param {string} groupId 群 ID * @param {string} fileId 文件 ID - * @returns {Promise<string | null>} Blob URL;失败时为 null + * @returns {Promise<string>} Blob URL */ export async function fetchGroupFileAsBlobUrl(groupId, fileId) { const entityHash = groupEntityHash(groupId) - const logicalPath = `chat/${fileId}` - try { - const { buffer, mimeType } = await fetchEvfsFile(entityHash, logicalPath) - return URL.createObjectURL(new Blob([buffer], { type: mimeType })) - } - catch { - return null - } + const { buffer, mimeType } = await fetchEvfsFile(entityHash, `chat/${fileId}`) + return URL.createObjectURL(new Blob([buffer], { type: mimeType })) } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs index 8522956ec..ec1e893e1 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/channelPermsTab.mjs @@ -1,3 +1,4 @@ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { mountTemplate } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { getChannelPermissions, putChannelPermissions } from '../endpoints/channelPerms.mjs' @@ -46,7 +47,7 @@ export async function renderChannelPermissionsPanel(context) { permissions = await getChannelPermissions(context.groupId, context.selectedChannelPermsId) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.channelPerms.updateFailed', { error: error.message }) + handleError('chat.group.settings.page.channelPerms.updateFailed')(error) } const overrideRoleIds = Object.keys(permissions) @@ -92,7 +93,7 @@ export async function renderChannelPermissionsPanel(context) { await renderChannelPermissionsPanel(context) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.channelPerms.updateFailed', { error: error.message }) + handleError('chat.group.settings.page.channelPerms.updateFailed')(error) } return } @@ -104,7 +105,7 @@ export async function renderChannelPermissionsPanel(context) { await renderChannelPermissionsPanel(context) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.channelPerms.updateFailed', { error: error.message }) + handleError('chat.group.settings.page.channelPerms.updateFailed')(error) } return } @@ -116,20 +117,20 @@ export async function renderChannelPermissionsPanel(context) { const perm = group.getAttribute('data-perm') const nextState = channelPermStateButton.getAttribute('data-state') if (!roleId || !perm || !nextState) return - const current = await getChannelPermissions(context.groupId, context.selectedChannelPermsId) - const allow = { ...current[roleId]?.allow } - const deny = { ...current[roleId]?.deny } - delete allow[perm] - delete deny[perm] - if (nextState === 'allow') allow[perm] = true - else if (nextState === 'deny') deny[perm] = true try { + const current = await getChannelPermissions(context.groupId, context.selectedChannelPermsId) + const allow = { ...current[roleId]?.allow } + const deny = { ...current[roleId]?.deny } + delete allow[perm] + delete deny[perm] + if (nextState === 'allow') allow[perm] = true + else if (nextState === 'deny') deny[perm] = true await putChannelPermissions(context.groupId, context.selectedChannelPermsId, roleId, allow, deny) showToastI18n('success', 'chat.group.settings.page.channelPerms.updated') await renderChannelPermissionsPanel(context) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.channelPerms.updateFailed', { error: error.message }) + handleError('chat.group.settings.page.channelPerms.updateFailed')(error) } }, { signal }) } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs index 82fc3696d..86aa9547e 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/emojisTab.mjs @@ -1,3 +1,4 @@ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { promptText } from '../../../../../../scripts/features/promptDialog.mjs' import { mountTemplate } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' @@ -44,7 +45,10 @@ async function renderGroupEmojis(context) { const container = document.getElementById('group-emojis-container') if (!container || !context.groupId) return const channelId = context.state?.groupSettings?.defaultChannelId || 'default' - const packsPayload = await listGroupEmojiPacks(context.groupId).catch(() => []) + const packsPayload = await listGroupEmojiPacks(context.groupId).catch(error => { + handleError('chat.hub.group.emojisLoadFailed')(error) + return [] + }) const packIds = packsPayload.map(p => p.packId).filter(Boolean) if (!packIds.includes(context.groupId)) packIds.unshift(context.groupId) @@ -53,7 +57,10 @@ async function renderGroupEmojis(context) { const [canManage, packDetail] = await Promise.all([ viewerCanManageMessages(context.state, context.groupId, channelId).catch(() => false), - getGroupEmojiPack(context.groupId, activePackId).catch(() => null), + getGroupEmojiPack(context.groupId, activePackId).catch(error => { + handleError('chat.hub.group.emojisLoadFailed')(error) + return null + }), ]) const entries = Array.isArray(packDetail?.items) ? packDetail.items : [] @@ -103,8 +110,8 @@ ${del} if (context.state?.groupSettings) context.state.groupSettings.defaultEmojiPackId = packId || null } - catch { - showToastI18n('error', 'chat.group.settings.page.defaultEmojiPack.failed') + catch (error) { + handleError('chat.group.settings.page.defaultEmojiPack.failed')(error) defaultSelect.value = previousValue } }) @@ -128,7 +135,7 @@ ${del} await ensureGroupEmojisPanel(context) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.emojis.create.packFailed', { error: error.message }) + handleError('chat.group.settings.page.emojis.create.packFailed')(error) } }) @@ -145,7 +152,7 @@ ${del} await ensureGroupEmojisPanel(context) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.emojis.uploadFailed', { error: error.message }) + handleError('chat.group.settings.page.emojis.uploadFailed')(error) } }) @@ -161,7 +168,7 @@ ${del} await ensureGroupEmojisPanel(context) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.emojis.deleteFailed', { error: error.message }) + handleError('chat.group.settings.page.emojis.deleteFailed')(error) } }) }) diff --git a/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs index f23ca5b2d..349c62fc9 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/membersTab.mjs @@ -1,3 +1,4 @@ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { mountTemplate } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n } from '../../../../../../scripts/i18n/index.mjs' @@ -21,9 +22,14 @@ async function kickMember(context, username) { if (!confirmI18n('chat.group.settings.page.kick.selfNodeWarning', { name: username })) return if (!confirmI18n('chat.group.settings.page.kick.confirm', { name: username })) return - await kickMemberRequest(context.groupId, username) - showToastI18n('success', 'chat.group.settings.page.kick.success') - await context.reload(context.groupId) + try { + await kickMemberRequest(context.groupId, username) + showToastI18n('success', 'chat.group.settings.page.kick.success') + await context.reload(context.groupId) + } + catch (error) { + handleError('chat.group.settings.page.kick.failed')(error) + } } /** @@ -37,12 +43,14 @@ async function banMember(context, username) { if (!picked) return try { const { banMemberWithScope } = await import('../endpoints/groupBan.mjs') - await banMemberWithScope(context.groupId, username, picked) + const result = await banMemberWithScope(context.groupId, username, picked) showToastI18n('success', 'chat.group.settings.page.banSuccess') + if (result.reputationSlash && result.reputationSlash.ok === false) + handleError('chat.group.settings.page.banFailed')(new Error(result.reputationSlash.error || 'reputation slash failed')) await context.reload(context.groupId) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.banFailed', { error: error.message }) + handleError('chat.group.settings.page.banFailed')(error) } } @@ -59,7 +67,7 @@ async function unbanMemberAction(context, username) { await context.reload(context.groupId) } catch (error) { - showToastI18n('error', 'chat.group.settings.page.unbanFailed', { error: error.message }) + handleError('chat.group.settings.page.unbanFailed')(error) } } diff --git a/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs b/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs index fcfcad769..a757b1513 100644 --- a/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs +++ b/src/public/parts/shells/chat/public/src/groupSettings/permissionsTab.mjs @@ -1,3 +1,4 @@ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { mountTemplate, renderTemplateAsHtmlString } from '../../../../../../scripts/features/template.mjs' import { showToastI18n } from '../../../../../../scripts/features/toast.mjs' import { confirmI18n, promptI18n } from '../../../../../../scripts/i18n/index.mjs' @@ -69,7 +70,7 @@ async function updateRolePermission(context, roleId, permission, enabled) { showToastI18n('success', 'chat.group.settings.page.permissionUpdated') } catch (error) { - showToastI18n('error', 'chat.group.settings.page.permissionUpdateFailed', { error: error.message }) + handleError('chat.group.settings.page.permissionUpdateFailed')(error) await context.reload(context.groupId) } } @@ -81,18 +82,27 @@ async function updateRolePermission(context, roleId, permission, enabled) { */ async function deleteRole(context, roleId) { if (!confirmI18n('chat.group.settings.page.delete.roleConfirm')) return - await deleteRoleRequest(context.groupId, roleId) - showToastI18n('success', 'chat.group.settings.page.delete.roleSuccess') - await context.reload(context.groupId) + try { + await deleteRoleRequest(context.groupId, roleId) + showToastI18n('success', 'chat.group.settings.page.delete.roleSuccess') + await context.reload(context.groupId) + } + catch (error) { + handleError('chat.group.settings.page.delete.roleFailed')(error) + } } -/** @param {import('./state.mjs').GroupSettingsContext} context @returns {void} */ -function showCreateRoleModal(context) { +/** @param {import('./state.mjs').GroupSettingsContext} context @returns {Promise<void>} */ +async function showCreateRoleModal(context) { const name = promptI18n('chat.group.settings.page.create.rolePrompt') if (!name?.trim()) return - createRole(context.groupId, name.trim()).then(async () => { + try { + await createRole(context.groupId, name.trim()) showToastI18n('success', 'chat.group.settings.page.create.roleSuccess') await context.reload(context.groupId) - }).catch(error => showToastI18n('error', 'chat.group.settings.page.create.roleFailed', { error: error.message })) + } + catch (error) { + handleError('chat.group.settings.page.create.roleFailed')(error) + } } diff --git a/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs b/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs index ddc819b54..2ca330d35 100644 --- a/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs +++ b/src/public/parts/shells/chat/public/src/groupViewerPermissions.mjs @@ -5,6 +5,7 @@ * 【数据结构】Record<string, boolean> 权限表;stateJson.viewerMemberPubKeyHash。 * 【关联】Hub composer、reactionHandlers;后端 groups/:id/state。 */ +import { handleError } from '/scripts/features/errorHandlers.mjs' import { getViewerPermissions } from './endpoints/groupCore.mjs' /** @@ -27,7 +28,10 @@ export async function fetchViewerChannelPermissions(stateJson, groupId, channelI const pubKeyHash = stateJson?.viewerMemberPubKeyHash if (!pubKeyHash) return {} const ch = channelId || governanceChannelIdFromState(stateJson) - return getViewerPermissions(groupId, pubKeyHash, ch).catch(() => ({})) + return getViewerPermissions(groupId, pubKeyHash, ch).catch(error => { + handleError('chat.hub.operationFailed')(error) + return {} + }) } /** diff --git a/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs b/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs index e984deb60..0d07ba77e 100644 --- a/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs +++ b/src/public/parts/shells/chat/public/src/lib/personalFilterClient.mjs @@ -1,8 +1,6 @@ import { filterSetsFromPersonalListEntries, isAuthorFilteredByPersonalSets } from '../../shared/personalFilter.mjs' import { getPersonalLists } from '../endpoints/prefs.mjs' -const EMPTY = filterSetsFromPersonalListEntries([]) - /** * @param {{ entries?: Array<{ scope?: string, kind?: string, value?: string }> }} [raw] API 响应 * @returns {ReturnType<typeof filterSetsFromPersonalListEntries>} 规范化过滤集 @@ -15,12 +13,7 @@ export function normalizePersonalFilterResponse(raw = { entries: [] }) { * @returns {Promise<ReturnType<typeof filterSetsFromPersonalListEntries>>} 过滤集 */ export async function fetchPersonalFilterSets() { - try { - return normalizePersonalFilterResponse(await getPersonalLists()) - } - catch { - return EMPTY - } + return normalizePersonalFilterResponse(await getPersonalLists()) } /** diff --git a/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs b/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs index 210495ec1..f8ab0a28d 100644 --- a/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs +++ b/src/public/parts/shells/chat/public/src/ui/groupFileUpload.mjs @@ -258,7 +258,7 @@ export function createFileHandlers(hub) { handleError('chat.hub.file.noKey')(new Error('downloadGroupFile: missing blob meta')) return } - if (hasParts) await resumeGroupFileDownload(groupId, fileId).catch(() => { }) + if (hasParts) await resumeGroupFileDownload(groupId, fileId) const fileIdForEvfs = String(meta?.fileId || '').trim() const entityHash = groupEntityHash(groupId) diff --git a/src/public/parts/shells/chat/src/api/client/privateState.mjs b/src/public/parts/shells/chat/src/api/client/privateState.mjs index 876d426ec..e93e59c11 100644 --- a/src/public/parts/shells/chat/src/api/client/privateState.mjs +++ b/src/public/parts/shells/chat/src/api/client/privateState.mjs @@ -25,6 +25,40 @@ export function createPrivateStateMethods(apiContext) { async set(entries) { return ns.set({ entries: Array.isArray(entries) ? entries : [] }) }, + /** + * 原子追加(同群同事件去重)。 + * @param {object} entry 书签条目 + * @returns {Promise<{ entries: object[], added: boolean }>} 写入后列表与是否新增 + */ + async add(entry) { + const { entries } = await ns.list() + const groupId = String(entry?.groupId || '') + const eventId = String(entry?.eventId || '') + if (groupId && eventId && entries.some(bookmark => bookmark?.groupId === groupId && bookmark?.eventId === eventId)) + return { entries, added: false } + entries.push(entry) + const next = await ns.set({ entries }) + return { entries: next.entries, added: true } + }, + /** + * 原子删除(eventId 优先,回落 href)。 + * @param {{ groupId?: string, eventId?: string, href?: string }} entry 匹配条件 + * @returns {Promise<{ entries: object[], removed: boolean }>} 写入后列表与是否删除 + */ + async remove(entry) { + const { entries } = await ns.list() + const groupId = String(entry?.groupId || '') + const eventId = String(entry?.eventId || '') + const href = String(entry?.href || '') + const next = entries.filter(bookmark => { + if (eventId) return !(String(bookmark?.groupId || '') === groupId && String(bookmark?.eventId || '') === eventId) + if (href) return String(bookmark?.href || '') !== href + return true + }) + if (next.length === entries.length) return { entries, removed: false } + const saved = await ns.set({ entries: next }) + return { entries: saved.entries, removed: true } + }, } }, /** diff --git a/src/public/parts/shells/chat/src/endpoints/preferences.mjs b/src/public/parts/shells/chat/src/endpoints/preferences.mjs index 767e77c49..29c67f772 100644 --- a/src/public/parts/shells/chat/src/endpoints/preferences.mjs +++ b/src/public/parts/shells/chat/src/endpoints/preferences.mjs @@ -26,6 +26,14 @@ export function registerPrefsRoutes(router) { const { client } = await chatClientFromReq(req) res.status(200).json(await client.bookmarks.set(req.body.entries || [])) }) + router.post(`${CHAT_API_PREFIX}/bookmarks`, authenticate, async (req, res) => { + const { client } = await chatClientFromReq(req) + res.status(200).json(await client.bookmarks.add(req.body.entry || {})) + }) + router.delete(`${CHAT_API_PREFIX}/bookmarks`, authenticate, async (req, res) => { + const { client } = await chatClientFromReq(req) + res.status(200).json(await client.bookmarks.remove(req.body.entry || {})) + }) router.get(`${CHAT_API_PREFIX}/group-folders`, authenticate, async (req, res) => { const { client } = await chatClientFromReq(req) diff --git a/src/public/parts/shells/chat/src/group/routes/governance.mjs b/src/public/parts/shells/chat/src/group/routes/governance.mjs index 269d7ce8e..c40e3b602 100644 --- a/src/public/parts/shells/chat/src/group/routes/governance.mjs +++ b/src/public/parts/shells/chat/src/group/routes/governance.mjs @@ -13,13 +13,16 @@ import { pubKeyHash } from 'npm:@steve02081504/fount-p2p/crypto' import { generateKeyRotationNonce, deriveNextFileMasterKey } from 'npm:@steve02081504/fount-p2p/crypto/key' import { verifyOwnerSuccessionThreshold } from 'npm:@steve02081504/fount-p2p/governance/owner_succession_ballot' import { addDenylistFromBanContent, addGroupBlockedPeers, removeGroupBlockedPeer } from 'npm:@steve02081504/fount-p2p/node/denylist' +import { applyVolatileSlashAlert, buildUnverifiedSlashAlert } from 'npm:@steve02081504/fount-p2p/node/reputation_store' import { httpError } from '../../../../../../../scripts/http_error.mjs' import { getUserByReq } from '../../../../../../../server/auth/index.mjs' import { appendSignedLocalEvent } from '../../chat/dag/append.mjs' import { appendKeyRotateEvent } from '../../chat/dag/channelOperations.mjs' import { adminPubKeyHashes } from '../../chat/dag/groupMaterializedState.mjs' +import { resolveLocalEventSigner } from '../../chat/dag/localSigner.mjs' import { getState } from '../../chat/dag/materialize.mjs' +import { publishVolatileToFederation } from '../../chat/federation/index.mjs' import { invalidateFederationRoomCache } from '../../chat/federation/room.mjs' import { mintRoomSecret } from '../../chat/federation/roomCredentials.mjs' import { getCurrentFileMasterKey, appendFileMasterKey } from '../../chat/file_keys/store.mjs' @@ -30,7 +33,10 @@ import { unbanTargetsFromMember, } from '../../chat/governance/banRules.mjs' import { signOwnerSuccessionAsLocalAdmin } from '../../chat/governance/ownerSuccessionSign.mjs' +import { broadcastEvent } from '../../chat/ws/groupWsBroadcast.mjs' +import { groupWsRoomKeyForReplica } from '../../chat/ws/groupWsRooms.mjs' import { + canGovSlash, canInChannel, governanceChannelId, resolveActiveMemberKey, @@ -277,28 +283,21 @@ export function registerGovernanceRoutes(router, authenticate) { return res.status(200).json({}) } - const resolvedTargetKey = resolveActiveMemberKey(state, targetMemberKey) - if (!resolvedTargetKey) - throw httpError(404, 'Member not found') - const resolvedMember = state.members[resolvedTargetKey] - const requiredPermission = action === 'ban' ? PERMISSIONS.BAN_MEMBERS : PERMISSIONS.KICK_MEMBERS - const callerEntity = String(member?.entityHash || '').trim().toLowerCase() - const ownerEntity = String(resolvedMember?.ownerEntityHash || '').trim().toLowerCase() - const isOwnerKickOwnAgent = action === 'kick' - && resolvedMember?.memberKind === 'agent' - && !!(ownerEntity && callerEntity === ownerEntity) - const isAdminKickAgent = action === 'kick' - && resolvedMember?.memberKind === 'agent' - && hasPermission(member, PERMISSIONS.ADMIN, state.roles, governanceChannel, state.channelPermissions) - const canModerate = action === 'kick' && resolvedMember?.memberKind === 'agent' - ? isOwnerKickOwnAgent || isAdminKickAgent - : hasPermission(member, requiredPermission, state.roles, governanceChannel, state.channelPermissions) - if (!canModerate) - throw httpError(403, 'No permission to moderate members') - if (resolvedTargetKey === memberKey && resolvedMember?.memberKind !== 'agent') - throw httpError(400, 'Cannot moderate yourself') - if (action === 'ban') { + const resolvedTargetKey = resolveMemberKey(state, targetMemberKey) + if (!resolvedTargetKey) + throw httpError(404, 'Member not found') + const resolvedMember = state.members[resolvedTargetKey] + if (!hasPermission(member, PERMISSIONS.BAN_MEMBERS, state.roles, governanceChannel, state.channelPermissions)) + throw httpError(403, 'No permission to moderate members') + if (resolvedTargetKey === memberKey && resolvedMember?.memberKind !== 'agent') + throw httpError(400, 'Cannot moderate yourself') + // 已封禁:幂等返回,避免重试再追加 member_ban / 再扣声誉 + if (resolvedMember?.status === 'banned') + return res.status(200).json({ banned: true, reputationSlash: { ok: true, alreadyBanned: true } }) + if (resolvedMember?.status !== 'active') + throw httpError(404, 'Member not found') + const banScope = req.body?.banScope?.trim().toLowerCase() if (!isBanScope(banScope)) throw httpError(400, 'banScope must be entity or node') @@ -309,7 +308,7 @@ export function registerGovernanceRoutes(router, authenticate) { catch (error) { throw httpError(400, error.message) } - await appendSignedLocalEvent(username, groupId, { + const banEvent = await appendSignedLocalEvent(username, groupId, { type: 'member_ban', timestamp: Date.now(), content: banContent, @@ -317,9 +316,48 @@ export function registerGovernanceRoutes(router, authenticate) { await rotateRoomSecretAfterModeration(username, groupId) await addGroupBlockedPeers(groupId, blockEntriesFromBanContent(banContent)) await addDenylistFromBanContent(banContent, groupId) - return res.status(200).json({}) + + /** @type {{ ok: boolean, error?: string, banEventId?: string }} */ + let reputationSlash = { ok: true, banEventId: banEvent.id } + try { + if (!canGovSlash(state, member)) + throw new Error('ADMIN or MANAGE_ROLES required') + const { sender } = await resolveLocalEventSigner(username, groupId) + const alert = buildUnverifiedSlashAlert( + sender, + { targetPubKeyHash: resolvedTargetKey, claim: 1 }, + state.groupSettings || {}, + ) + // 用 ban 事件 id 锚定 alert,重试同一 ban 时 volatile 侧可按 banEventId 去重 + alert.banEventId = banEvent.id + await applyVolatileSlashAlert(alert) + broadcastEvent(groupWsRoomKeyForReplica(groupId), alert) + await publishVolatileToFederation(groupId, alert) + } + catch (error) { + reputationSlash = { ok: false, error: error.message, banEventId: banEvent.id } + } + return res.status(200).json({ banned: true, reputationSlash }) } + const resolvedTargetKey = resolveActiveMemberKey(state, targetMemberKey) + if (!resolvedTargetKey) + throw httpError(404, 'Member not found') + const resolvedMember = state.members[resolvedTargetKey] + const callerEntity = String(member?.entityHash || '').trim().toLowerCase() + const ownerEntity = String(resolvedMember?.ownerEntityHash || '').trim().toLowerCase() + const isOwnerKickOwnAgent = resolvedMember?.memberKind === 'agent' + && !!(ownerEntity && callerEntity === ownerEntity) + const isAdminKickAgent = resolvedMember?.memberKind === 'agent' + && hasPermission(member, PERMISSIONS.ADMIN, state.roles, governanceChannel, state.channelPermissions) + const canModerate = resolvedMember?.memberKind === 'agent' + ? isOwnerKickOwnAgent || isAdminKickAgent + : hasPermission(member, PERMISSIONS.KICK_MEMBERS, state.roles, governanceChannel, state.channelPermissions) + if (!canModerate) + throw httpError(403, 'No permission to moderate members') + if (resolvedTargetKey === memberKey && resolvedMember?.memberKind !== 'agent') + throw httpError(400, 'Cannot moderate yourself') + const content = { targetMemberKey: resolvedTargetKey } if (action === 'kick' && resolvedMember?.memberKind !== 'agent') { diff --git a/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs b/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs index fddabbfad..560ea78c3 100644 --- a/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs +++ b/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs @@ -45,6 +45,25 @@ Deno.test('agent bookmarks isolated from operator ChatClient', async () => { const operatorList = await operatorClient.bookmarks.list() assertEquals(agentList.entries.map(row => row.groupId), ['agent-only']) assertEquals(operatorList.entries.map(row => row.groupId), ['operator-only']) + + const added = await operatorClient.bookmarks.add({ + groupId: 'g1', + eventId: 'ab'.repeat(32), + title: 't', + href: '#group:g1:default', + }) + assertEquals(added.added, true) + const dup = await operatorClient.bookmarks.add({ + groupId: 'g1', + eventId: 'ab'.repeat(32), + title: 't2', + href: '#group:g1:default', + }) + assertEquals(dup.added, false) + assertEquals(dup.entries.filter(row => row.eventId === 'ab'.repeat(32)).length, 1) + const removed = await operatorClient.bookmarks.remove({ groupId: 'g1', eventId: 'ab'.repeat(32) }) + assertEquals(removed.removed, true) + assertEquals(removed.entries.some(row => row.eventId === 'ab'.repeat(32)), false) }) Deno.test('agent notification preferences and read markers isolated from operator', async () => { diff --git a/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs b/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs index 65773e635..21c85bd68 100644 --- a/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs +++ b/src/public/parts/shells/chat/test/pure/viewer_log_dispatch.test.mjs @@ -18,8 +18,8 @@ const baseLog = [ Deno.test('applyWorldChatLogView uses GetChatLogForViewer', async () => { const filtered = [baseLog[0]] - /** @type {(req: object, viewer: object) => Promise<object[]>} */ - const getForViewer = async (req, viewer) => { + /** @type {(request: object, viewer: object) => Promise<object[]>} */ + const getForViewer = async (request, viewer) => { assertEquals(viewer.kind, 'char') return filtered } diff --git a/src/public/parts/shells/social/public/src/media.mjs b/src/public/parts/shells/social/public/src/media.mjs index 829d5c2eb..1e3b783cf 100644 --- a/src/public/parts/shells/social/public/src/media.mjs +++ b/src/public/parts/shells/social/public/src/media.mjs @@ -1,7 +1,7 @@ /** * Social 媒体附件:EVFS 存储(中立层上传)。 */ -import { uploadEvfsAttachment } from '/parts/shells:chat/shared/evfsMedia.mjs' +import { uploadEvfsAttachment } from '/scripts/endpoints/p2p/evfsMedia.mjs' const SOCIAL_ATTACHMENT_PREFIX = 'shells/social/attachments' From 1d89fbedb38ef59cb18795a202fdb4f27c84d12b Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 19:37:57 +0800 Subject: [PATCH 05/13] 1 --- .../shells/chat/public/emoji-packs/index.mjs | 2 +- .../shells/chat/public/hub/chatConfig.mjs | 5 +- .../shells/chat/public/hub/composerDraft.mjs | 34 ++-- .../hub/messages/messageVirtualList.mjs | 2 + .../chat/public/hub/messages/render/file.mjs | 51 +++++- .../shells/chat/public/hub/personalFilter.mjs | 3 +- .../public/hub/translationPrefsDialog.mjs | 20 ++- .../chat/public/src/api/groupGovernance.mjs | 149 ------------------ .../chat/public/src/deepLinkConsume.mjs | 8 +- .../chat/public/src/endpoints/entities.mjs | 6 +- .../chat/public/src/endpoints/groupClient.mjs | 7 +- .../chat/public/src/endpoints/groupCore.mjs | 3 +- .../shells/chat/src/api/client/helpers.mjs | 16 +- .../chat/src/api/client/privateState.mjs | 49 +++--- .../shells/chat/src/endpoints/preferences.mjs | 4 +- .../integration/entity_private_state.test.mjs | 6 +- 16 files changed, 149 insertions(+), 216 deletions(-) delete mode 100644 src/public/parts/shells/chat/public/src/api/groupGovernance.mjs diff --git a/src/public/parts/shells/chat/public/emoji-packs/index.mjs b/src/public/parts/shells/chat/public/emoji-packs/index.mjs index a3695274d..ee6d4730e 100644 --- a/src/public/parts/shells/chat/public/emoji-packs/index.mjs +++ b/src/public/parts/shells/chat/public/emoji-packs/index.mjs @@ -30,7 +30,7 @@ function addActionButton(actions, { i18nKey, fallback, className, onClick }) { button.dataset.i18n = i18nKey button.textContent = geti18n(i18nKey) || fallback button.addEventListener('click', () => { - void Promise.resolve(onClick()).catch(handleError('chat.emoji.previewActionFailed')) + void Promise.resolve().then(() => onClick()).catch(handleError('chat.emoji.previewActionFailed')) }) actions.appendChild(button) return button diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index 5b45ca283..a00ad2ca5 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -65,7 +65,10 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio getPartList('worlds').catch(() => []), getPartList('personas').catch(() => []), getPartList('plugins').catch(() => []), - listGroupPlugins(groupId).catch(() => []), + listGroupPlugins(groupId).catch(error => { + handleError('chat.hub.operationFailed')(error) + return [] + }), ]) const charlist = Array.isArray(initial?.charlist) ? initial.charlist : [] diff --git a/src/public/parts/shells/chat/public/hub/composerDraft.mjs b/src/public/parts/shells/chat/public/hub/composerDraft.mjs index e880b37fe..d0ccf14a3 100644 --- a/src/public/parts/shells/chat/public/hub/composerDraft.mjs +++ b/src/public/parts/shells/chat/public/hub/composerDraft.mjs @@ -56,12 +56,12 @@ export function loadDraft(groupId, channelId) { try { const input = document.getElementById('message-input') if (input instanceof HTMLTextAreaElement) input.value = '' - const cw = document.getElementById('content-warning') - if (cw instanceof HTMLInputElement) cw.value = '' - const sm = document.getElementById('sensitive-media') - if (sm instanceof HTMLInputElement) sm.checked = false - const extras = document.getElementById('composer-extras') - if (extras) extras.hidden = true + const contentWarningInput = document.getElementById('content-warning') + if (contentWarningInput instanceof HTMLInputElement) contentWarningInput.value = '' + const sensitiveMediaInput = document.getElementById('sensitive-media') + if (sensitiveMediaInput instanceof HTMLInputElement) sensitiveMediaInput.checked = false + const composerExtras = document.getElementById('composer-extras') + if (composerExtras) composerExtras.hidden = true const raw = localStorage.getItem(draftKey(groupId, channelId)) if (!raw) return @@ -70,13 +70,13 @@ export function loadDraft(groupId, channelId) { input.value = draft.text input.dispatchEvent(new Event('input', { bubbles: true })) } - if (cw instanceof HTMLInputElement && draft.content_warning) - cw.value = draft.content_warning - if (sm instanceof HTMLInputElement && draft.sensitive_media) - sm.checked = true - if (draft.content_warning || draft.sensitive_media) - if (extras) extras.hidden = false - + if (contentWarningInput instanceof HTMLInputElement && draft.content_warning) + contentWarningInput.value = draft.content_warning + if (sensitiveMediaInput instanceof HTMLInputElement && draft.sensitive_media) + sensitiveMediaInput.checked = true + if (draft.content_warning || draft.sensitive_media) + if (composerExtras) composerExtras.hidden = false + } catch { /* JSON 解析失败忽略 */ } } @@ -110,12 +110,12 @@ export function wireDraftAutoSave(getCtx) { */ const readFields = () => { const input = document.getElementById('message-input') - const cw = document.getElementById('content-warning') - const sm = document.getElementById('sensitive-media') + const contentWarningInput = document.getElementById('content-warning') + const sensitiveMediaInput = document.getElementById('sensitive-media') return { text: input instanceof HTMLTextAreaElement ? input.value : '', - content_warning: cw instanceof HTMLInputElement ? cw.value.trim() : '', - sensitive_media: sm instanceof HTMLInputElement ? sm.checked : false, + content_warning: contentWarningInput instanceof HTMLInputElement ? contentWarningInput.value.trim() : '', + sensitive_media: sensitiveMediaInput instanceof HTMLInputElement ? sensitiveMediaInput.checked : false, } } diff --git a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs index 9edee3582..700193050 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs @@ -24,12 +24,14 @@ import { bindMessageSurface, createMessageSurfacePipeline, } from './messageSurface.mjs' +import { revokeAllGroupFileBlobUrls } from './render/file.mjs' /** @returns {void} */ export function destroyChannelVirtualList() { store.messages.channelMessagePipeline?.destroy() store.messages.channelMessagePipeline = null store.messages.channelPipelineKey = null + revokeAllGroupFileBlobUrls() } /** @type {Promise<number> | null} */ diff --git a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs index 13340803e..fe7ec9130 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs @@ -6,6 +6,7 @@ import { createDocumentFragmentFromHtmlStringNoScriptActivation, renderTemplateAsHtmlString, } from '../../../../../../scripts/features/template.mjs' +import { onElementRemoved } from '../../../../../../scripts/lib/onElementRemoved.mjs' import { fetchGroupFileAsBlobUrl } from '../../../src/groupFileBlob.mjs' import { handleError } from '/scripts/features/errorHandlers.mjs' import { escapeHtml } from '/scripts/lib/escapeHtml.mjs' @@ -15,6 +16,44 @@ import { getMessageText } from './text.mjs' const LAZY_MEDIA_BYTES = 2 * 1024 * 1024 +/** @type {Set<string>} */ +const trackedBlobUrls = new Set() + +/** + * @param {string} url Blob URL + * @returns {void} + */ +function revokeTrackedBlobUrl(url) { + if (!trackedBlobUrls.delete(url)) return + URL.revokeObjectURL(url) +} + +/** + * 将 `blob:` src 的生命周期绑到媒体节点移除。 + * @param {ParentNode} root 扫描根 + * @returns {void} + */ +function bindBlobUrlCleanup(root) { + if (!root?.querySelectorAll) return + for (const el of root.querySelectorAll('[src^="blob:"]')) { + if (el.dataset.blobUrlTracked === '1') continue + const url = el.getAttribute('src') + if (!url || !trackedBlobUrls.has(url)) continue + el.dataset.blobUrlTracked = '1' + onElementRemoved(el, () => revokeTrackedBlobUrl(url)) + } +} + +/** + * 释放尚未/已无法绑定到 DOM 的全部群文件 Blob URL(频道虚列表销毁时调用)。 + * @returns {void} + */ +export function revokeAllGroupFileBlobUrls() { + for (const url of trackedBlobUrls) + URL.revokeObjectURL(url) + trackedBlobUrls.clear() +} + /** * @param {string} groupId 群 ID * @param {string} fileId 文件 ID @@ -22,7 +61,9 @@ const LAZY_MEDIA_BYTES = 2 * 1024 * 1024 */ async function loadGroupFileBlobUrl(groupId, fileId) { try { - return await fetchGroupFileAsBlobUrl(groupId, fileId) + const url = await fetchGroupFileAsBlobUrl(groupId, fileId) + trackedBlobUrls.add(url) + return url } catch (error) { handleError('chat.hub.file.loadFailed')(error) @@ -109,6 +150,7 @@ export async function renderMessageFileIdsHtml(message) { * @returns {void} */ export function wireMessageMediaPlaceholders(container) { + bindBlobUrlCleanup(container) if (container.dataset.mediaPlaceholdersWired === '1') return container.dataset.mediaPlaceholdersWired = '1' container.addEventListener('click', async event => { @@ -140,6 +182,11 @@ export function wireMessageMediaPlaceholders(container) { }) const frag = await createDocumentFragmentFromHtmlStringNoScriptActivation(html) const node = frag.firstElementChild - if (node) placeholder.replaceWith(node) + if (!node) { + revokeTrackedBlobUrl(blobUrl) + return + } + placeholder.replaceWith(node) + bindBlobUrlCleanup(node) }) } diff --git a/src/public/parts/shells/chat/public/hub/personalFilter.mjs b/src/public/parts/shells/chat/public/hub/personalFilter.mjs index d55a2fcbf..c10537318 100644 --- a/src/public/parts/shells/chat/public/hub/personalFilter.mjs +++ b/src/public/parts/shells/chat/public/hub/personalFilter.mjs @@ -26,8 +26,7 @@ export async function loadHubPersonalFilter() { } catch (error) { handleError('chat.hub.operationFailed')(error) - cachedFilter = normalizePersonalFilterResponse() - return cachedFilter + return cachedFilter || normalizePersonalFilterResponse() } } diff --git a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs index efb7115e2..a6c1216b1 100644 --- a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs +++ b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs @@ -16,10 +16,14 @@ import { closeOverlayModal } from './core/overlayModal.mjs' */ export async function mountTranslationPrefsPanel(panel, footer) { usingTemplates('/parts/shells:chat/src/templates') - const data = await getTranslationPrefs().catch(error => { + let data + try { + data = await getTranslationPrefs() + } + catch (error) { handleError('chat.hub.operationFailed')(error) - return { prefs: { autoTranslate: false } } - }) + data = { prefs: { autoTranslate: false } } + } const prefs = data.prefs || { autoTranslate: false } const root = await renderTemplate('hub/prefs/translation', { autoTranslateChecked: prefs.autoTranslate ? 'checked' : '', @@ -31,10 +35,14 @@ export async function mountTranslationPrefsPanel(panel, footer) { footer.querySelector('[data-action="close"]')?.addEventListener('click', () => closeOverlayModal()) footer.querySelector('[data-action="save"]')?.addEventListener('click', async () => { - const checked = panel.querySelector('#auto-translate') instanceof HTMLInputElement - && /** @type {HTMLInputElement} */ panel.querySelector('#auto-translate').checked try { - await putTranslationPrefs({ prefs: { ...prefs, autoTranslate: checked } }) + await putTranslationPrefs({ + prefs: { + ...prefs, + autoTranslate: panel.querySelector('#auto-translate') instanceof HTMLInputElement + && /** @type {HTMLInputElement} */ panel.querySelector('#auto-translate').checked, + }, + }) showToastI18n('success', 'chat.hub.translationPrefs.saved') closeOverlayModal() } diff --git a/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs b/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs deleted file mode 100644 index 1e0cdc43f..000000000 --- a/src/public/parts/shells/chat/public/src/api/groupGovernance.mjs +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 【文件】public/src/api/groupGovernance.mjs - * 【职责】群治理 API:fork、封对立分支、声誉、群主继任、轮换群钥、合并 DAG tips。 - * 【关联】groupClient.mjs;groupBan、审计与 Hub 管理 UI。 - */ -import { groupFetch, groupPath } from './groupClient.mjs' - -/** - * 将现有群 fork 为新群。 - * @param {string} sourceGroupId 源群 ID - * @param {object} [options] fork 请求体 - * @returns {Promise<any>} fork API 响应 - */ -export async function forkGroupAsNew(sourceGroupId, options = {}) { - return groupFetch(groupPath(sourceGroupId, 'fork'), { method: 'POST', json: options }) -} - -/** - * 拉黑对立治理分支上的签发者(采纳叶 = 当前选支)。 - * @param {string} groupId 群 ID - * @param {string} acceptedTipId 64 hex 叶 id - * @returns {Promise<{ blocked: string[] }>} 被拉黑公钥哈希列表 - */ -export async function blockOpposingForkBranch(groupId, acceptedTipId) { - return groupFetch(groupPath(groupId, 'fork', 'block-opposing'), { - method: 'POST', - json: { acceptedTipId }, - }) -} - -/** - * 追加用户级拉黑(`denylist.json`)。 - * @param {{ scope: string, value: string, groupId?: string }} entry 拉黑条目 - * @returns {Promise<void>} - */ -export async function blockUser(entry) { - const response = await fetch('/api/p2p/denylist', { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(entry), - }) - const data = await response.json() - if (!response.ok) throw new Error(data.error || 'denylist failed') -} - -/** - * 设置当前采纳的治理分支 tip。 - * @param {string} groupId 群 ID - * @param {string} tipId 分支 tip 事件 ID - * @returns {Promise<{ consensusBranchTip: string|null, localViewBranchTip: string|null, governanceFork: boolean }>} 更新后的分支状态 - */ -export async function setGovernanceBranch(groupId, tipId) { - const data = await groupFetch(groupPath(groupId, 'governance-branch'), { - method: 'PUT', - json: { tipId }, - }) - return { - consensusBranchTip: data.consensusBranchTip ?? null, - localViewBranchTip: data.localViewBranchTip ?? null, - governanceFork: !!data.governanceFork, - } -} - -/** - * 读取群主观信誉表(节点级 `/reputation`,不在 `groups/` 下)。 - * @returns {Promise<object>} `{ reputation }` - */ -export async function getGroupReputation() { - const response = await fetch('/api/parts/shells:chat/reputation', { credentials: 'include' }) - if (!response.ok) { - const data = await response.json().catch(() => ({})) - throw new Error(data.error || `HTTP ${response.status}`) - } - return response.json() -} - -/** - * 发布 reputation_reset 事件。 - * @param {string} groupId 群 ID - * @param {string} targetPubKeyHash 目标 64 hex - * @returns {Promise<{ applied: number }>} 应用计数 - */ -export async function postReputationReset(groupId, targetPubKeyHash) { - return groupFetch(groupPath(groupId, 'reputation', 'reset'), { - method: 'POST', - json: { targetPubKeyHash: String(targetPubKeyHash || '').trim().toLowerCase() }, - }) -} - -/** - * 发布声誉扣减事件。 - * @param {string} groupId 群 ID - * @param {object} body 扣减参数(`targetPubKeyHash`、`claim`、`verified`、`proof` 等) - * @returns {Promise<{ applied: number }>} 实际应用的事件数 - */ -export async function postReputationSlash(groupId, body) { - const payload = { - targetPubKeyHash: String(body.targetPubKeyHash || '').trim().toLowerCase(), - claim: Number(body.claim ?? 0.25), - } - if (body.verified) { - payload.verified = true - if (body.proof?.eventId) payload.proof = { eventId: String(body.proof.eventId).trim().toLowerCase() } - } - const data = await groupFetch(groupPath(groupId, 'reputation', 'slash'), { - method: 'POST', - json: payload, - }) - return { applied: Number(data.applied) || 0 } -} - -/** - * 合并 DAG 分叉 tip(§8 治理)。 - * @param {string} groupId 群 ID - * @returns {Promise<object>} merge API 响应 - */ -export async function mergeDagTips(groupId) { - return groupFetch(groupPath(groupId, 'dag', 'merge-tips'), { method: 'POST', json: {} }) -} - -/** - * 手动轮换群 GSH 密钥。 - * @param {string} groupId 群 ID - * @returns {Promise<object>} file-key-rotate 响应 - */ -export async function rotateGroupKey(groupId) { - return groupFetch(groupPath(groupId, 'file-key-rotate'), { method: 'POST', json: {} }) -} - -/** - * 群主继任联署提交。 - * @param {string} groupId 群 ID - * @param {object} body `{ proposedOwnerPubKeyHash, ballotId, adminSignatures?, thresholdRatio? }` - * @returns {Promise<object>} 服务端 JSON 响应 - */ -export async function submitOwnerSuccession(groupId, body) { - return groupFetch(groupPath(groupId, 'owner-succession'), { method: 'POST', json: body }) -} - -/** - * 解封成员。 - * @param {string} groupId 群 ID - * @param {string} pubKeyHash 成员公钥哈希(用户名键) - * @returns {Promise<void>} - */ -export async function unbanMember(groupId, pubKeyHash) { - await groupFetch(groupPath(groupId, 'members', pubKeyHash, 'unban'), { method: 'POST', json: {} }) -} diff --git a/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs b/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs index bd30e024e..ca92e6295 100644 --- a/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs +++ b/src/public/parts/shells/chat/public/src/deepLinkConsume.mjs @@ -75,7 +75,13 @@ export async function applyChatRunUri(raw) { const join = parseJoinRunUri(raw) if (join) { const groupState = await getGroupState(join.groupId).catch(() => null) - const viewer = await getViewer().catch(error => { handleError('chat.hub.operationFailed')(error); return {} }) + let viewer = {} + try { + viewer = await getViewer() + } + catch (error) { + handleError('chat.hub.operationFailed')(error) + } const pow = await resolvePowForJoin(join.groupId, groupState, viewer.nodeHash || '') await joinGroup(join.groupId, join.inviteCode, null, pow, join.roomSecret || join.introducerPubKeyHash || join.introducerNodeHash diff --git a/src/public/parts/shells/chat/public/src/endpoints/entities.mjs b/src/public/parts/shells/chat/public/src/endpoints/entities.mjs index 3bb0b94af..4f9486114 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/entities.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/entities.mjs @@ -25,8 +25,7 @@ export function localeQueryString(groupId) { * @returns {Promise<{ profile: object }>} 资料 JSON */ export async function getEntityProfile(entityHash, groupId) { - const queryString = localeQueryString(groupId) - return chatFetch(`/entities/${encodeURIComponent(entityHash)}${queryString ? `?${queryString}` : ''}`) + return chatFetch(`/entities/${encodeURIComponent(entityHash)}${localeQueryString(groupId) ? `?${localeQueryString(groupId)}` : ''}`) } /** @@ -37,8 +36,7 @@ export async function getEntityProfile(entityHash, groupId) { * @returns {Promise<object>} 更新后的资料 JSON(或代理写入时的 `{ queued: true, ... }`) */ export async function updateEntityProfile(entityHash, updates, groupId) { - const queryString = localeQueryString(groupId) - return chatFetch(`/entities/${encodeURIComponent(entityHash)}${queryString ? `?${queryString}` : ''}`, { + return chatFetch(`/entities/${encodeURIComponent(entityHash)}${localeQueryString(groupId) ? `?${localeQueryString(groupId)}` : ''}`, { method: 'PUT', json: { ...updates, ...groupId ? { groupId } : {} }, }) diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs index 0e09f53ca..fd119905a 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupClient.mjs @@ -25,13 +25,12 @@ export function groupPath(groupId, ...segments) { */ export async function chatFetch(path, options = {}) { const { json, ...init } = options - const headers = json - ? { 'Content-Type': 'application/json', ...init.headers } - : init.headers const response = await fetch(`${CHAT_API_CLIENT_PREFIX}${path}`, { ...init, credentials: 'include', - headers, + headers: json + ? { 'Content-Type': 'application/json', ...init.headers } + : init.headers, body: json ? JSON.stringify(json) : init.body, }) if (!response.ok) { diff --git a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs index 3561bbe38..ba3073876 100644 --- a/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs +++ b/src/public/parts/shells/chat/public/src/endpoints/groupCore.mjs @@ -201,8 +201,7 @@ export async function createFriendGroup(body, signal) { * @returns {Promise<string[]>} charname 列表 */ export async function listGroupChars(groupId, signal) { - const chars = await groupFetch(groupPath(groupId, 'chars'), { method: 'GET', signal }) - return Array.isArray(chars) ? chars : [] + return groupFetch(groupPath(groupId, 'chars'), { method: 'GET', signal }) } /** diff --git a/src/public/parts/shells/chat/src/api/client/helpers.mjs b/src/public/parts/shells/chat/src/api/client/helpers.mjs index b4ba8a7a4..2e4d8debc 100644 --- a/src/public/parts/shells/chat/src/api/client/helpers.mjs +++ b/src/public/parts/shells/chat/src/api/client/helpers.mjs @@ -9,7 +9,7 @@ * @param {string} entityHash 实体 * @param {string} dataName setting 名 * @param {(stored: object) => object} shape 读出规范化;set 时写入该对象 - * @returns {{ list: Function, set: Function }} list/set 命名空间 + * @returns {{ list: Function, set: Function, update: Function }} list/set/update 命名空间 */ export function createShellJsonNamespace(username, shell, entityHash, dataName, shape) { return { @@ -30,6 +30,18 @@ export function createShellJsonNamespace(username, shell, entityHash, dataName, assignEntityShellData(username, shell, entityHash, dataName, next) return next }, + /** + * 单次读改写:在同一 tick 内 load → mutator → assign,避免 list/set 之间的并发覆盖。 + * @param {(current: object) => object} mutator 基于当前值计算下一值 + * @returns {Promise<object>} 写入后的值 + */ + async update(mutator) { + const { loadEntityShellData, assignEntityShellData } = await import('../../../../../../../server/setting_loader.mjs') + const current = shape(loadEntityShellData(username, shell, entityHash, dataName) || {}) + const next = shape(mutator(current) || {}) + assignEntityShellData(username, shell, entityHash, dataName, next) + return next + }, } } @@ -38,7 +50,7 @@ export function createShellJsonNamespace(username, shell, entityHash, dataName, * @param {ChatApiContext} apiContext API 上下文 * @param {string} dataName setting 名 * @param {(stored: object) => object} shape 读出规范化 - * @returns {{ list: Function, set: Function }} list/set 命名空间 + * @returns {{ list: Function, set: Function, update: Function }} list/set/update 命名空间 */ export function createChatShellJsonNamespace(apiContext, dataName, shape) { return createShellJsonNamespace(apiContext.username, 'chat', apiContext.entityHash, dataName, shape) diff --git a/src/public/parts/shells/chat/src/api/client/privateState.mjs b/src/public/parts/shells/chat/src/api/client/privateState.mjs index e93e59c11..362f2c290 100644 --- a/src/public/parts/shells/chat/src/api/client/privateState.mjs +++ b/src/public/parts/shells/chat/src/api/client/privateState.mjs @@ -7,7 +7,7 @@ import { createChatShellJsonNamespace } from './helpers.mjs' export function createPrivateStateMethods(apiContext) { return { /** - * @returns {{ list: Function, set: Function }} 书签 + * @returns {{ list: Function, set: Function, add: Function, remove: Function }} 书签 */ get bookmarks() { const ns = createChatShellJsonNamespace(apiContext, 'bookmarks', stored => ({ @@ -31,14 +31,18 @@ export function createPrivateStateMethods(apiContext) { * @returns {Promise<{ entries: object[], added: boolean }>} 写入后列表与是否新增 */ async add(entry) { - const { entries } = await ns.list() - const groupId = String(entry?.groupId || '') - const eventId = String(entry?.eventId || '') - if (groupId && eventId && entries.some(bookmark => bookmark?.groupId === groupId && bookmark?.eventId === eventId)) - return { entries, added: false } - entries.push(entry) - const next = await ns.set({ entries }) - return { entries: next.entries, added: true } + const groupId = entry.groupId + const eventId = entry.eventId + let added = true + const next = await ns.update(({ entries }) => { + if (entries.some(bookmark => bookmark.groupId === groupId && bookmark.eventId === eventId)) { + added = false + return { entries } + } + entries.push(entry) + return { entries } + }) + return { entries: next.entries, added } }, /** * 原子删除(eventId 优先,回落 href)。 @@ -46,18 +50,23 @@ export function createPrivateStateMethods(apiContext) { * @returns {Promise<{ entries: object[], removed: boolean }>} 写入后列表与是否删除 */ async remove(entry) { - const { entries } = await ns.list() - const groupId = String(entry?.groupId || '') - const eventId = String(entry?.eventId || '') - const href = String(entry?.href || '') - const next = entries.filter(bookmark => { - if (eventId) return !(String(bookmark?.groupId || '') === groupId && String(bookmark?.eventId || '') === eventId) - if (href) return String(bookmark?.href || '') !== href - return true + const groupId = entry.groupId + const eventId = entry.eventId + const href = entry.href + let removed = true + const next = await ns.update(({ entries }) => { + const filtered = entries.filter(bookmark => { + if (eventId) return !(bookmark.groupId === groupId && bookmark.eventId === eventId) + if (href) return bookmark.href !== href + return true + }) + if (filtered.length === entries.length) { + removed = false + return { entries } + } + return { entries: filtered } }) - if (next.length === entries.length) return { entries, removed: false } - const saved = await ns.set({ entries: next }) - return { entries: saved.entries, removed: true } + return { entries: next.entries, removed } }, } }, diff --git a/src/public/parts/shells/chat/src/endpoints/preferences.mjs b/src/public/parts/shells/chat/src/endpoints/preferences.mjs index 29c67f772..0506abb75 100644 --- a/src/public/parts/shells/chat/src/endpoints/preferences.mjs +++ b/src/public/parts/shells/chat/src/endpoints/preferences.mjs @@ -28,11 +28,11 @@ export function registerPrefsRoutes(router) { }) router.post(`${CHAT_API_PREFIX}/bookmarks`, authenticate, async (req, res) => { const { client } = await chatClientFromReq(req) - res.status(200).json(await client.bookmarks.add(req.body.entry || {})) + res.status(200).json(await client.bookmarks.add(req.body.entry)) }) router.delete(`${CHAT_API_PREFIX}/bookmarks`, authenticate, async (req, res) => { const { client } = await chatClientFromReq(req) - res.status(200).json(await client.bookmarks.remove(req.body.entry || {})) + res.status(200).json(await client.bookmarks.remove(req.body.entry)) }) router.get(`${CHAT_API_PREFIX}/group-folders`, authenticate, async (req, res) => { diff --git a/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs b/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs index 560ea78c3..bc549fed4 100644 --- a/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs +++ b/src/public/parts/shells/chat/test/integration/entity_private_state.test.mjs @@ -53,14 +53,14 @@ Deno.test('agent bookmarks isolated from operator ChatClient', async () => { href: '#group:g1:default', }) assertEquals(added.added, true) - const dup = await operatorClient.bookmarks.add({ + const duplicate = await operatorClient.bookmarks.add({ groupId: 'g1', eventId: 'ab'.repeat(32), title: 't2', href: '#group:g1:default', }) - assertEquals(dup.added, false) - assertEquals(dup.entries.filter(row => row.eventId === 'ab'.repeat(32)).length, 1) + assertEquals(duplicate.added, false) + assertEquals(duplicate.entries.filter(row => row.eventId === 'ab'.repeat(32)).length, 1) const removed = await operatorClient.bookmarks.remove({ groupId: 'g1', eventId: 'ab'.repeat(32) }) assertEquals(removed.removed, true) assertEquals(removed.entries.some(row => row.eventId === 'ab'.repeat(32)), false) From a5adeffddb319d40cba6db900ad60663f9e5eee8 Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 19:50:50 +0800 Subject: [PATCH 06/13] 1 --- .../parts/shells/chat/public/emoji-packs/index.mjs | 9 +++++++-- src/public/parts/shells/chat/public/hub/chatConfig.mjs | 7 ++----- .../chat/public/hub/messages/messageVirtualList.mjs | 5 ++++- .../shells/chat/public/hub/messages/render/file.mjs | 2 +- .../shells/chat/public/hub/translationPrefsDialog.mjs | 6 ++---- src/public/parts/shells/chat/src/api/client/helpers.mjs | 2 +- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/public/parts/shells/chat/public/emoji-packs/index.mjs b/src/public/parts/shells/chat/public/emoji-packs/index.mjs index ee6d4730e..7fde37179 100644 --- a/src/public/parts/shells/chat/public/emoji-packs/index.mjs +++ b/src/public/parts/shells/chat/public/emoji-packs/index.mjs @@ -29,8 +29,13 @@ function addActionButton(actions, { i18nKey, fallback, className, onClick }) { button.className = className button.dataset.i18n = i18nKey button.textContent = geti18n(i18nKey) || fallback - button.addEventListener('click', () => { - void Promise.resolve().then(() => onClick()).catch(handleError('chat.emoji.previewActionFailed')) + button.addEventListener('click', async () => { + try { + await onClick() + } + catch (error) { + handleError('chat.emoji.previewActionFailed')(error) + } }) actions.appendChild(button) return button diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index a00ad2ca5..ada064aaf 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -65,14 +65,11 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio getPartList('worlds').catch(() => []), getPartList('personas').catch(() => []), getPartList('plugins').catch(() => []), - listGroupPlugins(groupId).catch(error => { - handleError('chat.hub.operationFailed')(error) - return [] - }), + listGroupPlugins(groupId), ]) const charlist = Array.isArray(initial?.charlist) ? initial.charlist : [] - const pluginlist = Array.isArray(activePlugins) ? activePlugins : Array.isArray(initial?.pluginlist) ? initial.pluginlist : [] + const pluginlist = Array.isArray(activePlugins) ? activePlugins : [] const freqMap = initial?.frequency_data || {} const worldname = initial?.worldname || '' const personaname = initial?.personaname || '' diff --git a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs index 700193050..276eb3fe5 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs @@ -26,7 +26,10 @@ import { } from './messageSurface.mjs' import { revokeAllGroupFileBlobUrls } from './render/file.mjs' -/** @returns {void} */ +/** + * 销毁当前聊天频道的虚拟列表与相关 Blob URL。 + * @returns {void} + */ export function destroyChannelVirtualList() { store.messages.channelMessagePipeline?.destroy() store.messages.channelMessagePipeline = null diff --git a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs index fe7ec9130..bb1aefa7a 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs @@ -187,6 +187,6 @@ export function wireMessageMediaPlaceholders(container) { return } placeholder.replaceWith(node) - bindBlobUrlCleanup(node) + bindBlobUrlCleanup(node.parentElement) }) } diff --git a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs index a6c1216b1..2adff44e2 100644 --- a/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs +++ b/src/public/parts/shells/chat/public/hub/translationPrefsDialog.mjs @@ -16,15 +16,13 @@ import { closeOverlayModal } from './core/overlayModal.mjs' */ export async function mountTranslationPrefsPanel(panel, footer) { usingTemplates('/parts/shells:chat/src/templates') - let data + let prefs = { autoTranslate: false } try { - data = await getTranslationPrefs() + prefs = (await getTranslationPrefs()).prefs || prefs } catch (error) { handleError('chat.hub.operationFailed')(error) - data = { prefs: { autoTranslate: false } } } - const prefs = data.prefs || { autoTranslate: false } const root = await renderTemplate('hub/prefs/translation', { autoTranslateChecked: prefs.autoTranslate ? 'checked' : '', }) diff --git a/src/public/parts/shells/chat/src/api/client/helpers.mjs b/src/public/parts/shells/chat/src/api/client/helpers.mjs index 2e4d8debc..af6ea86aa 100644 --- a/src/public/parts/shells/chat/src/api/client/helpers.mjs +++ b/src/public/parts/shells/chat/src/api/client/helpers.mjs @@ -38,7 +38,7 @@ export function createShellJsonNamespace(username, shell, entityHash, dataName, async update(mutator) { const { loadEntityShellData, assignEntityShellData } = await import('../../../../../../../server/setting_loader.mjs') const current = shape(loadEntityShellData(username, shell, entityHash, dataName) || {}) - const next = shape(mutator(current) || {}) + const next = shape(mutator(current)) assignEntityShellData(username, shell, entityHash, dataName, next) return next }, From 6646885faf126340adc4b7cdc049d0c06424e060 Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 21:22:11 +0800 Subject: [PATCH 07/13] 1 --- .../shells/chat/public/hub/chatConfig.mjs | 2 +- .../public/hub/messages/messageRefresh.mjs | 7 +- .../hub/messages/messageVirtualList.mjs | 9 ++- .../chat/public/hub/messages/render/file.mjs | 69 +++++++++++++------ 4 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index ada064aaf..8902b65a7 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -69,7 +69,7 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio ]) const charlist = Array.isArray(initial?.charlist) ? initial.charlist : [] - const pluginlist = Array.isArray(activePlugins) ? activePlugins : [] + const pluginlist = activePlugins const freqMap = initial?.frequency_data || {} const worldname = initial?.worldname || '' const personaname = initial?.personaname || '' diff --git a/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs b/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs index dd9c7266e..5e1fe25a7 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageRefresh.mjs @@ -300,7 +300,6 @@ export async function loadMessages() { refreshChannelView() await refreshReactionPerms() initChannelVirtualList(container) - store.messages.channelPipelineKey = pipelineKey } else await mountTemplate(container, 'hub/empty/loading', {}) @@ -338,12 +337,8 @@ export async function loadMessages() { if (store.messages.channelMessagePipeline) await store.messages.channelMessagePipeline.refresh() - else { + else initChannelVirtualList(container) - store.messages.channelPipelineKey = pipelineKey - } - if (softReload) - store.messages.channelPipelineKey = pipelineKey updateLastMessageId() // 有未读时滚到分割线;打开频道即标已读(badge 清零),分割线锚点保留到下次 load if (!softReload && !store.messages.firstUnreadEventId) scrollToBottom() diff --git a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs index 276eb3fe5..5d39a480e 100644 --- a/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/messageVirtualList.mjs @@ -24,17 +24,18 @@ import { bindMessageSurface, createMessageSurfacePipeline, } from './messageSurface.mjs' -import { revokeAllGroupFileBlobUrls } from './render/file.mjs' +import { revokeGroupFileBlobUrlsForChannel } from './render/file.mjs' /** * 销毁当前聊天频道的虚拟列表与相关 Blob URL。 * @returns {void} */ export function destroyChannelVirtualList() { + const channelKey = store.messages.channelPipelineKey store.messages.channelMessagePipeline?.destroy() store.messages.channelMessagePipeline = null store.messages.channelPipelineKey = null - revokeAllGroupFileBlobUrls() + revokeGroupFileBlobUrlsForChannel(channelKey) } /** @type {Promise<number> | null} */ @@ -109,6 +110,10 @@ async function doLoadOlderMessages() { */ export function initChannelVirtualList(container) { destroyChannelVirtualList() + const groupId = store.context.currentGroupId + const channelId = store.context.currentChannelId + if (groupId && channelId) + store.messages.channelPipelineKey = `${groupId}:${channelId}` store.messages.channelMessagePipeline = createMessageSurfacePipeline({ container, loadMoreTop: loadOlderMessages, diff --git a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs index bb1aefa7a..f40c887e3 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs @@ -16,8 +16,17 @@ import { getMessageText } from './text.mjs' const LAZY_MEDIA_BYTES = 2 * 1024 * 1024 -/** @type {Set<string>} */ -const trackedBlobUrls = new Set() +/** @type {Map<string, string>} blobUrl → `${groupId}:${channelId}` */ +const trackedBlobUrls = new Map() + +/** + * @returns {string | null} 当前群+频道键;缺任一侧则为 null + */ +function currentChannelBlobKey() { + const groupId = store.context.currentGroupId + const channelId = store.context.currentChannelId + return groupId && channelId ? `${groupId}:${channelId}` : null +} /** * @param {string} url Blob URL @@ -45,13 +54,17 @@ function bindBlobUrlCleanup(root) { } /** - * 释放尚未/已无法绑定到 DOM 的全部群文件 Blob URL(频道虚列表销毁时调用)。 + * 释放指定频道虚列表相关的群文件 Blob URL(其它频道的 URL 保留)。 + * @param {string | null | undefined} channelKey `${groupId}:${channelId}`;缺省则无操作 * @returns {void} */ -export function revokeAllGroupFileBlobUrls() { - for (const url of trackedBlobUrls) +export function revokeGroupFileBlobUrlsForChannel(channelKey) { + if (!channelKey) return + for (const [url, key] of trackedBlobUrls) { + if (key !== channelKey) continue + trackedBlobUrls.delete(url) URL.revokeObjectURL(url) - trackedBlobUrls.clear() + } } /** @@ -60,9 +73,15 @@ export function revokeAllGroupFileBlobUrls() { * @returns {Promise<string | null>} Blob URL;失败已 toast 时为 null */ async function loadGroupFileBlobUrl(groupId, fileId) { + const ownerKey = currentChannelBlobKey() try { const url = await fetchGroupFileAsBlobUrl(groupId, fileId) - trackedBlobUrls.add(url) + const liveKey = store.messages.channelPipelineKey || currentChannelBlobKey() + if (!ownerKey || liveKey !== ownerKey) { + URL.revokeObjectURL(url) + return null + } + trackedBlobUrls.set(url, ownerKey) return url } catch (error) { @@ -171,22 +190,28 @@ export function wireMessageMediaPlaceholders(container) { ) return } - const src = escapeHtml(blobUrl) - const html = mime.startsWith('video/') - ? await renderTemplateAsHtmlString('hub/messages/inline_video', { src }) - : mime.startsWith('audio/') - ? await renderTemplateAsHtmlString('hub/messages/inline_audio', { src }) - : await renderTemplateAsHtmlString('hub/messages/inline_image', { - fileName: escapeHtml(placeholder.querySelector('.truncate')?.textContent || fileId), - src, - }) - const frag = await createDocumentFragmentFromHtmlStringNoScriptActivation(html) - const node = frag.firstElementChild - if (!node) { + try { + const src = escapeHtml(blobUrl) + const html = mime.startsWith('video/') + ? await renderTemplateAsHtmlString('hub/messages/inline_video', { src }) + : mime.startsWith('audio/') + ? await renderTemplateAsHtmlString('hub/messages/inline_audio', { src }) + : await renderTemplateAsHtmlString('hub/messages/inline_image', { + fileName: escapeHtml(placeholder.querySelector('.truncate')?.textContent || fileId), + src, + }) + const frag = await createDocumentFragmentFromHtmlStringNoScriptActivation(html) + const node = frag.firstElementChild + if (!node) { + revokeTrackedBlobUrl(blobUrl) + return + } + placeholder.replaceWith(node) + bindBlobUrlCleanup(node.parentElement) + } + catch (error) { revokeTrackedBlobUrl(blobUrl) - return + handleError('chat.hub.file.loadFailed', {}, error) } - placeholder.replaceWith(node) - bindBlobUrlCleanup(node.parentElement) }) } From ae943b34762a0db92e35876067193e8f962181f6 Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 22:00:05 +0800 Subject: [PATCH 08/13] 1 --- src/public/parts/shells/cabinet/public/index.html | 10 ++++++++-- src/public/parts/shells/chat/public/hub/index.html | 9 +++++++-- .../shells/chat/public/hub/messages/render/file.mjs | 2 +- src/public/parts/shells/social/public/index.html | 1 + 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/public/parts/shells/cabinet/public/index.html b/src/public/parts/shells/cabinet/public/index.html index 1fb53abf8..6438c60bd 100644 --- a/src/public/parts/shells/cabinet/public/index.html +++ b/src/public/parts/shells/cabinet/public/index.html @@ -28,6 +28,7 @@ <h1 class="sr-only" data-i18n="cabinet.title"></h1> <div class="bg-base-100 border-b border-base-300 p-2 flex flex-wrap gap-1 items-center"> <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-square md:hidden" data-i18n="cabinet.openCabinets"> + <span class="sr-only" data-i18n="cabinet.openCabinets.title"></span> <img src="https://api.iconify.design/mdi/menu.svg" width="18" height="18" alt="" aria-hidden="true" /> </label> <p class="text-base font-semibold md:hidden" aria-hidden="true" data-i18n="cabinet.title"></p> @@ -46,12 +47,17 @@ <h1 class="sr-only" data-i18n="cabinet.title"></h1> </main> </div> <div class="drawer-side z-40"> - <label for="cabinet-drawer-toggle" class="drawer-overlay md:hidden"></label> + <label for="cabinet-drawer-toggle" class="drawer-overlay md:hidden"> + <span class="sr-only" data-i18n="cabinet.closeCabinets.title"></span> + </label> <aside class="w-64 min-h-full bg-base-100 border-r border-base-300 p-3 flex flex-col gap-2"> <div class="flex items-center justify-between gap-2"> <p class="text-lg font-semibold max-md:hidden" aria-hidden="true" data-i18n="cabinet.title"></p> <button id="btnNewCabinetDesktop" type="button" data-action="new-cabinet" class="btn btn-primary btn-sm btn-circle cabinet-local-only max-md:hidden md:inline-flex" data-i18n="cabinet.new.cabinet">+</button> - <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-circle md:hidden" data-i18n="cabinet.closeCabinets">×</label> + <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-circle md:hidden" data-i18n="cabinet.closeCabinets"> + <span class="sr-only" data-i18n="cabinet.closeCabinets.title"></span> + <span aria-hidden="true">×</span> + </label> </div> <ul id="cabinetList" class="menu menu-sm flex-1 overflow-y-auto"></ul> </aside> diff --git a/src/public/parts/shells/chat/public/hub/index.html b/src/public/parts/shells/chat/public/hub/index.html index d2506ac17..93b9d360d 100644 --- a/src/public/parts/shells/chat/public/hub/index.html +++ b/src/public/parts/shells/chat/public/hub/index.html @@ -271,11 +271,16 @@ <h1 class="sr-only" data-i18n="chat.hub.title"></h1> </div> <aside class="drawer-side z-50" aria-labelledby="files-title"> - <label for="files-drawer-toggle" class="drawer-overlay" data-i18n="chat.hub.ariaClose"></label> + <label for="files-drawer-toggle" class="drawer-overlay"> + <span class="sr-only" data-i18n="chat.hub.ariaClose.aria-label"></span> + </label> <div class="files-panel w-80 min-h-full bg-[var(--bg-channel)] border-l border-[var(--border)] p-4 flex flex-col gap-3 text-[var(--text-normal)]"> <header class="flex items-center justify-between gap-2"> <h2 id="files-title" class="font-bold text-lg" data-i18n="chat.hub.files.drawerTitle"></h2> - <label for="files-drawer-toggle" class="btn btn-ghost btn-sm btn-circle" data-i18n="chat.hub.ariaClose">×</label> + <label for="files-drawer-toggle" class="btn btn-ghost btn-sm btn-circle"> + <span class="sr-only" data-i18n="chat.hub.ariaClose.aria-label"></span> + <span aria-hidden="true">×</span> + </label> </header> <p id="files-convergent-warn" class="text-warning text-sm" data-i18n="chat.hub.convergentEncryptWarn"></p> <div class="flex flex-wrap gap-2 items-center"> diff --git a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs index f40c887e3..11182d92c 100644 --- a/src/public/parts/shells/chat/public/hub/messages/render/file.mjs +++ b/src/public/parts/shells/chat/public/hub/messages/render/file.mjs @@ -211,7 +211,7 @@ export function wireMessageMediaPlaceholders(container) { } catch (error) { revokeTrackedBlobUrl(blobUrl) - handleError('chat.hub.file.loadFailed', {}, error) + handleError('chat.hub.file.loadFailed')(error) } }) } diff --git a/src/public/parts/shells/social/public/index.html b/src/public/parts/shells/social/public/index.html index 04a241a0b..eb2151733 100644 --- a/src/public/parts/shells/social/public/index.html +++ b/src/public/parts/shells/social/public/index.html @@ -140,6 +140,7 @@ <h1 class="sr-only" data-i18n="social.title"></h1> <span class="icon icon-emoji" aria-hidden="true"></span> </button> <label class="composer-tool-btn btn btn-ghost btn-sm btn-circle media-upload-btn" data-i18n="social.composer.mediaButton"> + <span class="sr-only" data-i18n="social.composer.mediaButton.title"></span> <span class="icon icon-media" aria-hidden="true"></span> <input id="mediaInput" type="file" accept="image/*,video/*" multiple hidden /> </label> From 9101bab71bac3871d2416d6238b5a76043af0d76 Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 22:53:11 +0800 Subject: [PATCH 09/13] 1 --- src/decl/locale_data.ts | 17 +++++------------ src/public/pages/i18n-notes.md | 2 ++ .../parts/shells/cabinet/public/index.html | 18 +++++++++--------- .../shells/chat/public/hub/chatConfig.mjs | 6 +++--- .../parts/shells/chat/public/hub/index.html | 4 ++-- src/public/parts/shells/home/public/index.html | 8 +++++--- .../parts/shells/social/public/index.html | 4 ++-- 7 files changed, 28 insertions(+), 31 deletions(-) diff --git a/src/decl/locale_data.ts b/src/decl/locale_data.ts index d4211f8a1..ca50154b7 100644 --- a/src/decl/locale_data.ts +++ b/src/decl/locale_data.ts @@ -748,6 +748,7 @@ export type LocaleData = { title: string description: string sidebarTitle: string + closeSidebar: string itemDescription: string noDescription: string filterInput: { @@ -1932,6 +1933,7 @@ export type LocaleData = { ariaClose: { 'aria-label': string } + close: string serverBar: { 'aria-label': string } @@ -4162,10 +4164,7 @@ export type LocaleData = { mentionSuggest: { 'aria-label': string } - mediaButton: { - title: string - 'aria-label': string - } + mediaButton: string pollButton: { title: string 'aria-label': string @@ -4694,14 +4693,8 @@ export type LocaleData = { title: string 'aria-label': string } - openCabinets: { - title: string - 'aria-label': string - } - closeCabinets: { - title: string - 'aria-label': string - } + openCabinets: string + closeCabinets: string bootstrapFailed: string home_function_buttons: { main: { diff --git a/src/public/pages/i18n-notes.md b/src/public/pages/i18n-notes.md index 76d12cfc0..3d5366912 100644 --- a/src/public/pages/i18n-notes.md +++ b/src/public/pages/i18n-notes.md @@ -20,4 +20,6 @@ Use `data-i18n` / `setElementI18n` (swap the key to retarget) — not one-shot ` Icon-only controls: locale `{ title, aria-label }` ([locale-edits.md](../locales/locale-edits.md)); string keys on icon parents wipe children. +**Icon / empty `<label>` controls** (DaisyUI drawer toggle/overlay, file-upload chrome): axe forbids `aria-label` on bare `<label>`, and `role="button"` on `<label>` fails `aria-allowed-role`. Do **not** put `{ aria-label }` objects on the `<label>`. Use a **string** key on a child `<span class="sr-only" data-i18n="…">`; keep `{ title, aria-label }` for real `<button>`s. Drawer overlay + panel must sit inside one landmark (`aside.drawer-side`) so sr-only text does not trip `region`. + Setting `data-i18n` (or inserting markup that has it) is enough — body MutationObserver runs `i18nElement`; do not call it again. Use `setElementI18n` only when the key is unchanged but interpolation params change. diff --git a/src/public/parts/shells/cabinet/public/index.html b/src/public/parts/shells/cabinet/public/index.html index 6438c60bd..c843f1f32 100644 --- a/src/public/parts/shells/cabinet/public/index.html +++ b/src/public/parts/shells/cabinet/public/index.html @@ -27,8 +27,8 @@ <main class="flex-1 flex flex-col min-w-0 min-h-screen"> <h1 class="sr-only" data-i18n="cabinet.title"></h1> <div class="bg-base-100 border-b border-base-300 p-2 flex flex-wrap gap-1 items-center"> - <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-square md:hidden" data-i18n="cabinet.openCabinets"> - <span class="sr-only" data-i18n="cabinet.openCabinets.title"></span> + <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-square md:hidden"> + <span class="sr-only" data-i18n="cabinet.openCabinets"></span> <img src="https://api.iconify.design/mdi/menu.svg" width="18" height="18" alt="" aria-hidden="true" /> </label> <p class="text-base font-semibold md:hidden" aria-hidden="true" data-i18n="cabinet.title"></p> @@ -46,22 +46,22 @@ <h1 class="sr-only" data-i18n="cabinet.title"></h1> <ul id="contextMenu" class="menu menu-sm fixed hidden z-50 min-w-48 rounded-box border border-base-300 bg-base-100 p-1 shadow-xl" role="menu"></ul> </main> </div> - <div class="drawer-side z-40"> + <aside class="drawer-side z-40"> <label for="cabinet-drawer-toggle" class="drawer-overlay md:hidden"> - <span class="sr-only" data-i18n="cabinet.closeCabinets.title"></span> + <span class="sr-only" data-i18n="cabinet.closeCabinets"></span> </label> - <aside class="w-64 min-h-full bg-base-100 border-r border-base-300 p-3 flex flex-col gap-2"> + <div class="w-64 min-h-full bg-base-100 border-r border-base-300 p-3 flex flex-col gap-2"> <div class="flex items-center justify-between gap-2"> <p class="text-lg font-semibold max-md:hidden" aria-hidden="true" data-i18n="cabinet.title"></p> <button id="btnNewCabinetDesktop" type="button" data-action="new-cabinet" class="btn btn-primary btn-sm btn-circle cabinet-local-only max-md:hidden md:inline-flex" data-i18n="cabinet.new.cabinet">+</button> - <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-circle md:hidden" data-i18n="cabinet.closeCabinets"> - <span class="sr-only" data-i18n="cabinet.closeCabinets.title"></span> + <label for="cabinet-drawer-toggle" class="btn btn-ghost btn-sm btn-circle md:hidden"> + <span class="sr-only" data-i18n="cabinet.closeCabinets"></span> <span aria-hidden="true">×</span> </label> </div> <ul id="cabinetList" class="menu menu-sm flex-1 overflow-y-auto"></ul> - </aside> - </div> + </div> + </aside> </div> <dialog id="propsDialog" class="modal"> <div class="modal-box space-y-3 w-11/12 max-w-lg"> diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index 8902b65a7..462cefe59 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -147,11 +147,11 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio } host.querySelectorAll('.character-chat-freq-slider').forEach(slider => { - slider.addEventListener('input', async (inputEvent) => { - const row = inputEvent.target.closest('.character-chat-freq-row') + slider.addEventListener('change', async (changeEvent) => { + const row = changeEvent.target.closest('.character-chat-freq-row') const charname = row?.dataset?.char if (!charname) return - const frequency = Number(inputEvent.target.value) / 100 + const frequency = Number(changeEvent.target.value) / 100 try { await setGroupCharFrequency(groupId, charname, frequency) } diff --git a/src/public/parts/shells/chat/public/hub/index.html b/src/public/parts/shells/chat/public/hub/index.html index 93b9d360d..e118cfd7b 100644 --- a/src/public/parts/shells/chat/public/hub/index.html +++ b/src/public/parts/shells/chat/public/hub/index.html @@ -272,13 +272,13 @@ <h1 class="sr-only" data-i18n="chat.hub.title"></h1> <aside class="drawer-side z-50" aria-labelledby="files-title"> <label for="files-drawer-toggle" class="drawer-overlay"> - <span class="sr-only" data-i18n="chat.hub.ariaClose.aria-label"></span> + <span class="sr-only" data-i18n="chat.hub.close"></span> </label> <div class="files-panel w-80 min-h-full bg-[var(--bg-channel)] border-l border-[var(--border)] p-4 flex flex-col gap-3 text-[var(--text-normal)]"> <header class="flex items-center justify-between gap-2"> <h2 id="files-title" class="font-bold text-lg" data-i18n="chat.hub.files.drawerTitle"></h2> <label for="files-drawer-toggle" class="btn btn-ghost btn-sm btn-circle"> - <span class="sr-only" data-i18n="chat.hub.ariaClose.aria-label"></span> + <span class="sr-only" data-i18n="chat.hub.close"></span> <span aria-hidden="true">×</span> </label> </header> diff --git a/src/public/parts/shells/home/public/index.html b/src/public/parts/shells/home/public/index.html index 72bc1fdb0..64e550c5b 100644 --- a/src/public/parts/shells/home/public/index.html +++ b/src/public/parts/shells/home/public/index.html @@ -75,13 +75,15 @@ <h1 class="btn btn-ghost text-xl" id="page-title"></h1> </div> <!-- Sidebar (drawer-side) --> - <div class="drawer-side" data-view-transition-name="sidebar"> - <label for="drawer-toggle" aria-label="close sidebar" class="drawer-overlay"></label> + <aside class="drawer-side" data-view-transition-name="sidebar"> + <label for="drawer-toggle" class="drawer-overlay"> + <span class="sr-only" data-i18n="home.closeSidebar"></span> + </label> <div class="drawer-content sidebar-content p-4 w-80 min-h-full bg-base-300/90 sm:bg-base-300/30 text-base-content border-l-2 border-base-content flex flex-col"> <h2 class="text-xl mt-4 lg:mt-2 mb-2" data-i18n="home.sidebarTitle"></h2> <article id="item-description" class="markdown-body" data-i18n="home.itemDescription"></article> </div> - </div> + </aside> </div> <script type="module" src="./index.mjs"></script> diff --git a/src/public/parts/shells/social/public/index.html b/src/public/parts/shells/social/public/index.html index eb2151733..6b6b1f17b 100644 --- a/src/public/parts/shells/social/public/index.html +++ b/src/public/parts/shells/social/public/index.html @@ -139,8 +139,8 @@ <h1 class="sr-only" data-i18n="social.title"></h1> <button id="emojiPickButton" type="button" class="composer-tool-btn btn btn-ghost btn-sm btn-circle" data-i18n="social.composer.emojiButton"> <span class="icon icon-emoji" aria-hidden="true"></span> </button> - <label class="composer-tool-btn btn btn-ghost btn-sm btn-circle media-upload-btn" data-i18n="social.composer.mediaButton"> - <span class="sr-only" data-i18n="social.composer.mediaButton.title"></span> + <label class="composer-tool-btn btn btn-ghost btn-sm btn-circle media-upload-btn"> + <span class="sr-only" data-i18n="social.composer.mediaButton"></span> <span class="icon icon-media" aria-hidden="true"></span> <input id="mediaInput" type="file" accept="image/*,video/*" multiple hidden /> </label> From 551475680d40b1b6d2ce61af38f36c476d2d2398 Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 22:53:17 +0800 Subject: [PATCH 10/13] 1 --- src/public/locales/ar-SA.json | 17 +++++------------ src/public/locales/de-DE.json | 17 +++++------------ src/public/locales/emoji.json | 17 +++++------------ src/public/locales/en-UK.json | 17 +++++------------ src/public/locales/es-ES.json | 17 +++++------------ src/public/locales/fr-FR.json | 17 +++++------------ src/public/locales/hi-IN.json | 17 +++++------------ src/public/locales/is-IS.json | 17 +++++------------ src/public/locales/it-IT.json | 17 +++++------------ src/public/locales/ja-JP.json | 17 +++++------------ src/public/locales/ko-KR.json | 17 +++++------------ src/public/locales/lzh.json | 17 +++++------------ src/public/locales/nl-NL.json | 17 +++++------------ src/public/locales/pt-PT.json | 17 +++++------------ src/public/locales/ru-RU.json | 17 +++++------------ src/public/locales/uk-UA.json | 17 +++++------------ src/public/locales/vi-VN.json | 17 +++++------------ src/public/locales/zh-CN.json | 17 +++++------------ src/public/locales/zh-TW.json | 17 +++++------------ 19 files changed, 95 insertions(+), 228 deletions(-) diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 831cc9ed3..5c985461b 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -780,6 +780,7 @@ "title": "الصفحة الرئيسية", "description": "قلب تجربتك النابضة. هنا، قم بإدارة سكان خيالك - الشخصيات والعوالم والشخصيات. تبدأ قصصك وتتفرع من هذه الرابطة المركزية.", "sidebarTitle": "التفاصيل", + "closeSidebar": "Close sidebar", "itemDescription": "اختر عنصرًا هنا لعرض التفاصيل.", "noDescription": "لا يوجد وصف", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "إغلاق" }, + "close": "إغلاق", "serverBar": { "aria-label": "المجموعات والتنقل" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "اقتراحات الإشارة" }, - "mediaButton": { - "title": "أضف الوسائط", - "aria-label": "أضف الوسائط" - }, + "mediaButton": "أضف الوسائط", "pollButton": { "title": "الشروع في التصويت", "aria-label": "الشروع في التصويت" @@ -4714,14 +4713,8 @@ "title": "قائمة العناصر", "aria-label": "قائمة العناصر" }, - "openCabinets": { - "title": "فتح قائمة خزانة الملفات", - "aria-label": "فتح قائمة خزانة الملفات" - }, - "closeCabinets": { - "title": "إغلاق", - "aria-label": "إغلاق" - }, + "openCabinets": "فتح قائمة خزانة الملفات", + "closeCabinets": "إغلاق", "bootstrapFailed": "فشلت تهيئة خزانة الملفات: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index 9658848f7..5ed42136d 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -780,6 +780,7 @@ "title": "Startseite", "description": "Das Herzstück Ihres fount-Erlebnisses. Verwalten Sie hier die Bewohner Ihrer Fantasie – Charaktere, Welten und Personas. Ihre Geschichten beginnen und verzweigen sich in diesem zentralen Zusammenhang.", "sidebarTitle": "Details", + "closeSidebar": "Close sidebar", "itemDescription": "Wählen Sie ein Element aus, um Details anzuzeigen.", "noDescription": "Keine Beschreibung verfügbar", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Schließen" }, + "close": "Schließen", "serverBar": { "aria-label": "Gruppen und Navigation" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Erwähnungsvorschläge" }, - "mediaButton": { - "title": "Medien hinzufügen", - "aria-label": "Medien hinzufügen" - }, + "mediaButton": "Medien hinzufügen", "pollButton": { "title": "Umfrage starten", "aria-label": "Umfrage starten" @@ -4714,14 +4713,8 @@ "title": "Einträge", "aria-label": "Einträge" }, - "openCabinets": { - "title": "Cabinetliste öffnen", - "aria-label": "Cabinetliste öffnen" - }, - "closeCabinets": { - "title": "Schließen", - "aria-label": "Schließen" - }, + "openCabinets": "Cabinetliste öffnen", + "closeCabinets": "Schließen", "bootstrapFailed": "Die Initialisierung des Archivs ist fehlgeschlagen: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 737119b53..74ab586d4 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -780,6 +780,7 @@ "title": "🏠", "description": "❤️🏛️🎭🌍🧑‍🎨", "sidebarTitle": "ℹ️", + "closeSidebar": "Close sidebar", "itemDescription": "👆➡️ℹ️", "noDescription": "🤷‍♂️", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "✖️" }, + "close": "✖️", "serverBar": { "aria-label": "👥🧭" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "@️⃣💡" }, - "mediaButton": { - "title": "➕🖼️", - "aria-label": "➕🖼️" - }, + "mediaButton": "➕🖼️", "pollButton": { "title": "➕🗳️", "aria-label": "➕🗳️" @@ -4714,14 +4713,8 @@ "title": "📄📋", "aria-label": "📄📋" }, - "openCabinets": { - "title": "📂🗄️📋", - "aria-label": "📂🗄️📋" - }, - "closeCabinets": { - "title": "✖️", - "aria-label": "✖️" - }, + "openCabinets": "📂🗄️📋", + "closeCabinets": "✖️", "bootstrapFailed": "🗄️🚀❌:${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json index 9fdd4c1c5..f2f23928e 100644 --- a/src/public/locales/en-UK.json +++ b/src/public/locales/en-UK.json @@ -775,6 +775,7 @@ "title": "Home", "description": "The heart of your fount experience. Here, manage the denizens of your imagination—characters, worlds, and personas. Your stories begin and branch from this central nexus.", "sidebarTitle": "Details", + "closeSidebar": "Close sidebar", "itemDescription": "Select an item to view details.", "noDescription": "No description available.", "filterInput": { @@ -1959,6 +1960,7 @@ "ariaClose": { "aria-label": "Close" }, + "close": "Close", "serverBar": { "aria-label": "Groups and navigation" }, @@ -4185,10 +4187,7 @@ "mentionSuggest": { "aria-label": "Mention suggestions" }, - "mediaButton": { - "title": "Add media", - "aria-label": "Add media" - }, + "mediaButton": "Add media", "pollButton": { "title": "Start a poll", "aria-label": "Start a poll" @@ -4711,14 +4710,8 @@ "title": "Items", "aria-label": "Items" }, - "openCabinets": { - "title": "Open file cabinet list", - "aria-label": "Open file cabinet list" - }, - "closeCabinets": { - "title": "Close", - "aria-label": "Close" - }, + "openCabinets": "Open file cabinet list", + "closeCabinets": "Close", "bootstrapFailed": "File cabinet initialization failed: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index 7480fe3f7..7d4b7fb63 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -780,6 +780,7 @@ "title": "Inicio", "description": "El corazón de tu experiencia en fount. Aquí, gestiona los habitantes de tu imaginación: personajes, mundos y personas. Tus historias comienzan y se ramifican a partir de este nexo central.", "sidebarTitle": "Detalles", + "closeSidebar": "Close sidebar", "itemDescription": "Selecciona un elemento para ver los detalles.", "noDescription": "Sin descripción.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Cerrar" }, + "close": "Cerrar", "serverBar": { "aria-label": "Grupos y navegación" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Sugerencias de mención" }, - "mediaButton": { - "title": "Agregar medios", - "aria-label": "Agregar medios" - }, + "mediaButton": "Agregar medios", "pollButton": { "title": "Iniciar una votación", "aria-label": "Iniciar una votación" @@ -4714,14 +4713,8 @@ "title": "Lista de elementos", "aria-label": "Lista de elementos" }, - "openCabinets": { - "title": "Abrir lista de archivoes", - "aria-label": "Abrir lista de archivoes" - }, - "closeCabinets": { - "title": "Cerrar", - "aria-label": "Cerrar" - }, + "openCabinets": "Abrir lista de archivoes", + "closeCabinets": "Cerrar", "bootstrapFailed": "Error al inicializar el archivo: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index a759af1d1..37dbbe511 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -780,6 +780,7 @@ "title": "Accueil", "description": "Le cœur de votre expérience de fount. Ici, gérez les habitants de votre imagination : personnages, mondes et personnages. Vos histoires commencent et partent de ce lien central.", "sidebarTitle": "Détails", + "closeSidebar": "Close sidebar", "itemDescription": "Sélectionnez un élément pour en voir les détails.", "noDescription": "Aucune description disponible.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Fermer" }, + "close": "Fermer", "serverBar": { "aria-label": "Groupes et navigation" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Suggestions de mention" }, - "mediaButton": { - "title": "Ajouter un média", - "aria-label": "Ajouter un média" - }, + "mediaButton": "Ajouter un média", "pollButton": { "title": "Créer un sondage", "aria-label": "Créer un sondage" @@ -4714,14 +4713,8 @@ "title": "Liste des éléments", "aria-label": "Liste des éléments" }, - "openCabinets": { - "title": "Ouvrir la liste des armoires", - "aria-label": "Ouvrir la liste des armoires" - }, - "closeCabinets": { - "title": "Fermer", - "aria-label": "Fermer" - }, + "openCabinets": "Ouvrir la liste des armoires", + "closeCabinets": "Fermer", "bootstrapFailed": "Échec de l'initialisation de l'armoire : ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index 1cbf77a64..734c45a44 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -780,6 +780,7 @@ "title": "होम", "description": "आपके मूल अनुभव का हृदय. यहां, अपनी कल्पना के निवासियों-पात्रों, दुनियाओं और व्यक्तित्वों को प्रबंधित करें। आपकी कहानियाँ इसी केंद्रीय गठजोड़ से शुरू होती हैं और शाखाबद्ध होती हैं।", "sidebarTitle": "विवरण", + "closeSidebar": "Close sidebar", "itemDescription": "विवरण देखने के लिए यहाँ एक आइटम चुनें।", "noDescription": "कोई विवरण नहीं।", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "बंद करें" }, + "close": "बंद करें", "serverBar": { "aria-label": "समूह और नेविगेशन" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "मेंशन सुझाव" }, - "mediaButton": { - "title": "मीडिया जोड़ें", - "aria-label": "मीडिया जोड़ें" - }, + "mediaButton": "मीडिया जोड़ें", "pollButton": { "title": "मतदान शुरू करें", "aria-label": "मतदान शुरू करें" @@ -4714,14 +4713,8 @@ "title": "आइटम सूची", "aria-label": "आइटम सूची" }, - "openCabinets": { - "title": "फ़ाइल कैबिनेट सूची खोलें", - "aria-label": "फ़ाइल कैबिनेट सूची खोलें" - }, - "closeCabinets": { - "title": "बंद करें", - "aria-label": "बंद करें" - }, + "openCabinets": "फ़ाइल कैबिनेट सूची खोलें", + "closeCabinets": "बंद करें", "bootstrapFailed": "फ़ाइल कैबिनेट आरंभीकरण विफल: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index 4bd67c74f..df3134aec 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -780,6 +780,7 @@ "title": "Forsíða", "description": "Hjarta upplifunar þinnar. Hér skaltu stjórna þegnum ímyndunarafls þíns – persónur, heima og persónur. Sögur þínar byrja og greinast frá þessu miðlæga samhengi.", "sidebarTitle": "Nánar", + "closeSidebar": "Close sidebar", "itemDescription": "Veldu atriði hér til að sjá nánari upplýsingar.", "noDescription": "Engin lýsing", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Loka" }, + "close": "Loka", "serverBar": { "aria-label": "Hópar og leiðsögn" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Tillögur að mentíunum" }, - "mediaButton": { - "title": "Bættu við miðli", - "aria-label": "Bættu við miðli" - }, + "mediaButton": "Bættu við miðli", "pollButton": { "title": "Hefja atkvæðagreiðslu", "aria-label": "Hefja atkvæðagreiðslu" @@ -4714,14 +4713,8 @@ "title": "Atriðalisti", "aria-label": "Atriðalisti" }, - "openCabinets": { - "title": "Opnaðu skjalaskápalista", - "aria-label": "Opnaðu skjalaskápalista" - }, - "closeCabinets": { - "title": "Loka", - "aria-label": "Loka" - }, + "openCabinets": "Opnaðu skjalaskápalista", + "closeCabinets": "Loka", "bootstrapFailed": "Frumstilling skjalaskáps mistókst: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 602644880..5a693ad58 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -780,6 +780,7 @@ "title": "Home", "description": "Il cuore della tua esperienza di fount. Qui, gestisci gli abitanti della tua immaginazione: personaggi, mondi e personaggi. Le tue storie iniziano e si diramano da questo nesso centrale.", "sidebarTitle": "Dettagli", + "closeSidebar": "Close sidebar", "itemDescription": "Seleziona un elemento per visualizzarne i dettagli.", "noDescription": "Nessuna descrizione disponibile.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Vicino" }, + "close": "Vicino", "serverBar": { "aria-label": "Gruppi e navigazione" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Suggerimenti menzione" }, - "mediaButton": { - "title": "Aggiungi contenuti multimediali", - "aria-label": "Aggiungi contenuti multimediali" - }, + "mediaButton": "Aggiungi contenuti multimediali", "pollButton": { "title": "Avviare una votazione", "aria-label": "Initiate a vote" @@ -4714,14 +4713,8 @@ "title": "Elenco elementi", "aria-label": "Elenco elementi" }, - "openCabinets": { - "title": "Apri l'elenco degli schedari", - "aria-label": "Apri l'elenco degli schedari" - }, - "closeCabinets": { - "title": "Vicino", - "aria-label": "Vicino" - }, + "openCabinets": "Apri l'elenco degli schedari", + "closeCabinets": "Vicino", "bootstrapFailed": "Inizializzazione schedario non riuscita: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index ad12c54dd..7598ca41e 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -780,6 +780,7 @@ "title": "ホーム", "description": "あなたのfount体験の中心。ここでは、想像の世界の住人であるキャラクター、世界、ペルソナを管理します。あなたの物語は、この中心的なつながりから始まり、分岐していきます。", "sidebarTitle": "詳細", + "closeSidebar": "Close sidebar", "itemDescription": "項目を選択すると、ここに詳細が表示されます。", "noDescription": "説明はありません。", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "閉じる" }, + "close": "閉じる", "serverBar": { "aria-label": "グループとナビゲーション" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "メンション候補" }, - "mediaButton": { - "title": "メディアを追加", - "aria-label": "メディアを追加" - }, + "mediaButton": "メディアを追加", "pollButton": { "title": "投票を作成", "aria-label": "投票を作成" @@ -4714,14 +4713,8 @@ "title": "項目一覧", "aria-label": "項目一覧" }, - "openCabinets": { - "title": "ファイルキャビネットリストを開く", - "aria-label": "ファイルキャビネットリストを開く" - }, - "closeCabinets": { - "title": "閉じる", - "aria-label": "閉じる" - }, + "openCabinets": "ファイルキャビネットリストを開く", + "closeCabinets": "閉じる", "bootstrapFailed": "ファイル キャビネットの初期化に失敗しました: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index b41c378ae..c04808963 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -780,6 +780,7 @@ "title": "홈", "description": "fount 체험의 핵심입니다. 여기에서 캐릭터, 세계, 페르소나 등 상상 속 인물을 관리하세요. 여러분의 이야기는 이 중심 연결점에서 시작되고 분기됩니다.", "sidebarTitle": "세부 정보", + "closeSidebar": "Close sidebar", "itemDescription": "자세한 내용을 보려면 여기에서 항목을 선택하세요.", "noDescription": "설명이 없습니다.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "폐쇄" }, + "close": "폐쇄", "serverBar": { "aria-label": "그룹 및 탐색" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "멘션 제안" }, - "mediaButton": { - "title": "미디어 추가", - "aria-label": "미디어 추가" - }, + "mediaButton": "미디어 추가", "pollButton": { "title": "투표 시작", "aria-label": "투표 시작" @@ -4714,14 +4713,8 @@ "title": "항목 목록", "aria-label": "항목 목록" }, - "openCabinets": { - "title": "파일 캐비닛 목록 열기", - "aria-label": "파일 캐비닛 목록 열기" - }, - "closeCabinets": { - "title": "폐쇄", - "aria-label": "폐쇄" - }, + "openCabinets": "파일 캐비닛 목록 열기", + "closeCabinets": "폐쇄", "bootstrapFailed": "파일 캐비닛 초기화 실패: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index b23ac3397..e00b0231c 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -780,6 +780,7 @@ "title": "門戶", "description": "此乃泉源之中樞,君之奇思妙想,化為人物、世界、身份,皆由此生發。", "sidebarTitle": "詳覽", + "closeSidebar": "Close sidebar", "itemDescription": "請擇一物以觀其詳。", "noDescription": "無述", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "闔" }, + "close": "闔", "serverBar": { "aria-label": "群與導覽" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "呼名候選" }, - "mediaButton": { - "title": "添媒體", - "aria-label": "添媒體" - }, + "mediaButton": "添媒體", "pollButton": { "title": "發起投票", "aria-label": "發起投票" @@ -4714,14 +4713,8 @@ "title": "卷目", "aria-label": "卷目" }, - "openCabinets": { - "title": "啟藏經閣名錄", - "aria-label": "啟藏經閣名錄" - }, - "closeCabinets": { - "title": "闔", - "aria-label": "闔" - }, + "openCabinets": "啟藏經閣名錄", + "closeCabinets": "闔", "bootstrapFailed": "藏經閣初始化未遂:${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index b7432383c..faec55517 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -780,6 +780,7 @@ "title": "Startpagina", "description": "Het hart van uw font-ervaring. Beheer hier de bewoners van je verbeelding: personages, werelden en persona's. Jouw verhalen beginnen en vertakken zich vanuit dit centrale knooppunt.", "sidebarTitle": "Details", + "closeSidebar": "Close sidebar", "itemDescription": "Selecteer hier een item om de details te bekijken.", "noDescription": "Geen beschrijving", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Sluiten" }, + "close": "Sluiten", "serverBar": { "aria-label": "Groepen en navigatie" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Vermeldingssuggesties" }, - "mediaButton": { - "title": "Media toevoegen", - "aria-label": "Media toevoegen" - }, + "mediaButton": "Media toevoegen", "pollButton": { "title": "Start een stemming", "aria-label": "Start een stemming" @@ -4714,14 +4713,8 @@ "title": "Items", "aria-label": "Items" }, - "openCabinets": { - "title": "Archiefkastlijst openen", - "aria-label": "Archiefkastlijst openen" - }, - "closeCabinets": { - "title": "Sluiten", - "aria-label": "Sluiten" - }, + "openCabinets": "Archiefkastlijst openen", + "closeCabinets": "Sluiten", "bootstrapFailed": "Initialisatie van Cabinet mislukt: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index 841abbba7..6764f6b66 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -780,6 +780,7 @@ "title": "Início", "description": "O coração da sua experiência com o fount. Aqui, faça a gestão dos habitantes da sua imaginação – personagens, mundos e personas. As suas histórias começam e ramificam-se a partir deste nexo central.", "sidebarTitle": "Detalhes", + "closeSidebar": "Close sidebar", "itemDescription": "Selecione um item aqui para ver os detalhes.", "noDescription": "Sem descrição.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Encerramento" }, + "close": "Encerramento", "serverBar": { "aria-label": "Grupos e navegação" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Sugestões de menção" }, - "mediaButton": { - "title": "Adicionar media", - "aria-label": "Adicionar media" - }, + "mediaButton": "Adicionar media", "pollButton": { "title": "Iniciar uma votação", "aria-label": "Iniciar uma votação" @@ -4714,14 +4713,8 @@ "title": "Lista de itens", "aria-label": "Lista de itens" }, - "openCabinets": { - "title": "Abrir lista de armários", - "aria-label": "Abrir lista de armários" - }, - "closeCabinets": { - "title": "Encerramento", - "aria-label": "Encerramento" - }, + "openCabinets": "Abrir lista de armários", + "closeCabinets": "Encerramento", "bootstrapFailed": "Falha na inicialização do armário: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index de787ccae..9c8bd20ec 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -780,6 +780,7 @@ "title": "Главная", "description": "Сердце вашего опыта работы С fount. Здесь управляйте обитателями своего воображения — персонажами, мирами и личностями. Ваши истории начинаются и разветвляются из этой центральной точки.", "sidebarTitle": "Подробности", + "closeSidebar": "Close sidebar", "itemDescription": "Выберите элемент для просмотра подробностей.", "noDescription": "Описание отсутствует.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Закрыть" }, + "close": "Закрыть", "serverBar": { "aria-label": "Группы и навигация" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Подсказки упоминаний" }, - "mediaButton": { - "title": "Добавить медиа", - "aria-label": "Добавить медиа" - }, + "mediaButton": "Добавить медиа", "pollButton": { "title": "Создать опрос", "aria-label": "Создать опрос" @@ -4714,14 +4713,8 @@ "title": "Список элементов", "aria-label": "Список элементов" }, - "openCabinets": { - "title": "Открыть список шкафов", - "aria-label": "Открыть список шкафов" - }, - "closeCabinets": { - "title": "Закрыть", - "aria-label": "Закрыть" - }, + "openCabinets": "Открыть список шкафов", + "closeCabinets": "Закрыть", "bootstrapFailed": "Не удалось инициализировать файловый шкаф: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index 3814bb25d..ab8241917 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -780,6 +780,7 @@ "title": "Головна", "description": "Серце вашого досвіду fount. Тут керуйте мешканцями своєї уяви — персонажами, світами та персонажами. Ваші історії починаються і розгалужуються з цього центрального зв’язку.", "sidebarTitle": "Деталі", + "closeSidebar": "Close sidebar", "itemDescription": "Оберіть елемент, щоб переглянути деталі.", "noDescription": "Немає опису", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "Закрити" }, + "close": "Закрити", "serverBar": { "aria-label": "Групи та навігація" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Підказки згадок" }, - "mediaButton": { - "title": "Додайте медіа", - "aria-label": "Додайте медіа" - }, + "mediaButton": "Додайте медіа", "pollButton": { "title": "Ініціюйте голосування", "aria-label": "Ініціюйте голосування" @@ -4714,14 +4713,8 @@ "title": "Список елементів", "aria-label": "Список елементів" }, - "openCabinets": { - "title": "Відкрити список картотеки", - "aria-label": "Відкрити список картотеки" - }, - "closeCabinets": { - "title": "Закрити", - "aria-label": "Закрити" - }, + "openCabinets": "Відкрити список картотеки", + "closeCabinets": "Закрити", "bootstrapFailed": "Помилка ініціалізації картотеки: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index 33a5eeba2..43eecdcf8 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -780,6 +780,7 @@ "title": "Trang chủ", "description": "Đây là điểm khởi đầu cho trải nghiệm fount của bạn. Dù là những nhân vật sống động, thế giới rộng lớn hay danh tính độc đáo, mọi thứ trong vương quốc tưởng tượng đều nằm trong tay bạn. Mọi câu chuyện sẽ bắt đầu từ đây.", "sidebarTitle": "Chi tiết", + "closeSidebar": "Close sidebar", "itemDescription": "Chọn một mục ở đây để xem chi tiết.", "noDescription": "Không có mô tả.", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "đóng cửa" }, + "close": "đóng cửa", "serverBar": { "aria-label": "Nhóm và điều hướng" }, @@ -4188,10 +4190,7 @@ "mentionSuggest": { "aria-label": "Gợi ý nhắc đến" }, - "mediaButton": { - "title": "Thêm phương tiện", - "aria-label": "Thêm phương tiện" - }, + "mediaButton": "Thêm phương tiện", "pollButton": { "title": "Bắt đầu bỏ phiếu", "aria-label": "Bắt đầu bỏ phiếu" @@ -4714,14 +4713,8 @@ "title": "Danh sách mục", "aria-label": "Danh sách mục" }, - "openCabinets": { - "title": "Mở danh sách tủ hồ sơ", - "aria-label": "Mở danh sách tủ hồ sơ" - }, - "closeCabinets": { - "title": "đóng cửa", - "aria-label": "đóng cửa" - }, + "openCabinets": "Mở danh sách tủ hồ sơ", + "closeCabinets": "đóng cửa", "bootstrapFailed": "Khởi tạo nội dung tệp không thành công: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index d41916e30..5703eab62 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -780,6 +780,7 @@ "title": "主页", "description": "这里是您 fount 体验的起点。无论是鲜活的角色、宏大的世界,还是独特的身份,您想象国度中的一切尽在掌握。所有故事,将从这里开始。", "sidebarTitle": "详情", + "closeSidebar": "关闭侧栏", "itemDescription": "在此处选择一个项目以查看详细信息。", "noDescription": "无描述信息", "filterInput": { @@ -1964,6 +1965,7 @@ "ariaClose": { "aria-label": "关闭" }, + "close": "关闭", "serverBar": { "aria-label": "群组与导航" }, @@ -4194,10 +4196,7 @@ "mentionSuggest": { "aria-label": "提及建议" }, - "mediaButton": { - "title": "添加媒体", - "aria-label": "添加媒体" - }, + "mediaButton": "添加媒体", "pollButton": { "title": "发起投票", "aria-label": "发起投票" @@ -4726,14 +4725,8 @@ "title": "内容列表", "aria-label": "内容列表" }, - "openCabinets": { - "title": "打开文件柜列表", - "aria-label": "打开文件柜列表" - }, - "closeCabinets": { - "title": "关闭", - "aria-label": "关闭" - }, + "openCabinets": "打开文件柜列表", + "closeCabinets": "关闭", "bootstrapFailed": "文件柜初始化失败:${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index f4521d70e..f22c2c90c 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -779,6 +779,7 @@ "title": "首頁", "description": "這裡是您 fount 體驗的起點。無論是鮮活的角色、宏偉的世界,還是獨特的身分,您想像國度中的一切盡在掌握。所有故事,將從這裡開始。", "sidebarTitle": "詳情", + "closeSidebar": "Close sidebar", "itemDescription": "在此處選擇一個項目以檢視詳細資訊。", "noDescription": "無描述資訊", "filterInput": { @@ -1963,6 +1964,7 @@ "ariaClose": { "aria-label": "關閉" }, + "close": "關閉", "serverBar": { "aria-label": "群組與導覽" }, @@ -4187,10 +4189,7 @@ "mentionSuggest": { "aria-label": "提及建議" }, - "mediaButton": { - "title": "新增媒體", - "aria-label": "新增媒體" - }, + "mediaButton": "新增媒體", "pollButton": { "title": "發起投票", "aria-label": "發起投票" @@ -4713,14 +4712,8 @@ "title": "內容列表", "aria-label": "內容列表" }, - "openCabinets": { - "title": "開啟文件櫃列表", - "aria-label": "開啟文件櫃列表" - }, - "closeCabinets": { - "title": "關閉", - "aria-label": "關閉" - }, + "openCabinets": "開啟文件櫃列表", + "closeCabinets": "關閉", "bootstrapFailed": "文件櫃初始化失敗:${error}", "home_function_buttons": { "main": { From bbf405192889cd3a3d65fab38a8630cc4894015f Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 22:57:24 +0800 Subject: [PATCH 11/13] 1 --- src/public/locales/ar-SA.json | 2 +- src/public/locales/de-DE.json | 4 ++-- src/public/locales/emoji.json | 2 +- src/public/locales/es-ES.json | 4 ++-- src/public/locales/fr-FR.json | 2 +- src/public/locales/hi-IN.json | 2 +- src/public/locales/is-IS.json | 2 +- src/public/locales/it-IT.json | 16 ++++++++-------- src/public/locales/ja-JP.json | 2 +- src/public/locales/ko-KR.json | 18 +++++++++--------- src/public/locales/lzh.json | 2 +- src/public/locales/nl-NL.json | 2 +- src/public/locales/pt-PT.json | 16 ++++++++-------- src/public/locales/ru-RU.json | 2 +- src/public/locales/uk-UA.json | 2 +- src/public/locales/vi-VN.json | 8 ++++---- src/public/locales/zh-CN.json | 2 +- src/public/locales/zh-TW.json | 2 +- 18 files changed, 45 insertions(+), 45 deletions(-) diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 5c985461b..108e97509 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -780,7 +780,7 @@ "title": "الصفحة الرئيسية", "description": "قلب تجربتك النابضة. هنا، قم بإدارة سكان خيالك - الشخصيات والعوالم والشخصيات. تبدأ قصصك وتتفرع من هذه الرابطة المركزية.", "sidebarTitle": "التفاصيل", - "closeSidebar": "Close sidebar", + "closeSidebar": "إغلاق الشريط الجانبي", "itemDescription": "اختر عنصرًا هنا لعرض التفاصيل.", "noDescription": "لا يوجد وصف", "filterInput": { diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index 5ed42136d..4d5ad4ef8 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -780,7 +780,7 @@ "title": "Startseite", "description": "Das Herzstück Ihres fount-Erlebnisses. Verwalten Sie hier die Bewohner Ihrer Fantasie – Charaktere, Welten und Personas. Ihre Geschichten beginnen und verzweigen sich in diesem zentralen Zusammenhang.", "sidebarTitle": "Details", - "closeSidebar": "Close sidebar", + "closeSidebar": "Seitenleiste schließen", "itemDescription": "Wählen Sie ein Element aus, um Details anzuzeigen.", "noDescription": "Keine Beschreibung verfügbar", "filterInput": { @@ -4713,7 +4713,7 @@ "title": "Einträge", "aria-label": "Einträge" }, - "openCabinets": "Cabinetliste öffnen", + "openCabinets": "Aktenschrankliste öffnen", "closeCabinets": "Schließen", "bootstrapFailed": "Die Initialisierung des Archivs ist fehlgeschlagen: ${error}", "home_function_buttons": { diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 74ab586d4..35e739115 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -780,7 +780,7 @@ "title": "🏠", "description": "❤️🏛️🎭🌍🧑‍🎨", "sidebarTitle": "ℹ️", - "closeSidebar": "Close sidebar", + "closeSidebar": "✖️ℹ️", "itemDescription": "👆➡️ℹ️", "noDescription": "🤷‍♂️", "filterInput": { diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index 7d4b7fb63..05b73202f 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -780,7 +780,7 @@ "title": "Inicio", "description": "El corazón de tu experiencia en fount. Aquí, gestiona los habitantes de tu imaginación: personajes, mundos y personas. Tus historias comienzan y se ramifican a partir de este nexo central.", "sidebarTitle": "Detalles", - "closeSidebar": "Close sidebar", + "closeSidebar": "Cerrar panel lateral", "itemDescription": "Selecciona un elemento para ver los detalles.", "noDescription": "Sin descripción.", "filterInput": { @@ -4713,7 +4713,7 @@ "title": "Lista de elementos", "aria-label": "Lista de elementos" }, - "openCabinets": "Abrir lista de archivoes", + "openCabinets": "Abrir lista de archivadores", "closeCabinets": "Cerrar", "bootstrapFailed": "Error al inicializar el archivo: ${error}", "home_function_buttons": { diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index 37dbbe511..9512c336c 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -780,7 +780,7 @@ "title": "Accueil", "description": "Le cœur de votre expérience de fount. Ici, gérez les habitants de votre imagination : personnages, mondes et personnages. Vos histoires commencent et partent de ce lien central.", "sidebarTitle": "Détails", - "closeSidebar": "Close sidebar", + "closeSidebar": "Fermer le panneau latéral", "itemDescription": "Sélectionnez un élément pour en voir les détails.", "noDescription": "Aucune description disponible.", "filterInput": { diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index 734c45a44..14fca3147 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -780,7 +780,7 @@ "title": "होम", "description": "आपके मूल अनुभव का हृदय. यहां, अपनी कल्पना के निवासियों-पात्रों, दुनियाओं और व्यक्तित्वों को प्रबंधित करें। आपकी कहानियाँ इसी केंद्रीय गठजोड़ से शुरू होती हैं और शाखाबद्ध होती हैं।", "sidebarTitle": "विवरण", - "closeSidebar": "Close sidebar", + "closeSidebar": "साइडबार बंद करें", "itemDescription": "विवरण देखने के लिए यहाँ एक आइटम चुनें।", "noDescription": "कोई विवरण नहीं।", "filterInput": { diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index df3134aec..d525684bd 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -780,7 +780,7 @@ "title": "Forsíða", "description": "Hjarta upplifunar þinnar. Hér skaltu stjórna þegnum ímyndunarafls þíns – persónur, heima og persónur. Sögur þínar byrja og greinast frá þessu miðlæga samhengi.", "sidebarTitle": "Nánar", - "closeSidebar": "Close sidebar", + "closeSidebar": "Loka hliðarstiku", "itemDescription": "Veldu atriði hér til að sjá nánari upplýsingar.", "noDescription": "Engin lýsing", "filterInput": { diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 5a693ad58..020a6b10d 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -780,7 +780,7 @@ "title": "Home", "description": "Il cuore della tua esperienza di fount. Qui, gestisci gli abitanti della tua immaginazione: personaggi, mondi e personaggi. Le tue storie iniziano e si diramano da questo nesso centrale.", "sidebarTitle": "Dettagli", - "closeSidebar": "Close sidebar", + "closeSidebar": "Chiudi barra laterale", "itemDescription": "Seleziona un elemento per visualizzarne i dettagli.", "noDescription": "Nessuna descrizione disponibile.", "filterInput": { @@ -1754,7 +1754,7 @@ "suppressEveryone": "Ignora @tutti e @qui", "suppressRoles": "Ignora il gruppo di ruoli @", "mute": "muto", - "muteOff": "Vicino", + "muteOff": "Disattiva", "mute1h": "1 ora", "mute8h": "8 ore", "muteForever": "fino all'accensione manuale", @@ -1914,7 +1914,7 @@ "typing": "${names} sta scrivendo...", "charsHeader": "Ruolo", "settingsModalTitle": "Impostazioni", - "modalClose": "Vicino", + "modalClose": "Chiudi", "cancel": "Annulla", "bookmarkLocal": "locale", "groupsSection": "gruppo", @@ -1963,9 +1963,9 @@ "aria-label": "Punta della forcella DAG" }, "ariaClose": { - "aria-label": "Vicino" + "aria-label": "Chiudi" }, - "close": "Vicino", + "close": "Chiudi", "serverBar": { "aria-label": "Gruppi e navigazione" }, @@ -4536,7 +4536,7 @@ }, "dialog": { "close": { - "aria-label": "Vicino" + "aria-label": "Chiudi" } }, "actions": { @@ -4714,7 +4714,7 @@ "aria-label": "Elenco elementi" }, "openCabinets": "Apri l'elenco degli schedari", - "closeCabinets": "Vicino", + "closeCabinets": "Chiudi", "bootstrapFailed": "Inizializzazione schedario non riuscita: ${error}", "home_function_buttons": { "main": { @@ -5664,7 +5664,7 @@ "save": "Salva", "delete": "Eliminare", "confirm": "Confermare", - "close": "Vicino", + "close": "Chiudi", "translate": { "label": "Traduzione:", "showOriginal": "Mostra originale", diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index 7598ca41e..8e365b2a3 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -780,7 +780,7 @@ "title": "ホーム", "description": "あなたのfount体験の中心。ここでは、想像の世界の住人であるキャラクター、世界、ペルソナを管理します。あなたの物語は、この中心的なつながりから始まり、分岐していきます。", "sidebarTitle": "詳細", - "closeSidebar": "Close sidebar", + "closeSidebar": "サイドバーを閉じる", "itemDescription": "項目を選択すると、ここに詳細が表示されます。", "noDescription": "説明はありません。", "filterInput": { diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index c04808963..8d42ed809 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -780,7 +780,7 @@ "title": "홈", "description": "fount 체험의 핵심입니다. 여기에서 캐릭터, 세계, 페르소나 등 상상 속 인물을 관리하세요. 여러분의 이야기는 이 중심 연결점에서 시작되고 분기됩니다.", "sidebarTitle": "세부 정보", - "closeSidebar": "Close sidebar", + "closeSidebar": "사이드바 닫기", "itemDescription": "자세한 내용을 보려면 여기에서 항목을 선택하세요.", "noDescription": "설명이 없습니다.", "filterInput": { @@ -1754,7 +1754,7 @@ "suppressEveryone": "@everyone 및 @here 무시", "suppressRoles": "역할 그룹 @ 무시", "mute": "음소거", - "muteOff": "폐쇄", + "muteOff": "끄기", "mute1h": "1시간", "mute8h": "8시간", "muteForever": "수동으로 켜질 때까지", @@ -1914,7 +1914,7 @@ "typing": "${names}님이 입력 중입니다...", "charsHeader": "역할", "settingsModalTitle": "설정", - "modalClose": "폐쇄", + "modalClose": "닫기", "cancel": "취소", "bookmarkLocal": "로컬", "groupsSection": "그룹", @@ -1963,9 +1963,9 @@ "aria-label": "DAG 포크 팁" }, "ariaClose": { - "aria-label": "폐쇄" + "aria-label": "닫기" }, - "close": "폐쇄", + "close": "닫기", "serverBar": { "aria-label": "그룹 및 탐색" }, @@ -3097,7 +3097,7 @@ "upload": "스티커 업로드", "install": "설치", "uninstall": "제거", - "close": "폐쇄", + "close": "닫기", "cancel": "취소", "installed": "설치됨", "myPacks": "내 스티커 팩", @@ -4536,7 +4536,7 @@ }, "dialog": { "close": { - "aria-label": "폐쇄" + "aria-label": "닫기" } }, "actions": { @@ -4714,7 +4714,7 @@ "aria-label": "항목 목록" }, "openCabinets": "파일 캐비닛 목록 열기", - "closeCabinets": "폐쇄", + "closeCabinets": "닫기", "bootstrapFailed": "파일 캐비닛 초기화 실패: ${error}", "home_function_buttons": { "main": { @@ -5664,7 +5664,7 @@ "save": "저장", "delete": "삭제", "confirm": "확인", - "close": "폐쇄", + "close": "닫기", "translate": { "label": "번역:", "showOriginal": "원문 보기", diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index e00b0231c..603900d77 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -780,7 +780,7 @@ "title": "門戶", "description": "此乃泉源之中樞,君之奇思妙想,化為人物、世界、身份,皆由此生發。", "sidebarTitle": "詳覽", - "closeSidebar": "Close sidebar", + "closeSidebar": "阖侧栏", "itemDescription": "請擇一物以觀其詳。", "noDescription": "無述", "filterInput": { diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index faec55517..8f5cb7648 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -780,7 +780,7 @@ "title": "Startpagina", "description": "Het hart van uw font-ervaring. Beheer hier de bewoners van je verbeelding: personages, werelden en persona's. Jouw verhalen beginnen en vertakken zich vanuit dit centrale knooppunt.", "sidebarTitle": "Details", - "closeSidebar": "Close sidebar", + "closeSidebar": "Zijbalk sluiten", "itemDescription": "Selecteer hier een item om de details te bekijken.", "noDescription": "Geen beschrijving", "filterInput": { diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index 6764f6b66..fcc05dc39 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -780,7 +780,7 @@ "title": "Início", "description": "O coração da sua experiência com o fount. Aqui, faça a gestão dos habitantes da sua imaginação – personagens, mundos e personas. As suas histórias começam e ramificam-se a partir deste nexo central.", "sidebarTitle": "Detalhes", - "closeSidebar": "Close sidebar", + "closeSidebar": "Fechar barra lateral", "itemDescription": "Selecione um item aqui para ver os detalhes.", "noDescription": "Sem descrição.", "filterInput": { @@ -1754,7 +1754,7 @@ "suppressEveryone": "Ignore @todos e @aqui", "suppressRoles": "Ignorar grupo de funções @", "mute": "Silenciar", - "muteOff": "Encerramento", + "muteOff": "Desativar", "mute1h": "1 hora", "mute8h": "8 horas", "muteForever": "Até ser ligado manualmente", @@ -1914,7 +1914,7 @@ "typing": "${names} está digitando...", "charsHeader": "Papel", "settingsModalTitle": "Configurar", - "modalClose": "Encerramento", + "modalClose": "Fechar", "cancel": "Cancelar", "bookmarkLocal": "Local", "groupsSection": "Grupo", @@ -1963,9 +1963,9 @@ "aria-label": "Ponta do garfo DAG" }, "ariaClose": { - "aria-label": "Encerramento" + "aria-label": "Fechar" }, - "close": "Encerramento", + "close": "Fechar", "serverBar": { "aria-label": "Grupos e navegação" }, @@ -4536,7 +4536,7 @@ }, "dialog": { "close": { - "aria-label": "Encerramento" + "aria-label": "Fechar" } }, "actions": { @@ -4714,7 +4714,7 @@ "aria-label": "Lista de itens" }, "openCabinets": "Abrir lista de armários", - "closeCabinets": "Encerramento", + "closeCabinets": "Fechar", "bootstrapFailed": "Falha na inicialização do armário: ${error}", "home_function_buttons": { "main": { @@ -5664,7 +5664,7 @@ "save": "Guardar", "delete": "Eliminar", "confirm": "Confirmar", - "close": "Encerramento", + "close": "Fechar", "translate": { "label": "Tradução:", "showOriginal": "Mostrar original", diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index 9c8bd20ec..804e1a4b4 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -780,7 +780,7 @@ "title": "Главная", "description": "Сердце вашего опыта работы С fount. Здесь управляйте обитателями своего воображения — персонажами, мирами и личностями. Ваши истории начинаются и разветвляются из этой центральной точки.", "sidebarTitle": "Подробности", - "closeSidebar": "Close sidebar", + "closeSidebar": "Закрыть боковую панель", "itemDescription": "Выберите элемент для просмотра подробностей.", "noDescription": "Описание отсутствует.", "filterInput": { diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index ab8241917..081422aab 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -780,7 +780,7 @@ "title": "Головна", "description": "Серце вашого досвіду fount. Тут керуйте мешканцями своєї уяви — персонажами, світами та персонажами. Ваші історії починаються і розгалужуються з цього центрального зв’язку.", "sidebarTitle": "Деталі", - "closeSidebar": "Close sidebar", + "closeSidebar": "Закрити бічну панель", "itemDescription": "Оберіть елемент, щоб переглянути деталі.", "noDescription": "Немає опису", "filterInput": { diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index 43eecdcf8..6805c7e0e 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -780,7 +780,7 @@ "title": "Trang chủ", "description": "Đây là điểm khởi đầu cho trải nghiệm fount của bạn. Dù là những nhân vật sống động, thế giới rộng lớn hay danh tính độc đáo, mọi thứ trong vương quốc tưởng tượng đều nằm trong tay bạn. Mọi câu chuyện sẽ bắt đầu từ đây.", "sidebarTitle": "Chi tiết", - "closeSidebar": "Close sidebar", + "closeSidebar": "Đóng thanh bên", "itemDescription": "Chọn một mục ở đây để xem chi tiết.", "noDescription": "Không có mô tả.", "filterInput": { @@ -1963,9 +1963,9 @@ "aria-label": "Đầu nĩa DAG" }, "ariaClose": { - "aria-label": "đóng cửa" + "aria-label": "Đóng" }, - "close": "đóng cửa", + "close": "Đóng", "serverBar": { "aria-label": "Nhóm và điều hướng" }, @@ -4714,7 +4714,7 @@ "aria-label": "Danh sách mục" }, "openCabinets": "Mở danh sách tủ hồ sơ", - "closeCabinets": "đóng cửa", + "closeCabinets": "Đóng", "bootstrapFailed": "Khởi tạo nội dung tệp không thành công: ${error}", "home_function_buttons": { "main": { diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index 5703eab62..23d84cd02 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -780,7 +780,7 @@ "title": "主页", "description": "这里是您 fount 体验的起点。无论是鲜活的角色、宏大的世界,还是独特的身份,您想象国度中的一切尽在掌握。所有故事,将从这里开始。", "sidebarTitle": "详情", - "closeSidebar": "关闭侧栏", + "closeSidebar": "关闭侧边栏", "itemDescription": "在此处选择一个项目以查看详细信息。", "noDescription": "无描述信息", "filterInput": { diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index f22c2c90c..7d5b58c50 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -779,7 +779,7 @@ "title": "首頁", "description": "這裡是您 fount 體驗的起點。無論是鮮活的角色、宏偉的世界,還是獨特的身分,您想像國度中的一切盡在掌握。所有故事,將從這裡開始。", "sidebarTitle": "詳情", - "closeSidebar": "Close sidebar", + "closeSidebar": "關閉側邊欄", "itemDescription": "在此處選擇一個項目以檢視詳細資訊。", "noDescription": "無描述資訊", "filterInput": { From db4606455e69f80b3588db918bb3d8d718478b8e Mon Sep 17 00:00:00 2001 From: steve02081504 <steve02081504@EDEN.fukwold> Date: Fri, 7 Aug 2026 23:15:16 +0800 Subject: [PATCH 12/13] 1 --- src/public/locales/ar-SA.json | 1 - src/public/locales/de-DE.json | 1 - src/public/locales/emoji.json | 1 - src/public/locales/en-UK.json | 1 - src/public/locales/es-ES.json | 1 - src/public/locales/fr-FR.json | 1 - src/public/locales/hi-IN.json | 1 - src/public/locales/is-IS.json | 1 - src/public/locales/it-IT.json | 1 - src/public/locales/ja-JP.json | 1 - src/public/locales/ko-KR.json | 1 - src/public/locales/lzh.json | 3 +-- src/public/locales/nl-NL.json | 1 - src/public/locales/pt-PT.json | 1 - src/public/locales/ru-RU.json | 1 - src/public/locales/uk-UA.json | 1 - src/public/locales/vi-VN.json | 1 - src/public/locales/zh-CN.json | 1 - src/public/locales/zh-TW.json | 1 - .../shells/chat/public/hub/chatConfig.mjs | 18 ++++++------------ .../parts/shells/chat/public/hub/index.html | 4 ++-- 21 files changed, 9 insertions(+), 34 deletions(-) diff --git a/src/public/locales/ar-SA.json b/src/public/locales/ar-SA.json index 108e97509..7ff7b70b3 100644 --- a/src/public/locales/ar-SA.json +++ b/src/public/locales/ar-SA.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "إغلاق" }, - "close": "إغلاق", "serverBar": { "aria-label": "المجموعات والتنقل" }, diff --git a/src/public/locales/de-DE.json b/src/public/locales/de-DE.json index 4d5ad4ef8..ad1f327ba 100644 --- a/src/public/locales/de-DE.json +++ b/src/public/locales/de-DE.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Schließen" }, - "close": "Schließen", "serverBar": { "aria-label": "Gruppen und Navigation" }, diff --git a/src/public/locales/emoji.json b/src/public/locales/emoji.json index 35e739115..6d2f33668 100644 --- a/src/public/locales/emoji.json +++ b/src/public/locales/emoji.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "✖️" }, - "close": "✖️", "serverBar": { "aria-label": "👥🧭" }, diff --git a/src/public/locales/en-UK.json b/src/public/locales/en-UK.json index f2f23928e..e6f70eaba 100644 --- a/src/public/locales/en-UK.json +++ b/src/public/locales/en-UK.json @@ -1960,7 +1960,6 @@ "ariaClose": { "aria-label": "Close" }, - "close": "Close", "serverBar": { "aria-label": "Groups and navigation" }, diff --git a/src/public/locales/es-ES.json b/src/public/locales/es-ES.json index 05b73202f..0f2ba059e 100644 --- a/src/public/locales/es-ES.json +++ b/src/public/locales/es-ES.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Cerrar" }, - "close": "Cerrar", "serverBar": { "aria-label": "Grupos y navegación" }, diff --git a/src/public/locales/fr-FR.json b/src/public/locales/fr-FR.json index 9512c336c..219a25730 100644 --- a/src/public/locales/fr-FR.json +++ b/src/public/locales/fr-FR.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Fermer" }, - "close": "Fermer", "serverBar": { "aria-label": "Groupes et navigation" }, diff --git a/src/public/locales/hi-IN.json b/src/public/locales/hi-IN.json index 14fca3147..f3a4c84ff 100644 --- a/src/public/locales/hi-IN.json +++ b/src/public/locales/hi-IN.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "बंद करें" }, - "close": "बंद करें", "serverBar": { "aria-label": "समूह और नेविगेशन" }, diff --git a/src/public/locales/is-IS.json b/src/public/locales/is-IS.json index d525684bd..61ae23758 100644 --- a/src/public/locales/is-IS.json +++ b/src/public/locales/is-IS.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Loka" }, - "close": "Loka", "serverBar": { "aria-label": "Hópar og leiðsögn" }, diff --git a/src/public/locales/it-IT.json b/src/public/locales/it-IT.json index 020a6b10d..9a6aff2ba 100644 --- a/src/public/locales/it-IT.json +++ b/src/public/locales/it-IT.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Chiudi" }, - "close": "Chiudi", "serverBar": { "aria-label": "Gruppi e navigazione" }, diff --git a/src/public/locales/ja-JP.json b/src/public/locales/ja-JP.json index 8e365b2a3..6f0364dad 100644 --- a/src/public/locales/ja-JP.json +++ b/src/public/locales/ja-JP.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "閉じる" }, - "close": "閉じる", "serverBar": { "aria-label": "グループとナビゲーション" }, diff --git a/src/public/locales/ko-KR.json b/src/public/locales/ko-KR.json index 8d42ed809..8c7d01ce0 100644 --- a/src/public/locales/ko-KR.json +++ b/src/public/locales/ko-KR.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "닫기" }, - "close": "닫기", "serverBar": { "aria-label": "그룹 및 탐색" }, diff --git a/src/public/locales/lzh.json b/src/public/locales/lzh.json index 603900d77..a0cd2dfcb 100644 --- a/src/public/locales/lzh.json +++ b/src/public/locales/lzh.json @@ -780,7 +780,7 @@ "title": "門戶", "description": "此乃泉源之中樞,君之奇思妙想,化為人物、世界、身份,皆由此生發。", "sidebarTitle": "詳覽", - "closeSidebar": "阖侧栏", + "closeSidebar": "闔側欄", "itemDescription": "請擇一物以觀其詳。", "noDescription": "無述", "filterInput": { @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "闔" }, - "close": "闔", "serverBar": { "aria-label": "群與導覽" }, diff --git a/src/public/locales/nl-NL.json b/src/public/locales/nl-NL.json index 8f5cb7648..80824fde6 100644 --- a/src/public/locales/nl-NL.json +++ b/src/public/locales/nl-NL.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Sluiten" }, - "close": "Sluiten", "serverBar": { "aria-label": "Groepen en navigatie" }, diff --git a/src/public/locales/pt-PT.json b/src/public/locales/pt-PT.json index fcc05dc39..ea39fc51b 100644 --- a/src/public/locales/pt-PT.json +++ b/src/public/locales/pt-PT.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Fechar" }, - "close": "Fechar", "serverBar": { "aria-label": "Grupos e navegação" }, diff --git a/src/public/locales/ru-RU.json b/src/public/locales/ru-RU.json index 804e1a4b4..112db7c4d 100644 --- a/src/public/locales/ru-RU.json +++ b/src/public/locales/ru-RU.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Закрыть" }, - "close": "Закрыть", "serverBar": { "aria-label": "Группы и навигация" }, diff --git a/src/public/locales/uk-UA.json b/src/public/locales/uk-UA.json index 081422aab..aa437934c 100644 --- a/src/public/locales/uk-UA.json +++ b/src/public/locales/uk-UA.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Закрити" }, - "close": "Закрити", "serverBar": { "aria-label": "Групи та навігація" }, diff --git a/src/public/locales/vi-VN.json b/src/public/locales/vi-VN.json index 6805c7e0e..9b23047dd 100644 --- a/src/public/locales/vi-VN.json +++ b/src/public/locales/vi-VN.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "Đóng" }, - "close": "Đóng", "serverBar": { "aria-label": "Nhóm và điều hướng" }, diff --git a/src/public/locales/zh-CN.json b/src/public/locales/zh-CN.json index 23d84cd02..02272c8a4 100644 --- a/src/public/locales/zh-CN.json +++ b/src/public/locales/zh-CN.json @@ -1965,7 +1965,6 @@ "ariaClose": { "aria-label": "关闭" }, - "close": "关闭", "serverBar": { "aria-label": "群组与导航" }, diff --git a/src/public/locales/zh-TW.json b/src/public/locales/zh-TW.json index 7d5b58c50..225838b8c 100644 --- a/src/public/locales/zh-TW.json +++ b/src/public/locales/zh-TW.json @@ -1964,7 +1964,6 @@ "ariaClose": { "aria-label": "關閉" }, - "close": "關閉", "serverBar": { "aria-label": "群組與導覽" }, diff --git a/src/public/parts/shells/chat/public/hub/chatConfig.mjs b/src/public/parts/shells/chat/public/hub/chatConfig.mjs index 462cefe59..73e18fec4 100644 --- a/src/public/parts/shells/chat/public/hub/chatConfig.mjs +++ b/src/public/parts/shells/chat/public/hub/chatConfig.mjs @@ -68,21 +68,15 @@ export async function mountChatConfigPanel(groupId, channelId = 'default', optio listGroupPlugins(groupId), ]) - const charlist = Array.isArray(initial?.charlist) ? initial.charlist : [] - const pluginlist = activePlugins - const freqMap = initial?.frequency_data || {} - const worldname = initial?.worldname || '' - const personaname = initial?.personaname || '' - const availablePlugins = allPlugins.filter(p => !pluginlist.includes(p)) await mountTemplate(host, 'hub/config/panel_host', { phase: 'panel', - charlist, - pluginlist, - freqMap, + charlist: Array.isArray(initial?.charlist) ? initial.charlist : [], + pluginlist: activePlugins, + freqMap: initial?.frequency_data || {}, canEditWorldPlugins, - personaOptions: await buildSelectOptions(personas, personaname), - worldOptions: await buildSelectOptions(worlds, worldname), - availablePlugins, + personaOptions: await buildSelectOptions(personas, initial?.personaname || ''), + worldOptions: await buildSelectOptions(worlds, initial?.worldname || ''), + availablePlugins: allPlugins.filter(p => !activePlugins.includes(p)), }) document.getElementById('character-chat-persona')?.addEventListener('change', async (changeEvent) => { diff --git a/src/public/parts/shells/chat/public/hub/index.html b/src/public/parts/shells/chat/public/hub/index.html index e118cfd7b..962155e92 100644 --- a/src/public/parts/shells/chat/public/hub/index.html +++ b/src/public/parts/shells/chat/public/hub/index.html @@ -272,13 +272,13 @@ <h1 class="sr-only" data-i18n="chat.hub.title"></h1> <aside class="drawer-side z-50" aria-labelledby="files-title"> <label for="files-drawer-toggle" class="drawer-overlay"> - <span class="sr-only" data-i18n="chat.hub.close"></span> + <span class="sr-only" data-i18n="util.common.close"></span> </label> <div class="files-panel w-80 min-h-full bg-[var(--bg-channel)] border-l border-[var(--border)] p-4 flex flex-col gap-3 text-[var(--text-normal)]"> <header class="flex items-center justify-between gap-2"> <h2 id="files-title" class="font-bold text-lg" data-i18n="chat.hub.files.drawerTitle"></h2> <label for="files-drawer-toggle" class="btn btn-ghost btn-sm btn-circle"> - <span class="sr-only" data-i18n="chat.hub.close"></span> + <span class="sr-only" data-i18n="util.common.close"></span> <span aria-hidden="true">×</span> </label> </header> From 4fca0b8957cf36cd7704860ecb7926a2c91bf25a Mon Sep 17 00:00:00 2001 From: Taromati2 <taromati2@outlook.com> Date: Fri, 7 Aug 2026 15:16:40 +0000 Subject: [PATCH 13/13] file update~ --- src/decl/locale_data.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/decl/locale_data.ts b/src/decl/locale_data.ts index ca50154b7..8a2d4b51a 100644 --- a/src/decl/locale_data.ts +++ b/src/decl/locale_data.ts @@ -1933,7 +1933,6 @@ export type LocaleData = { ariaClose: { 'aria-label': string } - close: string serverBar: { 'aria-label': string }