diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml index 33f2a10b8..146e833c3 100644 --- a/.github/workflows/flatpak.yaml +++ b/.github/workflows/flatpak.yaml @@ -67,6 +67,9 @@ jobs: - name: Clone Ratspeak stack (rsReticulum, rsLXMF) env: WORKSPACE_ROOT: ${{ github.workspace }}/.rsstack + # Keep in sync with reticulum-sidecar.yaml / ratspeak-stack-ci-pins.env. + RS_RETICULUM_REF: 90774570eae7498554b4809d9203765368c5bbed + RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119 run: bash scripts/clone-ratspeak-stack.sh - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 diff --git a/.github/workflows/reticulum-sidecar.yaml b/.github/workflows/reticulum-sidecar.yaml index bad0e7672..406fa6431 100644 --- a/.github/workflows/reticulum-sidecar.yaml +++ b/.github/workflows/reticulum-sidecar.yaml @@ -3,6 +3,13 @@ name: Reticulum sidecar permissions: contents: read +# Stacked Nomad file/metadata work: pin siblings until upstream PRs merge, then float again. +# ratspeak/rsReticulum#26, ratspeak/rsLXMF#7 (rsNomad#7 merged — float). Keep in sync with +# scripts/ratspeak-stack-ci-pins.env and RATSPEAK_STACK_PR_ENTRIES in scripts/update.sh. +env: + RS_RETICULUM_REF: 90774570eae7498554b4809d9203765368c5bbed + RS_LXMF_REF: c3d8b44942e7726dbbe6bb53e0976d4c72134119 + on: workflow_dispatch: push: diff --git a/AGENTS.md b/AGENTS.md index e28e97c76..22cf777c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Adding a cross-boundary feature: **Local Linux CI (optional):** Container mode — `act:ci`, `act:tests`, `act:pr`, … (needs a Docker-compatible engine + act; Podman preferred). Host mode — `act:ci:native`, `act:tests:native`, … (no container engine). See [docs/ci-cd.md](docs/ci-cd.md). macOS/Windows packaging uses native `dist:mac` / `dist:win`. **`dist:mac`** / **`dist:mac:publish`** always run **`scripts/verify-mac-packaging.mjs`** (ZIP + DMG symlink asserts, no raw `.app` CI uploads). macOS signing env (`CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`, `CSC_IDENTITY_AUTO_DISCOVERY`) is scoped to **`macos-latest`** jobs in `release.yaml` / `build.yaml`; partial-secret validation fails the release job when `CSC_LINK` is set but notarization secrets are missing. -> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). +> **Update script sync:** When adding or removing packages from `patchedDependencies` in `pnpm-workspace.yaml`, keep `WATCH_ENTRIES` in `scripts/update.sh` in sync so the script warns on version changes to every patched dependency. When adding or removing Ratspeak overlays under `reticulum-sidecar/patches/`, keep `RATSPEAK_PATCH_ENTRIES` in `scripts/update.sh` (`check_ratspeak_patches`) in sync — `pnpm run update` queries upstream PRs (rsReticulum / rsLXMF) and warns when a local overlay can be removed. Stacked **feature** PRs that mesh-client CI pins (not overlays) live in `RATSPEAK_STACK_PR_ENTRIES` + `scripts/ratspeak-stack-ci-pins.env` (`check_ratspeak_stack_prs`) — today [rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) and [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7); clear pins when those merge. It also runs `check_ratspeak_upstream` (watched **published** releases for rsLXST / lrgp-rs / Ratspeak vs `reviewed-ref` pins, plus new `ratspeak` org repos) — keep `RATSPEAK_RELEASE_WATCH_ENTRIES` / `RATSPEAK_KNOWN_ORG_REPOS` in sync when adopting libs. LXMFace is not a published-release watch: its baseline is a vendored-file commit (`file:js/lxmface.js@`) compared with the latest GitHub commit that touched that file. `scripts/clone-ratspeak-stack.sh` floats **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`); overlays must apply or the clone fails. Ratspeak release watch uses stub-kind `games-parity` to nudge Games tab review when a published release is newer than the pin (`docs/reticulum-games-parity.md`). Peer default avatars use vendored **LXMFace** (`src/renderer/lib/reticulum/lxmface.ts`). `pnpm run update` also runs `rustup update` (or Homebrew `rust` on macOS without rustup) and `cargo build` in `reticulum-sidecar/` when `cargo` is on `PATH` (full-feature build includes `nomad-core` / rsNomad). **Pre-commit hook order:** diff --git a/docs/ci-cd.md b/docs/ci-cd.md index b8bd7c7df..fce1bac17 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -90,7 +90,7 @@ Path-filtered on `reticulum-sidecar/**` and related scripts: 1. **`lint` job (ubuntu-latest)** — `cargo fmt --check` + `cargo clippy` with `rns-stack,rns-ble,rns-rnode-tcp` (`-D warnings`) 2. **Build matrix** — stub + full-stack `cargo test` and release builds on Linux, macOS, and Windows (including WoA arm64 jobs) -CI and local **dev** clones float the `.rsstack/` workspace via `scripts/clone-ratspeak-stack.sh` to `origin/main` (overlays must apply; override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). **Release** packaging (`scripts/build-reticulum-sidecar-release.mjs`) runs the same clone and records the resolved commit SHAs for all five crates in `.rsstack/RESOLVED_SHAS.txt` so artifacts retain the exact source revisions used — pin via `RS_*_REF` when a release must not float. +CI and local **dev** clones float the `.rsstack/` workspace via `scripts/clone-ratspeak-stack.sh` to `origin/main` (overlays must apply; override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). When CI must build against open stacked ratspeak PRs, `scripts/ratspeak-stack-ci-pins.env` supplies defaults under `CI=true` (tracked by `RATSPEAK_STACK_PR_ENTRIES` in `scripts/update.sh`; see [reticulum-sidecar/patches/README.md](../reticulum-sidecar/patches/README.md#stacked-upstream-feature-prs-ci-pins)). **Release** packaging (`scripts/build-reticulum-sidecar-release.mjs`) runs the same clone and records the resolved commit SHAs for all five crates in `.rsstack/RESOLVED_SHAS.txt` so artifacts retain the exact source revisions used — pin via `RS_*_REF` when a release must not float. Local parity: `pnpm run reticulum:sidecar:clippy:full`, `pnpm run check:reticulum-sidecar` (pre-commit full-feature). See [development-environment.md](development-environment.md#reticulum-sidecar-optional). @@ -185,11 +185,14 @@ Automated dependency updates are configured in `.github/dependabot.yml`: - **GitHub Actions:** Grouped into one PR - **Open PRs:** `open-pull-requests-limit: 0` — Dependabot scans but does **not** open PRs. Dependency bumps are applied manually via `pnpm run update` (`scripts/update.sh`), which - also runs dedupe, Ratspeak overlay PR checks, and an upstream release / new-org-repo watch + also runs dedupe, Ratspeak overlay PR checks, stacked feature-PR pin watches + ([rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) ReplyFile, + [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7) multi-file attachments — see + `scripts/ratspeak-stack-ci-pins.env`), and an upstream release / new-org-repo watch (rsLXST, lrgp-rs, Ratspeak Games-parity when a newer published release exists, LXMFace `js/lxmface.js` commit). Sibling **rsReticulum** / **rsLXMF** / **rsNomad** / **rsLXST** / **lrgp-rs** float to `origin/main` via - `clone-ratspeak-stack.sh` (overlays must apply). See AGENTS.md §6. + `clone-ratspeak-stack.sh` (overlays must apply; CI may pin open stacked PRs). See AGENTS.md §6. ### Testing Dependabot PRs locally diff --git a/docs/reticulum.md b/docs/reticulum.md index 9128fab4c..edb3cbfb4 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -492,7 +492,7 @@ Firmware `.zip` files are selected locally (no in-app GitHub download). Disconne ## Building the sidecar (development) -`rns-stack` builds need the repo-local `.rsstack/` workspace checkouts `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. +`rns-stack` builds need the repo-local `.rsstack/` workspace checkouts `rsReticulum`, `rsLXMF`, `rsNomad`, `rsLXST`, and `lrgp-rs` (see `scripts/clone-ratspeak-stack.sh`). That script floats each to `origin/main` by default (bisect with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF`) and applies mesh-client overlays for rsReticulum/rsLXMF (fails if a patch will not apply). CI may pin open stacked feature PRs ([rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26), [rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7)) via `scripts/ratspeak-stack-ci-pins.env` — tracked by `pnpm run update`. Peer list / detail default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) (`src/renderer/lib/reticulum/lxmface.ts`) when no custom Lucide icon is set. End users of **GitHub Releases** or **Flatpak** do not need Rust. Developers and contributors do. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27a0d3934..03d594170 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,7 @@ overrides: markdown-it@<=14.1.1: '>=14.2.0 <15' postcss: ^8.5.25 shell-quote: ^1.9.0 + smol-toml: ^1.7.1 tar: ^7.5.18 tmp: ^0.2.6 undici@<7.29.0: ^7.29.0 @@ -4219,8 +4220,8 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - smol-toml@1.7.0: - resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} socks@2.8.10: @@ -7930,7 +7931,7 @@ snapshots: markdownlint: 0.41.1(supports-color@8.1.1) markdownlint-cli2-formatter-default: 0.0.6(markdownlint-cli2@0.23.2(supports-color@8.1.1)) micromatch: 4.0.8 - smol-toml: 1.7.0 + smol-toml: 1.8.0 transitivePeerDependencies: - supports-color @@ -8939,7 +8940,7 @@ snapshots: smart-buffer@4.2.0: {} - smol-toml@1.7.0: {} + smol-toml@1.8.0: {} socks@2.8.10: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7ac9e1166..0029bdf27 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,7 +59,7 @@ overrides: # GHSA-2883-xcg3-v3hh / GHSA-w5vr-8v7q-w6rv / GHSA-82fw-gwwq-j7x9 / # GHSA-4w3w-2rp5-g8jm / GHSA-w2rr-34g9-rvrj / GHSA-93r5-fhx6-vmg9 / # GHSA-8344-3jmq-59r6 / GHSA-6h8r-xr42-gp59 / GHSA-27p8-2357-5qqv / - # GHSA-c7q8-3ch8-vqpv). + # GHSA-c7q8-3ch8-vqpv / GHSA-7w5x-hrqm-74c2). # brace-expansion: keep a single 5.0.9 floor. GHSA-rgw5-rvv9-x895 is a # bypass of the CVE-2026-14257 mitigation and marks >=4.0.0 <5.0.9 vulnerable # (only >=5.0.9 counts as patched). CI audit is blocking. @@ -85,6 +85,7 @@ overrides: # baseline-browser-mapping: GHSA-w5vr-8v7q-w6rv — floor >=2.11.0. # vitest / @vitest/mocker: GHSA-82fw-gwwq-j7x9 — package.json pins vitest # ^4.1.11 (no override needed while the direct pin holds the floor). + # smol-toml: GHSA-7w5x-hrqm-74c2 (markdownlint-cli2) — floor >=1.7.1. # extract-zip (GHSA-7pqw-9j4j-h8q3): no patched release; Electron replaced it # with hardened @electron-internal/extract-zip (not the vulnerable package). # electron: deliberate major pin — bump with Flatpak sync + native rebuild smoke. @@ -100,6 +101,7 @@ overrides: markdown-it@<=14.1.1: '>=14.2.0 <15' postcss: ^8.5.25 shell-quote: ^1.9.0 + smol-toml: ^1.7.1 tar: ^7.5.18 tmp: ^0.2.6 undici@<7.29.0: ^7.29.0 diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index 9a17ece7c..fe4a3d41a 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -30,7 +30,6 @@ rns-stack = [ "dep:rns-crypto", "dep:argon2", "dep:zeroize", - "dep:rmpv", "dep:tempfile", "dep:notify", "dep:rusqlite", @@ -55,7 +54,8 @@ uuid = { version = "1", features = ["v4"] } hex = "0.4" sha2 = "0.10" tempfile = { version = "3", optional = true } -rmpv = { version = "1", optional = true } +# Always on: stub builds compile `nomad_file` metadata helpers (Resource name msgpack). +rmpv = "1" base64 = "0.22" bytes = "1" diff --git a/reticulum-sidecar/README.md b/reticulum-sidecar/README.md index e646c1224..6f47cd641 100644 --- a/reticulum-sidecar/README.md +++ b/reticulum-sidecar/README.md @@ -14,7 +14,7 @@ Install Rust (**1.85+**, edition 2024). Prefer [rustup](https://rustup.rs/). See ./scripts/clone-ratspeak-stack.sh ``` -That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` under `.rsstack/` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. +That floats `rsReticulum` / `rsLXMF` / `rsNomad` / `rsLXST` / `lrgp-rs` under `.rsstack/` to `origin/main` (override with `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` / `RS_LXST_REF` / `RS_LRGP_REF` for bisect). CI may temporarily pin rsReticulum / rsLXMF to open stacked PRs via `scripts/ratspeak-stack-ci-pins.env` (see [patches/README.md](patches/README.md#stacked-upstream-feature-prs-ci-pins); tracked by `pnpm run update`). Peer default avatars use [LXMFace](https://github.com/ratspeak/LXMFace) in the **renderer** (`src/renderer/lib/reticulum/lxmface.ts`), not this sidecar. **Default (stub stack)** — builds without `--features rns-stack`; Cargo still requires the `.rsstack/` checkouts on disk (CI runs `clone-ratspeak-stack.sh`; locally use the script above): diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index d1613d545..2f38f0c0e 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -4,6 +4,17 @@ Patches applied on top of [ratspeak/rsReticulum](https://github.com/ratspeak/rsR By default `scripts/clone-ratspeak-stack.sh` floats the `.rsstack/` checkouts to **`origin/main`** and applies these overlays (fails loud if a patch will not apply). Use `RS_RETICULUM_REF` / `RS_LXMF_REF` / `RS_NOMAD_REF` to pin a known-good SHA for bisect. Per-overlay **Base commit** tables below record the last regeneration baseline, not a permanent pin — when regenerating, prefer floated `origin/main` and record the short SHA in the PR. +## Stacked upstream feature PRs (CI pins) + +mesh-client sometimes depends on **open** ratspeak PRs that are not local overlays (new library APIs). Those are pinned for CI via `scripts/ratspeak-stack-ci-pins.env` (loaded when `CI=true`) and matching workflow `env` blocks. `pnpm run update` tracks them in `RATSPEAK_STACK_PR_ENTRIES` (`scripts/update.sh`) and warns when they merge so pins can be cleared. + +| Upstream | What we need | Pin / watch | +| -------- | ------------ | ----------- | +| [ratspeak/rsReticulum#26](https://github.com/ratspeak/rsReticulum/pull/26) | `RequestOutcome::ReplyFile` + `LinkClient::query` Resource metadata | `RS_RETICULUM_REF` | +| [ratspeak/rsLXMF#7](https://github.com/ratspeak/rsLXMF/pull/7) | Multi-file LXMF attachment pack/list APIs | `RS_LXMF_REF` | + +After both merge and floated `origin/main` includes them: delete or empty `ratspeak-stack-ci-pins.env`, drop workflow env pins, and remove the matching `RATSPEAK_STACK_PR_ENTRIES` rows. + ## Development — overlays/patches Overlays require **git checkouts** in the repo-local `.rsstack/` workspace (not a bare Cargo cache path): diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 81c16ccf0..a82a68755 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -13,8 +13,8 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use lxmf_core::constants::{ - AM_OPUS_OGG, DeliveryMethod, FIELD_FILE_ATTACHMENTS, FIELD_ICON_APPEARANCE, FIELD_REACTION, - REACTION_CONTENT, REACTION_TO, + AM_OPUS_OGG, DeliveryMethod, FIELD_ICON_APPEARANCE, FIELD_REACTION, REACTION_CONTENT, + REACTION_TO, }; use lxmf_core::message::LxMessage; @@ -51,7 +51,7 @@ use super::lxmf_delivery::{ LXMF_APP, PROPAGATION_SYNC_ANNOUNCE_SETTLE, send_lxmf_delivery_announce, spawn_lxmf_announce_loop, spawn_lxmf_inbound_receiver, spawn_lxmf_outbound_backchannel, }; -use super::nomad_file::nomad_file_name_from_path; +use super::nomad_file::{nomad_file_name_from_metadata_or_path, nomad_file_name_from_path}; use super::nomad_link_errors::map_nomad_link_error; use super::nomad_request_payload::nomad_page_request_payload; use super::nomad_server::NomadServerHandle; @@ -1326,7 +1326,7 @@ impl LiveBridge { force_path_ok: Option, path_ensure_kind: Option<&'static str>, my_gen: u64, - ) -> Result, NomadRemoteQueryError> { + ) -> Result<(Vec, Option>), NomadRemoteQueryError> { // Abort before touching the cancel slot so a superseded failover cannot // cancel the newer request that already owns last-request-wins. if self.nomad_link_generation.load(Ordering::SeqCst) != my_gen { @@ -1432,7 +1432,9 @@ impl LiveBridge { last_iface: None, }), query_result = query_fut => { - query_result.map_err(|e| { + query_result + .map(|resp| (resp.data, resp.metadata)) + .map_err(|e| { let raw = format!("{e}"); let code = map_nomad_link_error(&raw); NomadRemoteQueryError { @@ -1695,7 +1697,8 @@ impl LiveBridge { if bytes.len() > DEFAULT_MAX_FILE_BYTES { return nomad_response_too_large_json(&meta); } - let file_name = nomad_file_name_from_path(path); + let file_name = + nomad_file_name_from_metadata_or_path(meta.resource_metadata.as_deref(), path); let content_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes); let mut out = serde_json::json!({ @@ -5513,6 +5516,12 @@ fn mime_from_file_name(file_name: &str) -> String { "image/jpeg".into() } else if lower.ends_with(".gif") { "image/gif".into() + } else if lower.ends_with(".webp") { + "image/webp".into() + } else if lower.ends_with(".bmp") { + "image/bmp".into() + } else if lower.ends_with(".avif") { + "image/avif".into() } else { "application/octet-stream".into() } @@ -5552,22 +5561,44 @@ fn icon_appearance_json_from_message(msg: &LxMessage) -> Option Option { use base64::Engine as _; - let field = msg.get_field(FIELD_FILE_ATTACHMENTS)?; - let value = rmpv::decode::read_value(&mut Cursor::new(field.as_slice())).ok()?; - let files = value.as_array()?; - let first = files.first()?.as_array()?; - let file_name = first.first()?.as_str()?.to_string(); - let bytes = match first.get(1)? { - rmpv::Value::Binary(bin) => bin.clone(), - _ => return None, - }; - let mime_type = mime_from_file_name(&file_name); - Some(serde_json::json!({ - "file_name": file_name, - "mime_type": mime_type, - "size_bytes": bytes.len(), - "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes), - })) + let mut attachments: Vec = Vec::new(); + + if let Ok(files) = msg.file_attachments() { + for (file_name, bytes) in files { + let mime_type = mime_from_file_name(&file_name); + attachments.push(serde_json::json!({ + "file_name": file_name, + "mime_type": mime_type, + "size_bytes": bytes.len(), + "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes), + })); + } + } + + if attachments.is_empty() { + if let Ok(Some((format, bytes))) = msg.image_attachment() { + let ext = format.trim().trim_start_matches('.').to_lowercase(); + let file_name = if ext.is_empty() { + "image.bin".to_string() + } else { + format!("image.{ext}") + }; + let mime_type = mime_from_file_name(&file_name); + attachments.push(serde_json::json!({ + "file_name": file_name, + "mime_type": mime_type, + "size_bytes": bytes.len(), + "data_base64": base64::engine::general_purpose::STANDARD.encode(bytes), + })); + } + } + + let first = attachments.first()?.clone(); + let mut out = first; + if let Some(obj) = out.as_object_mut() { + obj.insert("attachments".into(), serde_json::Value::Array(attachments)); + } + Some(out) } fn audio_json_from_bytes(mode: u8, bytes: &[u8]) -> serde_json::Value { @@ -5813,6 +5844,8 @@ struct NomadRemoteQueryOk { force_path_ok: Option, path_ensure_kind: Option<&'static str>, elapsed_ms: u64, + /// Msgpack Resource metadata from a file response (`{"name": ...}`), if any. + resource_metadata: Option>, } /// Diagnostics for a failed remote Nomad Link query (page or file). @@ -6181,7 +6214,7 @@ fn path_table_route_from_entry(e: &PathTableRpcEntry) -> PathTableRoute { #[allow(clippy::too_many_arguments, clippy::result_large_err)] // Nomad Link diagnostics bundle fn finish_nomad_link_result( - result: Result, NomadRemoteQueryError>, + result: Result<(Vec, Option>), NomadRemoteQueryError>, hash_hex: &str, identity_hash_hex: &str, hops: u8, @@ -6194,7 +6227,7 @@ fn finish_nomad_link_result( elapsed_ms: u64, ) -> Result<(Vec, NomadRemoteQueryOk), NomadRemoteQueryError> { match result { - Ok(bytes) => { + Ok((bytes, resource_metadata)) => { tracing::debug!( target: "nomad", dest = %hash_hex, @@ -6207,6 +6240,7 @@ fn finish_nomad_link_result( force_path_ok = ?force_path_ok, path_ensure_kind = ?path_ensure_kind, elapsed_ms, + has_resource_metadata = resource_metadata.is_some(), "Nomad Link query ok" ); Ok(( @@ -6219,6 +6253,7 @@ fn finish_nomad_link_result( force_path_ok, path_ensure_kind, elapsed_ms, + resource_metadata, }, )) } @@ -7238,6 +7273,7 @@ mod announce_display_name_tests { force_path_ok: None, path_ensure_kind: None, elapsed_ms: 4200, + resource_metadata: None, }, ); assert_eq!(out["egress"], "tcp"); @@ -7259,6 +7295,7 @@ mod announce_display_name_tests { force_path_ok: Some(false), path_ensure_kind: Some("cached_hit"), elapsed_ms: 1200, + resource_metadata: None, }; // Same helper used by remote page and file oversized branches. let out = nomad_response_too_large_json(&meta); diff --git a/reticulum-sidecar/src/stack/nomad_file.rs b/reticulum-sidecar/src/stack/nomad_file.rs index d4622dbaa..111fea318 100644 --- a/reticulum-sidecar/src/stack/nomad_file.rs +++ b/reticulum-sidecar/src/stack/nomad_file.rs @@ -11,9 +11,48 @@ pub fn nomad_file_name_from_path(path: &str) -> String { .to_string() } +/// Prefer Resource metadata `{"name": }` when present (NomadNet +/// `serve_file` / `ReplyFile`), else fall back to the request path basename. +pub fn nomad_file_name_from_metadata_or_path(metadata: Option<&[u8]>, path: &str) -> String { + if let Some(meta) = metadata { + if let Some(name) = file_name_from_resource_metadata(meta) { + return name; + } + } + nomad_file_name_from_path(path) +} + +fn file_name_from_resource_metadata(metadata: &[u8]) -> Option { + let value = rmpv::decode::read_value(&mut &metadata[..]).ok()?; + let map = value.as_map()?; + for (key, val) in map { + if key.as_str() != Some("name") { + continue; + } + let raw = match val { + rmpv::Value::Binary(bin) => bin.as_slice(), + rmpv::Value::String(s) => s.as_bytes(), + _ => continue, + }; + let name = String::from_utf8_lossy(raw); + let base = name + .rsplit(['/', '\\']) + .next() + .unwrap_or(name.as_ref()) + .trim(); + if !base.is_empty() { + return Some(base.to_string()); + } + } + None +} + #[cfg(test)] mod tests { - use super::nomad_file_name_from_path; + use super::{ + file_name_from_resource_metadata, nomad_file_name_from_metadata_or_path, + nomad_file_name_from_path, + }; #[test] fn file_name_from_path_uses_basename() { @@ -28,4 +67,29 @@ mod tests { fn file_name_from_path_falls_back_when_empty() { assert_eq!(nomad_file_name_from_path("/file/"), "downloaded_file"); } + + #[test] + fn metadata_name_wins_over_path_basename() { + let mut meta = Vec::new(); + rmpv::encode::write_value( + &mut meta, + &rmpv::Value::Map(vec![( + rmpv::Value::String("name".into()), + rmpv::Value::Binary(b"photos/pic.png".to_vec()), + )]), + ) + .unwrap(); + assert_eq!( + file_name_from_resource_metadata(&meta).as_deref(), + Some("pic.png") + ); + assert_eq!( + nomad_file_name_from_metadata_or_path(Some(&meta), "/file/other.bin"), + "pic.png" + ); + assert_eq!( + nomad_file_name_from_metadata_or_path(None, "/file/other.bin"), + "other.bin" + ); + } } diff --git a/scripts/clone-ratspeak-stack.sh b/scripts/clone-ratspeak-stack.sh index ad4a920db..409f87eba 100755 --- a/scripts/clone-ratspeak-stack.sh +++ b/scripts/clone-ratspeak-stack.sh @@ -21,6 +21,13 @@ export RS_RETICULUM_DIR="${RNS_DIR}" export RS_LXMF_DIR="${LXMF_DIR}" # Optional bisect / known-good overrides. Unset or empty → float to origin/main. +# In CI, load temporary stacked pins when present (local clones stay floating). +if [[ "${CI:-}" == 'true' && -f "${SCRIPT_DIR}/ratspeak-stack-ci-pins.env" ]]; then + set -a + # shellcheck disable=SC1091 + source "${SCRIPT_DIR}/ratspeak-stack-ci-pins.env" + set +a +fi RS_RETICULUM_REF="${RS_RETICULUM_REF:-}" RS_LXMF_REF="${RS_LXMF_REF:-}" RS_NOMAD_REF="${RS_NOMAD_REF:-}" diff --git a/scripts/clone-ratspeak-stack.test.mjs b/scripts/clone-ratspeak-stack.test.mjs index 9df5b0b5e..e8f8a2560 100644 --- a/scripts/clone-ratspeak-stack.test.mjs +++ b/scripts/clone-ratspeak-stack.test.mjs @@ -82,7 +82,16 @@ function runEnsureRepo({ remoteUrl, destDir, pinRef = '', env = {}, mergeStderr ].join('\n'); return execFileSync('bash', ['-c', script], { encoding: 'utf8', - env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', ...env }, + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + // Host shells often export RS_STACK_DISCARD_DIRTY=1 while debugging clones. + RS_STACK_DISCARD_DIRTY: '', + // Avoid CI pin file when sourcing the script under unit tests. + CI: '', + ...env, + }, }); } @@ -93,6 +102,8 @@ describe('clone-ratspeak-stack.sh float policy', () => { expect(cloneScript).toContain('checkout --quiet --detach'); expect(cloneScript).toMatch(/RS_RETICULUM_REF="\$\{RS_RETICULUM_REF:-\}"/); expect(cloneScript).toMatch(/RS_LXMF_REF="\$\{RS_LXMF_REF:-\}"/); + expect(cloneScript).toContain('ratspeak-stack-ci-pins.env'); + expect(cloneScript).toContain('CI:-'); expect(cloneScript).toContain('export RS_RETICULUM_DIR='); expect(cloneScript).toContain('export RS_LXMF_DIR='); expect(cloneScript).toContain('refuse to float/pin'); diff --git a/scripts/ratspeak-stack-ci-pins.env b/scripts/ratspeak-stack-ci-pins.env new file mode 100644 index 000000000..93a0d4992 --- /dev/null +++ b/scripts/ratspeak-stack-ci-pins.env @@ -0,0 +1,11 @@ +# Temporary CI pins for stacked Nomad file/metadata work (mesh-client#966). +# Applied only when CI=true (see clone-ratspeak-stack.sh). Clear assignments +# (or delete this file) when the matching RATSPEAK_STACK_PR_ENTRIES in +# scripts/update.sh report upstream MERGED: +# ratspeak/rsReticulum#26 ReplyFile + LinkClient::query metadata +# ratspeak/rsLXMF#7 multi-file attachment APIs +# (Colorado-Mesh/rsNomad#7 already merged — float rsNomad to origin/main.) +# +# Use := so workflow env overrides still win when explicitly set. +: "${RS_RETICULUM_REF:=90774570eae7498554b4809d9203765368c5bbed}" +: "${RS_LXMF_REF:=c3d8b44942e7726dbbe6bb53e0976d4c72134119}" diff --git a/scripts/update.sh b/scripts/update.sh index 44ce8a42e..292d5b6e0 100755 --- a/scripts/update.sh +++ b/scripts/update.sh @@ -373,6 +373,70 @@ check_ratspeak_patches() { fi } +# Track stacked upstream feature PRs that mesh-client pins until they land on main +# (scripts/ratspeak-stack-ci-pins.env + optional workflow env). Not overlay patches — +# these are dependency APIs (ReplyFile, multi-file LXMF attachments, …). +# Format: "owner/repo|pr-number|display-label|cleanup-hint" +# Keep in sync with scripts/ratspeak-stack-ci-pins.env and reticulum-sidecar/patches/README.md. +RATSPEAK_STACK_PR_ENTRIES=( + 'ratspeak/rsReticulum|26|rsReticulum ReplyFile / LinkClient query metadata|clear RS_RETICULUM_REF from scripts/ratspeak-stack-ci-pins.env (+ reticulum-sidecar.yaml / flatpak.yaml env pins)' + 'ratspeak/rsLXMF|7|rsLXMF multi-file attachment APIs|clear RS_LXMF_REF from scripts/ratspeak-stack-ci-pins.env (+ reticulum-sidecar.yaml / flatpak.yaml env pins)' +) + +check_ratspeak_stack_prs() { + local has_stack_pr_warning=0 + local entry repo pr label cleanup url state + + echo '' + echo 'Checking stacked Ratspeak feature PRs (CI pins until merge)...' + + if [ "${#RATSPEAK_STACK_PR_ENTRIES[@]}" -eq 0 ]; then + echo ' No stacked feature PRs tracked.' + return 0 + fi + + for entry in "${RATSPEAK_STACK_PR_ENTRIES[@]}"; do + IFS='|' read -r repo pr label cleanup <<< "${entry}" + url="https://github.com/${repo}/pull/${pr}" + state="$(github_pr_state "${repo}" "${pr}")" + case "${state}" in + open) + echo " ${label}: still open — ${url}" + echo " CI pins via scripts/ratspeak-stack-ci-pins.env (and matching workflow env)." + ;; + merged) + warn_box "${label} (stacked feature PR)" "CI pin" "upstream MERGED" "${url}" + echo " Reason tracked: ${repo}#${pr} merged — ${cleanup}" + echo " then drop this entry from RATSPEAK_STACK_PR_ENTRIES in scripts/update.sh." + has_stack_pr_warning=1 + HAS_WARNING=1 + ;; + closed) + warn_box "${label} (stacked feature PR)" "CI pin" "PR closed (not merged?)" "${url}" + echo " Reason tracked: ${repo}#${pr} closed without merge — verify pin still needed," + echo " then ${cleanup} and drop entry from RATSPEAK_STACK_PR_ENTRIES." + has_stack_pr_warning=1 + HAS_WARNING=1 + ;; + *) + echo " ${label}: could not query ${repo}#${pr} (install gh or check network) — ${url}" + ;; + esac + done + + if [ "${has_stack_pr_warning}" -eq 0 ]; then + echo ' Stacked feature PR check complete (pins still match open upstream).' + fi +} + +# Test hook: exercise check_ratspeak_stack_prs (fake gh/curl via PATH). +if [ "${UPDATE_SH_TEST_HOOK:-}" = 'stack-prs-only' ]; then + HAS_WARNING=0 + check_ratspeak_stack_prs + printf 'HAS_WARNING=%s\n' "${HAS_WARNING}" + exit 0 +fi + # GET GitHub API path (gh preferred, curl fallback). Body on stdout. # Exit 0 = body (may be empty), exit 2 = rate-limit payload detected (empty body). # Callers must handle exit 2 in the parent shell (command substitution drops side effects). @@ -589,6 +653,10 @@ RATSPEAK_KNOWN_ORG_REPOS=( print_ratspeak_upstream_catalog() { local entry + echo 'RATSPEAK_STACK_PR_ENTRIES:' + for entry in "${RATSPEAK_STACK_PR_ENTRIES[@]}"; do + echo " ${entry}" + done echo 'RATSPEAK_RELEASE_WATCH_ENTRIES:' for entry in "${RATSPEAK_RELEASE_WATCH_ENTRIES[@]}"; do echo " ${entry}" @@ -838,6 +906,7 @@ done check_pinned_majors check_ratspeak_patches +check_ratspeak_stack_prs check_ratspeak_upstream if [ "${HAS_WARNING}" -eq 0 ]; then diff --git a/scripts/update.test.mjs b/scripts/update.test.mjs index f09f1e168..2bbbba813 100644 --- a/scripts/update.test.mjs +++ b/scripts/update.test.mjs @@ -104,6 +104,9 @@ describe('update.sh Reticulum stack functionality check', () => { it('prints Ratspeak upstream catalog (upstream-catalog-only)', () => { const result = runUpdate([], { UPDATE_SH_TEST_HOOK: 'upstream-catalog-only' }); expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('RATSPEAK_STACK_PR_ENTRIES:'); + expect(result.stdout).toContain('ratspeak/rsReticulum|26|'); + expect(result.stdout).toContain('ratspeak/rsLXMF|7|'); expect(result.stdout).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES:'); expect(result.stdout).toContain('ratspeak/rsLXST||rsLXST voice (lxst-telephony)|v0.2.0'); expect(result.stdout).toContain('ratspeak/lrgp-rs||lrgp-rs games (LRGP)|v0.4.1'); @@ -122,6 +125,81 @@ describe('update.sh Reticulum stack functionality check', () => { expect(result.stdout).toContain(' lrgp-rs'); }); + it('wires check_ratspeak_stack_prs between overlay and upstream checks', () => { + expect(updateScript).toContain('check_ratspeak_stack_prs()'); + expect(updateScript).toContain('RATSPEAK_STACK_PR_ENTRIES'); + expect(updateScript).toContain('ratspeak/rsReticulum|26|'); + expect(updateScript).toContain('ratspeak/rsLXMF|7|'); + expect(updateScript).toContain('ratspeak-stack-ci-pins.env'); + const patchesCall = updateScript.lastIndexOf('\ncheck_ratspeak_patches\n'); + const stackPrsCall = updateScript.lastIndexOf('\ncheck_ratspeak_stack_prs\n'); + const upstreamCall = updateScript.lastIndexOf('\ncheck_ratspeak_upstream\n'); + expect(patchesCall).toBeGreaterThanOrEqual(0); + expect(stackPrsCall).toBeGreaterThan(patchesCall); + expect(upstreamCall).toBeGreaterThan(stackPrsCall); + }); + + it('stack-prs-only reports open pins without warning', () => { + const binDir = mkdtempSync(path.join(os.tmpdir(), 'mesh-update-stack-prs-')); + tempDirs.push(binDir); + const ghPath = path.join(binDir, 'gh'); + writeFileSync( + ghPath, + `#!/bin/bash +# Fake gh api for stack PR state +if [[ "$*" == *repos/ratspeak/rsReticulum/pulls/26* ]] || [[ "$*" == *repos/ratspeak/rsLXMF/pulls/7* ]]; then + printf '%s' '{"state":"open","merged":false}' + exit 0 +fi +printf '%s' '{}' +exit 0 +`, + 'utf8', + ); + chmodSync(ghPath, 0o755); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'stack-prs-only', + PATH: `${binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('still open'); + expect(result.stdout).toContain('rsReticulum ReplyFile'); + expect(result.stdout).toContain('rsLXMF multi-file'); + expect(result.stdout).toContain('HAS_WARNING=0'); + expect(result.stdout).not.toContain('WARNING:'); + }); + + it('stack-prs-only warns when a stacked PR is merged', () => { + const binDir = mkdtempSync(path.join(os.tmpdir(), 'mesh-update-stack-merged-')); + tempDirs.push(binDir); + const ghPath = path.join(binDir, 'gh'); + writeFileSync( + ghPath, + `#!/bin/bash +if [[ "$*" == *repos/ratspeak/rsReticulum/pulls/26* ]]; then + printf '%s' '{"state":"closed","merged":true,"merged_at":"2026-09-09T00:00:00Z"}' + exit 0 +fi +if [[ "$*" == *repos/ratspeak/rsLXMF/pulls/7* ]]; then + printf '%s' '{"state":"open","merged":false}' + exit 0 +fi +printf '%s' '{}' +exit 0 +`, + 'utf8', + ); + chmodSync(ghPath, 0o755); + const result = runUpdate([], { + UPDATE_SH_TEST_HOOK: 'stack-prs-only', + PATH: `${binDir}:${process.env.PATH ?? ''}`, + }); + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(result.stdout).toContain('upstream MERGED'); + expect(result.stdout).toContain('ratspeak-stack-ci-pins.env'); + expect(result.stdout).toContain('HAS_WARNING=1'); + }); + it('wires check_ratspeak_upstream after overlay PR checks', () => { expect(updateScript).toContain('check_ratspeak_upstream()'); expect(updateScript).toContain('RATSPEAK_RELEASE_WATCH_ENTRIES'); diff --git a/src/renderer/components/NomadNetworkPanel.tsx b/src/renderer/components/NomadNetworkPanel.tsx index c3066192a..a9260ef3e 100644 --- a/src/renderer/components/NomadNetworkPanel.tsx +++ b/src/renderer/components/NomadNetworkPanel.tsx @@ -42,6 +42,7 @@ import { readNomadPageFitWidth, writeNomadPageFitWidth, } from '@/renderer/lib/nomad/nomadPageFitWidth'; +import { nomadRasterDataUrl } from '@/renderer/lib/nomad/nomadRasterPreview'; import { isReticulumSidecarRunning } from '@/renderer/lib/reticulum/reticulumSidecarReads'; import type { NomadNodeRow, NomadPageRequestData } from '@/shared/nomad-types'; @@ -282,6 +283,11 @@ export default function NomadNetworkPanel({ const [pageLoadingRemainingSec, setPageLoadingRemainingSec] = useState(0); const [fileDownloading, setFileDownloading] = useState(false); const [fileDownloadError, setFileDownloadError] = useState(null); + const [filePreview, setFilePreview] = useState<{ + fileName: string; + dataUrl: string; + contentBase64: string; + } | null>(null); const [nodeListCollapsed, setNodeListCollapsed] = useState( () => localStorage.getItem(NOMAD_NODE_LIST_COLLAPSED_STORAGE_KEY) === 'true', ); @@ -481,6 +487,7 @@ export default function NomadNetworkPanel({ fileDownloadInFlightRef.current = true; setFileDownloading(true); setFileDownloadError(null); + setFilePreview(null); try { const normalizedPath = normalizeNomadPagePath(path); const res = await fetchNomadFile(hash, normalizedPath); @@ -490,7 +497,12 @@ export default function NomadNetworkPanel({ return; } const fileName = res.file_name ?? normalizedPath.split('/').pop() ?? 'downloaded_file'; - downloadNomadFileFromBase64(fileName, res.content_base64); + const dataUrl = nomadRasterDataUrl(fileName, res.content_base64); + if (dataUrl) { + setFilePreview({ fileName, dataUrl, contentBase64: res.content_base64 }); + } else { + downloadNomadFileFromBase64(fileName, res.content_base64); + } } catch (e) { // Failure point: unexpected fetchNomadFile reject. Fallback: humanize if possible. if (!mountedRef.current) return; @@ -1076,6 +1088,39 @@ export default function NomadNetworkPanel({ {t('nomadNetwork.fileDownloadFailed', { error: fileDownloadError })}

) : null} + {filePreview ? ( +
+

{filePreview.fileName}

+ {filePreview.fileName} +
+ + +
+
+ ) : null} {pageLoading ? (

diff --git a/src/renderer/components/ReticulumAttachmentLine.tsx b/src/renderer/components/ReticulumAttachmentLine.tsx index b5e46d1c4..38b89d0e3 100644 --- a/src/renderer/components/ReticulumAttachmentLine.tsx +++ b/src/renderer/components/ReticulumAttachmentLine.tsx @@ -28,6 +28,13 @@ function reticulumAttachmentLabel( return t('chatPanel.reticulumFileAttachment', { name: fileName }); } +function dataUrlToBase64(dataUrl: string): string | null { + const comma = dataUrl.indexOf(','); + if (comma < 0) return null; + const b64 = dataUrl.slice(comma + 1).trim(); + return b64.length > 0 ? b64 : null; +} + /** Read-only label (and inline image when cached) for historic LXMF `[file:name:mime]` payloads. */ export function ReticulumAttachmentLine({ payload, @@ -38,6 +45,7 @@ export function ReticulumAttachmentLine({ const [imageDataUrl, setImageDataUrl] = useState(null); const [imageFailed, setImageFailed] = useState(false); const [fetchedFor, setFetchedFor] = useState(null); + const [busy, setBusy] = useState(false); const mimeType = parsed?.mimeType; const canRenderImage = @@ -75,6 +83,45 @@ export function ReticulumAttachmentLine({ const showImage = fetchKey != null && fetchedFor === fetchKey && Boolean(imageDataUrl) && !imageFailed; + const onSave = async () => { + if (!attachmentPath || busy) return; + setBusy(true); + try { + let dataBase64 = + fetchedFor === fetchKey && imageDataUrl != null ? dataUrlToBase64(imageDataUrl) : null; + if (!dataBase64) { + const res = await window.electronAPI.chat.readReticulumAttachmentAsDataUrl({ + filePath: attachmentPath, + mimeType: parsed.mimeType, + }); + dataBase64 = res.dataUrl ? dataUrlToBase64(res.dataUrl) : null; + } + if (!dataBase64) { + const bytes = await window.electronAPI.chat.readReticulumAttachmentBytes(attachmentPath); + dataBase64 = bytes.dataBase64; + } + if (!dataBase64) return; + await window.electronAPI.chat.saveReticulumAttachment({ + fileName: parsed.fileName, + dataBase64, + promptSave: true, + }); + } catch { + // catch-no-log-ok: save dialog cancel / read failure + } finally { + setBusy(false); + } + }; + + const onReveal = async () => { + if (!attachmentPath) return; + try { + await window.electronAPI.chat.showItemInFolder(attachmentPath); + } catch { + // catch-no-log-ok: reveal unavailable + } + }; + return (

{showImage && imageDataUrl ? ( @@ -87,6 +134,29 @@ export function ReticulumAttachmentLine({ /> ) : null} {label} + {attachmentPath ? ( +
+ + +
+ ) : null}
); } diff --git a/src/renderer/lib/nomad/nomadRasterPreview.ts b/src/renderer/lib/nomad/nomadRasterPreview.ts new file mode 100644 index 000000000..43df1632a --- /dev/null +++ b/src/renderer/lib/nomad/nomadRasterPreview.ts @@ -0,0 +1,20 @@ +/** Common Nomad `/file/...` raster extensions for inline preview. */ +const NOMAD_RASTER_EXT = /\.(png|jpe?g|gif|webp|bmp|avif)$/i; + +export function isNomadRasterFileName(fileName: string): boolean { + return NOMAD_RASTER_EXT.test(fileName.trim()); +} + +/** Build a browser data URL from Nomad file base64 for raster preview. */ +export function nomadRasterDataUrl(fileName: string, contentBase64: string): string | null { + if (!isNomadRasterFileName(fileName)) return null; + const lower = fileName.toLowerCase(); + let mime = 'application/octet-stream'; + if (lower.endsWith('.png')) mime = 'image/png'; + else if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) mime = 'image/jpeg'; + else if (lower.endsWith('.gif')) mime = 'image/gif'; + else if (lower.endsWith('.webp')) mime = 'image/webp'; + else if (lower.endsWith('.bmp')) mime = 'image/bmp'; + else if (lower.endsWith('.avif')) mime = 'image/avif'; + return `data:${mime};base64,${contentBase64}`; +} diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index dcae23f70..ad8df9a80 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -770,7 +770,9 @@ "heardByRepeatersDetail_few": "Slyšeno {{count}}: {{names}}", "searchResults_few": "{{count}} výsledky", "reticulumSendAwaitingPeerReceipt": "Odesláno (čeká se na potvrzení od kolegy)", - "reticulumChatNeedsLxmfDelivery": "Tento protějšek zatím nemá doručovací adresu LXMF (Chat nemůže používat cíl pouze pro hlas). Počkejte na oznámení LXMF nebo vložte jeho 32místný hash LXMF." + "reticulumChatNeedsLxmfDelivery": "Tento protějšek zatím nemá doručovací adresu LXMF (Chat nemůže používat cíl pouze pro hlas). Počkejte na oznámení LXMF nebo vložte jeho 32místný hash LXMF.", + "saveAttachment": "Uložit...", + "revealAttachment": "Zobrazit ve složce" }, "chatPayload": { "mention": "Zmínit {{label}}", @@ -3456,7 +3458,9 @@ "pageProgressFailoverIface": "Zkusit jinou trasu přes {{iface}}…", "pageProgressFailoverGeneric": "Zkouším jinou trasu...", "pageProgressNoAlternate": "Žádné jiné trasy nejsou k dispozici — dokončení tohoto pokusu...", - "hopsAway_few": "{{count}} skoky daleko" + "hopsAway_few": "{{count}} skoky daleko", + "downloadFile": "Stáhnout", + "dismissPreview": "Zamítnout" }, "packetDistribution": { "overallDistribution": "Celková distribuce", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index a8f6d4067..0432fcbdd 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} zusätzlicher nicht identifizierter Spediteur (Hashes: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} weitere nicht identifizierte Spediteure (Hashes: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Gesendet (wartet auf Peer-Eingang)", - "reticulumChatNeedsLxmfDelivery": "Dieser Peer hat noch keine LXMF-Lieferadresse (Chat kann kein Voice-only-Ziel verwenden). Warten Sie auf eine LXMF-Ankündigung oder fügen Sie ihren 32-stelligen LXMF-Hash ein." + "reticulumChatNeedsLxmfDelivery": "Dieser Peer hat noch keine LXMF-Lieferadresse (Chat kann kein Voice-only-Ziel verwenden). Warten Sie auf eine LXMF-Ankündigung oder fügen Sie ihren 32-stelligen LXMF-Hash ein.", + "saveAttachment": "Speichern…", + "revealAttachment": "In Ordner" }, "chatPayload": { "mention": "Erwähne {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Versuche eine andere Route über {{iface}} ({{hops}} Hops)…", "pageProgressFailoverIface": "Versuche eine andere Route über {{iface}}…", "pageProgressFailoverGeneric": "Eine andere Route wird versucht...", - "pageProgressNoAlternate": "Keine anderen Routen verfügbar — Beenden dieses Versuchs..." + "pageProgressNoAlternate": "Keine anderen Routen verfügbar — Beenden dieses Versuchs...", + "downloadFile": "Herunterladen", + "dismissPreview": "Ablehnen" }, "packetDistribution": { "overallDistribution": "Gesamtverteilung", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index d8e97a88d..58fa25535 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -496,6 +496,8 @@ "sendButton": "Send", "sendButtonDm": "DM", "sendButtonSending": "...", + "saveAttachment": "Save…", + "revealAttachment": "Show in folder", "waitingMessagesQueued": "{{count}} queued message(s) on radio", "waitingMessagesSyncProgress": "Syncing {{processed}} / {{total}} from radio…", "waitingMessagesSyncProgressIndeterminate": "Syncing queued messages from radio…", @@ -3458,6 +3460,8 @@ "pageLoadingCountdownOverdue": "Loading page… still working", "pageLoadingRetryCountdown": "First attempt timed out — refreshing path and retrying… {{time}} left", "pageLoadingRetryOverdue": "Refreshing path and retrying… still working", + "downloadFile": "Download", + "dismissPreview": "Dismiss", "pageLoadingTimeLeft": "{{time}} left on this attempt", "pageLoadingStillWorking": "Still working…", "pageProgressLinking": "Connecting via {{iface}} ({{hops}} hops)…", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index a796edf20..bb0a02429 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} reenviador no identificado adicional (hashes: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} reenviadores no identificados adicionales (hashes: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Enviado (pendiente de recibo del compañero)", - "reticulumChatNeedsLxmfDelivery": "Este compañero aún no tiene una dirección de entrega LXMF (el chat no puede usar un destino de solo voz). Espere un anuncio de LXMF o pegue su hash LXMF de 32 caracteres." + "reticulumChatNeedsLxmfDelivery": "Este compañero aún no tiene una dirección de entrega LXMF (el chat no puede usar un destino de solo voz). Espere un anuncio de LXMF o pegue su hash LXMF de 32 caracteres.", + "saveAttachment": "Salvar…", + "revealAttachment": "Mostrar en la carpeta" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Intentando una ruta diferente a través de {{iface}} ({{hops}} hops)...", "pageProgressFailoverIface": "Intentando una ruta diferente a través de {{iface}}...", "pageProgressFailoverGeneric": "Probando una ruta diferente...", - "pageProgressNoAlternate": "No hay otras rutas disponibles — finalizando este intento..." + "pageProgressNoAlternate": "No hay otras rutas disponibles — finalizando este intento...", + "downloadFile": "Descargar", + "dismissPreview": "Descartar" }, "packetDistribution": { "overallDistribution": "Distribución general", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index 383278750..10904bcdd 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} expéditeur supplémentaire non identifié (hachages : {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} expéditeurs supplémentaires non identifiés (hachages : {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Envoyé (en attente de réception par les pairs)", - "reticulumChatNeedsLxmfDelivery": "Ce pair n'a pas encore d'adresse de livraison LXMF (le chat ne peut pas utiliser une destination vocale uniquement). Attendez une annonce LXMF ou collez leur hachage LXMF de 32 caractères." + "reticulumChatNeedsLxmfDelivery": "Ce pair n'a pas encore d'adresse de livraison LXMF (le chat ne peut pas utiliser une destination vocale uniquement). Attendez une annonce LXMF ou collez leur hachage LXMF de 32 caractères.", + "saveAttachment": "Sauvegarder…", + "revealAttachment": "Montrer dans son dossier" }, "chatPayload": { "mention": "Mention {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Essayer un autre itinéraire via {{iface}} ({{hops}} sauts)…", "pageProgressFailoverIface": "Essayer un autre itinéraire via {{iface}}…", "pageProgressFailoverGeneric": "Essayer un autre itinéraire...", - "pageProgressNoAlternate": "Aucun autre itinéraire disponible — terminer cette tentative…" + "pageProgressNoAlternate": "Aucun autre itinéraire disponible — terminer cette tentative…", + "downloadFile": "Télécharger", + "dismissPreview": "Ignorer" }, "packetDistribution": { "overallDistribution": "Répartition globale", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 23dc35152..1d59afd83 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} forwarder tak dikenal tambahan (hash: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} forwarder tak dikenal tambahan (hash: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Terkirim (menunggu tanda terima sejawat)", - "reticulumChatNeedsLxmfDelivery": "Rekan ini belum memiliki alamat pengiriman LXMF (Obrolan tidak dapat menggunakan tujuan khusus Suara). Tunggu pengumuman LXMF, atau tempelkan hash LXMF 32 karakternya." + "reticulumChatNeedsLxmfDelivery": "Rekan ini belum memiliki alamat pengiriman LXMF (Obrolan tidak dapat menggunakan tujuan khusus Suara). Tunggu pengumuman LXMF, atau tempelkan hash LXMF 32 karakternya.", + "saveAttachment": "Simpan", + "revealAttachment": "Tampilkan dalam Folder" }, "chatPayload": { "mention": "Sebutkan {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Mencoba rute yang berbeda melalui {{iface}} ({{hops}} hop)...", "pageProgressFailoverIface": "Mencoba rute lain melalui {{iface}}…", "pageProgressFailoverGeneric": "Mencoba rute yang berbeda...", - "pageProgressNoAlternate": "Tidak ada rute lain yang tersedia — menyelesaikan upaya ini..." + "pageProgressNoAlternate": "Tidak ada rute lain yang tersedia — menyelesaikan upaya ini...", + "downloadFile": "Unduh", + "dismissPreview": "Tutup" }, "packetDistribution": { "overallDistribution": "Distribusi Keseluruhan", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index bb293f729..efdcb826b 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} ulteriore spedizioniere non identificato (hash: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} ulteriori spedizionieri non identificati (hash: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Inviato (in attesa di ricezione da parte del pari)", - "reticulumChatNeedsLxmfDelivery": "Questo peer non ha ancora un indirizzo di consegna LXMF (la chat non può utilizzare una destinazione solo vocale). Attendi un annuncio LXMF o incolla il loro hash LXMF di 32 caratteri." + "reticulumChatNeedsLxmfDelivery": "Questo peer non ha ancora un indirizzo di consegna LXMF (la chat non può utilizzare una destinazione solo vocale). Attendi un annuncio LXMF o incolla il loro hash LXMF di 32 caratteri.", + "saveAttachment": "Salva…", + "revealAttachment": "Nella cartella" }, "chatPayload": { "mention": "Menziona {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Prova un percorso diverso tramite {{iface}} ({{hops}} Hops)...", "pageProgressFailoverIface": "Prova un percorso diverso tramite {{iface}}...", "pageProgressFailoverGeneric": "Sto provando un percorso diverso...", - "pageProgressNoAlternate": "Nessun altro percorso disponibile — terminare questo tentativo..." + "pageProgressNoAlternate": "Nessun altro percorso disponibile — terminare questo tentativo...", + "downloadFile": "Download", + "dismissPreview": "Ignora" }, "packetDistribution": { "overallDistribution": "Distribuzione complessiva", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 8f8dc09f2..e08ee7133 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}}追加の未確認フォワーダー(ハッシュ: {{hashes}} )", "heardByRepeatersAdditional_other": "{{count}}追加の未確認フォワーダー(ハッシュ: {{hashes}} )", "reticulumSendAwaitingPeerReceipt": "送信済み(ピアレシート待ち)", - "reticulumChatNeedsLxmfDelivery": "このピアにはまだLXMF配信アドレスがありません(チャットは音声のみの宛先を使用できません)。LXMFの発表を待つか、32文字のLXMFハッシュを貼り付けます。" + "reticulumChatNeedsLxmfDelivery": "このピアにはまだLXMF配信アドレスがありません(チャットは音声のみの宛先を使用できません)。LXMFの発表を待つか、32文字のLXMFハッシュを貼り付けます。", + "saveAttachment": "保存...", + "revealAttachment": "フォルダを表示" }, "chatPayload": { "mention": "{{label}} について言及してください", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "{{iface}} ( {{hops}}ホップ)経由で別のルートを試しています…", "pageProgressFailoverIface": "{{iface}}経由で別のルートを試しています…", "pageProgressFailoverGeneric": "別のルートを試しています…", - "pageProgressNoAlternate": "他に利用可能なルートはありません—この試みを終了しています…" + "pageProgressNoAlternate": "他に利用可能なルートはありません—この試みを終了しています…", + "downloadFile": "ダウンロード", + "dismissPreview": "却下" }, "packetDistribution": { "overallDistribution": "全体の分布", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index cc788c838..3b85b3530 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} 추가 미확인 전달자 (해시: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} 추가 미확인 전달자 (해시: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "전송 완료 (동료 영수증 대기 중)", - "reticulumChatNeedsLxmfDelivery": "이 동료는 아직 LXMF 배달 주소가 없습니다 (채팅은 음성 전용 목적지를 사용할 수 없습니다). LXMF가 발표될 때까지 기다리거나 32자 LXMF 해시를 붙여넣습니다." + "reticulumChatNeedsLxmfDelivery": "이 동료는 아직 LXMF 배달 주소가 없습니다 (채팅은 음성 전용 목적지를 사용할 수 없습니다). LXMF가 발표될 때까지 기다리거나 32자 LXMF 해시를 붙여넣습니다.", + "saveAttachment": "저장…", + "revealAttachment": "폴더에 표시" }, "chatPayload": { "mention": "{{label}}을(를) 언급하세요", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "{{iface}} ({{hops}} HOPS) 을 (를) 통해 다른 경로로 시도 중...", "pageProgressFailoverIface": "{{iface}} 을 (를) 경유하는 다른 경로로 시도 중...", "pageProgressFailoverGeneric": "다른 경로로 시도 중...", - "pageProgressNoAlternate": "사용 가능한 다른 경로가 없습니다. 이 시도를 완료하는 중..." + "pageProgressNoAlternate": "사용 가능한 다른 경로가 없습니다. 이 시도를 완료하는 중...", + "downloadFile": "다운로드", + "dismissPreview": "무시하기" }, "packetDistribution": { "overallDistribution": "전체 분포", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index a5206b53a..c5ef9b196 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} extra niet-geïdentificeerde expediteur (hashes: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} extra niet-geïdentificeerde expediteurs (hashes: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Verzonden (in afwachting van ontvangst door collega's)", - "reticulumChatNeedsLxmfDelivery": "Deze peer heeft nog geen LXMF-bezorgadres (chat kan geen bestemming met alleen spraak gebruiken). Wacht op een LXMF-aankondiging of plak hun LXMF-hash van 32 tekens." + "reticulumChatNeedsLxmfDelivery": "Deze peer heeft nog geen LXMF-bezorgadres (chat kan geen bestemming met alleen spraak gebruiken). Wacht op een LXMF-aankondiging of plak hun LXMF-hash van 32 tekens.", + "saveAttachment": "Opslaan", + "revealAttachment": "Tonen in map" }, "chatPayload": { "mention": "Vermeld {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Een andere route proberen via {{iface}} ({{hops}} hops)...", "pageProgressFailoverIface": "Een andere route proberen via {{iface}}…", "pageProgressFailoverGeneric": "Probeer een andere route...", - "pageProgressNoAlternate": "Geen andere routes beschikbaar — deze poging afronden..." + "pageProgressNoAlternate": "Geen andere routes beschikbaar — deze poging afronden...", + "downloadFile": "Download", + "dismissPreview": "Afwijzen" }, "packetDistribution": { "overallDistribution": "Algemene distributie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index a16b60ca6..7a31fe869 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -788,7 +788,9 @@ "searchResults_few": "{{count}} wyniki", "searchResults_many": "{{count}} wyników", "reticulumSendAwaitingPeerReceipt": "Wysłano (oczekiwanie na pokwitowanie)", - "reticulumChatNeedsLxmfDelivery": "Ten partner nie ma jeszcze adresu dostawy LXMF (czat nie może używać miejsca docelowego tylko dla połączeń głosowych). Poczekaj na ogłoszenie LXMF lub wklej 32-znakowy skrót LXMF." + "reticulumChatNeedsLxmfDelivery": "Ten partner nie ma jeszcze adresu dostawy LXMF (czat nie może używać miejsca docelowego tylko dla połączeń głosowych). Poczekaj na ogłoszenie LXMF lub wklej 32-znakowy skrót LXMF.", + "saveAttachment": "Zapisz", + "revealAttachment": "Pokaż w folderze" }, "chatPayload": { "mention": "Wspomnij o {{label}}", @@ -3494,7 +3496,9 @@ "pageProgressFailoverGeneric": "Próbuję wybrać inną trasę…", "pageProgressNoAlternate": "Brak innych dostępnych tras — kończenie tej próby…", "hopsAway_few": "{{count}} przeskoki dalej", - "hopsAway_many": "{{count}} przeskoków dalej" + "hopsAway_many": "{{count}} przeskoków dalej", + "downloadFile": "Pobierz", + "dismissPreview": "Odrzuć" }, "packetDistribution": { "overallDistribution": "Ogólna dystrybucja", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 205a22b3f..57238ab3d 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} encaminhador adicional não identificado (hashes: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} encaminhadores adicionais não identificados (hashes: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Enviado (aguardando recebimento dos pares)", - "reticulumChatNeedsLxmfDelivery": "Este ponto ainda não tem endereço de entrega LXMF (o chat não pode usar um destino apenas de voz). Aguarde um anúncio LXMF ou cole o hash LXMF de 32 caracteres." + "reticulumChatNeedsLxmfDelivery": "Este ponto ainda não tem endereço de entrega LXMF (o chat não pode usar um destino apenas de voz). Aguarde um anúncio LXMF ou cole o hash LXMF de 32 caracteres.", + "saveAttachment": "Guardar…", + "revealAttachment": "_Mostrar na pasta" }, "chatPayload": { "mention": "Mencionar {{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "Tentando uma rota diferente via {{iface}} ({{hops}} saltos)…", "pageProgressFailoverIface": "Tentando um trajeto diferente via {{iface}}…", "pageProgressFailoverGeneric": "Tentando uma rota diferente...", - "pageProgressNoAlternate": "Não há outras rotas disponíveis — concluindo esta tentativa..." + "pageProgressNoAlternate": "Não há outras rotas disponíveis — concluindo esta tentativa...", + "downloadFile": "Download", + "dismissPreview": "Dispensar" }, "packetDistribution": { "overallDistribution": "Distribuição Geral", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index d025f671c..37adf676a 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -788,7 +788,9 @@ "searchResults_few": "{{count}} результата", "searchResults_many": "{{count}} результатов", "reticulumSendAwaitingPeerReceipt": "Отправлено (ожидает получения коллегой)", - "reticulumChatNeedsLxmfDelivery": "Этот узел еще не имеет адреса доставки LXMF (чат не может использовать пункт назначения только для голоса). Дождитесь объявления LXMF или вставьте их 32-символьный хэш LXMF." + "reticulumChatNeedsLxmfDelivery": "Этот узел еще не имеет адреса доставки LXMF (чат не может использовать пункт назначения только для голоса). Дождитесь объявления LXMF или вставьте их 32-символьный хэш LXMF.", + "saveAttachment": "Спасти…", + "revealAttachment": "Показать в папке" }, "chatPayload": { "mention": "Упоминание {{label}}", @@ -3494,7 +3496,9 @@ "pageProgressFailoverGeneric": "Попробовать другой маршрут...", "pageProgressNoAlternate": "Нет других доступных маршрутов — завершение этой попытки...", "hopsAway_few": "На расстоянии {{count}} хопов", - "hopsAway_many": "На расстоянии {{count}} хопов" + "hopsAway_many": "На расстоянии {{count}} хопов", + "downloadFile": "Загрузить", + "dismissPreview": "Пропустить" }, "packetDistribution": { "overallDistribution": "Общее распределение", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 7ece41a22..5505b8329 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}} EK tanımlanamayan nakliyeci (hash'ler: {{hashes}})", "heardByRepeatersAdditional_other": "{{count}} kimliği belirsiz ek göndericiler (hash'ler: {{hashes}})", "reticulumSendAwaitingPeerReceipt": "Gönderildi (akran makbuzu bekleniyor)", - "reticulumChatNeedsLxmfDelivery": "Bu akranın henüz LXMF teslimat adresi yok (Sohbet yalnızca Sesli bir hedef kullanamaz). Bir LXMF duyurusu bekleyin veya 32 karakterlik LXMF karmasını yapıştırın." + "reticulumChatNeedsLxmfDelivery": "Bu akranın henüz LXMF teslimat adresi yok (Sohbet yalnızca Sesli bir hedef kullanamaz). Bir LXMF duyurusu bekleyin veya 32 karakterlik LXMF karmasını yapıştırın.", + "saveAttachment": "Kaydetmek…", + "revealAttachment": "Dizinde" }, "chatPayload": { "mention": "{{label}}'dan bahsedin", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "{{iface}} ({{hops}} atlama) üzerinden farklı bir rota deniyor…", "pageProgressFailoverIface": "{{iface}} üzerinden farklı bir rota deniyor…", "pageProgressFailoverGeneric": "Farklı bir rota deniyor...", - "pageProgressNoAlternate": "Başka rota yok — bu girişim tamamlanıyor…" + "pageProgressNoAlternate": "Başka rota yok — bu girişim tamamlanıyor…", + "downloadFile": "İndir", + "dismissPreview": "Yok say" }, "packetDistribution": { "overallDistribution": "Genel Dağıtım", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 5c9ff5ab9..62d4588fc 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -788,7 +788,9 @@ "searchResults_few": "{{count}} результати", "searchResults_many": "{{count}} результатів", "reticulumSendAwaitingPeerReceipt": "Надіслано (очікується отримання від колеги)", - "reticulumChatNeedsLxmfDelivery": "Цей вузол ще не має адреси доставки LXMF (чат не може використовувати призначення лише для голосу). Дочекайтеся оголошення LXMF або вставте їх 32-символьний хеш LXMF." + "reticulumChatNeedsLxmfDelivery": "Цей вузол ще не має адреси доставки LXMF (чат не може використовувати призначення лише для голосу). Дочекайтеся оголошення LXMF або вставте їх 32-символьний хеш LXMF.", + "saveAttachment": "Зберегти", + "revealAttachment": "У теці" }, "chatPayload": { "mention": "Згадайте {{label}}", @@ -3494,7 +3496,9 @@ "pageProgressFailoverGeneric": "Спробувати інший маршрут...", "pageProgressNoAlternate": "Немає інших доступних маршрутів — завершення цієї спроби...", "hopsAway_few": "за {{count}} хопи", - "hopsAway_many": "за {{count}} хопів" + "hopsAway_many": "за {{count}} хопів", + "downloadFile": "Завантаження", + "dismissPreview": "Відпустити" }, "packetDistribution": { "overallDistribution": "Загальний розподіл", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index d1493e9dd..cf448423c 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -750,7 +750,9 @@ "heardByRepeatersAdditional_one": "{{count}}其他身份不明的货运代理(哈希值: {{hashes}} )", "heardByRepeatersAdditional_other": "{{count}}其他身份不明的转发商(哈希: {{hashes}} )", "reticulumSendAwaitingPeerReceipt": "已发送(等待同行回执)", - "reticulumChatNeedsLxmfDelivery": "此对等方还没有LXMF交付地址(聊天不能使用仅语音目的地)。等待LXMF公告,或粘贴其32个字符的LXMF哈希。" + "reticulumChatNeedsLxmfDelivery": "此对等方还没有LXMF交付地址(聊天不能使用仅语音目的地)。等待LXMF公告,或粘贴其32个字符的LXMF哈希。", + "saveAttachment": "保存", + "revealAttachment": "在文件夹中" }, "chatPayload": { "mention": "提及{{label}}", @@ -3416,7 +3418,9 @@ "pageProgressFailover": "正在尝试通过{{iface}} ( {{hops}}跳)选择其他路线……", "pageProgressFailoverIface": "尝试通过{{iface}}选择其他路线…", "pageProgressFailoverGeneric": "正在尝试其他路线…", - "pageProgressNoAlternate": "没有其他路线可用—正在完成此尝试…" + "pageProgressNoAlternate": "没有其他路线可用—正在完成此尝试…", + "downloadFile": "下载", + "dismissPreview": "解散" }, "packetDistribution": { "overallDistribution": "总体分布",