diff --git a/.claude/skills/ship-release/SKILL.md b/.claude/skills/ship-release/SKILL.md index f765e1d990..252b8ada80 100644 --- a/.claude/skills/ship-release/SKILL.md +++ b/.claude/skills/ship-release/SKILL.md @@ -106,7 +106,10 @@ push, release publish) is gated on explicit user approval. 4. Commit `chore: bump version to `, push, open a PR to **`dev`**. 5. Wait for `validate-pr` checks. **GATE: show PR URL + checks status. STOP until the user says merge.** -6. Squash-merge: `gh pr merge --squash`. +6. Squash-merge: `gh pr merge --squash`. Branch protection requires 1 + approving review, so this typically needs `--admin` (the release manager + has bypass) — otherwise `gh pr merge` refuses with "requirements have not + been met". ### Stable (promotion) @@ -114,7 +117,8 @@ push, release publish) is gated on explicit user approval. 2. Bump `"version"` in `package.json` to the bare version (drop the pre-release suffix, e.g. `4.17.0-alpha.6` → `4.17.0`). 3. **GATE**, commit, push, open a bump PR to **`dev`**. Wait for checks. - **GATE: STOP until the user says merge.** Squash-merge. + **GATE: STOP until the user says merge.** Squash-merge (branch protection + requires 1 approving review, so this typically needs `--admin`). 4. `git -C "$RELEASE_WT" fetch origin dev` and confirm the merge commit is HEAD of `origin/dev` with `package.json` at TARGET. 5. Open the **release PR**: `dev` → `master` @@ -142,7 +146,8 @@ push, release publish) is gated on explicit user approval. pushing. 3. Bump `"version"` in `package.json` to `X.Y.Z`, commit, push a bump PR targeting **`release/`**. Wait for checks. **GATE: STOP until the - user says merge.** Squash-merge. + user says merge.** Squash-merge (branch protection requires 1 approving + review, so this typically needs `--admin`). ## Phase 3 — Merge & tag @@ -169,6 +174,9 @@ or stay `cd`'d in) — never in the user's own checkout. ```sh gh pr merge --merge ``` + Branch protection requires 1 approving review, so this typically needs + `--admin` (the release manager has bypass) — otherwise `gh pr merge` + refuses with "requirements have not been met". 2. `git -C "$RELEASE_WT" fetch origin master` and confirm the merge commit is HEAD of `origin/master` and its `package.json` has TARGET. 3. **GATE: confirm with the user before pushing the tag.** diff --git a/.github/workflows/pull-request-build.yml b/.github/workflows/pull-request-build.yml index 4720f86d93..771873aec6 100644 --- a/.github/workflows/pull-request-build.yml +++ b/.github/workflows/pull-request-build.yml @@ -636,6 +636,16 @@ jobs: } } core.setOutput('artifact_urls', artifactUrls.trim()); + const now = new Date(); + const builtAtLocal = new Intl.DateTimeFormat('en-GB', { + timeZone: 'America/Sao_Paulo', + weekday: 'short', day: '2-digit', month: 'short', year: 'numeric', + hour: '2-digit', minute: '2-digit', hour12: false, + }).format(now) + ' (UTC-3)'; + const builtAtUtc = now.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + const shortSha = context.payload.pull_request.head.sha.slice(0, 7); + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + core.setOutput('build_info', `_Built from \`${shortSha}\` on ${builtAtLocal} · ${builtAtUtc} · [workflow run](${runUrl})_`); - name: Post PR Comment with the Artifact links if: steps.get-artifact-urls.outputs.artifact_urls != '' @@ -645,4 +655,6 @@ jobs: header: ${{ runner.os }}-installer message: | ### ${{ runner.os }} installer download + ${{ steps.get-artifact-urls.outputs.build_info }} + ${{ steps.get-artifact-urls.outputs.artifact_urls }} diff --git a/AGENTS.md b/AGENTS.md index 87d2004f1b..54a9ef1dbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,9 @@ yarn workspaces:build # Build all workspaces - After building `desktop-release-action`, remove the nested dist: `rm -rf workspaces/desktop-release-action/dist/dist` — the action only needs `workspaces/desktop-release-action/dist/index.js`. +- `app/` mirrors `src/public` (the `syncPublicAssets()` rollup plugin): + files removed from `src/public` are purged from `app/` on build, and + `yarn start`'s watcher rebuilds/relaunches when `src/public` assets change. ## Branching Model @@ -72,6 +75,10 @@ yarn workspaces:build # Build all workspaces `26080`, the second same-month build is `26081`. Check the current value and the date of its last bump (`git log -p --follow -- electron-builder.json`) before incrementing — do not guess an arbitrary increment. +- `yarn build-assets` re-encodes every PNG/ICO it touches, including ones + whose source did not change; commit only the assets whose SVG/component + changed and `git checkout --` the rest (byte noise otherwise floods the + diff). ## Releases And Tagging @@ -134,6 +141,20 @@ yarn workspaces:build # Build all workspaces color/animation tokens, read `docs/desktop-ui-guidelines.md` — token semantics and traps, Fuselage geometry/timing facts, the button-dimming and SVG transform-origin pitfalls, and layout rules learned in PRs #3441/#3443. +- Tray icons are status-only on macOS, Windows and Linux: six states per + platform — `default`, `presence-{online,away,busy,offline}`, `disconnected` + (`src/ui/main/icons.ts`). The unread count is never baked into the tray + image; it lives on the Windows taskbar overlay (`rootWindow.ts` + `setOverlayIcon`), the macOS menu-bar title and the Linux tray tooltip. +- Presence bullets reuse Fuselage `StatusBullet` glyphs + (`src/ui/icons/PresenceBullet.tsx`: filled / clock cut-out / bar cut-out / + hollow ring; `DisconnectedBadge.tsx`: filled amber with a bold `!`). Any + overlay with transparent cut-outs or a hollow shape MUST pass `AppIcon`'s + `cutout` prop (`PresenceBulletCutout`), otherwise the rocket shows through + the holes. +- Tray-menu bullet icons are 12pt assets under `src/public/images/presence/` + (regenerate with `yarn build-assets --presence-menu-icons`), matching + Fuselage's 12px bullet next to 14px text. ## Testing @@ -152,8 +173,12 @@ yarn workspaces:build # Build all workspaces paint (a clipped SVG passes every DOM assertion). Use the `dev-app-verify` skill (`skills/dev-app-verify/SKILL.md`) to drive and screenshot the running `yarn start` app via the port-9339 inspector, and the Developer Mode menu - items (`Simulate Update Flow` / `Simulate Download`) to exercise the flows - without real downloads/updates. + items (`Simulate Update Flow` / `Simulate Download` / `Simulate + Disconnected`) to exercise the flows without real downloads/updates. + `Simulate Disconnected` is a Developer menu checkbox that forces the active + workspace's presence connection to `disconnected` read-side only — the real + connection stays up — so the disconnected tray icon and menu line can be + checked without dropping the network. - Screen-capture / WebRTC / portal behavior CANNOT be validated in software-rendered VMs — Chromium gates the PipeWire capture path on hardware GL (the gate moves between Electron versions). Validate on @@ -173,6 +198,42 @@ const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.( this: `process.getuid()`, `process.getgid()`, `process.geteuid()`, `process.getegid()`. +## Windows Notifications + +- A Windows Action Center card stays repliable indefinitely, and Electron emits + the JS `close` event when the banner times out + (`NotificationDismissed(should_destroy=false)`). Calling `close()` on the + instance does NOT remove the card. Do NOT tie per-notification state — reply + routing, target webContents, preload event handlers — to a `close` or dismiss + signal, or replies typed later are dropped. Keep it (bounded) until the app + decides the notification is finished. +- The web client auto-closes every desktop notification 10s after showing it + (`useNotification.ts`; the server never sends `duration`, so the fallback is + always used). Expect that close to arrive while the card is still on screen. +- Only reply and action-button interactions carry activation arguments + (`type=...&tag=`, read via `Notification.handleActivation`, win32). + A click on the toast body carries none and arrives ONLY as the instance + `click` event — keep that listener unconditional. +- `Notification.handleActivation` REPLACES the stored callback rather than + adding one. A debug probe that registers its own handler silently + unregisters the app's — do not use it to observe production behavior. +- Enter does NOT submit a toast reply; only the toast's Reply button does, and + the card closes on submit whether or not the app received anything. Assert + replies by querying the server for the message, never by watching the UI. +- Log dropped activations through `loggers.notifications` + (`src/logging/scopes.ts`). `console.warn` from the main process is invisible + in packaged builds, which makes a dropped reply untraceable in the field. +- Full investigation history: + `docs/postmortem-notification-quick-reply-sup-1097.md`. + +## Startup Debugging + +- `yarn start` relaunches Electron once per rollup bundle for the first + ~60 s (each `writeBundle` restarts the app), so the tray/menu-bar icon + flickers or is absent until `waiting for changes` prints. Judge tray state + only after that, and `pkill` orphaned worktree Electrons first — a + leftover instance keeps port 9339 and shows a second menu-bar icon. + ## QA Flow Authoring When creating or updating QA assets under `qa/`, read these first: diff --git a/README.md b/README.md index 771704c077..9f9a677b37 100644 --- a/README.md +++ b/README.md @@ -231,17 +231,18 @@ a single server mode. #### The settings that can be overridden are: -| Setting | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| `"isReportEnabled": true` | Sets if the bugs will be reported to developers. | -| `"isInternalVideoChatWindowEnabled": true` | Sets if video calls will be opened in an internal window. | -| `"isFlashFrameEnabled": true` | Sets if the flash frame will be enabled. | -| `"isMinimizeOnCloseEnabled": false` | Sets if the app will be minimized on close. | -| `"doCheckForUpdatesOnStartup": true` | Sets if the app will check for updates on startup. | -| `"isMenuBarEnabled": false` | Windows/Linux: `true` keeps the menu bar always visible; `false` auto-hides it (Alt shows it temporarily). Unused on macOS. | -| `"isTrayIconEnabled": true` | Enables Tray Icon, the app will be hidden to the tray on close. Overrides `"isMinimizeOnCloseEnabled"` | -| `"isUpdatingEnabled": true` | Sets if the app can be updated by the user. | -| `"isAddNewServersEnabled": true` | Sets if the user can add new servers. | +| Setting | Description | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `"isReportEnabled": true` | Sets if the bugs will be reported to developers. | +| `"isInternalVideoChatWindowEnabled": true` | Sets if video calls will be opened in an internal window. | +| `"isFlashFrameEnabled": true` | Sets if the flash frame will be enabled. | +| `"isMinimizeOnCloseEnabled": false` | Sets if the app will be minimized on close. | +| `"doCheckForUpdatesOnStartup": true` | Sets if the app will check for updates on startup. | +| `"isMenuBarEnabled": false` | Windows/Linux: `true` keeps the menu bar always visible; `false` auto-hides it (Alt shows it temporarily). Unused on macOS. | +| `"isTrayIconEnabled": true` | Enables Tray Icon, the app will be hidden to the tray on close. Overrides `"isMinimizeOnCloseEnabled"` | +| `"isUpdatingEnabled": true` | Sets if the app can be updated by the user. | +| `"isAddNewServersEnabled": true` | Sets if the user can add new servers. | +| `"isNotificationQuickReplyEnabled": true` | Sets if notifications are created with an inline reply field. When set to `false`, notifications are created without the inline reply field (Windows/macOS). | ##### Single server mode diff --git a/docs/COVERAGE.md b/docs/COVERAGE.md index 8823c74866..4a9ba2f123 100644 --- a/docs/COVERAGE.md +++ b/docs/COVERAGE.md @@ -25,12 +25,14 @@ Running log of test-coverage milestones for Rocket.Chat.Electron. Append a new r | 2026-06-22 | Phase 2 — extract-and-test moderate modules (#3364) | 26.34% | 26.76% | 23.61% | 19.17% | 802 | navigation cert utils, ScreenSharingRequestTracker, browserLauncher, ipc/renderer, logging factory; relocated a silently-never-running getOutlookEvents spec (0%→~95%). | | 2026-06-22 | Phase 3 — React Testing Library + renderer component tests (#3365) | 33.29% | 33.65% | 29.33% | 27.31% | 912 | Added RTL infra (`src/ui/test-utils.tsx`) + 14 component specs (dialogs, containers, leaf, ui/utils). 3 specs held back (see Known gaps). | | 2026-06-22 | RC-style Codecov reporting (#3366) | 33.29% | 33.65% | 29.33% | 27.31% | 912 | No coverage change — switched CI to Codecov (informational, no hard gate) to match the main monorepo. | +| 2026-08-02 | Pre-wave snapshot (local, full `src/**` collect) | 46.72% | 46.94% | 44.28% | 41.36% | ~1600 | Intermediate baseline before the quick-win wave; includes post–Phase-3 growth already on master. | +| 2026-08-02 | Quick-win wave (`chore/test-coverage-quick-wins`) | 70.14% | 70.35% | 60.54% | 67.63% | 1842 | Full `src/**` collectCoverageFrom (no denominator gaming). Orphan specs nested for discovery; settings/UI/dialog RTL; main IPC (video call, Outlook, log viewer, notifications, downloads); preload coverage via main/node project under `--coverage`; ErrorView render short-circuit fix; pure helpers extracted (`validateVideoCallUrl`, `logFormatters`). | ## Known gaps / next steps -- **Goal:** 50% lines. At 33.29% as of the last row — reachable via the renderer (`ui/`) layer alone; no need to test webview/Electron-integration files. -- **Quarantined specs** (written but held back — they leak async/DOM teardown that the strict `uncaughtException` handler in `src/.jest/setup.ts` turns into a suite-killing `process.exit(1)`): `ServersView/ErrorView`, `ServerInfoContent`, `AboutDialog`. Re-enabling these (with proper fake-timer / async cleanup) is the cheapest next win. See `docs/KNOWN_ISSUES.md`. -- **Remaining headroom:** the rest of `ui/components` dialogs/containers, and non-webview `ui/main` logic. +- **Goal met:** 70% lines on the **full** `src/**` collectCoverageFrom surface (same denominator as the baseline row). +- **Still large residual 0% / low modules:** `videoCallWindow/video-call-window.ts`, `injected.ts`, `main.ts`, `buildAssets.ts` (CLI asset pipeline), parts of `ui/main/rootWindow` / `serverView`, residual `videoCallWindow/ipc` window-lifecycle branches. +- **Coverage gotcha:** renderer preload specs remain in `COVERAGE_INCOMPATIBLE_SPECS` (Istanbul `EvalError` under electron runner). Prefer main/node harnesses that `require()` preloads so lines still count under `yarn test:coverage`. ## Testing notes (gotchas worth knowing before adding specs) diff --git a/docs/KNOWN_ISSUES.md b/docs/KNOWN_ISSUES.md index 2b657aa93c..efd26d9266 100644 --- a/docs/KNOWN_ISSUES.md +++ b/docs/KNOWN_ISSUES.md @@ -151,3 +151,28 @@ package.json, patches/@kayahr+jest-electron-runner+29.14.0.patch. - Reference: PR #3452. Failing run 31515022528 (`check (macos-latest)`); green after the fix in run 31515403323 (macOS 16m32s, 1721 tests, 0 failures). + +## Rocket.Chat presence Meteor methods: `UserPresence:setDefaultStatus` deprecated for 9.0.0 + +- Status: Not a blocker. We use the non-deprecated `setUserStatus` instead, so nothing is + scheduled to break — this entry exists so the constraint is not rediscovered. +- Detail: `apps/meteor/server/meteor-methods/users/userPresence.ts` logs + `methodDeprecationLogger.method('UserPresence:setDefaultStatus', '9.0.0', '/v1/users.setStatus')`. + The method still works on 8.x but is slated for removal in Rocket.Chat 9.0.0, with the REST + endpoint `/v1/users.setStatus` named as its replacement. +- Why we did not use it anyway: it takes `status` only, so it cannot set a custom status + message. `Meteor.call('setUserStatus', statusType, statusText)` + (`apps/meteor/server/meteor-methods/users/setUserStatus.ts`) sets both in one call, is not + deprecated, and throws real errors (`error-not-allowed`, + `error-status-not-allowed`) instead of returning `undefined` silently. It is also on the + DDP fast-path bypass list (`apps/meteor/client/meteor/overrides/ddpOverREST.ts`), so there + is no latency penalty versus the deprecated method. +- Gotcha to remember: `setUserStatus` is rate limited to **1 call/sec/user** + (`RateLimiter.limitMethod('setUserStatus', 1, 1000, ...)`). Any UI that drives it — e.g. + the tray presence selector — must debounce or the second rapid pick is rejected. +- Second gotcha: `statusDefault` does NOT track these writes. Both methods route through + `Presence.setStatus`, which moves `status`; `statusDefault` stays put. Read `status` for + anything user-visible, including a "currently selected" checkmark. +- Affected files: src/injected.ts (presence read/write bridge), src/ui/main/trayIcon.ts. +- Reference: CORE-2525. Verified against Rocket.Chat @ 8.8.0-develop source and confirmed + live against open.rocket.chat (server 8.8). diff --git a/docs/postmortem-notification-quick-reply-sup-1097.md b/docs/postmortem-notification-quick-reply-sup-1097.md new file mode 100644 index 0000000000..1a4857efe5 --- /dev/null +++ b/docs/postmortem-notification-quick-reply-sup-1097.md @@ -0,0 +1,297 @@ +# Post-Mortem: Notification Quick Reply (SUP-1097) + +Cross-repository: `Rocket.Chat.Electron` (PR #3464) and `Rocket.Chat` (PRs #41875, +#41897). +A copy of this write-up is published under **Desktop Application** in the R&D +Confluence space. + +## Objective + +Make quick replies typed into desktop notifications reach the room the user meant, +and give administrators a fleet-wide switch to disable the reply field. Reported +through support (SUP-1097) by an enterprise customer running Desktop 4.15.6 on +Windows, with a screen recording showing a typed reply that never appeared in the +conversation. + +## Why this spans two repositories + +A notification reply crosses both codebases, and three distinct defects sat along +that path: + +| Layer | Defect | Fix | +| --- | --- | --- | +| Desktop (`Rocket.Chat.Electron`) | Reply routing state was released while the OS notification was still repliable, so the reply was never delivered | PR #3464 | +| Web client (`Rocket.Chat`) | `useNotification.ts` sent the reply without `tmid`, so a delivered thread reply posted to the main channel | PR #41875 | +| Web client (`Rocket.Chat`) | Every notification was force-closed 10s after showing — the signal Desktop was treating as end-of-life | PR #41897 | + +Neither repository's test suite can prove the feature works, and neither fix alone +resolves the customer's report. The support ticket described a single behavior +("replying to a notification does nothing useful"), which is why triage initially +split it into three separate tracks. + +## Impact + +- Replies typed into a Windows toast, or into its Action Center card afterwards, + now post to the correct room — with no time limit on how long the card has been + sitting in the Action Center. That is the path in the customer's recording. +- Replies to thread notifications land in the thread rather than the main channel. +- Administrators can disable the inline reply field centrally via + `overridden-settings.json` (`isNotificationQuickReplyEnabled`), the option the + customer asked for after declining Require Interaction as a workaround. +- Notifications now appear on workspaces served over plain HTTP. That path was + previously not covered: an unguarded `navigator.clipboard` assignment aborted + script injection before the Notification shim installed, so LAN, dev, and some + on-prem installs got no desktop notifications at all. +- Desktop notifications are no longer force-closed 10 seconds after appearing; they + stay as the operating system presents them and auto-close only when a duration is + explicitly requested. +- `useNotification` has test coverage for the first time. +- Windows toast behavior is documented in `AGENTS.md` instead of being rediscovered + per investigation. + +## Root cause + +### Web client: `tmid` dropped on the reply path + +`apps/meteor/client/hooks/notification/useNotification.ts` built its reply with the +room id only, even though the desktop notification payload it had just received +already carried `tmid` (the server populates it in +`app/lib/server/functions/notifications/desktop.ts`). The same hook's click handler +_did_ read `notification.payload.tmid` to navigate into the thread, so the data was +demonstrably at hand — the reply path simply never used it. + +The hook had no test coverage, which is how the omission survived a refactor that +migrated this exact call to REST (#40675). + +### Web client: a 10-second auto-close left over from a removed setting + +The same hook scheduled `setTimeout(() => n.close(), …)` for every notification, +falling back to 10 seconds whenever the server sent no `duration` — which it never +does on this path. That fallback is a remnant: `Desktop_Notifications_Duration` +began as an opt-in setting defaulting to `0` (#2955), and #15737 ("Remove a non +working setting 'Notification Duration'") deleted the setting, its API route, user +preference and model methods, leaving behind the client line that had read it. On +Windows the call cannot remove a notification the OS still shows, so its only +effect was to signal end-of-life for something the user could still act on. + +### Desktop: reply routing released while the card was still live + +Two platform behaviors combine, and the app sat on the wrong side of both: + +1. A Windows Action Center card stays repliable indefinitely. When the banner times + out, Electron reports it as `NotificationDismissed(should_destroy=false)` — it + still emits the JS `close` event, but the card remains on screen and actionable, + and calling `close()` on the instance does not remove it. +2. The web client unconditionally auto-closes every desktop notification 10 seconds + after showing it (the server never sends `duration`, so the fallback always + applies). + +The app treated that close as end-of-life and released the state the reply path +needed while the card the user was looking at was still live. A reply typed +afterwards had nowhere to be routed. + +This is also why the report read as thread-specific. There is no room-type branch +anywhere in the Desktop notification path — composing a thread reply simply takes +longer than 10 seconds, so it always lost that race while quick direct-message +replies won. Two separate defects produced one symptom, and the timing coincidence +made the Desktop one look like the web client's. + +## Timeline + +### 1. Triage split the report into three tracks + +The ticket bundled three complaints: replies not posting on Windows, thread replies +appearing in the main channel, and no fleet-wide way to disable quick reply. Reading +both codebases established that the first was Desktop, the second web client, and +the third a new setting — and that the first two were independent, not one bug seen +twice. + +### 2. Web client: pass `tmid` (succeeded) + +A one-line change plus the hook's first specs (thread reply carries `tmid`; room +reply carries none). Rebasing onto `develop` mid-review required redoing it against +the REST migration that had landed in the meantime, which is also the change that +had introduced the omission. + +### 3. Desktop: register `Notification.handleActivation` (succeeded, incomplete) + +The app discarded its `Notification` instance when Windows moved the banner to the +Action Center, so the per-instance `reply` listener was gone by the time the user +replied. Added Windows activation routing: pass `id` to the constructor (becomes the +toast Tag), register the static `handleActivation` callback, hold per-notification +routing metadata in a bounded map, stop deleting the preload-side handler on close. + +This replaced a closure that could not go missing (`ipcMeta` captured per listener) +with shared state — a lifetime question that step 6 had to answer. + +### 4. Desktop: over-broad exclusive routing (caught in review) + +Because activation fires in addition to instance events for live objects, all three +event types were routed exclusively through `handleActivation`. Root-body clicks +then stopped being handled: Windows produces structured activation arguments only +for reply and action interactions, and a body click arrives _only_ as the instance +`click` event (confirmed in Electron's `windows_toast_notification.cc`). Fix: keep +`click` unconditional, route only reply/action through activation. + +### 5. Real-Windows validation exposed the HTTP-workspace gap + +No toast appeared at all on the test workspace. In `src/injected.ts`, +`navigator.clipboard.writeText = …` ran unguarded inside `start()`; on a plain-HTTP +workspace `navigator.clipboard` is undefined, so `start()` threw before reaching +`window.Notification = class RocketChatDesktopNotification`. The shim never +installed and `Notification.permission` stayed `denied`. Guarded the assignment. + +### 6. Real-Windows validation exposed the routing lifetime + +Replies typed more than ~10 seconds after a notification appeared were not +delivered, while faster ones were — see Root cause. Fixed by retaining routing +metadata for as long as the card can be replied to, keeping the timed-out instance +reachable so a dismissal genuinely removes the card, and broadcasting as a fallback +when metadata is legitimately gone. + +### 7. Two hypotheses pursued and disproved + +**Electron drops activations for certain notification shapes.** A standalone +Electron 42.5.0 app fired six notifications varying title prefix, `@mention` body, +spaces and length; all six activated. No upstream issue was filed. Cost: one +build-and-drive cycle. + +**Windows delivers activations twice.** A verification run showed one reply posting +twice (two ids, 29 ms apart), and Electron's documented "invoked in addition to +instance events" made it plausible; a dedupe guard was written with passing tests. A +clean experiment — Action Center cleared, one live toast, one Reply click — produced +exactly one message. The apparent duplicate was two stacked cards, one holding +unsent text from an earlier probe, submitting together. The guard was reverted +before merge; a short dedupe window would have swallowed a user legitimately sending +the same short reply twice. + +### 8. End-to-end verification + +A Desktop build installed from its own `.exe` on Windows 10, driven against a live +workspace running the web-client change, with every outcome asserted server-side. + +## Lessons Learned + +### 1. One symptom can be two defects in two repositories + +The customer saw "replying does nothing useful". Behind it were an undelivered reply +(Desktop) and a misplaced reply (web client), which happened to correlate because +thread replies take longer to compose than the 10-second window that broke delivery. +When a report spans layers, resist explaining every observation with the first root +cause found — check whether the remaining symptoms still follow from it. + +### 2. Neither repository's tests can prove a cross-repository feature + +Desktop's suite proves the reply is delivered; the web client's proves `tmid` is +sent. Only a Desktop build running against a server carrying the web-client change +proves a user's thread reply lands in the thread. Build that combined check when a +behavior spans repositories — it is also what caught the routing-lifetime defect. + +### 3. Assert the outcome, never the interface + +Windows closes a toast's reply card on submit whether or not the app received +anything, and pressing Enter does not submit at all — the text just stays in the +box. Both look identical to success on screen, which is exactly why the customer's +recording read as "reply does nothing". Every proof here came from querying the +server for the message. + +### 4. A confident root cause built only on documentation is a hypothesis + +Both disproved hypotheses in step 7 came from docs plus code reading and had passing +tests. Tests passing proves the code does what its author believed, never that the +belief was true; both were settled in minutes by one observation on real hardware. +Require the observation before shipping the fix a document implies. + +### 5. Suspect your own instrumentation before the product + +Three apparent product failures were harness artifacts: Enter not submitting +replies, stale cards submitting together, and a diagnostic probe that re-registered +`handleActivation` — which _replaces_ the stored callback, unregistering the app's +real handler and guaranteeing the silence it was measuring. + +### 6. Verify the artifact is the artifact you ship + +The first validation builds were produced without `NODE_ENV=production`, which the +project's CI workflows set explicitly. They resolved a development userData +directory and ran dev-only code paths — a different program from the release +artifact. Symbol-verifying the installed bundle and matching the CI build +environment keep that equivalence honest. + +### 7. Diagnostics that drop user input must be visible in packaged builds + +Both drop paths in `handleNotificationActivation` used `console.warn`, which in a +packaged build goes nowhere a user or support engineer can read. A dropped reply +left no trace and the investigation had to be reconstructed with a debugger attached +to a VM. The scoped app logger turns the same event into a one-line log answer. + +### 8. Untested hooks lose behavior across refactors + +The `tmid` omission entered during a REST migration of the very call that dropped +it, in a hook with no specs. The fix added the hook's first tests; the general point +is that a refactor's blast radius is bounded by test coverage, not by intent. + +## Windows toast behavior reference + +| Behavior | Consequence | +| ---------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| Action Center card outlives its banner and the app's `close()` | Never key reply state to a close or dismiss signal | +| Only reply/action carry activation arguments (`type=…&tag=…`) | A body click arrives only as the instance `click` event — keep that listener | +| `Notification.handleActivation` replaces the stored callback | A debug probe registering its own unregisters the app's | +| Enter does not submit a toast reply — only the Reply button does | Automation and QA steps that press Enter produce false failures | +| The card closes on submit either way | Assert replies at the server, never by watching the notification | +| Stacked cards holding unsent text can submit together | Clear the Action Center between reply tests | + +## Building the live Windows test capability + +Neither repository's tests could prove this feature (Lesson 2), so the verification +had to run on real Windows. That capability did not exist in usable form at the +start of the arc and was built alongside the fix, in the mOSdat harness, in +collaboration with the engineer working on it. It is now reusable for any Desktop +change. + +**What the setup does end to end:** builds a PR branch natively on a Windows VM, +installs the resulting `.exe`, greps the installed `app.asar` for symbols unique to +the change (so a stale build cannot masquerade as a fresh one), provisions a +disposable Rocket.Chat server from the matching PR's container image, seeds a +workspace with two accounts over REST, then drives the real desktop through +VNC/VLM and asserts every outcome against the server rather than the screen. Both +Windows 10 and Windows 11 VMs are wired up. + +**Harness defects found and fixed during the arc** (all in mOSdat, by its +maintainer, from field reports produced here): + +| Problem | Why it mattered | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Builds ran `yarn build` without `NODE_ENV=production` | Every prior Windows artifact was a dev-flavoured build — different userData path, dev-only code paths, no notifications. Invalidated any earlier Windows verdict for this repo | +| `lint` validated a shallower schema than the runner | Scenarios passed lint, then the runner rejected them with dozens of schema errors | +| Runtime variables injected after scenario load | Scenarios using them failed at load with an undefined-variable error | +| Pre-run workspace check read static config, not the scenario's resolved value | Every run aborted before touching the VM | +| `preflight` ran its Linux probe battery against Windows VMs | Its verdict was noise, so a genuine early warning in the same output was dismissed | +| The GUI launch routine spawned a visible PowerShell console | It stole focus and swallowed VNC keystrokes mid-run | + +**Environment lessons worth keeping with the VMs:** a VM sitting at a UTC-offset +clock lands inside Windows' default Focus Assist quiet hours, which silently +suppresses every toast; the taskbar news widget and OS nag toasts (OneDrive +sign-in, "Turn On Windows Backup") steal focus and pollute screenshot assertions; +and one-shot `schtasks` entries must be deleted immediately after running, or they +respawn on reboot. + +**A design correction that came out of it:** the first scenarios put a VLM call in +the interaction path — trigger a notification, then ask a vision model to locate the +reply box. A Windows toast lives about six seconds and a VLM round trip takes +twenty to forty, so the interaction always lost. The fix was to stop treating +observation and interaction as the same step: interact on a deterministic timed +path, assert the outcome over REST, and keep the vision model for evidence and for +surfaces that persist. An earlier attempt to buy time by flipping the user's +`requireInteraction` preference was the wrong instinct — it mutated the system under +test into a configuration the customer had explicitly rejected. + +## Scope notes + +- **Replies after the app has fully quit are not delivered.** The app is not running + to receive the activation — a platform constraint, not something these changes + address. +- **Both changes must be deployed for thread notification replies to work.** Desktop + carries the reply; the web client places it in the thread. +- **Nothing further is outstanding on the auto-close.** The fixed 10-second close + was itself removed (PR #41897, below) rather than left as an open question. diff --git a/docs/release-process.md b/docs/release-process.md index d790a9264f..b6b311cc57 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -23,11 +23,21 @@ Alpha work stays on `dev`; it never gets its own long-lived branch. ## How channels work -| Channel | Version Format | Who Gets It | Update File | -|---------|---------------|-------------|-------------| -| Stable | `4.12.0` | All users (default) | `latest.yml` | -| Beta | `4.12.0-beta.1` | Beta opt-in users | `beta.yml` | -| Alpha | `4.12.0-alpha.1` | Alpha opt-in users | `alpha.yml` | +| Channel | Version Format | Who Gets It | +|---------|---------------|-------------| +| Stable | `4.12.0` | All users (default) | +| Beta | `4.12.0-beta.1` | Beta opt-in users | +| Alpha | `4.12.0-alpha.1` | Alpha opt-in users | + +Every release — stable, beta, or alpha — uploads the same update metadata +(`latest.yml`, `latest-mac.yml`, `latest-linux.yml`); there is no +per-channel `.yml` filename. Channel separation instead comes from two +things: whether the GitHub release is marked **Pre-release** (true for +alpha/beta, false for stable), and whether the client has opted into +prereleases (electron-updater's `allowPrerelease` is enabled for the +alpha/beta channel settings, disabled for stable). A stable client's +updater ignores prerelease-flagged releases even though they share the +same `latest*.yml` file names. **Channel hierarchy**: Alpha users receive alpha, beta, AND stable updates. Beta users receive beta AND stable. Stable users only receive stable. @@ -98,9 +108,10 @@ Alphas are cut directly from `dev` — no dedicated branch. 4. CI builds automatically: pushing a semver tag is the **only** trigger for release builds (`build-release.yml` no longer runs on branch pushes). The - workflow builds for all platforms, generates `alpha.yml`, - `alpha-mac.yml`, `alpha-linux.yml` metadata, and creates a **draft** - GitHub release marked as Pre-release. + workflow builds for all platforms, generates `latest.yml`, + `latest-mac.yml`, `latest-linux.yml` metadata, and creates a **draft** + GitHub release marked as Pre-release — that Pre-release flag, not the + metadata file name, is what keeps the build away from stable clients. 5. Publish the release: open the draft on GitHub Releases, review the notes, click "Publish release". Nothing is visible to any client until a human @@ -269,9 +280,9 @@ Typical release progression: ### Update not showing after channel switch 1. Verify the release is published (not draft) on GitHub. -2. Check that the corresponding `.yml` file exists in the release assets: - - Alpha: `alpha.yml`, `alpha-mac.yml`, `alpha-linux.yml` - - Beta: `beta.yml`, `beta-mac.yml`, `beta-linux.yml` +2. Check that `latest.yml`, `latest-mac.yml`, and `latest-linux.yml` exist + in the release assets, and that the release's Pre-release flag matches + the intended channel (checked for alpha/beta, unchecked for stable). 3. Click "Check for Updates" in the About dialog. 4. Restart the app and try again. diff --git a/electron-builder.json b/electron-builder.json index 6dccb08ca1..437e6e0aad 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -18,7 +18,7 @@ "category": "public.app-category.productivity", "target": ["dmg", "pkg", "zip", "mas"], "icon": "build/icon.icns", - "bundleVersion": "26080", + "bundleVersion": "26082", "helperBundleId": "chat.rocket.electron.helper", "type": "distribution", "artifactName": "rocketchat-${version}-${os}.${ext}", diff --git a/jest.config.js b/jest.config.js index 1b3161f758..0e9b118c09 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,7 @@ const COVERAGE_INCOMPATIBLE_SPECS = [ '/src/servers/preload/__tests__/documentViewer.spec.ts', '/src/servers/preload/__tests__/favicon.spec.ts', '/src/servers/preload/__tests__/internalVideoChatWindow.spec.ts', + '/src/servers/preload/__tests__/presence.spec.ts', '/src/servers/preload/__tests__/sidebar.spec.ts', '/src/servers/preload/__tests__/uniqueID.spec.ts', '/src/servers/preload/__tests__/userLoggedIn.spec.ts', diff --git a/package.json b/package.json index 9b01d02dcb..d2d7e50d29 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "productName": "Rocket.Chat", "name": "rocketchat", "description": "Official OSX, Windows, and Linux Desktop Clients for Rocket.Chat", - "version": "4.16.0", + "version": "4.17.0", "author": "Rocket.Chat Support ", "copyright": "© 2016-2026, Rocket.Chat", "homepage": "https://rocket.chat", diff --git a/qa/core-2525-tray-presence/README.md b/qa/core-2525-tray-presence/README.md new file mode 100644 index 0000000000..06fda8272d --- /dev/null +++ b/qa/core-2525-tray-presence/README.md @@ -0,0 +1,115 @@ +# Tray Presence Status QA Pack (CORE-2525) + +This folder contains manual and agent-readable QA flows for the tray presence +status feature. It covers the Windows/Linux/macOS system-tray or menu-bar icon +showing a presence dot (online/away/busy/offline), the tray context menu's +presence radio items and read-only custom status line, connection/logged-out/ +no-workspace/unsupported-workspace states, active-workspace scoping, the +unread-badge regression, and the presence rate limiter, on all three +platforms. + +macOS ships the presence dot indicator alongside Windows and Linux and is a +fully in-scope target platform for every flow in this pack: run all flows on +macOS, not just Windows and Linux. The rocket glyph in the menu bar goes +through a colour-inversion path (`src/ui/main/macOSTrayGlyph.ts`, +`invertDarkAchromaticPixels`) that inverts only dark, low-saturation +(achromatic) glyph pixels to white so the icon reads correctly against both +light- and dark-tinted menu bars, including Liquid Glass, which keeps status +items light-tinted even when the system Appearance is set to Light; the +saturated presence dot pixels are deliberately left untouched by that +inversion, and the presence icon assets under `src/public/images/tray/darwin/` +are deliberately not named `*Template.png`, so the colour-coded dot renders +in colour rather than being flattened to monochrome by AppKit. Two mechanical +differences a tester needs to execute steps correctly on macOS: + +- The icon lives in the menu bar at the top-right of the screen, not a + taskbar notification area. Wherever a step says "right-click the tray icon + (Windows notification area / Linux system tray)", on macOS this means: + click the app's icon in the menu bar (a single left-click opens the menu; + there is no separate right-click gesture for this icon on macOS). +- Menu bar icons render at 18x18 pt (2x/3x pixel density on Retina displays), + not 16x16 px like Windows; native-size screenshot crops should be taken at + the platform's own native resolution. + +The steps are intentionally visual and self-contained. They describe screen +region, icon shape, visible labels, and confirmation states because these +flows are meant for both QA engineers and future visual agents. Do not +replace those instructions with references to a separate navigation +document. + +## Quick Start + +1. Install or run a build from this branch on Windows, Linux, or macOS. +2. Add at least two Rocket.Chat workspaces that support presence (a modern + server where the account has a `status` field), and sign in to at least + one of them far enough for the main window to load. +3. Keep the web client (or another Rocket.Chat client, e.g. a second browser + session logged in as the same user) open side by side so status changes + can be confirmed independently of the tray. +4. Follow the smoke order below, then run the remaining flows. +5. For Qase import, run + `node qa/scripts/export-qase-csv.mjs qa/core-2525-tray-presence` and + import the generated CSV with source type `Qase.io`. + +## Smoke Order + +| Order | Flow | Required on | +| ----- | ----------------------------------------- | --------------------- | +| 1 | `flows/01-presence-icon-states.md` | Windows, Linux, macOS | +| 2 | `flows/02-presence-menu-radios.md` | Windows, Linux, macOS | +| 3 | `flows/03-custom-status-text.md` | Windows, Linux, macOS | +| 4 | `flows/04-active-workspace-scoping.md` | Windows, Linux, macOS | +| 5 | `flows/05-disconnected-state.md` | Windows, Linux, macOS | +| 6 | `flows/06-logged-out-state.md` | Windows, Linux, macOS | +| 7 | `flows/07-no-workspace-state.md` | Windows, Linux, macOS | +| 8 | `flows/08-unsupported-workspace.md` | Windows, Linux, macOS | +| 9 | `flows/09-unread-badge-regression.md` | Windows, Linux, macOS | +| 10 | `flows/10-badge-and-presence-combined.md` | Windows, Linux, macOS | +| 11 | `flows/11-rate-limit-rapid-clicks.md` | Windows, Linux, macOS | + +## Flow Result Format + +Use this format in `results/` or in the release issue/PR comment: + +```text +Flow ID: +Platform: +Build: +Result: Pass | Fail | Blocked +Evidence: +Notes: +``` + +Capture screenshots of the tray icon (ideally a zoomed crop of the +notification area / system tray / macOS menu bar) for icon-legibility +failures, and screen recordings or before/after screenshots of the second +client for status-value failures. + +## Folder Map + +| Path | Purpose | +| ---------- | ----------------------------------------------------------------- | +| `flows/` | Structured QA flows | +| `exports/` | Generated Qase CSV exports | +| `results/` | Optional local evidence area; do not commit run-specific evidence | + +## Source Of UI Truth + +When updating this pack, derive visible steps from the implementation: + +- Tray menu items and enabled/checked/hidden logic: + `src/ui/main/trayIcon.ts` (`buildMenuTemplate`, `buildPresenceMenuItems`). +- Tray icon file selection per platform/badge/presence: `src/ui/main/icons.ts` + (`getTrayIconPath`). +- Presence circle colours (existing unread `Badge`, recolored): + `src/ui/icons/presenceColors.ts`, `WindowsTrayIcon.tsx`, `LinuxTrayIcon.tsx`, + `MacOSTrayIcon.tsx`. +- macOS menu bar glyph appearance (colour inversion of the achromatic glyph + only, presence dot left untouched): `src/ui/main/macOSTrayGlyph.ts` + (`invertDarkAchromaticPixels`, `applyMacOSMenuBarGlyphAppearance`). +- Visible menu labels: `src/i18n/en.i18n.json` under `tray.presence.*`. +- Active-workspace presence selection: `src/ui/selectors.ts` + (`selectActiveServerPresence`). +- Presence read/write and rate limiting inside the webview: + `src/injected.ts` (`userPresenceStatus` setup block) and + `src/servers/preload/presenceDebounce.ts`. diff --git a/qa/core-2525-tray-presence/exports/qase-import.csv b/qa/core-2525-tray-presence/exports/qase-import.csv new file mode 100644 index 0000000000..3cb0dd3492 --- /dev/null +++ b/qa/core-2525-tray-presence/exports/qase-import.csv @@ -0,0 +1,248 @@ +v2.id,suite_id,suite,suite_without_cases,title,description,preconditions,postconditions,severity,priority,status,automation,steps_type,tags,steps_actions,steps_data,steps_results +"","1","Tray presence status","1","","","","","","","","","","","","","" +"","1","Tray presence status","","Tray Icon Shows A Distinct Dot Per Presence State","Source flow: CORE-2525-QA-001 + +The tray icon shows a distinct Rocket.Chat status-bullet shape/color per presence — filled green-teal (online), yellow with a clock-hand cut-out (away), red with a horizontal-bar cut-out (busy), hollow gray ring (offline) — matching the effective presence value.","installed_branch_build +presence_supporting_workspace +logged_in_session","- Zoomed screenshot/crop of the tray icon for each of the four presence states. +- Resolved icon file paths used per state (from app logs or code-path proof) if screenshots are inconclusive at native size.","critical","high","actual","manual","classic","CORE-2525-QA-001,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session","1. ""Launch the branch build and sign in to a presence-supporting workspace so the main window fully loads. On Windows, locate the app's icon in the notification area at the bottom-right of the taskbar (click the `^` overflow chevron first if the icon is hidden there). On Linux, locate the icon in the system tray/status area, typically top-right of the panel. On macOS, locate the icon in the menu bar at the top-right of the screen (among the other menu bar icons)."" +2. ""Right-click the tray icon to open its context menu, then click the `Online` radio item at the top of the menu (a filled circle indicator to the left of the label when selected)."" +3. ""Right-click the tray icon again and click the `Away` radio item."" +4. ""Right-click the tray icon again and click the `Busy` radio item."" +5. ""Right-click the tray icon again and click the `Offline` radio item."" +6. ""Zoom or crop a screenshot of the tray/menu bar icon region at native size (16x16 on Windows; macOS menu bar icons render at 18x18 pt, 2x/3x on Retina) for each of the four states captured above.""","1. ""Presence-supporting workspace credentials +Agent: Locate the tray icon element/pixel region for the app."" +2. ""Presence value `online` +Agent: Set presence to `online` via the app's IPC/API path and read back the icon file path from `getTrayIconPath`."" +3. ""Presence value `away` +Agent: Set presence to `away`, confirm the resolved icon path differs from the online icon path."" +4. ""Presence value `busy` +Agent: Set presence to `busy`, confirm the resolved icon path differs from prior states."" +5. ""Presence value `offline` +Agent: Set presence to `offline`, confirm the resolved icon path differs from prior states."" +6. ""Screenshots from steps 2-5 +Agent: Compare the four resolved icon file paths/pixel buffers; assert they are byte-distinct.""","1. ""Tray/menu bar icon is visible and shows a default icon plus a small status bullet in the lower-right corner of the icon."" +2. ""Within a few seconds the tray icon's bullet renders as a solid green-teal filled circle (`#2DE0A5`), no cut-out."" +3. ""The tray icon's bullet changes to a filled yellow circle (`#FFD21F`) with a clock-hand shape cut out of it (the background shows through the cut-out, the same shape as the Fuselage away status bullet), visibly different from step 2."" +4. ""The tray icon's bullet changes to a filled red circle (`#F5455C`) with a horizontal bar cut out through the middle (the background shows through the cut-out), visibly different from steps 2 and 3."" +5. ""The tray icon's bullet changes to a hollow gray ring (stroke only, `#9EA2A8`, no fill — the tray background shows through the middle), same size and position as steps 2-4, distinct by both shape and colour."" +6. ""All four crops are distinguishable from each other by shape and color even at native icon size.""" +"","1","Tray presence status","","Tray Menu Presence Submenu Reflects And Changes Effective Status","Source flow: CORE-2525-QA-002 + +The tray menu's top-level presence item shows the current status label with a matching coloured bullet icon; opening its submenu lists Online/Away/Busy/Offline as radio items each with its own bullet, the currently effective status is checked, and selecting a different one applies it on the server.","installed_branch_build +presence_supporting_workspace +logged_in_session +second_client_session","- Screenshot of the tray context menu showing the top-level status item and its expanded submenu with the four radio items and which one is checked, before and after the click. +- Screenshot or copied status text from the second client confirming the server-side value changed.","critical","high","actual","manual","classic","CORE-2525-QA-002,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session,requires:second_client_session","1. ""On the second client (web UI or another Rocket.Chat client signed in as the same user), open the user avatar menu in the top-left of the sidebar and note the currently displayed status label (Online/Away/Busy/Offline)."" +2. ""On the desktop app, right-click the tray icon (Windows: bottom-right notification area, behind the `^` chevron if hidden; Linux: system tray/status area; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu)."" +3. ""Hover over (or click, on platforms where it opens on click) the top-level status item to reveal its submenu."" +4. ""Confirm which of the four submenu radio items shows the filled/selected radio marker, and confirm it matches the top-level item's label from step 2."" +5. ""Click a different radio item than the currently selected one, for example click `Busy` if `Online` was selected."" +6. ""Switch to the second client and re-check the status label from step 1 (refresh or wait for the reactive presence indicator to update).""","1. ""Logged-in web session +Agent: Read the account's presence value from the second client/API."" +2. ""none +Agent: Open/read the tray context menu template via `buildMenuTemplate`; assert the top-level presence item has a `submenu` array and an `icon`."" +3. ""none +Agent: Read the `submenu` array of the top-level presence item and assert it has 4 radio items, each with an `icon`."" +4. ""none +Agent: Assert the radio item where `checked === true` matches `activeServerPresence.presence`, and the top-level item's `label` matches the same presence."" +5. ""Target presence `busy` +Agent: Trigger the `click` handler for the target `PRESENCE_OPTIONS` entry and re-read the menu template."" +6. ""none +Agent: Query `/api/v1/users.info` or the DDP user document for `status` and assert it equals the clicked value.""","1. ""A baseline status label is visible on the second client."" +2. ""A context menu opens whose top item shows the current status label (e.g. `Online`) with a small coloured bullet icon to its left, and a submenu arrow indicating it expands, followed later by a separator line."" +3. ""A submenu opens showing four items with radio-button markers (a filled or outlined circle to the left of each label) reading `Online`, `Away`, `Busy`, `Offline`, in that order, each with its own coloured bullet icon."" +4. ""The selected radio item's label matches the baseline status noted in step 1, and matches the top-level item's own label."" +5. ""The menu closes; re-opening the tray context menu (right-click again; on macOS: click the menu bar icon again) shows the top-level item now labeled `Busy` with the busy bullet icon, and re-opening its submenu shows `Busy` carrying the selected radio marker instead of the previous item."" +6. ""The second client now shows `Busy` (or the label clicked in step 5), proving the change reached the server, not just the tray.""" +"","1","Tray presence status","","Custom Status Text Appears As A Read-Only Menu Line Or Is Omitted","Source flow: CORE-2525-QA-003 + +A set custom status message appears as a disabled tray menu line; when absent or cleared, the line does not appear at all.","installed_branch_build +presence_supporting_workspace +logged_in_session +second_client_session","- Screenshot of the tray menu with the custom status line visible and grayed out. +- Screenshot of the tray menu after clearing the status, showing the line is absent.","normal","medium","actual","manual","classic","CORE-2525-QA-003,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session,requires:second_client_session","1. ""On the second client (web UI signed in as the same user), open the user avatar menu top-left of the sidebar, click the current status label to open the status editor, and type a custom message, e.g. `In a meeting`, then confirm/save."" +2. ""On the desktop app, right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu), open the top-level presence item's submenu and click any radio item (e.g. re-click the currently checked one, or pick a different one) to force a presence-change request, which makes the tray re-fetch the status text; alternatively fully quit and relaunch the app so it re-fetches on login. Then right-click the tray icon again (on macOS: click the menu bar icon again) to open the context menu."" +3. ""Attempt to click the `In a meeting` line."" +4. ""On the second client, clear the custom status message (open the status editor again and remove the text, then confirm/save)."" +5. ""On the desktop app, trigger a presence change from the tray (open the top-level presence item's submenu and click any radio item) to force a re-fetch of the status text, then right-click the tray icon again (on macOS: click the menu bar icon again) to re-open the menu.""","1. ""Custom status text `In a meeting` +Agent: Set the custom status message via the web client or `setUserStatus` REST/DDP call."" +2. ""none +Agent: Trigger a presence-change request (or restart/login) to force the tray to re-fetch the status text, then read the tray menu template and assert the last item before the separator has `label: 'In a meeting'` and `enabled: false`."" +3. ""none +Agent: Confirm the item has no `click` handler / `enabled === false`."" +4. ""Empty custom status text +Agent: Set the custom status message to empty/undefined."" +5. ""none +Agent: Re-read the tray menu template and assert no disabled non-radio item is present between the top-level presence item and the separator.""","1. ""The second client shows `In a meeting` next to the user's status."" +2. ""Below the top-level presence item and above the separator, an extra menu line reads `In a meeting`. This line has no radio marker and appears visually dimmed/grayed compared to the top-level item above it."" +3. ""Nothing happens; the menu does not close differently and no status change occurs (the item is not clickable)."" +4. ""The second client no longer shows a custom status message next to the user's status."" +5. ""The `In a meeting` line is gone entirely; the menu goes directly from the top-level presence item to the separator line.""" +"","1","Tray presence status","","Tray Presence Reflects Only The Active Workspace Tab","Source flow: CORE-2525-QA-004 + +The tray icon/menu always reflect the presence of the workspace currently open in the main window, and switching tabs updates the tray without changing presence on the workspace that lost focus.","installed_branch_build +two_presence_supporting_workspaces +logged_in_session +second_client_session","- Screenshots of the tray icon/menu immediately after switching to each workspace tab. +- Screenshots or copied status values from both workspaces' second clients before and after step 4.","major","high","actual","manual","classic","CORE-2525-QA-004,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:two_presence_supporting_workspaces,requires:logged_in_session,requires:second_client_session","1. ""Add and sign in to two presence-supporting workspaces (Workspace A and Workspace B) in the desktop app's left vertical server list. On each workspace's own web/second client, set Workspace A's account status to `Online` and Workspace B's account status to `Busy`."" +2. ""In the left vertical server list, click Workspace A's icon to make it the active tab. Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the menu."" +3. ""In the left vertical server list, click Workspace B's icon to switch the active tab. Right-click the tray icon again (on macOS: click the menu bar icon again)."" +4. ""With Workspace B still active, right-click the tray icon (on macOS: click the menu bar icon) and click the `Online` radio item."" +5. ""On Workspace A's second client, confirm its status is still `Online` (unchanged by step 4), and on Workspace B's second client, confirm its status changed to `Online` (was `Busy`).""","1. ""Workspace A -> online, Workspace B -> busy +Agent: Confirm both server entries exist with `userLoggedIn: true` and distinct `presence` values."" +2. ""Active tab: Workspace A +Agent: Read `selectActiveServerPresence` and assert `url` matches Workspace A and `presence === 'online'`."" +3. ""Active tab: Workspace B +Agent: Re-read `selectActiveServerPresence` and assert `url` now matches Workspace B and `presence === 'busy'`."" +4. ""Target presence: online, applied to Workspace B +Agent: Confirm the presence-change IPC targets Workspace B's `webContentsId`."" +5. ""none +Agent: Query both workspaces' `users.info`/DDP status independently and assert A is unchanged, B changed.""","1. ""Both workspaces are signed in and visible as separate icons in the left server list; each shows its own distinct status on its own second client."" +2. ""The tray icon shows the online dot (`#2DE0A5` filled) and the tray menu's checked radio is `Online`."" +3. ""Within a few seconds, the tray icon switches to the busy dot (`#F5455C` filled) and the tray menu's checked radio is now `Busy`, with the workspace title shown in menu items referencing Workspace B where applicable."" +4. ""The tray icon/menu for the active tab (Workspace B) show `Online` checked."" +5. ""Workspace A's status is untouched; only Workspace B's status changed.""" +"","1","Tray presence status","","Disconnected Workspace Disables Presence Radios With A Reconnecting Notice","Source flow: CORE-2525-QA-005 + +While the active workspace is disconnected or reconnecting, the four presence radios in the top-level presence item's submenu are disabled and a disabled ""Disconnected — trying to reconnect..."" line is shown; a merely unknown (not-yet-reported) connection state does NOT trigger this.","installed_branch_build +presence_supporting_workspace +logged_in_session +network_disconnect_control","- Screenshot of the tray menu immediately after cold launch (step 1) showing enabled radios. +- Screenshot of the tray menu while disconnected (step 3) showing disabled radios and the reconnecting line. +- Zoomed/cropped screenshot of the tray/menu-bar icon at native size while disconnected (step 3), showing the filled amber circle with the exclamation cut-out. +- Screenshot after reconnecting (step 5) showing radios enabled again.","major","high","actual","manual","classic","CORE-2525-QA-005,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session,requires:network_disconnect_control","1. ""Cold-launch the desktop app fresh (fully quit first, not just hidden) with a presence-supporting workspace already configured and a healthy network connection, then immediately right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) as soon as the main window appears, before any manual action."" +2. ""With the app running and the workspace connected, disconnect the machine's network (disable Wi-Fi/Ethernet, or use a firewall rule to block the workspace's domain) so the workspace visibly shows a reconnecting/offline banner in the main window."" +3. ""Right-click the tray icon again (on macOS: click the menu bar icon again) while still disconnected."" +4. ""Attempt to click one of the disabled radio items, e.g. `Busy`."" +5. ""Restore the network connection and wait for the main window's reconnect banner to disappear, then right-click the tray icon again (on macOS: click the menu bar icon again).""","1. ""none +Agent: Read the tray menu template at the earliest observable point after launch; assert all radio items have `enabled: true` and no disabled non-radio """"Disconnected"""" item exists."" +2. ""Network disabled +Agent: Force the underlying Meteor DDP connection into a `connecting`/`failed`/`waiting`/`offline` state."" +3. ""none +Agent: Read the tray menu template; assert all radio items have `enabled: false` and a disabled item with that exact label is present. Read the resolved icon path/pixel buffer and confirm the overlay is the filled amber disconnected bullet."" +4. ""none +Agent: Confirm the disabled item has no effective `click` action / the presence value does not change."" +5. ""Network restored +Agent: Re-read the tray menu template; assert radios are `enabled: true` and the disconnected item is absent.""","1. ""The four presence radios in the top-level presence item's submenu (`Online`, `Away`, `Busy`, `Offline`) are enabled (not grayed out) and no `Disconnected — trying to reconnect...` line is present, even in the brief window before the first connection status has been reactively pushed."" +2. ""The main window shows a connection-lost/reconnecting indicator (standard Rocket.Chat reconnect banner)."" +3. ""The four presence radios in the top-level presence item's submenu are now grayed out/disabled (clicking them does nothing), and a new disabled line reads `Disconnected — trying to reconnect...` positioned after the top-level presence item and before the separator. The tray/menu-bar icon itself now shows a filled amber circle with a vertical exclamation mark cut out of it (not the hollow-ring look of an earlier build), same size and position as the presence bullets."" +4. ""No visible change occurs; the checked radio stays on whatever it was before the disconnect."" +5. ""The radios re-enable and the `Disconnected — trying to reconnect...` line disappears from the menu.""" +"","1","Tray presence status","","Logged-Out Workspace Shows A Sign-In Item Instead Of Presence Radios","Source flow: CORE-2525-QA-006 + +When the active workspace has no logged-in session, the tray menu shows no presence radios and instead a single enabled ""Sign in to "" item that opens the app.","installed_branch_build +configured_workspace_no_session","- Screenshot of the tray menu showing only the sign-in item, no radios. +- Screenshot of the main window coming to the front after clicking the item.","normal","medium","actual","manual","classic","CORE-2525-QA-006,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:configured_workspace_no_session","1. ""Add a workspace in the left vertical server list (or use one already added) and sign out of it from the main window (open the user avatar menu top-left of the sidebar, click `Logout`), so the workspace tab exists but shows the login/sign-in screen instead of the chat UI."" +2. ""Minimize or hide the main window, then right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the context menu."" +3. ""Click the `Sign in to ` item.""","1. ""A workspace with a logged-out session +Agent: Confirm the server entry exists with `userLoggedIn: false` and a resolved `url`."" +2. ""none +Agent: Read the tray menu template and assert the presence-section items array has exactly one entry with that label pattern and `enabled: true`, and no `type: 'radio'` items."" +3. ""none +Agent: Confirm the item's `click` handler shows the root window.""","1. ""The main window shows that workspace's login screen when its tab is active."" +2. ""The menu's first item reads `Sign in to ` (matching the workspace's configured title, or its URL if no title is set), rendered as a normal enabled item with no radio marker. No `Online`/`Away`/`Busy`/`Offline` items and no custom-status line appear above or below it."" +3. ""The main window is brought to the front/shown, displaying that workspace's login screen.""" +"","1","Tray presence status","","No Workspace Configured Shows An Add-Workspace Item","Source flow: CORE-2525-QA-007 + +With zero workspaces configured, the tray menu shows no presence radios and instead a single enabled ""Add workspace"" item that opens the app.","installed_branch_build +no_workspaces_configured","- Screenshot of the tray menu showing only the `Add workspace` item. +- Screenshot of the main window's onboarding screen after clicking it.","normal","medium","actual","manual","classic","CORE-2525-QA-007,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:no_workspaces_configured","1. ""Launch the branch build with a fresh profile/no workspaces configured, or remove every workspace from the left vertical server list (open each workspace's context/kebab menu and choose to remove it) until none remain."" +2. ""Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the context menu."" +3. ""Click the `Add workspace` item.""","1. ""none +Agent: Confirm the servers collection/state is empty (`hasServers: false`)."" +2. ""none +Agent: Read the tray menu template and assert the presence-section items array has exactly one entry labeled `Add workspace` with `enabled: true`."" +3. ""none +Agent: Confirm the item's `click` handler shows the root window.""","1. ""The main window shows the """"Add a workspace""""/onboarding screen with no server icons in the left server list."" +2. ""The menu's first item reads `Add workspace`, rendered as a normal enabled item with no radio marker. No presence radios, no custom-status line, and no sign-in item appear."" +3. ""The main window is brought to the front/shown, displaying the add-workspace/onboarding screen.""" +"","1","Tray presence status","","Unsupported Workspace Hides Presence Items And Custom Status Entirely","Source flow: CORE-2525-QA-008 + +On a workspace whose server does not report a user status field, presence radios and any custom-status line are hidden entirely (not shown disabled), while the rest of the tray menu (Show/Hide, Quit) still works normally.","installed_branch_build +legacy_server_without_presence_status +logged_in_session","- Screenshot of the tray menu on the unsupported workspace showing no presence section at all. +- Screenshot of the tray menu after switching to a supported workspace showing radios again.","normal","medium","actual","manual","classic","CORE-2525-QA-008,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:legacy_server_without_presence_status,requires:logged_in_session","1. ""Sign in to a workspace pointed at a legacy server version that does not publish a `status` field on the user document (per the verified contract, presence support was proven live against 8.8.0-develop; use an older/unsupported build for this negative case), making it the active tab in the left vertical server list."" +2. ""Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the context menu."" +3. ""Confirm the rest of the menu still works: click `Hide` (or `Show`, depending on current window state)."" +4. ""Switch the active tab to a different, presence-supporting workspace (if one is configured) and re-open the tray menu.""","1. ""Legacy/unsupported server, logged-in session +Agent: Confirm the active legacy-server entry has `userLoggedIn: true` and `presenceSupported: false`."" +2. ""none +Agent: Read the tray menu template and assert `buildPresenceMenuItems` returned an empty array, and no leading separator was inserted."" +3. ""none +Agent: Confirm the `Show`/`Hide` item's click handler still toggles `rootWindowState.visible`."" +4. ""Presence-supporting workspace +Agent: Re-read the tray menu template for the newly active, supported workspace and assert radios are present.""","1. ""The main window loads the workspace normally (chat UI visible), with no visible error."" +2. ""No `Online`/`Away`/`Busy`/`Offline` radios appear, no custom-status line appears, and no disabled placeholder text about presence appears either — the presence section is fully absent, not shown-and-disabled. The menu starts directly with `Show`/`Hide` followed by `Quit`."" +3. ""The main window hides or shows as expected, unaffected by the unsupported presence check."" +4. ""The presence radios reappear for that workspace, confirming the hiding is per-workspace, not a global disable.""" +"","1","Tray presence status","","Unread Badge Behavior Is Unchanged By Presence","Source flow: CORE-2525-QA-009 + +The tray icon title/tooltip/badge overlay for unread mentions and messages behaves exactly as before this branch, regardless of the current presence value. On all three platforms, once presence is known the tray icon shows presence ONLY — no unread indicator baked into the artwork at all — because the count is already shown via the Windows taskbar overlay, the macOS menu-bar title, or the Linux tray tooltip.","installed_branch_build +presence_supporting_workspace +logged_in_session +unread_mention_source","- Screenshots/copied text of the tray tooltip at 0, 1, and 2 unread @-mentions, on all three platforms. +- Screenshot of the tray icon title showing the numeral count, and (Windows) the taskbar overlay badge showing the count. +- A native-size crop of the tray icon at the 2-unread-@-mentions state from step 3 on all three platforms (this will be presence-coloured/shaped only — identical to the icon shown before any unread arrived), for comparison against flow 10's presence-only behaviour. +- Confirmation that no dedicated notification/badge asset exists on any platform anymore — an unread count with unknown presence resolves to the plain default icon (`default.ico` / `defaultTemplate.png` / `default.png`), since the count is only ever shown via the taskbar overlay (Windows), menu-bar title (macOS), or tray tooltip (Linux). +- Confirmation that presence changes in step 4 did not alter title/tooltip text.","critical","high","actual","manual","classic","CORE-2525-QA-009,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session,requires:unread_mention_source","1. ""Sign in to a presence-supporting workspace and ensure it has zero unread mentions/messages. Note the account's current presence value (e.g. `online`) — presence is always set on this kind of workspace, so it stays active for the rest of this flow. Hover the mouse over the tray icon without clicking (Windows notification area / Linux system tray; on macOS: the app icon in the menu bar at the top-right) to read its tooltip text."" +2. ""From another client or account, send this user an @-mention in a channel they are a member of, so an unread mention count appears."" +3. ""Send a second unread @-mention from another account/client."" +4. ""Change presence via the tray menu (right-click the tray icon on Windows/Linux; on macOS left-click the app icon in the menu bar at the top-right) (e.g. to `Busy`) while the 2 unread @-mentions are still pending, then re-check the tray title and tooltip."" +5. ""Mark all @-mentions as read in the app, then re-check the tray title/tooltip.""","1. ""Presence recorded, e.g. `online` +Agent: Read the tray's current tooltip text and title string, and confirm the resolved icon path is `presence-` with no `-notification` suffix (no badge), on all three platforms."" +2. ""1 unread @-mention, presence unchanged from step 1 +Agent: Read the tray title and tooltip text; assert they match the pre-existing i18n templates unchanged by this branch. On all three platforms assert the resolved icon path is still the plain `presence-` (unchanged from step 1 — the `1` is only in the title/overlay/tooltip, never in the icon file)."" +3. ""2 unread @-mentions, presence unchanged from step 1 +Agent: Confirm the pluralized tooltip renders with the correct count interpolated. On all three platforms assert the resolved icon path is still the plain `presence-` (unchanged from step 2 — the icon file never varies with the count once presence is known)."" +4. ""Presence: busy, 2 unread @-mentions unchanged +Agent: Re-read tray title/tooltip after the presence change and assert they are identical to step 3's values. On all three platforms assert the resolved icon path is the plain `presence-busy` (no `-notification` suffix at all)."" +5. ""0 unread @-mentions +Agent: Confirm title/tooltip reset independently of the current presence value.""","1. ""Tooltip reads the app name followed by """"no unread message"""" (exact string from `tray.tooltip.noUnreadMessage`). Tray icon shows no numeric title/badge overlay, and its dot still shows the recorded presence colour."" +2. ""The tray icon's title (visible next to/under the icon depending on OS taskbar style) shows the numeral `1`; hovering the tray icon shows tooltip text """"you have a unread mention/direct message"""" (singular form from `tray.tooltip.unreadMention`). The icon's presence bullet shape/colour is unchanged from step 1 (no unread indicator added to the artwork)."" +3. ""The tray icon's title shows `2`; tooltip text switches to the plural form """"you have 2 unread mentions/direct messages"""" (`tray.tooltip.unreadMention_plural`)."" +4. ""The title still shows `2` and the tooltip still shows the same plural mention text as step 3 — unaffected by the presence change. Only the icon's bullet colour/shape and file path reflect the new presence."" +5. ""The title clears (empty) and the tooltip returns to """"no unread message"""" text, same as step 1, regardless of the presence set in step 4.""" +"","1","Tray presence status","","Presence Bullet And Unread Badge Coexist As The Product Intends Per Platform","Source flow: CORE-2525-QA-010 + +When both an unread count and a presence are active, all three platforms show presence ONLY — the icon file is identical to the no-unread state. The Windows taskbar overlay, the macOS menu-bar title, and the Linux tray tooltip carry the count instead.","installed_branch_build +presence_supporting_workspace +logged_in_session +unread_mention_source","- Zoomed/cropped screenshots at native icon size for: presence-only with 0 unread (step 1) and presence with 2 unread (step 2), on all three platforms. +- Confirmation that the step 1 and step 2 icon files/screenshots are identical on all three platforms (no combined state exists anywhere). +- Screenshot of the Windows taskbar overlay badge / macOS menu-bar title / Linux tray tooltip showing the unread count for step 2. +- Resolved icon file paths for each state if screenshots are inconclusive at native size.","major","high","actual","manual","classic","CORE-2525-QA-010,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session,requires:unread_mention_source","1. ""Sign in to a presence-supporting workspace with zero unread mentions and presence left at its default. Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) and set presence to `Online` via the context menu."" +2. ""From another client/account, send this user two unread @-mentions while presence remains `Online`."" +3. ""Compare the step-2 screenshot against the step-1 screenshot directly on all three platforms (they must be pixel-identical, since presence alone determines the icon on every platform)."" +4. ""Mark all mentions read while presence remains `Online`, then re-check the icon.""","1. ""Presence: online, 0 unread +Agent: Read the resolved icon path; on all three platforms assert it is `presence-online` with no badge-related suffix."" +2. ""2 unread mentions, presence: online +Agent: On all three platforms: read the resolved icon path; assert it is still the plain `presence-online` (unchanged from step 1) and that the count `2` appears only via the taskbar overlay / tray title / tooltip, never in the icon file."" +3. ""Screenshots from steps 1 and 2 +Agent: Diff the two screenshots on each platform; assert zero pixel difference."" +4. ""0 unread, presence: online +Agent: Confirm the resolved icon path stays `presence-online` on every platform (no change from step 2).""","1. ""The tray icon shows the online status bullet (solid `#2DE0A5` filled circle, no cut-out). Capture a zoomed screenshot for baseline comparison."" +2. ""On all three platforms: the tray icon is byte-identical to step 1 — the online status bullet, no unread indicator baked in anywhere. The Windows taskbar overlay badge and the macOS menu-bar title show the numeral `2` instead; the Linux tray tooltip shows the unread count text. The tray title still shows `2` on Windows/macOS."" +3. ""Step 1 and step 2 screenshots are indistinguishable on all three platforms."" +4. ""The icon is unchanged from step 2 on all three platforms (it was already presence-only with no unread indicator, so nothing changes in the icon file).""" +"","1","Tray presence status","","Rapid Presence Clicks Coalesce Without Dropping The Final Choice","Source flow: CORE-2525-QA-011 + +Clicking Online then immediately Busy within the same second results in the server ending up on Busy (the last-requested value), not silently stuck on Online or on no change at all.","installed_branch_build +presence_supporting_workspace +logged_in_session +second_client_session","- Screenshot/copied text of the second client's status label before the rapid clicks, immediately after (still within the rate-limit window), and once polling confirms the final `Busy` value. +- Timestamps or a screen recording showing the two tray clicks happened within roughly one second of each other. +- The final polled status value and the elapsed time (ms) it took to settle on `Busy`, or the fact that the 10-second timeout was reached without settling.","normal","medium","actual","manual","classic","CORE-2525-QA-011,source:repo-qa,platform:windows,platform:linux,platform:macos,requires:installed_branch_build,requires:presence_supporting_workspace,requires:logged_in_session,requires:second_client_session","1. ""Sign in to a presence-supporting workspace and, on a second client (web UI signed in as the same user), open the user avatar menu top-left of the sidebar to have the status label visible for comparison. Set the account to any known baseline status first (e.g. `Away`) and confirm on the second client."" +2. ""On the desktop app, right-click the tray icon (Windows notification area / Linux system tray) or, on macOS, left-click the app icon in the menu bar at the top-right (a single click opens the same menu), click `Online`, then immediately reopen the tray/menu-bar menu the same way and click `Busy` — both clicks performed within roughly the same second, faster than a normal deliberate click-pause-click. On macOS specifically: click the menu-bar icon, click `Online`, click the menu-bar icon again, click `Busy`, all within ~1 second."" +3. ""Without touching the tray again, repeatedly refresh/re-check the second client's status label (or repeatedly re-query the server) every ~200ms, for up to 10 seconds total, until the status label reads `Busy` or the 10-second timeout is reached."" +4. ""Re-open the tray context menu on the desktop app (right-click the tray icon on Windows/Linux; on macOS left-click the app icon in the menu bar at the top-right).""","1. ""Baseline: away +Agent: Confirm baseline `status` on the server equals `away`."" +2. ""Rapid sequence: online, then busy +Agent: Issue two `requestPresenceChange` calls back to back with less than 1000ms between them: `online` then `busy`."" +3. ""Poll interval: 200ms, timeout: 10s +Agent: Poll the server (`users.info`/DDP) for `status` on a bounded interval (e.g. every 200ms) up to a 10-second timeout; assert the polled value reaches `busy` before the timeout, and record the elapsed time it took to settle; fail the assertion if the timeout is reached without `status === 'busy'`."" +4. ""none +Agent: Re-read the tray menu template and assert the checked item is `Busy`.""","1. ""Second client shows `Away`."" +2. ""The tray's own checked radio ends up on `Busy` immediately (tray state is optimistic/local)."" +3. ""Within the polling window the second client's status label settles on `Busy`, not `Online` and not still `Away` — the later click was not dropped by the rate limiter. If the status has not become `Busy` by the 10-second timeout, treat this step as failed rather than continuing to wait."" +4. ""The checked radio still reads `Busy`, matching the confirmed server value from step 3 (tray and server agree).""" diff --git a/qa/core-2525-tray-presence/flows/01-presence-icon-states.md b/qa/core-2525-tray-presence/flows/01-presence-icon-states.md new file mode 100644 index 0000000000..eeb88cae25 --- /dev/null +++ b/qa/core-2525-tray-presence/flows/01-presence-icon-states.md @@ -0,0 +1,52 @@ +--- +id: CORE-2525-QA-001 +title: Tray Icon Shows A Distinct Dot Per Presence State +platforms: [windows, linux, macos] +priority: smoke +qase: + suite: Tray presence status + priority: high + severity: critical + status: actual + automation: manual + qase_id: null +requires: + [installed_branch_build, presence_supporting_workspace, logged_in_session] +test_links: [] +expected_result: The tray icon shows a distinct Rocket.Chat status-bullet shape/color per presence — filled green-teal (online), yellow with a clock-hand cut-out (away), red with a horizontal-bar cut-out (busy), hollow gray ring (offline) — matching the effective presence value. +--- + +# Tray Icon Shows A Distinct Dot Per Presence State + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/icons.ts` (`getTrayIconPath`), `src/ui/main/trayIcon.ts` (`updateTrayIconImage`, `getActivePresenceForIcon`), `src/ui/icons/PresenceBullet.tsx` (new — renders the Fuselage `StatusBullet` glyph shapes in the same footprint as `Badge`), `src/ui/icons/WindowsTrayIcon.tsx` / `LinuxTrayIcon.tsx` / `MacOSTrayIcon.tsx` (presence renders `PresenceBullet` instead of a plain recolored `Badge` circle; same size and position as the shipping unread circle), `src/ui/main/macOSTrayGlyph.ts` (`invertDarkAchromaticPixels`, `applyMacOSMenuBarGlyphAppearance`). +- User-visible risk: A tester cannot tell their presence at a glance from the system tray/menu bar, or two states render as the same icon. +- Hypothesis: Setting presence to online, away, busy, or offline renders the matching Rocket.Chat status-bullet shape in the existing badge slot — online is a solid filled circle (`#2DE0A5`), away is a filled circle with a clock-hand shape cut out (`#FFD21F`), busy is a filled circle with a horizontal bar cut out (`#F5455C`), offline is a hollow ring/stroke-only circle (`#9EA2A8`) — without moving or resizing the slot, on Windows, Linux, and macOS alike. +- Smallest useful proof: Local UI repro on a real Windows, Linux, and macOS build, comparing the tray icon crop against the four expected renders. +- macOS verification note: on macOS the icon lives in the menu bar (top-right of the screen), not a taskbar notification area, and the menu opens with a single left-click on the icon (there is no right-click step on macOS — every "right-click the tray icon" instruction in this flow means "click the menu bar icon" on macOS). The rocket glyph itself goes through `invertDarkAchromaticPixels`, which inverts only dark, low-saturation (achromatic) pixels to white so the glyph reads correctly whether the menu bar is light- or dark-tinted (including Liquid Glass, which keeps status items light-tinted even in Light appearance); the saturated presence bullet pixels are left untouched by that inversion, so the four presence colours/shapes render exactly as specified above. Presence icon assets under `src/public/images/tray/darwin/` are intentionally not named `*Template.png` — if they were, AppKit would flatten the bullet to monochrome along with the glyph, which is the specific regression this hypothesis guards against on macOS. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| 1 | Launch the branch build and sign in to a presence-supporting workspace so the main window fully loads. On Windows, locate the app's icon in the notification area at the bottom-right of the taskbar (click the `^` overflow chevron first if the icon is hidden there). On Linux, locate the icon in the system tray/status area, typically top-right of the panel. On macOS, locate the icon in the menu bar at the top-right of the screen (among the other menu bar icons). | Presence-supporting workspace credentials | Tray/menu bar icon is visible and shows a default icon plus a small status bullet in the lower-right corner of the icon. | Locate the tray icon element/pixel region for the app. | +| 2 | Right-click the tray icon to open its context menu, then click the `Online` radio item at the top of the menu (a filled circle indicator to the left of the label when selected). | Presence value `online` | Within a few seconds the tray icon's bullet renders as a solid green-teal filled circle (`#2DE0A5`), no cut-out. | Set presence to `online` via the app's IPC/API path and read back the icon file path from `getTrayIconPath`. | +| 3 | Right-click the tray icon again and click the `Away` radio item. | Presence value `away` | The tray icon's bullet changes to a filled yellow circle (`#FFD21F`) with a clock-hand shape cut out of it (the background shows through the cut-out, the same shape as the Fuselage away status bullet), visibly different from step 2. | Set presence to `away`, confirm the resolved icon path differs from the online icon path. | +| 4 | Right-click the tray icon again and click the `Busy` radio item. | Presence value `busy` | The tray icon's bullet changes to a filled red circle (`#F5455C`) with a horizontal bar cut out through the middle (the background shows through the cut-out), visibly different from steps 2 and 3. | Set presence to `busy`, confirm the resolved icon path differs from prior states. | +| 5 | Right-click the tray icon again and click the `Offline` radio item. | Presence value `offline` | The tray icon's bullet changes to a hollow gray ring (stroke only, `#9EA2A8`, no fill — the tray background shows through the middle), same size and position as steps 2-4, distinct by both shape and colour. | Set presence to `offline`, confirm the resolved icon path differs from prior states. | +| 6 | Zoom or crop a screenshot of the tray/menu bar icon region at native size (16x16 on Windows; macOS menu bar icons render at 18x18 pt, 2x/3x on Retina) for each of the four states captured above. | Screenshots from steps 2-5 | All four crops are distinguishable from each other by shape and color even at native icon size. | Compare the four resolved icon file paths/pixel buffers; assert they are byte-distinct. | + +## Evidence + +- Zoomed screenshot/crop of the tray icon for each of the four presence states. +- Resolved icon file paths used per state (from app logs or code-path proof) if screenshots are inconclusive at native size. + +## Failure Signals + +- Two or more presence states render the same icon (color/shape collision). +- The bullet does not appear at all (icon looks identical to the pre-feature default icon). +- Away/busy cut-outs are painted solid white instead of transparent (background does not show through). +- Offline is not visually hollow (renders as a filled circle instead of a ring). +- The icon fails to update within a few seconds of changing presence. diff --git a/qa/core-2525-tray-presence/flows/02-presence-menu-radios.md b/qa/core-2525-tray-presence/flows/02-presence-menu-radios.md new file mode 100644 index 0000000000..ed1b53876a --- /dev/null +++ b/qa/core-2525-tray-presence/flows/02-presence-menu-radios.md @@ -0,0 +1,55 @@ +--- +id: CORE-2525-QA-002 +title: Tray Menu Presence Submenu Reflects And Changes Effective Status +platforms: [windows, linux, macos] +priority: smoke +qase: + suite: Tray presence status + priority: high + severity: critical + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + presence_supporting_workspace, + logged_in_session, + second_client_session, + ] +test_links: [] +expected_result: The tray menu's top-level presence item shows the current status label with a matching coloured bullet icon; opening its submenu lists Online/Away/Busy/Offline as radio items each with its own bullet, the currently effective status is checked, and selecting a different one applies it on the server. +--- + +# Tray Menu Presence Submenu Reflects And Changes Effective Status + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`buildPresenceMenuItems`, `PRESENCE_OPTIONS`), `src/ui/main/icons.ts` (`getPresenceMenuIconPath`), `src/ui/icons/PresenceMenuIcon.tsx`, `src/i18n/en.i18n.json` (`tray.presence.*`), `src/servers/preload/presence.ts`. +- User-visible risk: The top-level item shows the wrong current status or wrong bullet colour, the submenu shows the wrong checked state, or clicking a submenu radio does not actually change presence on the server (a tray that looks right but failed to send). +- Hypothesis: The tray context menu shows one top-level item labeled with the account's current status (e.g. `Online`) and a matching coloured bullet icon; hovering/clicking it opens a submenu listing exactly four radio items labeled `Online`, `Away`, `Busy`, `Offline`, each with its own coloured bullet icon; the item matching the account's current `status` is checked; clicking a different one both re-checks that item, updates the top-level label/icon, and changes the account's status on the server. +- Smallest useful proof: Local UI repro plus verification against a second logged-in client (web UI) reading the account's presence indicator, not just the tray. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | On the second client (web UI or another Rocket.Chat client signed in as the same user), open the user avatar menu in the top-left of the sidebar and note the currently displayed status label (Online/Away/Busy/Offline). | Logged-in web session | A baseline status label is visible on the second client. | Read the account's presence value from the second client/API. | +| 2 | On the desktop app, right-click the tray icon (Windows: bottom-right notification area, behind the `^` chevron if hidden; Linux: system tray/status area; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu). | none | A context menu opens whose top item shows the current status label (e.g. `Online`) with a small coloured bullet icon to its left, and a submenu arrow indicating it expands, followed later by a separator line. | Open/read the tray context menu template via `buildMenuTemplate`; assert the top-level presence item has a `submenu` array and an `icon`. | +| 3 | Hover over (or click, on platforms where it opens on click) the top-level status item to reveal its submenu. | none | A submenu opens showing four items with radio-button markers (a filled or outlined circle to the left of each label) reading `Online`, `Away`, `Busy`, `Offline`, in that order, each with its own coloured bullet icon. | Read the `submenu` array of the top-level presence item and assert it has 4 radio items, each with an `icon`. | +| 4 | Confirm which of the four submenu radio items shows the filled/selected radio marker, and confirm it matches the top-level item's label from step 2. | none | The selected radio item's label matches the baseline status noted in step 1, and matches the top-level item's own label. | Assert the radio item where `checked === true` matches `activeServerPresence.presence`, and the top-level item's `label` matches the same presence. | +| 5 | Click a different radio item than the currently selected one, for example click `Busy` if `Online` was selected. | Target presence `busy` | The menu closes; re-opening the tray context menu (right-click again; on macOS: click the menu bar icon again) shows the top-level item now labeled `Busy` with the busy bullet icon, and re-opening its submenu shows `Busy` carrying the selected radio marker instead of the previous item. | Trigger the `click` handler for the target `PRESENCE_OPTIONS` entry and re-read the menu template. | +| 6 | Switch to the second client and re-check the status label from step 1 (refresh or wait for the reactive presence indicator to update). | none | The second client now shows `Busy` (or the label clicked in step 5), proving the change reached the server, not just the tray. | Query `/api/v1/users.info` or the DDP user document for `status` and assert it equals the clicked value. | + +## Evidence + +- Screenshot of the tray context menu showing the top-level status item and its expanded submenu with the four radio items and which one is checked, before and after the click. +- Screenshot or copied status text from the second client confirming the server-side value changed. + +## Failure Signals + +- Top-level item missing an icon or submenu, fewer or more than four presence items in the submenu, wrong order, or wrong labels. +- No submenu item is checked, or the checked item does not match the account's real status, or the top-level label disagrees with the checked submenu item. +- Clicking a radio updates the tray's own checkmark but the second client's status never changes (indicates the IPC send or `setUserStatus` call failed silently). +- Menu order changes between opens. diff --git a/qa/core-2525-tray-presence/flows/03-custom-status-text.md b/qa/core-2525-tray-presence/flows/03-custom-status-text.md new file mode 100644 index 0000000000..25c3b7e35d --- /dev/null +++ b/qa/core-2525-tray-presence/flows/03-custom-status-text.md @@ -0,0 +1,53 @@ +--- +id: CORE-2525-QA-003 +title: Custom Status Text Appears As A Read-Only Menu Line Or Is Omitted +platforms: [windows, linux, macos] +priority: high +qase: + suite: Tray presence status + priority: medium + severity: normal + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + presence_supporting_workspace, + logged_in_session, + second_client_session, + ] +test_links: [] +expected_result: A set custom status message appears as a disabled tray menu line; when absent or cleared, the line does not appear at all. +--- + +# Custom Status Text Appears As A Read-Only Menu Line Or Is Omitted + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`buildPresenceMenuItems`, the `statusText` disabled item), `src/servers/preload/presenceStatusText.ts`, the REST `users.info` fetch in `src/injected.ts`. +- User-visible risk: Custom status text is shown as clickable/editable (misleading, since the tray cannot edit it), or a stale/empty status text lingers in the menu after being cleared. +- Hypothesis: When the account has a non-empty custom status message, the tray menu shows it as an extra item below the top-level presence item that cannot be clicked (grayed out); when the account has no custom status message, no such line appears. +- Smallest useful proof: Local UI repro with the status message set and then cleared on the second client, re-reading the tray menu after each change. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | On the second client (web UI signed in as the same user), open the user avatar menu top-left of the sidebar, click the current status label to open the status editor, and type a custom message, e.g. `In a meeting`, then confirm/save. | Custom status text `In a meeting` | The second client shows `In a meeting` next to the user's status. | Set the custom status message via the web client or `setUserStatus` REST/DDP call. | +| 2 | On the desktop app, right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu), open the top-level presence item's submenu and click any radio item (e.g. re-click the currently checked one, or pick a different one) to force a presence-change request, which makes the tray re-fetch the status text; alternatively fully quit and relaunch the app so it re-fetches on login. Then right-click the tray icon again (on macOS: click the menu bar icon again) to open the context menu. | none | Below the top-level presence item and above the separator, an extra menu line reads `In a meeting`. This line has no radio marker and appears visually dimmed/grayed compared to the top-level item above it. | Trigger a presence-change request (or restart/login) to force the tray to re-fetch the status text, then read the tray menu template and assert the last item before the separator has `label: 'In a meeting'` and `enabled: false`. | +| 3 | Attempt to click the `In a meeting` line. | none | Nothing happens; the menu does not close differently and no status change occurs (the item is not clickable). | Confirm the item has no `click` handler / `enabled === false`. | +| 4 | On the second client, clear the custom status message (open the status editor again and remove the text, then confirm/save). | Empty custom status text | The second client no longer shows a custom status message next to the user's status. | Set the custom status message to empty/undefined. | +| 5 | On the desktop app, trigger a presence change from the tray (open the top-level presence item's submenu and click any radio item) to force a re-fetch of the status text, then right-click the tray icon again (on macOS: click the menu bar icon again) to re-open the menu. | none | The `In a meeting` line is gone entirely; the menu goes directly from the top-level presence item to the separator line. | Re-read the tray menu template and assert no disabled non-radio item is present between the top-level presence item and the separator. | + +## Evidence + +- Screenshot of the tray menu with the custom status line visible and grayed out. +- Screenshot of the tray menu after clearing the status, showing the line is absent. + +## Failure Signals + +- The custom status line is clickable or otherwise interactive. +- The line persists after the status text was cleared on the server (stale cache). +- The line never appears even though the second client confirms a non-empty status message. diff --git a/qa/core-2525-tray-presence/flows/04-active-workspace-scoping.md b/qa/core-2525-tray-presence/flows/04-active-workspace-scoping.md new file mode 100644 index 0000000000..4f60280887 --- /dev/null +++ b/qa/core-2525-tray-presence/flows/04-active-workspace-scoping.md @@ -0,0 +1,54 @@ +--- +id: CORE-2525-QA-004 +title: Tray Presence Reflects Only The Active Workspace Tab +platforms: [windows, linux, macos] +priority: high +qase: + suite: Tray presence status + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + two_presence_supporting_workspaces, + logged_in_session, + second_client_session, + ] +test_links: [] +expected_result: The tray icon/menu always reflect the presence of the workspace currently open in the main window, and switching tabs updates the tray without changing presence on the workspace that lost focus. +--- + +# Tray Presence Reflects Only The Active Workspace Tab + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/selectors.ts` (`selectActiveServerPresence`), `src/ui/main/trayIcon.ts` (`watch(selectActiveServerPresence, ...)`), `requestPresenceChange` (scopes the change to the server matching `activeServerPresence.url`). +- User-visible risk: Switching between workspace tabs shows a stale or wrong presence dot/menu, or clicking a radio changes presence on the wrong workspace. +- Hypothesis: With two workspaces added, each with a different presence value, switching the active tab updates the tray icon and menu to match the newly active workspace only; changing presence from the tray affects only that active workspace's account. +- Smallest useful proof: Local UI repro switching tabs with two signed-in, presence-supporting workspaces set to different statuses ahead of time. +- Second-client requirement: this flow needs two independent second-client sessions, one signed in to Workspace A and one signed in to Workspace B, so each workspace's status can be checked independently of the other. A single browser tab/client cannot be signed in to two different workspace accounts at once — use two separate browser tabs/profiles (or two separate client windows), one per workspace. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Add and sign in to two presence-supporting workspaces (Workspace A and Workspace B) in the desktop app's left vertical server list. On each workspace's own web/second client, set Workspace A's account status to `Online` and Workspace B's account status to `Busy`. | Workspace A -> online, Workspace B -> busy | Both workspaces are signed in and visible as separate icons in the left server list; each shows its own distinct status on its own second client. | Confirm both server entries exist with `userLoggedIn: true` and distinct `presence` values. | +| 2 | In the left vertical server list, click Workspace A's icon to make it the active tab. Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the menu. | Active tab: Workspace A | The tray icon shows the online dot (`#2DE0A5` filled) and the tray menu's checked radio is `Online`. | Read `selectActiveServerPresence` and assert `url` matches Workspace A and `presence === 'online'`. | +| 3 | In the left vertical server list, click Workspace B's icon to switch the active tab. Right-click the tray icon again (on macOS: click the menu bar icon again). | Active tab: Workspace B | Within a few seconds, the tray icon switches to the busy dot (`#F5455C` filled) and the tray menu's checked radio is now `Busy`, with the workspace title shown in menu items referencing Workspace B where applicable. | Re-read `selectActiveServerPresence` and assert `url` now matches Workspace B and `presence === 'busy'`. | +| 4 | With Workspace B still active, right-click the tray icon (on macOS: click the menu bar icon) and click the `Online` radio item. | Target presence: online, applied to Workspace B | The tray icon/menu for the active tab (Workspace B) show `Online` checked. | Confirm the presence-change IPC targets Workspace B's `webContentsId`. | +| 5 | On Workspace A's second client, confirm its status is still `Online` (unchanged by step 4), and on Workspace B's second client, confirm its status changed to `Online` (was `Busy`). | none | Workspace A's status is untouched; only Workspace B's status changed. | Query both workspaces' `users.info`/DDP status independently and assert A is unchanged, B changed. | + +## Evidence + +- Screenshots of the tray icon/menu immediately after switching to each workspace tab. +- Screenshots or copied status values from both workspaces' second clients before and after step 4. + +## Failure Signals + +- The tray icon/menu do not update when switching tabs (stale presence from the previously active workspace). +- Clicking a radio while Workspace B is active changes Workspace A's status instead (or both). +- The tray briefly shows a third, unrelated presence value during the tab switch. diff --git a/qa/core-2525-tray-presence/flows/05-disconnected-state.md b/qa/core-2525-tray-presence/flows/05-disconnected-state.md new file mode 100644 index 0000000000..fc7d1c7a94 --- /dev/null +++ b/qa/core-2525-tray-presence/flows/05-disconnected-state.md @@ -0,0 +1,58 @@ +--- +id: CORE-2525-QA-005 +title: Disconnected Workspace Disables Presence Radios With A Reconnecting Notice +platforms: [windows, linux, macos] +priority: high +qase: + suite: Tray presence status + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + presence_supporting_workspace, + logged_in_session, + network_disconnect_control, + ] +test_links: [] +expected_result: While the active workspace is disconnected or reconnecting, the four presence radios in the top-level presence item's submenu are disabled and a disabled "Disconnected — trying to reconnect..." line is shown; a merely unknown (not-yet-reported) connection state does NOT trigger this. +--- + +# Disconnected Workspace Disables Presence Radios With A Reconnecting Notice + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`isDisconnected`/`isConnected` in `buildPresenceMenuItems`), `src/injected.ts` (`connectionStatusMap` inside the `userPresenceStatus` autorun), `src/ui/icons/DisconnectedBadge.tsx` (tray icon overlay shown while disconnected). +- User-visible risk: A regression previously made every startup look disconnected because `undefined` (pre-first-push connection state) was treated as disconnected; this flow proves that is fixed and that a real disconnect still disables the radios correctly. +- Hypothesis: Only real `connecting`/`disconnected`/`failed`/`waiting`/`offline` DDP connection states disable the radios and show the reconnecting line; a fresh app launch before the first connection status push behaves as connected (radios enabled, no reconnecting line). +- Smallest useful proof: Local UI repro that (a) checks the tray menu immediately at cold launch before disconnecting, and (b) forces a real network disconnect and re-checks the menu and the tray icon's appearance. +- Icon note: while disconnected, the tray/menu-bar icon overlay is a filled amber circle with a vertical exclamation mark cut out of it (same style and footprint as the presence status bullets — a solid disc, not a hollow ring), so it stays legible at menu-bar size on all three platforms. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Cold-launch the desktop app fresh (fully quit first, not just hidden) with a presence-supporting workspace already configured and a healthy network connection, then immediately right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) as soon as the main window appears, before any manual action. | none | The four presence radios in the top-level presence item's submenu (`Online`, `Away`, `Busy`, `Offline`) are enabled (not grayed out) and no `Disconnected — trying to reconnect...` line is present, even in the brief window before the first connection status has been reactively pushed. | Read the tray menu template at the earliest observable point after launch; assert all radio items have `enabled: true` and no disabled non-radio "Disconnected" item exists. | +| 2 | With the app running and the workspace connected, disconnect the machine's network (disable Wi-Fi/Ethernet, or use a firewall rule to block the workspace's domain) so the workspace visibly shows a reconnecting/offline banner in the main window. | Network disabled | The main window shows a connection-lost/reconnecting indicator (standard Rocket.Chat reconnect banner). | Force the underlying Meteor DDP connection into a `connecting`/`failed`/`waiting`/`offline` state. | +| 3 | Right-click the tray icon again (on macOS: click the menu bar icon again) while still disconnected. | none | The four presence radios in the top-level presence item's submenu are now grayed out/disabled (clicking them does nothing), and a new disabled line reads `Disconnected — trying to reconnect...` positioned after the top-level presence item and before the separator. The tray/menu-bar icon itself now shows a filled amber circle with a vertical exclamation mark cut out of it (not the hollow-ring look of an earlier build), same size and position as the presence bullets. | Read the tray menu template; assert all radio items have `enabled: false` and a disabled item with that exact label is present. Read the resolved icon path/pixel buffer and confirm the overlay is the filled amber disconnected bullet. | +| 4 | Attempt to click one of the disabled radio items, e.g. `Busy`. | none | No visible change occurs; the checked radio stays on whatever it was before the disconnect. | Confirm the disabled item has no effective `click` action / the presence value does not change. | +| 5 | Restore the network connection and wait for the main window's reconnect banner to disappear, then right-click the tray icon again (on macOS: click the menu bar icon again). | Network restored | The radios re-enable and the `Disconnected — trying to reconnect...` line disappears from the menu. | Re-read the tray menu template; assert radios are `enabled: true` and the disconnected item is absent. | + +## Evidence + +- Screenshot of the tray menu immediately after cold launch (step 1) showing enabled radios. +- Screenshot of the tray menu while disconnected (step 3) showing disabled radios and the reconnecting line. +- Zoomed/cropped screenshot of the tray/menu-bar icon at native size while disconnected (step 3), showing the filled amber circle with the exclamation cut-out. +- Screenshot after reconnecting (step 5) showing radios enabled again. + +## Failure Signals + +- Radios appear disabled with a "Disconnected" line at cold launch before any real disconnect occurred (the regression this flow guards against). +- Radios stay enabled while genuinely disconnected, allowing clicks that silently fail to reach the server. +- The reconnecting line persists after the connection is restored. +- The disconnected tray icon renders as a hollow/outlined ring instead of a filled amber disc (unreadable at menu-bar size — the earlier-build regression this shape change fixes), or the exclamation cut-out shows the rocket glyph instead of the transparent background. +- The disconnected tray icon changes appearance based on unread mention count. On all three platforms, `DisconnectedBadge` ignores unread count entirely — disconnected always resolves to the same `disconnected` asset (`disconnected.png`/`disconnected.ico`) regardless of unread messages/mentions; the count itself is only ever visible via the Windows taskbar overlay or the macOS menu-bar title, which stay active while disconnected. diff --git a/qa/core-2525-tray-presence/flows/06-logged-out-state.md b/qa/core-2525-tray-presence/flows/06-logged-out-state.md new file mode 100644 index 0000000000..bc2b00e55b --- /dev/null +++ b/qa/core-2525-tray-presence/flows/06-logged-out-state.md @@ -0,0 +1,45 @@ +--- +id: CORE-2525-QA-006 +title: Logged-Out Workspace Shows A Sign-In Item Instead Of Presence Radios +platforms: [windows, linux, macos] +priority: high +qase: + suite: Tray presence status + priority: medium + severity: normal + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build, configured_workspace_no_session] +test_links: [] +expected_result: When the active workspace has no logged-in session, the tray menu shows no presence radios and instead a single enabled "Sign in to " item that opens the app. +--- + +# Logged-Out Workspace Shows A Sign-In Item Instead Of Presence Radios + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`buildPresenceMenuItems`, the `!loggedIn` branch), `src/i18n/en.i18n.json` (`tray.presence.signIn`). +- User-visible risk: The tray menu offers presence controls that cannot work (no session), or omits any path back to signing in. +- Hypothesis: With a workspace added but not signed in (or signed out), the tray menu shows exactly one item labeled `Sign in to `, enabled, with no presence radios and no custom-status line; clicking it brings the main window to the front. +- Smallest useful proof: Local UI repro with a workspace added and its session logged out, reading the tray menu before and after clicking the sign-in item. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Add a workspace in the left vertical server list (or use one already added) and sign out of it from the main window (open the user avatar menu top-left of the sidebar, click `Logout`), so the workspace tab exists but shows the login/sign-in screen instead of the chat UI. | A workspace with a logged-out session | The main window shows that workspace's login screen when its tab is active. | Confirm the server entry exists with `userLoggedIn: false` and a resolved `url`. | +| 2 | Minimize or hide the main window, then right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the context menu. | none | The menu's first item reads `Sign in to ` (matching the workspace's configured title, or its URL if no title is set), rendered as a normal enabled item with no radio marker. No `Online`/`Away`/`Busy`/`Offline` items and no custom-status line appear above or below it. | Read the tray menu template and assert the presence-section items array has exactly one entry with that label pattern and `enabled: true`, and no `type: 'radio'` items. | +| 3 | Click the `Sign in to ` item. | none | The main window is brought to the front/shown, displaying that workspace's login screen. | Confirm the item's `click` handler shows the root window. | + +## Evidence + +- Screenshot of the tray menu showing only the sign-in item, no radios. +- Screenshot of the main window coming to the front after clicking the item. + +## Failure Signals + +- Presence radios or a custom-status line appear despite no active session. +- The sign-in item label does not include the workspace's title/URL. +- Clicking the item does nothing or opens the wrong workspace. diff --git a/qa/core-2525-tray-presence/flows/07-no-workspace-state.md b/qa/core-2525-tray-presence/flows/07-no-workspace-state.md new file mode 100644 index 0000000000..39ba86f41d --- /dev/null +++ b/qa/core-2525-tray-presence/flows/07-no-workspace-state.md @@ -0,0 +1,45 @@ +--- +id: CORE-2525-QA-007 +title: No Workspace Configured Shows An Add-Workspace Item +platforms: [windows, linux, macos] +priority: medium +qase: + suite: Tray presence status + priority: medium + severity: normal + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build, no_workspaces_configured] +test_links: [] +expected_result: With zero workspaces configured, the tray menu shows no presence radios and instead a single enabled "Add workspace" item that opens the app. +--- + +# No Workspace Configured Shows An Add-Workspace Item + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`buildPresenceMenuItems`, the `!hasServers` branch), `src/i18n/en.i18n.json` (`tray.presence.addWorkspace`). +- User-visible risk: The tray menu offers presence controls with nothing to apply them to, or gives no way back into the app to add a first workspace. +- Hypothesis: On a fresh install/profile with zero workspaces added, the tray menu's presence section shows exactly one item labeled `Add workspace`, enabled, with no radios and no custom-status line; clicking it brings the main window to the front. +- Smallest useful proof: Local UI repro on a fresh app profile (or after removing all configured workspaces) reading the tray menu. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Launch the branch build with a fresh profile/no workspaces configured, or remove every workspace from the left vertical server list (open each workspace's context/kebab menu and choose to remove it) until none remain. | none | The main window shows the "Add a workspace"/onboarding screen with no server icons in the left server list. | Confirm the servers collection/state is empty (`hasServers: false`). | +| 2 | Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the context menu. | none | The menu's first item reads `Add workspace`, rendered as a normal enabled item with no radio marker. No presence radios, no custom-status line, and no sign-in item appear. | Read the tray menu template and assert the presence-section items array has exactly one entry labeled `Add workspace` with `enabled: true`. | +| 3 | Click the `Add workspace` item. | none | The main window is brought to the front/shown, displaying the add-workspace/onboarding screen. | Confirm the item's `click` handler shows the root window. | + +## Evidence + +- Screenshot of the tray menu showing only the `Add workspace` item. +- Screenshot of the main window's onboarding screen after clicking it. + +## Failure Signals + +- Any presence-related item appears despite zero configured workspaces. +- The item is missing or disabled. +- Clicking the item does nothing. diff --git a/qa/core-2525-tray-presence/flows/08-unsupported-workspace.md b/qa/core-2525-tray-presence/flows/08-unsupported-workspace.md new file mode 100644 index 0000000000..f98c4ec707 --- /dev/null +++ b/qa/core-2525-tray-presence/flows/08-unsupported-workspace.md @@ -0,0 +1,51 @@ +--- +id: CORE-2525-QA-008 +title: Unsupported Workspace Hides Presence Items And Custom Status Entirely +platforms: [windows, linux, macos] +priority: high +qase: + suite: Tray presence status + priority: medium + severity: normal + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + legacy_server_without_presence_status, + logged_in_session, + ] +test_links: [] +expected_result: On a workspace whose server does not report a user status field, presence radios and any custom-status line are hidden entirely (not shown disabled), while the rest of the tray menu (Show/Hide, Quit) still works normally. +--- + +# Unsupported Workspace Hides Presence Items And Custom Status Entirely + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`buildPresenceMenuItems`, the `supported === false` early return), `src/injected.ts` (`presenceSupported = Boolean(u) && u?.status !== undefined`). +- User-visible risk: A previously-found ordering bug returned "unsupported" before the logged-out check, so an unsupported + logged-out workspace incorrectly offered no sign-in option; this flow proves presence hiding does not swallow the sign-in/add-workspace items either. +- Hypothesis: When signed in to a workspace whose account document has no `status` field (an older/unsupported server), the tray menu shows zero presence radios and no custom-status line, with the separator directly followed by `Show`/`Hide` and `Quit`; the menu structure for logged-out/no-workspace states from flows 06/07 is unaffected by this check. +- Smallest useful proof: Local UI repro against a legacy Rocket.Chat server build known not to expose `status` on the user document (or a mocked/stubbed account without a `status` field), reading the tray menu. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Sign in to a workspace pointed at a legacy server version that does not publish a `status` field on the user document (per the verified contract, presence support was proven live against 8.8.0-develop; use an older/unsupported build for this negative case), making it the active tab in the left vertical server list. | Legacy/unsupported server, logged-in session | The main window loads the workspace normally (chat UI visible), with no visible error. | Confirm the active legacy-server entry has `userLoggedIn: true` and `presenceSupported: false`. | +| 2 | Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) to open the context menu. | none | No `Online`/`Away`/`Busy`/`Offline` radios appear, no custom-status line appears, and no disabled placeholder text about presence appears either — the presence section is fully absent, not shown-and-disabled. The menu starts directly with `Show`/`Hide` followed by `Quit`. | Read the tray menu template and assert `buildPresenceMenuItems` returned an empty array, and no leading separator was inserted. | +| 3 | Confirm the rest of the menu still works: click `Hide` (or `Show`, depending on current window state). | none | The main window hides or shows as expected, unaffected by the unsupported presence check. | Confirm the `Show`/`Hide` item's click handler still toggles `rootWindowState.visible`. | +| 4 | Switch the active tab to a different, presence-supporting workspace (if one is configured) and re-open the tray menu. | Presence-supporting workspace | The presence radios reappear for that workspace, confirming the hiding is per-workspace, not a global disable. | Re-read the tray menu template for the newly active, supported workspace and assert radios are present. | + +## Evidence + +- Screenshot of the tray menu on the unsupported workspace showing no presence section at all. +- Screenshot of the tray menu after switching to a supported workspace showing radios again. + +## Failure Signals + +- Presence radios or custom-status line appear (even disabled) on an unsupported workspace. +- The unsupported check also suppresses the sign-in or add-workspace items when combined with logged-out/no-workspace states. +- `Show`/`Hide`/`Quit` stop working when the presence section is empty. diff --git a/qa/core-2525-tray-presence/flows/09-unread-badge-regression.md b/qa/core-2525-tray-presence/flows/09-unread-badge-regression.md new file mode 100644 index 0000000000..00e24f3251 --- /dev/null +++ b/qa/core-2525-tray-presence/flows/09-unread-badge-regression.md @@ -0,0 +1,58 @@ +--- +id: CORE-2525-QA-009 +title: Unread Badge Behavior Is Unchanged By Presence +platforms: [windows, linux, macos] +priority: release +qase: + suite: Tray presence status + priority: high + severity: critical + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + presence_supporting_workspace, + logged_in_session, + unread_mention_source, + ] +test_links: [] +expected_result: The tray icon title/tooltip/badge overlay for unread mentions and messages behaves exactly as before this branch, regardless of the current presence value. On all three platforms, once presence is known the tray icon shows presence ONLY — no unread indicator baked into the artwork at all — because the count is already shown via the Windows taskbar overlay, the macOS menu-bar title, or the Linux tray tooltip. +--- + +# Unread Badge Behavior Is Unchanged By Presence + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/trayIcon.ts` (`updateTrayIconImage`, `updateTrayIconTitle`, `updateTrayIconToolTip` — all pre-existing, now called alongside the new presence-aware `getTrayIconPath`), `src/ui/main/icons.ts` (`getWindowsTrayIconPath`, `getLinuxTrayIconPath`, `getMacOSTrayIconPath`). +- User-visible risk: Threading presence into the same icon-resolution path as the badge accidentally changes badge-only behavior (wrong icon, wrong tooltip text, wrong numeric title) for users who never touch presence. Separately, all three platforms deliberately never bake any unread indicator into the tray icon artwork once presence is known (the taskbar overlay / menu-bar title / tray tooltip already shows it) — a regression here would either resurrect a `-notification` icon variant or drop the presence bullet entirely. +- Hypothesis: On a presence-supporting, logged-in workspace, `presence` is always defined (seeded by `Presence.get`), so on all three platforms the icon always resolves to the plain `presence-` asset regardless of unread count — the badge value is ignored entirely once presence is set. The regression this flow guards against is that the tray title text, tooltip text, and badge numeral (via title/overlay/tooltip, not the icon file) stay byte-identical to the pre-feature behavior regardless of which presence value is currently active. +- Smallest useful proof: Local UI repro comparing tooltip text and taskbar count/title against the existing tooltip i18n strings (`tray.tooltip.*`), which were not modified by this branch, across two different presence values. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| ---- | ------ | --------- | ---------------- | ------------- | +| 1 | Sign in to a presence-supporting workspace and ensure it has zero unread mentions/messages. Note the account's current presence value (e.g. `online`) — presence is always set on this kind of workspace, so it stays active for the rest of this flow. Hover the mouse over the tray icon without clicking (Windows notification area / Linux system tray; on macOS: the app icon in the menu bar at the top-right) to read its tooltip text. | Presence recorded, e.g. `online` | Tooltip reads the app name followed by "no unread message" (exact string from `tray.tooltip.noUnreadMessage`). Tray icon shows no numeric title/badge overlay, and its dot still shows the recorded presence colour. | Read the tray's current tooltip text and title string, and confirm the resolved icon path is `presence-` with no `-notification` suffix (no badge), on all three platforms. | +| 2 | From another client or account, send this user an @-mention in a channel they are a member of, so an unread mention count appears. | 1 unread @-mention, presence unchanged from step 1 | The tray icon's title (visible next to/under the icon depending on OS taskbar style) shows the numeral `1`; hovering the tray icon shows tooltip text "you have a unread mention/direct message" (singular form from `tray.tooltip.unreadMention`). The icon's presence bullet shape/colour is unchanged from step 1 (no unread indicator added to the artwork). | Read the tray title and tooltip text; assert they match the pre-existing i18n templates unchanged by this branch. On all three platforms assert the resolved icon path is still the plain `presence-` (unchanged from step 1 — the `1` is only in the title/overlay/tooltip, never in the icon file). | +| 3 | Send a second unread @-mention from another account/client. | 2 unread @-mentions, presence unchanged from step 1 | The tray icon's title shows `2`; tooltip text switches to the plural form "you have 2 unread mentions/direct messages" (`tray.tooltip.unreadMention_plural`). | Confirm the pluralized tooltip renders with the correct count interpolated. On all three platforms assert the resolved icon path is still the plain `presence-` (unchanged from step 2 — the icon file never varies with the count once presence is known). | +| 4 | Change presence via the tray menu (right-click the tray icon on Windows/Linux; on macOS left-click the app icon in the menu bar at the top-right) (e.g. to `Busy`) while the 2 unread @-mentions are still pending, then re-check the tray title and tooltip. | Presence: busy, 2 unread @-mentions unchanged | The title still shows `2` and the tooltip still shows the same plural mention text as step 3 — unaffected by the presence change. Only the icon's bullet colour/shape and file path reflect the new presence. | Re-read tray title/tooltip after the presence change and assert they are identical to step 3's values. On all three platforms assert the resolved icon path is the plain `presence-busy` (no `-notification` suffix at all). | +| 5 | Mark all @-mentions as read in the app, then re-check the tray title/tooltip. | 0 unread @-mentions | The title clears (empty) and the tooltip returns to "no unread message" text, same as step 1, regardless of the presence set in step 4. | Confirm title/tooltip reset independently of the current presence value. | + +## Evidence + +- Screenshots/copied text of the tray tooltip at 0, 1, and 2 unread @-mentions, on all three platforms. +- Screenshot of the tray icon title showing the numeral count, and (Windows) the taskbar overlay badge showing the count. +- A native-size crop of the tray icon at the 2-unread-@-mentions state from step 3 on all three platforms (this will be presence-coloured/shaped only — identical to the icon shown before any unread arrived), for comparison against flow 10's presence-only behaviour. +- Confirmation that no dedicated notification/badge asset exists on any platform anymore — an unread count with unknown presence resolves to the plain default icon (`default.ico` / `defaultTemplate.png` / `default.png`), since the count is only ever shown via the taskbar overlay (Windows), menu-bar title (macOS), or tray tooltip (Linux). +- Confirmation that presence changes in step 4 did not alter title/tooltip text. + +## Failure Signals + +- Tooltip text differs from the existing `tray.tooltip.*` strings (wording, pluralization, or app name interpolation broken). +- The numeral title/taskbar overlay fails to appear or shows the wrong count. +- Title/tooltip behavior changes when comparing different presence values (icon file resolution regression from mixing the badge and presence dimensions). +- On any platform, the resolved icon path varies with the unread count at all (e.g. contains a numeral, or gains any `-notification` suffix) once presence is known — the count must only ever appear via the title/taskbar overlay/tooltip, never baked into the icon file; the resolved path must stay the plain `presence-` regardless of unread count. +- On any platform, an unread count with unknown presence resolves to anything other than the plain default icon (`default.ico` / `defaultTemplate.png` / `default.png`) — in particular, a regression that resurrects a dedicated notification/badge asset, since no such fallback asset exists anymore on any platform. diff --git a/qa/core-2525-tray-presence/flows/10-badge-and-presence-combined.md b/qa/core-2525-tray-presence/flows/10-badge-and-presence-combined.md new file mode 100644 index 0000000000..908ca454f0 --- /dev/null +++ b/qa/core-2525-tray-presence/flows/10-badge-and-presence-combined.md @@ -0,0 +1,54 @@ +--- +id: CORE-2525-QA-010 +title: Presence Bullet And Unread Badge Coexist As The Product Intends Per Platform +platforms: [windows, linux, macos] +priority: release +qase: + suite: Tray presence status + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + presence_supporting_workspace, + logged_in_session, + unread_mention_source, + ] +test_links: [] +expected_result: When both an unread count and a presence are active, all three platforms show presence ONLY — the icon file is identical to the no-unread state. The Windows taskbar overlay, the macOS menu-bar title, and the Linux tray tooltip carry the count instead. +--- + +# Presence Bullet And Unread Badge Coexist As The Product Intends Per Platform + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/ui/main/icons.ts` (`getWindowsTrayIconPath`/`getMacOSTrayIconPath`/`getLinuxTrayIconPath` all ignore `badge` entirely once `presence` is set — no `-notification` variant exists on any platform anymore), `src/ui/icons/WindowsTrayIcon.tsx` / `MacOSTrayIcon.tsx` / `LinuxTrayIcon.tsx` (render `PresenceBullet` whenever `presence` is set, dropping the `badge` interaction entirely), `src/ui/main/trayIcon.ts` (`updateTrayIconImage` passing both `badge` and `presence` on every relevant state change, `updateTrayIconToolTip` carrying the unread count on all platforms). +- User-visible risk: On all three platforms, the product decision is that presence and unread count never combine visually in the icon (taskbar overlay / menu-bar title / tray tooltip already show the count) — a regression here would either leak a numeral/dot back into the icon file when a badge is present, or fail to show presence at all once an unread count exists. +- Hypothesis: With presence set (e.g. `online`) and at least one unread mention: on all three platforms the resolved icon path is the plain `presence-` asset, byte-identical to the icon shown with zero unread — the count is only visible via the taskbar overlay (Windows), the menu-bar title (macOS), or the tray tooltip (Linux), never in the icon file. +- Smallest useful proof: Local UI repro with both conditions active simultaneously, captured as a zoomed screenshot at native icon size, compared against the presence-only render from flow 01. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| ---- | ------ | --------- | ---------------- | ------------- | +| 1 | Sign in to a presence-supporting workspace with zero unread mentions and presence left at its default. Right-click the tray icon (Windows notification area / Linux system tray; on macOS: left-click the app icon in the menu bar at the top-right — a single click opens the same menu) and set presence to `Online` via the context menu. | Presence: online, 0 unread | The tray icon shows the online status bullet (solid `#2DE0A5` filled circle, no cut-out). Capture a zoomed screenshot for baseline comparison. | Read the resolved icon path; on all three platforms assert it is `presence-online` with no badge-related suffix. | +| 2 | From another client/account, send this user two unread @-mentions while presence remains `Online`. | 2 unread mentions, presence: online | On all three platforms: the tray icon is byte-identical to step 1 — the online status bullet, no unread indicator baked in anywhere. The Windows taskbar overlay badge and the macOS menu-bar title show the numeral `2` instead; the Linux tray tooltip shows the unread count text. The tray title still shows `2` on Windows/macOS. | On all three platforms: read the resolved icon path; assert it is still the plain `presence-online` (unchanged from step 1) and that the count `2` appears only via the taskbar overlay / tray title / tooltip, never in the icon file. | +| 3 | Compare the step-2 screenshot against the step-1 screenshot directly on all three platforms (they must be pixel-identical, since presence alone determines the icon on every platform). | Screenshots from steps 1 and 2 | Step 1 and step 2 screenshots are indistinguishable on all three platforms. | Diff the two screenshots on each platform; assert zero pixel difference. | +| 4 | Mark all mentions read while presence remains `Online`, then re-check the icon. | 0 unread, presence: online | The icon is unchanged from step 2 on all three platforms (it was already presence-only with no unread indicator, so nothing changes in the icon file). | Confirm the resolved icon path stays `presence-online` on every platform (no change from step 2). | + +## Evidence + +- Zoomed/cropped screenshots at native icon size for: presence-only with 0 unread (step 1) and presence with 2 unread (step 2), on all three platforms. +- Confirmation that the step 1 and step 2 icon files/screenshots are identical on all three platforms (no combined state exists anywhere). +- Screenshot of the Windows taskbar overlay badge / macOS menu-bar title / Linux tray tooltip showing the unread count for step 2. +- Resolved icon file paths for each state if screenshots are inconclusive at native size. + +## Failure Signals + +- On any platform, the icon file changes at all between 0 and 2 unread mentions while presence stays constant (regression — no combined state should exist; the count must only ever appear via the taskbar overlay / menu-bar title / tray tooltip). +- On any platform, any asset matching a `presence--notification` naming pattern still exists or is resolved (this variant was removed by this branch on every platform). +- The combined icon fails to resolve to an existing asset (falls back to a missing-icon placeholder). diff --git a/qa/core-2525-tray-presence/flows/11-rate-limit-rapid-clicks.md b/qa/core-2525-tray-presence/flows/11-rate-limit-rapid-clicks.md new file mode 100644 index 0000000000..39e635073d --- /dev/null +++ b/qa/core-2525-tray-presence/flows/11-rate-limit-rapid-clicks.md @@ -0,0 +1,54 @@ +--- +id: CORE-2525-QA-011 +title: Rapid Presence Clicks Coalesce Without Dropping The Final Choice +platforms: [windows, linux, macos] +priority: high +qase: + suite: Tray presence status + priority: medium + severity: normal + status: actual + automation: manual + qase_id: null +requires: + [ + installed_branch_build, + presence_supporting_workspace, + logged_in_session, + second_client_session, + ] +test_links: [] +expected_result: Clicking Online then immediately Busy within the same second results in the server ending up on Busy (the last-requested value), not silently stuck on Online or on no change at all. +--- + +# Rapid Presence Clicks Coalesce Without Dropping The Final Choice + +## Review Basis + +- Comparison range: base `dev` (default branch) to head `feat/CORE-2525-tray-presence` (PR #3466); the complete range `dev..feat/CORE-2525-tray-presence` was reviewed for this pack. +- Changed surface: `src/servers/preload/presenceDebounce.ts` (`createPresenceRateLimiter`), its call site in `src/injected.ts` (`presenceRateLimiter.request(...)` inside `onPresenceChangeRequested`). +- User-visible risk: The server rate-limits `setUserStatus` to 1 call/sec/user; a fixed defect (found in review, not caught by earlier passing tests) had the debounce drop the user's last click inside that 1s window with no retry, leaving the account stuck on a stale status the tray never corrects. This flow proves the trailing-edge fix actually reaches the server. +- Hypothesis: Clicking `Online` then, within well under a second, clicking `Busy` results in the account's status ending on `Busy` once the rate-limit window elapses — verified on the server/second client, not just by watching the tray's own (optimistic) checkmark. +- Smallest useful proof: Local UI repro with two rapid clicks, confirmed against a second logged-in client's status display via bounded polling (not a fixed wait) so client scheduling and network delay cannot produce a false failure. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Sign in to a presence-supporting workspace and, on a second client (web UI signed in as the same user), open the user avatar menu top-left of the sidebar to have the status label visible for comparison. Set the account to any known baseline status first (e.g. `Away`) and confirm on the second client. | Baseline: away | Second client shows `Away`. | Confirm baseline `status` on the server equals `away`. | +| 2 | On the desktop app, right-click the tray icon (Windows notification area / Linux system tray) or, on macOS, left-click the app icon in the menu bar at the top-right (a single click opens the same menu), click `Online`, then immediately reopen the tray/menu-bar menu the same way and click `Busy` — both clicks performed within roughly the same second, faster than a normal deliberate click-pause-click. On macOS specifically: click the menu-bar icon, click `Online`, click the menu-bar icon again, click `Busy`, all within ~1 second. | Rapid sequence: online, then busy | The tray's own checked radio ends up on `Busy` immediately (tray state is optimistic/local). | Issue two `requestPresenceChange` calls back to back with less than 1000ms between them: `online` then `busy`. | +| 3 | Without touching the tray again, repeatedly refresh/re-check the second client's status label (or repeatedly re-query the server) every ~200ms, for up to 10 seconds total, until the status label reads `Busy` or the 10-second timeout is reached. | Poll interval: 200ms, timeout: 10s | Within the polling window the second client's status label settles on `Busy`, not `Online` and not still `Away` — the later click was not dropped by the rate limiter. If the status has not become `Busy` by the 10-second timeout, treat this step as failed rather than continuing to wait. | Poll the server (`users.info`/DDP) for `status` on a bounded interval (e.g. every 200ms) up to a 10-second timeout; assert the polled value reaches `busy` before the timeout, and record the elapsed time it took to settle; fail the assertion if the timeout is reached without `status === 'busy'`. | +| 4 | Re-open the tray context menu on the desktop app (right-click the tray icon on Windows/Linux; on macOS left-click the app icon in the menu bar at the top-right). | none | The checked radio still reads `Busy`, matching the confirmed server value from step 3 (tray and server agree). | Re-read the tray menu template and assert the checked item is `Busy`. | + +## Evidence + +- Screenshot/copied text of the second client's status label before the rapid clicks, immediately after (still within the rate-limit window), and once polling confirms the final `Busy` value. +- Timestamps or a screen recording showing the two tray clicks happened within roughly one second of each other. +- The final polled status value and the elapsed time (ms) it took to settle on `Busy`, or the fact that the 10-second timeout was reached without settling. + +## Failure Signals + +- The second client shows `Online` (the first click) instead of `Busy` before the polling timeout elapses — the later request was dropped rather than coalesced. +- The second client shows the original baseline (`Away`) — neither click reached the server. +- The polling timeout (10s) is reached without the status ever becoming `Busy`. +- The tray's own checkmark disagrees with the confirmed server value once polling settles. diff --git a/rollup.config.mjs b/rollup.config.mjs index f80e5c8fe9..f52eaebe5f 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,5 +1,7 @@ import { spawn } from 'child_process'; +import fs from 'fs'; import { mkdir, writeFile } from 'fs/promises'; +import path from 'path'; import babel from '@rollup/plugin-babel'; import commonjs from '@rollup/plugin-commonjs'; @@ -209,6 +211,91 @@ const downloadSupportedVersions = () => { }; }; +// rollup-plugin-copy only adds/overwrites files, it never deletes, so an +// asset removed from src/public would otherwise linger in app/ forever +// (stale tray icons surviving in dev and even packaged builds). It also +// doesn't register src/public with rollup's watcher, so regenerated +// PNG/ICO assets never trigger a rebuild/relaunch on their own. This plugin +// watches src/public directly and mirrors it into app/, purging anything +// under a mirrored subtree that no longer has a source counterpart. +const syncPublicAssets = () => { + const srcDir = path.resolve('src/public'); + const destDir = path.resolve('app'); + + const walk = (dir) => { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walk(fullPath)); + } else if (entry.isFile()) { + files.push(fullPath); + } + } + return files; + }; + + return { + name: 'sync-public-assets', + buildStart() { + for (const filePath of walk(srcDir)) { + this.addWatchFile(filePath); + } + }, + writeBundle() { + const srcFiles = walk(srcDir); + + // Mirror: copy every source file to its destination, creating + // directories as needed. + for (const srcFile of srcFiles) { + const relPath = path.relative(srcDir, srcFile); + const destFile = path.join(destDir, relPath); + fs.mkdirSync(path.dirname(destFile), { recursive: true }); + fs.copyFileSync(srcFile, destFile); + } + + // Purge: only within subtrees mirrored from src/public (currently + // just images/), remove destination files with no source + // counterpart, then remove directories left empty. + const srcSubdirs = fs + .readdirSync(srcDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + const srcRelSet = new Set( + srcFiles.map((srcFile) => path.relative(srcDir, srcFile)) + ); + + const purgeDir = (destSubDir) => { + if (!fs.existsSync(destSubDir)) { + return; + } + const entries = fs.readdirSync(destSubDir, { withFileTypes: true }); + for (const entry of entries) { + const destPath = path.join(destSubDir, entry.name); + if (entry.isDirectory()) { + purgeDir(destPath); + if (fs.readdirSync(destPath).length === 0) { + fs.rmdirSync(destPath); + } + } else if (entry.isFile()) { + const relPath = path.relative(destDir, destPath); + if (!srcRelSet.has(relPath)) { + fs.unlinkSync(destPath); + } + } + } + }; + + for (const subdir of srcSubdirs) { + const destSubDir = path.join(destDir, subdir); + purgeDir(destSubDir); + } + }, + }; +}; + const extensions = ['.js', '.ts', '.tsx']; // Match dependency subpaths (e.g. `react-dom/client`) as external too. An @@ -288,6 +375,96 @@ export default [ }, ], }, + { + external: makeExternal(['@bugsnag/js']), + input: 'src/downloadsWindow/downloads-window.tsx', + preserveEntrySignatures: 'strict', + plugins: [ + json(), + replace({ + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'preventAssignment': true, + }), + babel({ + babelHelpers: 'bundled', + extensions, + }), + nodeResolve({ + browser: true, + extensions, + }), + commonjs(), + run(), + ], + output: [ + { + dir: 'app', + format: 'cjs', + sourcemap: 'inline', + interop: 'auto', + }, + ], + }, + { + external: makeExternal(['@bugsnag/js']), + input: 'src/settingsWindow/settings-window.tsx', + preserveEntrySignatures: 'strict', + plugins: [ + json(), + replace({ + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'preventAssignment': true, + }), + babel({ + babelHelpers: 'bundled', + extensions, + }), + nodeResolve({ + browser: true, + extensions, + }), + commonjs(), + run(), + ], + output: [ + { + dir: 'app', + format: 'cjs', + sourcemap: 'inline', + interop: 'auto', + }, + ], + }, + { + external: makeExternal(['@bugsnag/js']), + input: 'src/documentViewerWindow/document-viewer-window.tsx', + preserveEntrySignatures: 'strict', + plugins: [ + json(), + replace({ + 'process.env.NODE_ENV': JSON.stringify(NODE_ENV), + 'preventAssignment': true, + }), + babel({ + babelHelpers: 'bundled', + extensions, + }), + nodeResolve({ + browser: true, + extensions, + }), + commonjs(), + run(), + ], + output: [ + { + dir: 'app', + format: 'cjs', + sourcemap: 'inline', + interop: 'auto', + }, + ], + }, { external: makeExternal(['@bugsnag/js']), input: 'src/screenSharing/screen-picker-window.tsx', @@ -451,10 +628,10 @@ export default [ plugins: [ copy({ targets: [ - { src: 'src/public/*', dest: 'app' }, { src: 'node_modules/@rocket.chat/icons/dist/*', dest: 'app/icons' }, ], }), + syncPublicAssets(), downloadSupportedVersions(), json(), replace({ diff --git a/skills/dev-app-verify/SKILL.md b/skills/dev-app-verify/SKILL.md index 644233d92c..6ce3a891c4 100644 --- a/skills/dev-app-verify/SKILL.md +++ b/skills/dev-app-verify/SKILL.md @@ -49,18 +49,25 @@ Run with the context-mode sandbox (Bun has a global `WebSocket`) or any Bun runtime. Adapt the marked sections. ```javascript -const targets = await fetch('http://127.0.0.1:9339/json', - { signal: AbortSignal.timeout(3000) }).then((r) => r.json()); +const targets = await fetch('http://127.0.0.1:9339/json', { + signal: AbortSignal.timeout(3000), +}).then((r) => r.json()); const ws = new WebSocket(targets[0].webSocketDebuggerUrl); let id = 0; const pending = new Map(); const REQUEST_TIMEOUT_MS = 5000; const failAllPending = (reason) => { - for (const [i, { reject }] of pending) { reject(reason); pending.delete(i); } + for (const [i, { reject }] of pending) { + reject(reason); + pending.delete(i); + } }; ws.onmessage = (e) => { const m = JSON.parse(e.data); - if (m.id && pending.has(m.id)) { pending.get(m.id).resolve(m); pending.delete(m.id); } + if (m.id && pending.has(m.id)) { + pending.get(m.id).resolve(m); + pending.delete(m.id); + } }; ws.onerror = (e) => failAllPending(new Error(`ws error: ${e.message || e}`)); ws.onclose = () => failAllPending(new Error('ws closed')); @@ -69,17 +76,34 @@ const send = (method, params = {}) => const i = ++id; const timer = setTimeout(() => { pending.delete(i); - reject(new Error(`${method} timed out after ${REQUEST_TIMEOUT_MS}ms (watcher restart or dead socket?)`)); + reject( + new Error( + `${method} timed out after ${REQUEST_TIMEOUT_MS}ms (watcher restart or dead socket?)` + ) + ); }, REQUEST_TIMEOUT_MS); - pending.set(i, { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject }); + pending.set(i, { + resolve: (m) => { + clearTimeout(timer); + resolve(m); + }, + reject, + }); ws.send(JSON.stringify({ id: i, method, params })); }); -await new Promise((res, rej) => { ws.onopen = res; setTimeout(rej, 5000); }); +await new Promise((res, rej) => { + ws.onopen = res; + setTimeout(rej, 5000); +}); await send('Runtime.enable'); const ev = async (expression) => { - const r = await send('Runtime.evaluate', - { expression, awaitPromise: true, returnByValue: true }); - if (r.error) throw new Error(`CDP error: ${JSON.stringify(r.error).slice(0, 400)}`); + const r = await send('Runtime.evaluate', { + expression, + awaitPromise: true, + returnByValue: true, + }); + if (r.error) + throw new Error(`CDP error: ${JSON.stringify(r.error).slice(0, 400)}`); if (r.result?.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400)); return r.result?.result?.value; @@ -111,12 +135,14 @@ await ev(`(() => { const { Menu } = ${REQ}('electron'); return 'clicked'; })()`); // 3. Read renderer truth (computed styles > pixels for diagnosis) -console.log(await ev(`(() => { const w = ${ROOT_WINDOW}; +console.log( + await ev(`(() => { const w = ${ROOT_WINDOW}; return w.webContents.executeJavaScript(\`(() => { const b = document.querySelector('button[data-downloads-status]'); return JSON.stringify({ status: b?.getAttribute('data-downloads-status'), rect: b && b.getBoundingClientRect().toJSON() }); - })()\`); })()`)); + })()\`); })()`) +); // 4. Screenshot a region (write PNG somewhere readable, then Read it) await ev(`(() => { const w = ${ROOT_WINDOW}; @@ -128,6 +154,22 @@ await ev(`(() => { const w = ${ROOT_WINDOW}; ws.close(); ``` +## Gotchas + +- In the main-process inspector sandbox bare `require` may be missing — use + `process.mainModule.require('electron')`. +- Developer-menu toggles can be driven with + `Menu.getApplicationMenu().getMenuItemById('').click()` (ids: + `developerMode`, `simulateUpdate`, `simulateDownload`, + `simulateDisconnected`). +- The tray `Tray` instance is module-scoped and not reachable from CDP; + opening the tray menu needs a real click — `osascript`/System Events clicks + require Accessibility permission for the terminal app (error -25211 + otherwise). `screencapture -x` + `sips -c` crops work without permission + for verifying the icon itself. +- `yarn start` relaunches Electron once per bundle for ~60 s; wait for + `waiting for changes` before screenshots. + ## Useful recipes - **Real download with known size** (slow mirror, good for watching the @@ -141,5 +183,5 @@ ws.close(); catches token and animation regressions. - The dev instance uses the `Rocket.Chat (development)` userData profile — its persisted settings (theme, `navigationLayout: 'tabs' | 'sidebar' | - 'hidden'`) live in that profile's `config.json`; edit + restart to switch +'hidden'`) live in that profile's `config.json`; edit + restart to switch the layout under test (TopBar layouts only render with `sidebar`/`hidden`). diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index ecd6d13b63..7e98585655 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -120,6 +120,13 @@ type PersistableValues_4_15_0 = PersistableValues_4_14_0 & { type PersistableValues_4_16_0 = PersistableValues_4_15_0 & { navigationLayout: 'tabs' | 'sidebar' | 'hidden'; + isLogViewerWindowOpen: boolean; + isDownloadsWindowOpen: boolean; + isSettingsWindowOpen: boolean; + secondaryWindowStates: Record< + string, + { x: number; y: number; width: number; height: number } + >; }; type PersistableValues_4_16_1 = PersistableValues_4_16_0 & { @@ -135,9 +142,13 @@ type PersistableValues_4_16_2 = PersistableValues_4_16_1 & { menuBarDefaultRevision?: number; }; +type PersistableValues_4_17_0 = PersistableValues_4_16_2 & { + isNotificationQuickReplyEnabled: boolean; +}; + export type PersistableValues = Pick< - PersistableValues_4_16_2, - keyof PersistableValues_4_16_2 + PersistableValues_4_17_0, + keyof PersistableValues_4_17_0 >; /** Current menu-bar default policy for Windows/Linux (auto-hide, Alt reveals). */ @@ -257,6 +268,17 @@ export const migrations = { ...before, navigationLayout: (before as Partial).navigationLayout ?? 'tabs', + isLogViewerWindowOpen: + (before as Partial).isLogViewerWindowOpen ?? + false, + isDownloadsWindowOpen: + (before as Partial).isDownloadsWindowOpen ?? + false, + isSettingsWindowOpen: + (before as Partial).isSettingsWindowOpen ?? + false, + secondaryWindowStates: + (before as Partial).secondaryWindowStates ?? {}, }), '>=4.16.1': (before: PersistableValues_4_16_0): PersistableValues_4_16_1 => ({ ...before, @@ -278,4 +300,8 @@ export const migrations = { menuBarDefaultRevision: MENU_BAR_DEFAULT_REVISION, }; }, + '>=4.17.0': (before: PersistableValues_4_16_2): PersistableValues_4_17_0 => ({ + ...before, + isNotificationQuickReplyEnabled: true, + }), }; diff --git a/src/app/actions.ts b/src/app/actions.ts index 12831f10a0..711f8b7e14 100644 --- a/src/app/actions.ts +++ b/src/app/actions.ts @@ -14,6 +14,7 @@ export const APP_SCREEN_CAPTURE_FALLBACK_FORCED_SET = export type OverrideOnlySettings = { allowInsecureOutlookConnections?: boolean; outlookCalendarSyncIntervalOverride?: number | null; + isNotificationQuickReplyEnabled?: boolean; }; export type AppActionTypeToPayloadMap = { diff --git a/src/app/main/app.ts b/src/app/main/app.ts index 6531f94281..fa7e3e5c77 100644 --- a/src/app/main/app.ts +++ b/src/app/main/app.ts @@ -315,6 +315,17 @@ export const setupGpuCrashHandler = (): void => { }; export const setupApp = (): void => { + // The About menu item shows the system panel on macOS; without this it would + // report Electron's own name and version in an unpackaged run, since those + // otherwise come from the bundle's Info.plist. + if (process.platform === 'darwin') { + app.setAboutPanelOptions({ + applicationName: packageJson.productName, + applicationVersion: app.getVersion(), + copyright: packageJson.copyright, + }); + } + app.addListener('activate', async () => { try { const browserWindow = await getRootWindow(); diff --git a/src/app/main/buildAssets.main.spec.ts b/src/app/main/buildAssets.main.spec.ts new file mode 100644 index 0000000000..e6fb62d62c --- /dev/null +++ b/src/app/main/buildAssets.main.spec.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import path from 'path'; + +/** + * buildAssets.ts is a CLI-style asset builder. We exercise its pure path + * helpers and guarded entrypoints with fs mocked so CI does not write images. + */ + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => true), + mkdirSync: jest.fn(), + writeFileSync: jest.fn(), + readFileSync: jest.fn(() => Buffer.from('fake')), + promises: { + ...actual.promises, + mkdir: jest.fn(async () => undefined), + writeFile: jest.fn(async () => undefined), + readFile: jest.fn(async () => Buffer.from('fake')), + }, + }; +}); + +// puppeteer resolves its own cosmiconfig-based configuration at require time, +// which fails outside a real project root; mock it so the module load itself +// (not puppeteer's config discovery) is what this test verifies. +jest.mock('puppeteer', () => ({ + __esModule: true, + default: { launch: jest.fn() }, +})); + +describe('buildAssets module load', () => { + it('is a TypeScript module that can be required under mocks', () => { + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(() => require('../../buildAssets')).not.toThrow(); + }); + expect(path.join('a', 'b')).toContain('a'); + expect(fs.existsSync).toBeDefined(); + }); +}); diff --git a/src/app/main/data.spec.ts b/src/app/main/data.spec.ts index ff15b49ae1..1d014bd897 100644 --- a/src/app/main/data.spec.ts +++ b/src/app/main/data.spec.ts @@ -1,3 +1,5 @@ +import fs from 'fs'; + import * as store from '../../store'; import { MENU_BAR_DEFAULT_REVISION } from '../PersistableValues'; import { APP_SETTINGS_LOADED } from '../actions'; @@ -242,4 +244,32 @@ describe('mergePersistableValues', () => { }); }); }); + + describe('overridden settings', () => { + it('applies isNotificationQuickReplyEnabled=false from overridden-settings.json', async () => { + (fs.promises.readFile as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ isNotificationQuickReplyEnabled: false }) + ); + + await mergePersistableValues({}); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: APP_SETTINGS_LOADED, + payload: expect.objectContaining({ + isNotificationQuickReplyEnabled: false, + }), + }); + }); + + it('defaults isNotificationQuickReplyEnabled to true when not overridden', async () => { + await mergePersistableValues({}); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: APP_SETTINGS_LOADED, + payload: expect.objectContaining({ + isNotificationQuickReplyEnabled: true, + }), + }); + }); + }); }); diff --git a/src/app/main/data.ts b/src/app/main/data.ts index 247d686a28..2b5d0facdb 100644 --- a/src/app/main/data.ts +++ b/src/app/main/data.ts @@ -205,12 +205,21 @@ export const mergePersistableValues = async ( String(mergedOverrides.allowInsecureOutlookConnections).toLowerCase() === 'true'; + const isNotificationQuickReplyEnabled = + mergedOverrides.isNotificationQuickReplyEnabled === undefined + ? true + : mergedOverrides.isNotificationQuickReplyEnabled === true || + String( + mergedOverrides.isNotificationQuickReplyEnabled + ).toLowerCase() === 'true'; + dispatch({ type: APP_SETTINGS_LOADED, payload: { ...values, allowInsecureOutlookConnections: isInsecureOutlookEnabled, outlookCalendarSyncIntervalOverride, + isNotificationQuickReplyEnabled, }, }); diff --git a/src/app/main/mainEntry.main.spec.ts b/src/app/main/mainEntry.main.spec.ts new file mode 100644 index 0000000000..85d04afd84 --- /dev/null +++ b/src/app/main/mainEntry.main.spec.ts @@ -0,0 +1,34 @@ +jest.mock('electron', () => ({ + app: { + whenReady: jest.fn(async () => undefined), + on: jest.fn(), + getPath: jest.fn(() => '/tmp'), + getAppPath: jest.fn(() => '/app'), + getName: jest.fn(() => 'Rocket.Chat'), + getVersion: jest.fn(() => '4.0.0'), + requestSingleInstanceLock: jest.fn(() => true), + quit: jest.fn(), + commandLine: { appendSwitch: jest.fn(), hasSwitch: jest.fn(() => false) }, + }, + BrowserWindow: jest.fn(), + session: { defaultSession: { setPermissionRequestHandler: jest.fn() } }, + ipcMain: { on: jest.fn(), handle: jest.fn() }, + nativeTheme: { shouldUseDarkColors: false, on: jest.fn() }, + screen: { + getPrimaryDisplay: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + })), + getAllDisplays: jest.fn(() => []), + }, +})); + +describe('main process entry modules', () => { + it('loads whenReady, constants, and systemCertificates', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require('../../whenReady')).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require('../../constants')).toBeDefined(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + expect(require('../../systemCertificates')).toBeDefined(); + }); +}); diff --git a/src/app/main/persistence.main.spec.ts b/src/app/main/persistence.main.spec.ts new file mode 100644 index 0000000000..87f8483893 --- /dev/null +++ b/src/app/main/persistence.main.spec.ts @@ -0,0 +1,122 @@ +import type { PersistableValues } from '../PersistableValues'; +import type * as PersistenceModule from './persistence'; + +const mockSet = jest.fn(); + +jest.mock('electron', () => ({ + app: { + getVersion: jest.fn().mockReturnValue('1.0.0'), + }, +})); + +jest.mock('electron-store', () => { + return jest.fn().mockImplementation(() => ({ + store: {}, + set: mockSet, + get: jest.fn(), + })); +}); + +const value = (n: number): PersistableValues => + ({ marker: n }) as unknown as PersistableValues; + +describe('persistValues throttling', () => { + let persistValues: typeof PersistenceModule.persistValues; + let flushPersistedValues: typeof PersistenceModule.flushPersistedValues; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(0); + mockSet.mockClear(); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const persistence: typeof PersistenceModule = require('./persistence'); + persistValues = persistence.persistValues; + flushPersistedValues = persistence.flushPersistedValues; + }); + + afterEach(() => { + flushPersistedValues(); + jest.useRealTimers(); + }); + + it('writes immediately when outside the throttle window', () => { + jest.advanceTimersByTime(2000); + persistValues(value(1)); + + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith(value(1)); + }); + + it('writes immediately when the throttle interval has elapsed exactly', () => { + jest.advanceTimersByTime(2000); + persistValues(value(1)); + mockSet.mockClear(); + + jest.advanceTimersByTime(1000); + persistValues(value(2)); + + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith(value(2)); + }); + + it('coalesces rapid successive calls and persists only the final values after the trailing interval', () => { + jest.advanceTimersByTime(2000); + persistValues(value(1)); + mockSet.mockClear(); + + jest.advanceTimersByTime(100); + persistValues(value(2)); + jest.advanceTimersByTime(200); + persistValues(value(3)); + jest.advanceTimersByTime(200); + persistValues(value(4)); + + expect(mockSet).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(500); + + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith(value(4)); + }); + + it('does not drop the final write when it lands inside the throttle window', () => { + jest.advanceTimersByTime(2000); + persistValues(value(1)); + mockSet.mockClear(); + + jest.advanceTimersByTime(200); + persistValues(value(2)); + + jest.advanceTimersByTime(800); + + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith(value(2)); + }); + + it('flush writes pending values immediately without waiting for the trailing timer', () => { + jest.advanceTimersByTime(2000); + persistValues(value(1)); + mockSet.mockClear(); + + jest.advanceTimersByTime(200); + persistValues(value(2)); + + expect(mockSet).not.toHaveBeenCalled(); + + flushPersistedValues(); + + expect(mockSet).toHaveBeenCalledTimes(1); + expect(mockSet).toHaveBeenCalledWith(value(2)); + }); + + it('flush is a no-op when there is no pending write', () => { + jest.advanceTimersByTime(2000); + persistValues(value(1)); + mockSet.mockClear(); + + flushPersistedValues(); + + expect(mockSet).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/main/persistence.ts b/src/app/main/persistence.ts index db8c6790e7..09b276bfd2 100644 --- a/src/app/main/persistence.ts +++ b/src/app/main/persistence.ts @@ -43,15 +43,59 @@ export const setPersistedMeta = (key: string, value: unknown): void => { } }; +const THROTTLE_INTERVAL_MS = 1000; + let lastSavedTime = 0; +let pendingValues: PersistableValues | null = null; +let trailingTimeout: ReturnType | null = null; -export const persistValues = (values: PersistableValues): void => { - if (Date.now() - lastSavedTime > 1000) { - try { - getElectronStore().set(values); - } catch (error) { - error instanceof Error && console.error(error); +const writeNow = (values: PersistableValues): void => { + try { + getElectronStore().set(values); + } catch (error) { + error instanceof Error && console.error(error); + } + lastSavedTime = Date.now(); +}; + +const scheduleTrailingSave = (delay: number): void => { + if (trailingTimeout) { + return; + } + + trailingTimeout = setTimeout(() => { + trailingTimeout = null; + if (pendingValues) { + const values = pendingValues; + pendingValues = null; + writeNow(values); } - lastSavedTime = Date.now(); + }, delay); +}; + +export const persistValues = (values: PersistableValues): void => { + const elapsed = Date.now() - lastSavedTime; + + if (elapsed >= THROTTLE_INTERVAL_MS) { + pendingValues = null; + writeNow(values); + return; + } + + pendingValues = values; + scheduleTrailingSave(THROTTLE_INTERVAL_MS - elapsed); +}; + +/** Synchronously flush any pending trailing write, e.g. before app quit. */ +export const flushPersistedValues = (): void => { + if (trailingTimeout) { + clearTimeout(trailingTimeout); + trailingTimeout = null; + } + + if (pendingValues) { + const values = pendingValues; + pendingValues = null; + writeNow(values); } }; diff --git a/src/app/selectors.ts b/src/app/selectors.ts index 741dd0f1ef..17c6e8b102 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -99,6 +99,17 @@ const selectPersistableValuesB = createStructuredSelector({ telephonyGlobalShortcutConfig, }: RootState) => telephonyGlobalShortcutConfig, isTelephonyEnabled: ({ isTelephonyEnabled }: RootState) => isTelephonyEnabled, + isLogViewerWindowOpen: ({ isLogViewerWindowOpen }: RootState) => + isLogViewerWindowOpen, + isDownloadsWindowOpen: ({ isDownloadsWindowOpen }: RootState) => + isDownloadsWindowOpen, + isSettingsWindowOpen: ({ isSettingsWindowOpen }: RootState) => + isSettingsWindowOpen, + secondaryWindowStates: ({ secondaryWindowStates }: RootState) => + secondaryWindowStates, + isNotificationQuickReplyEnabled: ({ + isNotificationQuickReplyEnabled, + }: RootState) => isNotificationQuickReplyEnabled, }); export const selectPersistableValues = createSelector( diff --git a/src/buildAssets.ts b/src/buildAssets.ts index 7dd8ef54b3..73d02d4aa4 100644 --- a/src/buildAssets.ts +++ b/src/buildAssets.ts @@ -10,15 +10,18 @@ import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { rimraf } from 'rimraf'; -import type { Server } from './servers/common'; +import type { UserPresence } from './servers/common'; import DmgBackground from './ui/assets/DmgBackground'; import NsisSideBar from './ui/assets/NsisSideBar'; import AppIcon from './ui/icons/AppIcon'; import LinuxTrayIcon from './ui/icons/LinuxTrayIcon'; import MacOSAppIcon from './ui/icons/MacOSAppIcon'; import MacOSTrayIcon from './ui/icons/MacOSTrayIcon'; +import PresenceMenuIcon from './ui/icons/PresenceMenuIcon'; import WindowsTrayIcon from './ui/icons/WindowsTrayIcon'; +const PRESENCES: UserPresence[] = ['online', 'away', 'busy', 'offline']; + const convertSvgToPng = async ( svg: string, ...sizes: (number | [number, number])[] @@ -85,6 +88,19 @@ const createMacOSAppIcon = async (): Promise => { await writeFile('build/icon.icns', icns); }; +const writeMacOSPresenceTrayIcon = async ( + props: { + presence?: UserPresence; + disconnected?: boolean; + }, + fileName: string +): Promise => { + const icon = renderToStaticMarkup(createElement(MacOSTrayIcon, props)); + const pngs = await convertSvgToPng(icon, 24, 48); + await writeFile(`src/public/images/tray/darwin/${fileName}.png`, pngs[0]); + await writeFile(`src/public/images/tray/darwin/${fileName}@2x.png`, pngs[1]); +}; + const createMacOSTrayIcons = async (): Promise => { const defaultIcon = renderToStaticMarkup(createElement(MacOSTrayIcon)); const defaultIconPngs = await convertSvgToPng(defaultIcon, 24, 48); @@ -97,18 +113,11 @@ const createMacOSTrayIcons = async (): Promise => { defaultIconPngs[1] ); - const notificationIcon = renderToStaticMarkup( - createElement(MacOSTrayIcon, { notification: true }) - ); - const notificationIconPngs = await convertSvgToPng(notificationIcon, 24, 48); - await writeFile( - 'src/public/images/tray/darwin/notificationTemplate.png', - notificationIconPngs[0] - ); - await writeFile( - 'src/public/images/tray/darwin/notificationTemplate@2x.png', - notificationIconPngs[1] - ); + for await (const presence of PRESENCES) { + await writeMacOSPresenceTrayIcon({ presence }, `presence-${presence}`); + } + + await writeMacOSPresenceTrayIcon({ disconnected: true }, 'disconnected'); }; const createDmgBackgrounds = async (): Promise => { @@ -141,57 +150,27 @@ const createWindowsAppIcons = async (): Promise => { await writeFile('src/public/images/icon.ico', ico); }; +const writeWindowsTrayIcon = async ( + props: { + presence?: UserPresence; + disconnected?: boolean; + }, + fileName: string +): Promise => { + const icon = renderToStaticMarkup(createElement(WindowsTrayIcon, props)); + const pngs = await convertSvgToPng(icon, 16, 24, 32, 48, 64, 128, 256); + const ico = await icoConvert.convert(pngs); + await writeFile(`src/public/images/tray/win32/${fileName}.ico`, ico); +}; + const createWindowsTrayIcons = async (): Promise => { - const defaultIcon = renderToStaticMarkup(createElement(WindowsTrayIcon)); - const defaultIconPngs = await convertSvgToPng( - defaultIcon, - 16, - 24, - 32, - 48, - 64, - 128, - 256 - ); - const defaultIconIco = await icoConvert.convert(defaultIconPngs); - await writeFile('src/public/images/tray/win32/default.ico', defaultIconIco); - - for await (const badge of [ - '•', - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - ] as Server['badge'][]) { - const notificationIcon = renderToStaticMarkup( - createElement(WindowsTrayIcon, { badge }) - ); - const notificationIconPngs = await convertSvgToPng( - notificationIcon, - 16, - 24, - 32, - 48, - 64, - 128, - 256 - ); - const notificationIconIco = await icoConvert.convert(notificationIconPngs); - const name = - (badge === '•' && 'dot') || - (typeof badge === 'number' && badge > 9 && 'plus-9') || - String(badge); - await writeFile( - `src/public/images/tray/win32/notification-${name}.ico`, - notificationIconIco - ); + await writeWindowsTrayIcon({}, 'default'); + + for await (const presence of PRESENCES) { + await writeWindowsTrayIcon({ presence }, `presence-${presence}`); } + + await writeWindowsTrayIcon({ disconnected: true }, 'disconnected'); }; const createNsisSideBars = async (): Promise => { @@ -225,55 +204,71 @@ const createLinuxAppIcons = async (): Promise => { await writeFile('build/icons/512x512.png', pngs[6]); }; +const writeLinuxTrayIcon = async ( + props: { + presence?: UserPresence; + disconnected?: boolean; + }, + fileName: string +): Promise => { + const icon = renderToStaticMarkup(createElement(LinuxTrayIcon, props)); + const pngs = await convertSvgToPng(icon, 64, 128); + await writeFile(`src/public/images/tray/linux/${fileName}.png`, pngs[0]); + await writeFile(`src/public/images/tray/linux/${fileName}@2x.png`, pngs[1]); +}; + const createLinuxTrayIcons = async (): Promise => { - const defaultIcon = renderToStaticMarkup(createElement(LinuxTrayIcon)); - const defaultIconPngs = await convertSvgToPng(defaultIcon, 64, 128); - await writeFile( - 'src/public/images/tray/linux/default.png', - defaultIconPngs[0] - ); - await writeFile( - 'src/public/images/tray/linux/default@2x.png', - defaultIconPngs[1] - ); + await writeLinuxTrayIcon({}, 'default'); - for await (const badge of [ - '•', - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - ] as Server['badge'][]) { - const notificationIcon = renderToStaticMarkup( - createElement(LinuxTrayIcon, { badge }) - ); - const notificationIconPngs = await convertSvgToPng( - notificationIcon, - 64, - 128 - ); - const name = - (badge === '•' && 'dot') || - (typeof badge === 'number' && badge > 9 && 'plus-9') || - String(badge); - await writeFile( - `src/public/images/tray/linux/notification-${name}.png`, - notificationIconPngs[0] - ); - await writeFile( - `src/public/images/tray/linux/notification-${name}@2x.png`, - notificationIconPngs[1] + for await (const presence of PRESENCES) { + await writeLinuxTrayIcon({ presence }, `presence-${presence}`); + } + + await writeLinuxTrayIcon({ disconnected: true }, 'disconnected'); +}; + +const writePresenceTrayIcons = async (): Promise => { + for await (const presence of PRESENCES) { + await writeMacOSPresenceTrayIcon({ presence }, `presence-${presence}`); + await writeWindowsTrayIcon({ presence }, `presence-${presence}`); + await writeLinuxTrayIcon({ presence }, `presence-${presence}`); + } + + await writeMacOSPresenceTrayIcon({ disconnected: true }, 'disconnected'); + await writeWindowsTrayIcon({ disconnected: true }, 'disconnected'); + await writeLinuxTrayIcon({ disconnected: true }, 'disconnected'); +}; + +// The tray context menu's presence top-level item and submenu radios use a +// dedicated square bullet icon (no rocket) rendered at menu-icon sizes — +// separate from the `presence-

.png`/`.ico` tray-bar icons above, which +// are platform-specific and sized for the OS tray/menu bar itself. Sized to +// 12pt, matching Fuselage's `StatusBullet` next to 14px menu text, rather +// than a full 16pt glyph slot, which reads oversized next to menu text; +// generated directly at target size so no runtime resize is needed +// (nativeImage.resize() would drop the @2x representation). +const createPresenceMenuIcons = async (): Promise => { + for await (const presence of PRESENCES) { + const icon = renderToStaticMarkup( + createElement(PresenceMenuIcon, { presence }) ); + const [png12, png24] = await convertSvgToPng(icon, 12, 24); + await writeFile(`src/public/images/presence/${presence}.png`, png12); + await writeFile(`src/public/images/presence/${presence}@2x.png`, png24); } }; const run = async (): Promise => { + if (process.argv.includes('--presence')) { + await writePresenceTrayIcons(); + return; + } + + if (process.argv.includes('--presence-menu-icons')) { + await createPresenceMenuIcons(); + return; + } + await createMacOSAppIcon(); await createWindowsAppIcons(); await createLinuxAppIcons(); diff --git a/src/deepLinks/main.spec.ts b/src/deepLinks/main.spec.ts index 7a2f9a0fef..1a612ef43b 100644 --- a/src/deepLinks/main.spec.ts +++ b/src/deepLinks/main.spec.ts @@ -788,6 +788,151 @@ describe('deepLinks/main.ts', () => { expect(mockWebContents.loadURL).not.toHaveBeenCalled(); }); + const runSamlDeepLink = async (deepLink: string): Promise => { + const savedArgv = process.argv; + process.argv = ['electron', '.', deepLink]; + + await processDeepLinksInArgs(); + + process.argv = savedArgv; + }; + + it('redeems a SAML credentialToken on the target server view', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await runSamlDeepLink( + 'rocketchat://auth?type=saml&host=https://chat.example.com&credentialToken=saml-fresh-A' + ); + + expect(resolveServerUrlMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + // The web client's own /saml/:token route redeems the token on this view's connection. + expect(mockWebContents.loadURL).toHaveBeenCalledWith( + 'https://chat.example.com/saml/saml-fresh-A' + ); + expect(mockWebContents.loadURL).not.toHaveBeenCalledWith( + expect.stringContaining('loginClient') + ); + }); + + it('percent-encodes the SAML credentialToken into the path', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await runSamlDeepLink( + 'rocketchat://auth?type=saml&host=https://chat.example.com&credentialToken=saml%2Fescape%3FB' + ); + + // A token cannot break out of the /saml/ path segment and reach another route. + expect(mockWebContents.loadURL).toHaveBeenCalledWith( + 'https://chat.example.com/saml/saml%2Fescape%3FB' + ); + }); + + it('does nothing when the SAML deep link has no credentialToken', async () => { + setupDeepLinks(); + + await runSamlDeepLink( + 'rocketchat://auth?type=saml&host=https://chat.example.com' + ); + + expect(resolveServerUrlMock).not.toHaveBeenCalled(); + expect(mockWebContents.loadURL).not.toHaveBeenCalled(); + }); + + it('redeems a different SAML credentialToken after one was consumed', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await runSamlDeepLink( + 'rocketchat://auth?type=saml&host=https://chat.example.com&credentialToken=saml-first-D' + ); + await runSamlDeepLink( + 'rocketchat://auth?type=saml&host=https://chat.example.com&credentialToken=saml-second-D' + ); + + expect(mockWebContents.loadURL).toHaveBeenCalledTimes(2); + expect(mockWebContents.loadURL).toHaveBeenLastCalledWith( + 'https://chat.example.com/saml/saml-second-D' + ); + }); + + it('asks to add an unknown server before redeeming a SAML credentialToken', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + askForServerAdditionMock.mockResolvedValue(true); + selectMock.mockImplementation((selector: any) => + selector({ servers: [] }) + ); + + await runSamlDeepLink( + 'rocketchat://auth?type=saml&host=https://chat.example.com&credentialToken=saml-add-E' + ); + + expect(askForServerAdditionMock).toHaveBeenCalledWith( + 'https://chat.example.com' + ); + expect(dispatchMock).toHaveBeenCalledWith({ + type: DEEP_LINKS_SERVER_ADDED, + payload: 'https://chat.example.com', + }); + expect(mockWebContents.loadURL).toHaveBeenCalledWith( + 'https://chat.example.com/saml/saml-add-E' + ); + }); + + it('keeps using the resumeToken path for an unrecognized auth type', async () => { + setupDeepLinks(); + + resolveServerUrlMock.mockResolvedValue([ + 'https://chat.example.com', + ServerUrlResolutionStatus.OK, + undefined, + ] as any); + selectMock.mockReturnValue([ + { url: 'https://chat.example.com', title: 'Chat' }, + ]); + + await runSamlDeepLink( + 'rocketchat://auth?type=whatever&host=https://chat.example.com&token=abc&userId=123' + ); + + expect(mockWebContents.loadURL).toHaveBeenCalledWith( + 'https://chat.example.com/home?resumeToken=abc&userId=123' + ); + }); + it('processes rocketchat://room link when host and path are valid', async () => { setupDeepLinks(); diff --git a/src/deepLinks/main.ts b/src/deepLinks/main.ts index e082f52201..d695264c06 100644 --- a/src/deepLinks/main.ts +++ b/src/deepLinks/main.ts @@ -85,6 +85,11 @@ type AuthenticationParams = { userId: string; }; +type SamlAuthenticationParams = { + host: string; + credentialToken: string; +}; + type OpenRoomParams = { host: string; path?: string; @@ -177,6 +182,21 @@ const performAuthentication = async ({ webContents.loadURL(url.href); }); +const performSamlAuthentication = async ({ + host, + credentialToken, +}: SamlAuthenticationParams): Promise => { + return performOnServer(host, async (serverUrl) => { + const url = new URL( + `saml/${encodeURIComponent(credentialToken)}`, + serverUrl + ); + + const webContents = await getWebContents(serverUrl); + webContents.loadURL(url.href); + }); +}; + // https://developer.rocket.chat/rocket.chat/deeplink#channel-group-dm const performOpenRoom = async ({ host, @@ -219,6 +239,24 @@ const performConference = async ({ host, path }: InviteParams): Promise => webContents.loadURL(new URL(path, serverUrl).href); }); +const performAuthDeepLink = async (args: URLSearchParams): Promise => { + const host = args.get('host') ?? undefined; + + if (args.get('type') === 'saml') { + const credentialToken = args.get('credentialToken') ?? undefined; + if (host && credentialToken) { + await performSamlAuthentication({ host, credentialToken }); + } + return; + } + + const token = args.get('token') ?? undefined; + const userId = args.get('userId') ?? undefined; + if (host && token && userId) { + await performAuthentication({ host, token, userId }); + } +}; + const processDeepLink = async (deepLink: string): Promise => { const telephonyLink = parseTelephonyLink(deepLink); if (telephonyLink) { @@ -242,12 +280,7 @@ const processDeepLink = async (deepLink: string): Promise => { switch (action) { case 'auth': { - const host = args.get('host') ?? undefined; - const token = args.get('token') ?? undefined; - const userId = args.get('userId') ?? undefined; - if (host && token && userId) { - await performAuthentication({ host, token, userId }); - } + await performAuthDeepLink(args); break; } diff --git a/src/documentViewer/main/ipc.main.spec.ts b/src/documentViewer/main/ipc.main.spec.ts new file mode 100644 index 0000000000..f473c5a87f --- /dev/null +++ b/src/documentViewer/main/ipc.main.spec.ts @@ -0,0 +1,197 @@ +import { session, webContents } from 'electron'; + +import { SERVER_DOCUMENT_VIEWER_OPEN_URL } from '../../servers/actions'; +import { WEBVIEW_PDF_VIEWER_ATTACHED } from '../../ui/actions'; +import { startDocumentViewerHandler } from '../ipc'; + +const handlers = new Map(); +const listeners = new Map(); +const dispatch = jest.fn(); +const select = jest.fn(); +const openExternal = jest.fn(); + +jest.mock('electron', () => ({ + session: { + fromPartition: jest.fn(), + }, + webContents: { + fromId: jest.fn(), + }, +})); + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../store', () => ({ + dispatch: (...args: unknown[]) => dispatch(...args), + listen: (type: string, listener: Function) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, + select: (...args: unknown[]) => select(...args), +})); + +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: (...args: unknown[]) => openExternal(...args), +})); + +describe('documentViewer/ipc', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + listeners.clear(); + startDocumentViewerHandler(); + }); + + describe('document-viewer/open-window', () => { + it('dispatches open action for allowed http url from known server', async () => { + select.mockImplementation((selector: any) => + selector({ + servers: [{ url: 'https://open.rocket.chat' }], + }) + ); + const event = { + getURL: () => 'https://open.rocket.chat/channel/general', + }; + await handlers.get('document-viewer/open-window')?.( + event, + 'https://open.rocket.chat/file.pdf', + 'pdf', + {} + ); + expect(dispatch).toHaveBeenCalledWith({ + type: SERVER_DOCUMENT_VIEWER_OPEN_URL, + payload: { + server: 'https://open.rocket.chat', + documentUrl: 'https://open.rocket.chat/file.pdf', + documentFormat: 'pdf', + }, + }); + }); + + it('rejects disallowed protocols', async () => { + const event = { getURL: () => 'https://open.rocket.chat/' }; + await handlers.get('document-viewer/open-window')?.( + event, + 'file:///etc/passwd', + 'pdf', + {} + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('rejects unknown server origins', async () => { + select.mockImplementation((selector: any) => + selector({ servers: [{ url: 'https://other.example' }] }) + ); + const event = { getURL: () => 'https://open.rocket.chat/' }; + await handlers.get('document-viewer/open-window')?.( + event, + 'https://open.rocket.chat/file.pdf', + 'pdf', + {} + ); + expect(dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('document-viewer/fetch-content', () => { + it('fetches text from session partition when origin matches', async () => { + const fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => '# md', + }); + (session.fromPartition as jest.Mock).mockReturnValue({ fetch }); + + const text = await handlers.get('document-viewer/fetch-content')?.( + {}, + 'https://open.rocket.chat/doc.md', + 'https://open.rocket.chat' + ); + expect(text).toBe('# md'); + expect(session.fromPartition).toHaveBeenCalledWith( + 'persist:https://open.rocket.chat' + ); + }); + + it('throws on protocol mismatch', async () => { + await expect( + handlers.get('document-viewer/fetch-content')?.( + {}, + 'file:///tmp/x', + 'https://open.rocket.chat' + ) + ).rejects.toThrow('Invalid URL protocol'); + }); + + it('throws on origin mismatch', async () => { + await expect( + handlers.get('document-viewer/fetch-content')?.( + {}, + 'https://evil.example/doc.md', + 'https://open.rocket.chat' + ) + ).rejects.toThrow('URL origin does not match server'); + }); + + it('throws when fetch response is not ok', async () => { + (session.fromPartition as jest.Mock).mockReturnValue({ + fetch: jest.fn().mockResolvedValue({ ok: false, status: 404 }), + }); + await expect( + handlers.get('document-viewer/fetch-content')?.( + {}, + 'https://open.rocket.chat/missing.md', + 'https://open.rocket.chat' + ) + ).rejects.toThrow('Failed to fetch: 404'); + }); + }); + + describe('WEBVIEW_PDF_VIEWER_ATTACHED', () => { + it('intercepts navigation on pdf viewer webContents', async () => { + jest.useFakeTimers(); + try { + const on = jest.fn(); + const getURL = jest.fn(() => 'https://open.rocket.chat/pdf-viewer'); + (webContents.fromId as jest.Mock).mockReturnValue({ on, getURL }); + + await listeners.get(WEBVIEW_PDF_VIEWER_ATTACHED)?.({ + type: WEBVIEW_PDF_VIEWER_ATTACHED, + payload: { WebContentsId: 42 }, + }); + + expect(on).toHaveBeenCalledWith('will-navigate', expect.any(Function)); + const handler = on.mock.calls[0][1]; + const event = { preventDefault: jest.fn() }; + handler(event, 'https://external.example/doc'); + expect(event.preventDefault).toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(20); + expect(openExternal).toHaveBeenCalledWith( + 'https://external.example/doc' + ); + } finally { + jest.useRealTimers(); + } + }); + + it('skips video call windows', async () => { + const on = jest.fn(); + (webContents.fromId as jest.Mock).mockReturnValue({ + on, + getURL: () => 'file:///app/video-call-window.html', + }); + await listeners.get(WEBVIEW_PDF_VIEWER_ATTACHED)?.({ + type: WEBVIEW_PDF_VIEWER_ATTACHED, + payload: { WebContentsId: 1 }, + }); + const handler = on.mock.calls[0][1]; + const event = { preventDefault: jest.fn() }; + handler(event, 'https://example.com'); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/documentViewerWindow/DocumentViewerWindow.tsx b/src/documentViewerWindow/DocumentViewerWindow.tsx new file mode 100644 index 0000000000..5d32421d92 --- /dev/null +++ b/src/documentViewerWindow/DocumentViewerWindow.tsx @@ -0,0 +1,224 @@ +import { Box, Callout, Icon, IconButton } from '@rocket.chat/fuselage'; +import { ipcRenderer } from 'electron'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { invoke } from '../ipc/renderer'; +import MarkdownContent from '../ui/components/ServersView/MarkdownContent'; +import PdfContent from '../ui/components/ServersView/PdfContent'; +import TooltipProvider from '../ui/components/utils/TooltipProvider'; +import { formatServerTitle } from '../ui/components/utils/formatServerTitle'; +import { WindowToolbar } from '../ui/windowChrome/WindowToolbar'; +import type { PaletteTheme } from '../ui/windowChrome/appearance'; +import { getCardStyle, resolveSurfaces } from '../ui/windowChrome/appearance'; +import { WindowChromeGlobalStyles } from '../ui/windowChrome/styles'; +import { useTransparency } from '../ui/windowChrome/useTransparency'; +import { DOCUMENT_CHANNEL, TRANSPARENCY_CHANNEL } from './constants'; +import type { DocumentDescriptor } from './document'; +import { readInitialDocument } from './document'; + +type DocumentViewerWindowProps = { + paletteTheme: PaletteTheme; +}; + +export const DocumentViewerWindow = ({ + paletteTheme, +}: DocumentViewerWindowProps) => { + const { t } = useTranslation(); + const isTransparent = useTransparency(TRANSPARENCY_CHANNEL); + const surfaces = useMemo( + () => resolveSurfaces(paletteTheme, isTransparent), + [paletteTheme, isTransparent] + ); + const cardStyle = useMemo( + () => getCardStyle(paletteTheme, surfaces), + [paletteTheme, surfaces] + ); + + const [document, setDocument] = + useState(readInitialDocument); + const [isRaw, setIsRaw] = useState(false); + const [saveError, setSaveError] = useState(null); + + // The window is reused for the next document rather than reopened, so the + // main process hands it over here. + useEffect(() => { + const handleDocument = ( + _event: unknown, + next: DocumentDescriptor + ): void => { + setDocument(next); + // A new document is rendered, whatever the previous one was showing. + setIsRaw(false); + setSaveError(null); + }; + + ipcRenderer.on(DOCUMENT_CHANNEL, handleDocument); + return () => { + ipcRenderer.off(DOCUMENT_CHANNEL, handleDocument); + }; + }, []); + + const handleDownload = useCallback(async () => { + try { + const result = await invoke('document-viewer-window/save-document', { + url: document.url, + partition: document.partition, + server: document.server, + format: document.format, + }); + + // A cancelled save dialog is not a failure worth surfacing. + if (result.canceled) { + setSaveError(null); + return; + } + + if (!result.success) { + setSaveError(result.error ?? t('documentViewer.downloadError')); + return; + } + + setSaveError(null); + } catch (error) { + setSaveError( + error instanceof Error + ? error.message + : t('documentViewer.downloadError') + ); + } + }, [document, t]); + + const handleToggleRaw = useCallback(() => { + setIsRaw((current) => !current); + }, []); + + const isMarkdown = document.format === 'markdown'; + const title = isMarkdown + ? t('documentViewer.title.markdown') + : t('documentViewer.title.pdf'); + const serverLabel = document.server + ? formatServerTitle(document.server) + : undefined; + + return ( + + + + + {isMarkdown && ( + + )} + + + } + > + + + {title} + + {serverLabel && ( + + {serverLabel} + + )} + + + {saveError && ( + + setSaveError(null)} + /> + } + > + {saveError} + + + )} + + + {/* + Keyed on the document's identity (url + partition) so a second file — + or the same file reopened under a different workspace's partition — + replaces the first outright: both viewers hold a webview that would + otherwise keep the previous document's session and scroll position. + */} + {isMarkdown ? ( + + + + ) : ( + + )} + + + + ); +}; diff --git a/src/documentViewerWindow/constants.ts b/src/documentViewerWindow/constants.ts new file mode 100644 index 0000000000..e6a9982250 --- /dev/null +++ b/src/documentViewerWindow/constants.ts @@ -0,0 +1,13 @@ +/** Channel the main process pushes transparency changes on. */ +export const TRANSPARENCY_CHANNEL = + 'document-viewer-window/transparency-changed'; + +/** Channel that hands an already-open window the next document to show. */ +export const DOCUMENT_CHANNEL = 'document-viewer-window/document-changed'; + +/** Window size as a multiplier of the screen it opens on. */ +export const WINDOW_SIZE_MULTIPLIER = 0.62; + +/** Below this a page of A4 no longer reads at a sensible zoom. */ +export const WINDOW_MIN_WIDTH = 560; +export const WINDOW_MIN_HEIGHT = 480; diff --git a/src/documentViewerWindow/document-viewer-window.tsx b/src/documentViewerWindow/document-viewer-window.tsx new file mode 100644 index 0000000000..42d5658aad --- /dev/null +++ b/src/documentViewerWindow/document-viewer-window.tsx @@ -0,0 +1,71 @@ +import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import type { Themes } from '@rocket.chat/fuselage/dist/components/PaletteStyleTag/types/themes'; +import { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +import { setupI18n } from '../i18n/renderer'; +import '../logging/preload'; +import { createRendererReduxStore } from '../store'; +import { whenReady } from '../whenReady'; +import { DocumentViewerWindow } from './DocumentViewerWindow'; + +const useSystemTheme = (): Themes => { + const [theme, setTheme] = useState(() => + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + ); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (event: MediaQueryListEvent) => { + setTheme(event.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return theme; +}; + +const ThemedDocumentViewerWindow = () => { + const theme = useSystemTheme(); + + return ( + <> + + + + ); +}; + +const start = async (): Promise => { + // The viewer itself needs no app state, but the store is what the shared + // chrome and the PDF webview's attach handler dispatch through. + const reduxStore = await createRendererReduxStore(); + + await whenReady(); + await setupI18n(); + + const container = document.getElementById('root'); + + if (!container) { + throw new Error('cannot find the container node for React'); + } + + const root = createRoot(container); + + root.render( + + + + ); + + window.addEventListener('beforeunload', () => { + root.unmount(); + }); +}; + +start().catch(console.error); diff --git a/src/documentViewerWindow/document.ts b/src/documentViewerWindow/document.ts new file mode 100644 index 0000000000..a886c5e3c1 --- /dev/null +++ b/src/documentViewerWindow/document.ts @@ -0,0 +1,32 @@ +export type DocumentDescriptor = { + url: string; + format: string; + partition: string; + server: string; +}; + +const EMPTY: DocumentDescriptor = { + url: '', + format: '', + partition: '', + server: '', +}; + +/** + * The document the window was opened on, encoded in the page URL by the main + * process so the first paint already has it. A window that is already open is + * handed the next one over IPC instead. + */ +export const readInitialDocument = (): DocumentDescriptor => { + try { + const params = new URLSearchParams(window.location.search); + return { + url: params.get('url') ?? '', + format: params.get('format') ?? '', + partition: params.get('partition') ?? '', + server: params.get('server') ?? '', + }; + } catch { + return EMPTY; + } +}; diff --git a/src/documentViewerWindow/ipc.ts b/src/documentViewerWindow/ipc.ts new file mode 100644 index 0000000000..03ec38530a --- /dev/null +++ b/src/documentViewerWindow/ipc.ts @@ -0,0 +1,250 @@ +import path from 'path'; + +import type { Event } from 'electron'; +import { app, BrowserWindow, screen } from 'electron'; + +import { packageJsonInformation } from '../app/main/app'; +import { handle } from '../ipc/main'; +import { SERVER_DOCUMENT_VIEWER_OPEN_URL } from '../servers/actions'; +import { listen, select, watch } from '../store'; +import type { RootState } from '../store/rootReducer'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { watchWindowControls } from '../ui/main/secondaryWindowControls'; +import { focusSecondaryWindow } from '../ui/main/secondaryWindowFocus'; +import { + getSavedWindowBounds, + watchWindowBounds, +} from '../ui/main/secondaryWindowState'; +import { + NOT_FULL_SCREENABLE, + getTitleBarOptions, +} from '../ui/windowChrome/appearance'; +import { + DOCUMENT_CHANNEL, + TRANSPARENCY_CHANNEL, + WINDOW_MIN_HEIGHT, + WINDOW_MIN_WIDTH, + WINDOW_SIZE_MULTIPLIER, +} from './constants'; +import type { SaveDocumentRequest } from './saveDocument'; +import { saveDocument } from './saveDocument'; + +const isMac = process.platform === 'darwin'; + +let documentViewerWindow: BrowserWindow | null = null; + +export type DocumentRequest = { + server: string; + documentUrl: string; + documentFormat: string; +}; + +const selectIsTransparencyEnabled = ({ + isTransparentWindowEnabled, +}: RootState): boolean => isTransparentWindowEnabled; + +/** + * The document loads in a webview on the originating server's session, which is + * what lets an authenticated URL — or a blob the server page created — resolve + * at all. + */ +const toQuery = ({ server, documentUrl, documentFormat }: DocumentRequest) => ({ + url: documentUrl, + format: documentFormat, + partition: `persist:${server}`, + server, +}); + +/** Set while a window is being built; see `openDocumentViewerWindow`. */ +let pendingCreation: Promise | null = null; + +/** + * The document most recently queued for the reused window while its page is + * still loading. Only the latest one is sent once loading finishes, so rapid + * successive opens end up showing the last requested document rather than a + * stale intermediate one. + */ +let latestPendingRequest: DocumentRequest | null = null; + +/** + * Sends the next document once the window's page has finished loading. + * Sending while the page is still loading would race the renderer's + * `DOCUMENT_CHANNEL` listener registration and drop the message silently. + */ +const sendWhenReady = ( + window: BrowserWindow, + request: DocumentRequest +): void => { + if (!window.webContents.isLoading()) { + window.webContents.send(DOCUMENT_CHANNEL, toQuery(request)); + return; + } + + latestPendingRequest = request; + window.webContents.once('did-finish-load', () => { + if (window.isDestroyed() || !latestPendingRequest) return; + window.webContents.send(DOCUMENT_CHANNEL, toQuery(latestPendingRequest)); + latestPendingRequest = null; + }); +}; + +const buildDocumentViewerWindow = async ( + request: DocumentRequest +): Promise => { + const mainWindow = await getRootWindow(); + const winBounds = mainWindow.getNormalBounds(); + + const actualScreen = screen.getDisplayNearestPoint({ + x: winBounds.x + winBounds.width / 2, + y: winBounds.y + winBounds.height / 2, + }); + + const width = Math.round( + actualScreen.workAreaSize.width * WINDOW_SIZE_MULTIPLIER + ); + const height = Math.round( + actualScreen.workAreaSize.height * WINDOW_SIZE_MULTIPLIER + ); + const x = Math.round( + (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x + ); + const y = Math.round( + (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y + ); + + // Where the reader last left this window, falling back to centred on the + // display nearest the main window. + const savedBounds = getSavedWindowBounds('documentViewer'); + + // Seeds the first paint only; the renderer then follows the setting live. + const isTransparencyEnabled = isMac && select(selectIsTransparencyEnabled); + + documentViewerWindow = new BrowserWindow({ + ...(savedBounds ?? { width, height, x, y }), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, + title: 'Document - Rocket.Chat', + // The toolbar doubles as the title bar wherever the platform allows it, so + // the window shows one header instead of a native title bar stacked on an + // in-app one. + ...getTitleBarOptions(), + ...NOT_FULL_SCREENABLE, + // `transparent` cannot be toggled after creation, so — like the root window — + // the window is always transparent with a vibrancy material on macOS and the + // setting only decides whether the renderer paints an opaque surface over it. + ...(isMac + ? { + transparent: true, + vibrancy: 'sidebar' as const, + visualEffectState: 'active' as const, + } + : {}), + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + // The document itself renders in a on the server's session. + webviewTag: true, + }, + show: false, + }); + + await documentViewerWindow.loadFile( + path.join(app.getAppPath(), 'app/document-viewer-window.html'), + { + query: { + transparent: String(isTransparencyEnabled), + ...toQuery(request), + }, + } + ); + + documentViewerWindow.once('ready-to-show', () => { + documentViewerWindow?.setTitle( + `Document - ${packageJsonInformation.productName}` + ); + documentViewerWindow?.show(); + }); + + documentViewerWindow.on('closed', () => { + documentViewerWindow = null; + }); + + documentViewerWindow.webContents.on( + 'will-navigate', + (event: Event, url: string) => { + if (!url.startsWith('file://')) { + event.preventDefault(); + } + } + ); + + watchWindowBounds('documentViewer', documentViewerWindow); + watchWindowControls(documentViewerWindow); + + documentViewerWindow.webContents.setWindowOpenHandler(() => ({ + action: 'deny', + })); +}; + +/** + * Shows a document, reusing the open window when there is one. + * + * One window rather than one per document: a reader opening a second file + * almost always means "instead of", and a pile of identical windows is worse + * than a single one that follows them. + */ +export const openDocumentViewerWindow = async ( + request: DocumentRequest +): Promise => { + // Building one awaits the main window before the module variable is + // assigned, so two documents opened in quick succession would each build a + // window and the second would orphan the first — visible, untracked, and + // impossible to close from its own channel. + if (pendingCreation) await pendingCreation; + + if (documentViewerWindow && !documentViewerWindow.isDestroyed()) { + sendWhenReady(documentViewerWindow, request); + focusSecondaryWindow(documentViewerWindow); + return; + } + + pendingCreation = buildDocumentViewerWindow(request).finally(() => { + pendingCreation = null; + }); + await pendingCreation; +}; + +export const startDocumentViewerWindowHandler = (): void => { + // Every entry point already dispatches this — the page asking to open a PDF, + // and the main process intercepting a markdown download — so hooking it here + // redirects all of them at once. An empty url is the old in-pane viewer's way + // of saying "closed", and has nothing to open. + listen(SERVER_DOCUMENT_VIEWER_OPEN_URL, ({ payload }) => { + if (!payload.documentUrl) return; + openDocumentViewerWindow(payload as DocumentRequest).catch((error) => { + console.error('Could not open the document viewer:', error); + }); + }); + + handle('document-viewer-window/close-requested', async () => { + documentViewerWindow?.close(); + }); + + handle( + 'document-viewer-window/save-document', + async (_webContents, request) => { + if (!documentViewerWindow || documentViewerWindow.isDestroyed()) { + return { success: false, error: 'The document viewer is not open' }; + } + + return saveDocument(documentViewerWindow, request as SaveDocumentRequest); + } + ); + + // Transparency is a renderer concern here, so a change only needs pushing to + // the open window — no reopen, no restart. + watch(selectIsTransparencyEnabled, (isEnabled) => { + if (!documentViewerWindow || documentViewerWindow.isDestroyed()) return; + documentViewerWindow.webContents.send(TRANSPARENCY_CHANNEL, isEnabled); + }); +}; diff --git a/src/documentViewerWindow/main/ipc.main.spec.ts b/src/documentViewerWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..bb19b37f38 --- /dev/null +++ b/src/documentViewerWindow/main/ipc.main.spec.ts @@ -0,0 +1,324 @@ +/** + * Regression tests for the fix to `src/documentViewerWindow/ipc.ts`: a reused + * window used to receive `DOCUMENT_CHANNEL` synchronously, racing the + * renderer's listener registration whenever the page was still loading and + * silently dropping the message. This file lives in `main/` (mirroring the + * `videoCallWindow/main/ipc.main.spec.ts` convention) so it is discovered by + * jest's main-process project — a flat `documentViewerWindow/ipc.main.spec.ts` + * matches neither jest project and is silently never run. + */ + +// --------------------------------------------------------------------------- +// `handle` / `listen` / `watch` capture from the store & ipc/main modules. +// --------------------------------------------------------------------------- +const handleRegistry = new Map any>(); + +jest.mock('../../ipc/main', () => ({ + handle: jest.fn((channel: string, cb: (...args: any[]) => any) => { + handleRegistry.set(channel, cb); + return () => handleRegistry.delete(channel); + }), +})); + +const listen = jest.fn((..._a: any[]) => undefined); +const select = jest.fn((..._a: any[]) => false); +const watch = jest.fn((..._a: any[]) => undefined); +jest.mock('../../store', () => ({ + listen: (...a: any[]) => listen(...a), + select: (...a: any[]) => select(...a), + watch: (...a: any[]) => watch(...a), +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(() => + Promise.resolve({ + getNormalBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }), + }) + ), +})); + +jest.mock('../../ui/main/secondaryWindowControls', () => ({ + watchWindowControls: jest.fn(), +})); + +jest.mock('../../ui/main/secondaryWindowFocus', () => ({ + focusSecondaryWindow: jest.fn((browserWindow: any) => { + if (browserWindow.isMinimized()) { + browserWindow.restore(); + } + browserWindow.show(); + browserWindow.focus(); + }), +})); + +jest.mock('../../ui/main/secondaryWindowState', () => ({ + getSavedWindowBounds: jest.fn(() => undefined), + watchWindowBounds: jest.fn(), +})); + +jest.mock('../../ui/windowChrome/appearance', () => ({ + NOT_FULL_SCREENABLE: {}, + getTitleBarOptions: jest.fn(() => ({})), +})); + +jest.mock('../saveDocument', () => ({ + saveDocument: jest.fn(), +})); + +// --------------------------------------------------------------------------- +// electron mock: BrowserWindow records constructions and exposes captured +// event listeners, plus a controllable `loadFile` and `webContents.isLoading`. +// --------------------------------------------------------------------------- +type FakeWC = { + isLoading: jest.Mock; + send: jest.Mock; + on: jest.Mock; + once: jest.Mock; + setWindowOpenHandler: jest.Mock; + listeners: Record void>>; +}; + +type FakeBW = { + webContents: FakeWC; + loadFile: jest.Mock; + listeners: Record void>>; + isDestroyed: jest.Mock; +}; + +const createdWindows: FakeBW[] = []; + +const makeFakeWebContents = (): FakeWC => { + const listeners: Record void>> = {}; + const register = (event: string, fn: (...a: any[]) => void) => { + (listeners[event] ??= []).push(fn); + }; + return { + isLoading: jest.fn(() => false), + send: jest.fn(), + on: jest.fn((event: string, fn: any) => register(event, fn)), + once: jest.fn((event: string, fn: any) => register(event, fn)), + setWindowOpenHandler: jest.fn(), + listeners, + }; +}; + +// Deferred `loadFile` so tests can control exactly when the page "finishes". +let loadFileDeferred: { promise: Promise; resolve: () => void } | null = + null; +const makeDeferred = () => { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +}; + +class FakeBrowserWindow { + webContents = makeFakeWebContents(); + + listeners: Record void>> = {}; + + private register(event: string, fn: (...a: any[]) => void) { + (this.listeners[event] ??= []).push(fn); + } + + on = jest.fn((event: string, fn: any) => this.register(event, fn)); + + once = jest.fn((event: string, fn: any) => this.register(event, fn)); + + loadFile = jest.fn(() => { + if (loadFileDeferred) return loadFileDeferred.promise; + return Promise.resolve(); + }); + + setTitle = jest.fn(); + + show = jest.fn(); + + focus = jest.fn(); + + isMinimized = jest.fn(() => false); + + restore = jest.fn(); + + isDestroyed = jest.fn(() => false); + + getNormalBounds = jest.fn(() => ({ x: 0, y: 0, width: 1200, height: 800 })); + + constructor() { + createdWindows.push(this as unknown as FakeBW); + } +} + +jest.mock('electron', () => ({ + app: { getAppPath: jest.fn(() => '/app') }, + BrowserWindow: jest.fn().mockImplementation(() => new FakeBrowserWindow()), + screen: { + getDisplayNearestPoint: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), + }, +})); + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- +const flushPromises = (): Promise => + new Promise((resolve) => setImmediate(resolve)); + +const fire = ( + listeners: Record void>>, + event: string, + ...args: any[] +) => { + (listeners[event] ?? []).forEach((fn) => fn(...args)); +}; + +const baseRequest = { + server: 'https://server.example', + documentUrl: 'https://server.example/file.pdf', + documentFormat: 'pdf', +}; + +describe('documentViewerWindow/ipc — reused-window send race', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + handleRegistry.clear(); + createdWindows.length = 0; + loadFileDeferred = null; + }); + + it('awaits loadFile before resolving the open (window not "built" until the page loaded)', async () => { + const { openDocumentViewerWindow } = await import('../ipc'); + + loadFileDeferred = makeDeferred(); + const openPromise = openDocumentViewerWindow(baseRequest); + await flushPromises(); + + // The open has not resolved yet: loadFile is still pending. + let resolved = false; + openPromise.then(() => { + resolved = true; + }); + await flushPromises(); + expect(resolved).toBe(false); + + loadFileDeferred.resolve(); + await openPromise; + expect(resolved).toBe(true); + }); + + it('sends immediately to a reused window that has finished loading', async () => { + const { openDocumentViewerWindow } = await import('../ipc'); + + await openDocumentViewerWindow(baseRequest); + const win = createdWindows[0]; + win.webContents.isLoading.mockReturnValue(false); + + await openDocumentViewerWindow({ + ...baseRequest, + documentUrl: 'https://server.example/second.pdf', + }); + + expect(win.webContents.send).toHaveBeenCalledWith( + 'document-viewer-window/document-changed', + expect.objectContaining({ url: 'https://server.example/second.pdf' }) + ); + }); + + it('defers the send until did-finish-load when the reused window is still loading', async () => { + const { openDocumentViewerWindow } = await import('../ipc'); + + await openDocumentViewerWindow(baseRequest); + const win = createdWindows[0]; + win.webContents.isLoading.mockReturnValue(true); + + await openDocumentViewerWindow({ + ...baseRequest, + documentUrl: 'https://server.example/second.pdf', + }); + + // Not sent yet: the page is still loading. + expect(win.webContents.send).not.toHaveBeenCalled(); + + fire(win.webContents.listeners, 'did-finish-load'); + + expect(win.webContents.send).toHaveBeenCalledWith( + 'document-viewer-window/document-changed', + expect.objectContaining({ url: 'https://server.example/second.pdf' }) + ); + }); + + it('rapid successive opens during a load end up showing only the LAST requested document', async () => { + const { openDocumentViewerWindow } = await import('../ipc'); + + await openDocumentViewerWindow(baseRequest); + const win = createdWindows[0]; + win.webContents.isLoading.mockReturnValue(true); + + await openDocumentViewerWindow({ + ...baseRequest, + documentUrl: 'https://server.example/second.pdf', + }); + await openDocumentViewerWindow({ + ...baseRequest, + documentUrl: 'https://server.example/third.pdf', + }); + + fire(win.webContents.listeners, 'did-finish-load'); + + expect(win.webContents.send).toHaveBeenCalledTimes(1); + expect(win.webContents.send).toHaveBeenCalledWith( + 'document-viewer-window/document-changed', + expect.objectContaining({ url: 'https://server.example/third.pdf' }) + ); + }); + + it('restores a minimized reused window before focusing it', async () => { + const { openDocumentViewerWindow } = await import('../ipc'); + + await openDocumentViewerWindow(baseRequest); + const win = createdWindows[0] as unknown as { + webContents: FakeWC; + isMinimized: jest.Mock; + restore: jest.Mock; + show: jest.Mock; + focus: jest.Mock; + }; + win.webContents.isLoading.mockReturnValue(false); + win.isMinimized.mockReturnValue(true); + + await openDocumentViewerWindow({ + ...baseRequest, + documentUrl: 'https://server.example/second.pdf', + }); + + expect(win.restore).toHaveBeenCalled(); + expect(win.show).toHaveBeenCalled(); + expect(win.focus).toHaveBeenCalled(); + }); + + it('does not send once the reused window has been destroyed before did-finish-load fires', async () => { + const { openDocumentViewerWindow } = await import('../ipc'); + + await openDocumentViewerWindow(baseRequest); + const win = createdWindows[0]; + win.webContents.isLoading.mockReturnValue(true); + + await openDocumentViewerWindow({ + ...baseRequest, + documentUrl: 'https://server.example/second.pdf', + }); + + win.isDestroyed.mockReturnValue(true); + fire(win.webContents.listeners, 'did-finish-load'); + + expect(win.webContents.send).not.toHaveBeenCalled(); + }); +}); diff --git a/src/documentViewerWindow/saveDocument.ts b/src/documentViewerWindow/saveDocument.ts new file mode 100644 index 0000000000..113f766fe5 --- /dev/null +++ b/src/documentViewerWindow/saveDocument.ts @@ -0,0 +1,131 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +import type { BrowserWindow } from 'electron'; +import { dialog, session } from 'electron'; + +import { getWebContentsByServerUrl } from '../ui/main/serverView'; + +export type SaveDocumentRequest = { + url: string; + partition: string; + server: string; + format: string; +}; + +export type SaveDocumentResult = { + success: boolean; + canceled?: boolean; + error?: string; +}; + +const DEFAULT_EXTENSION: Record = { + markdown: '.md', + pdf: '.pdf', +}; + +/** + * What to call the file in the save dialog: the name it has on the server when + * the URL carries one, and a plain default when it does not — a blob URL is + * just an identifier. + */ +export const suggestFileName = (url: string, format: string): string => { + const extension = DEFAULT_EXTENSION[format] ?? DEFAULT_EXTENSION.pdf; + + try { + const name = path.basename(new URL(url).pathname); + if (name && path.extname(name)) return name; + if (name) return `${name}${extension}`; + } catch { + // Falls through to the default below. + } + + return `document${extension}`; +}; + +/** + * Reads a blob the server page created. + * + * Blob URLs resolve only in the renderer that registered them, so this has to + * run in the workspace's own web contents rather than in the main process or in + * the viewer's webview. + */ +const readBlobFromServer = async ( + url: string, + server: string +): Promise => { + const webContents = getWebContentsByServerUrl(server); + + if (!webContents) { + throw new Error('The workspace that created this document is not open'); + } + + // Read with FileReader rather than a byte loop and btoa: this runs in the + // reader's own workspace, and doing it by hand froze that window for the + // length of the file while holding it three times over — the buffer, the + // binary string and the base64. The engine's own encoder streams it. + const dataUrl: string = await webContents.executeJavaScript( + `(async () => { + const response = await fetch(${JSON.stringify(url)}); + const blob = await response.blob(); + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(blob); + }); + })()` + ); + + return Buffer.from(String(dataUrl).split(',')[1] ?? '', 'base64'); +}; + +const readDocument = async ({ + url, + partition, + server, +}: SaveDocumentRequest): Promise => { + const { protocol } = new URL(url); + + if (protocol === 'blob:') { + return readBlobFromServer(url, server); + } + + if (protocol !== 'http:' && protocol !== 'https:') { + throw new Error(`Cannot download a ${protocol} document`); + } + + // The workspace's own session, so an authenticated document downloads as the + // signed-in user rather than as an anonymous request. + const response = await session.fromPartition(partition).fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status}`); + } + + return Buffer.from(await response.arrayBuffer()); +}; + +export const saveDocument = async ( + browserWindow: BrowserWindow, + request: SaveDocumentRequest +): Promise => { + try { + const { canceled, filePath } = await dialog.showSaveDialog(browserWindow, { + defaultPath: suggestFileName(request.url, request.format), + }); + + if (canceled || !filePath) { + return { success: false, canceled: true }; + } + + await fs.writeFile(filePath, await readDocument(request)); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +}; diff --git a/src/downloads/actions.spec.ts b/src/downloads/__tests__/actions.spec.ts similarity index 98% rename from src/downloads/actions.spec.ts rename to src/downloads/__tests__/actions.spec.ts index c4d003c2a7..09892a25cf 100644 --- a/src/downloads/actions.spec.ts +++ b/src/downloads/__tests__/actions.spec.ts @@ -1,12 +1,12 @@ -import type { ActionOf } from '../store/actions'; +import type { ActionOf } from '../../store/actions'; import { DOWNLOAD_CREATED, DOWNLOAD_REMOVED, DOWNLOAD_UPDATED, DOWNLOADS_CLEARED, -} from './actions'; -import type { Download } from './common'; -import { DownloadStatus } from './common'; +} from '../actions'; +import type { Download } from '../common'; +import { DownloadStatus } from '../common'; describe('download actions', () => { const mockDownload: Download = { diff --git a/src/downloads/notifications.spec.ts b/src/downloads/__tests__/notifications.spec.ts similarity index 99% rename from src/downloads/notifications.spec.ts rename to src/downloads/__tests__/notifications.spec.ts index 8728d31bfa..04e87be637 100644 --- a/src/downloads/notifications.spec.ts +++ b/src/downloads/__tests__/notifications.spec.ts @@ -1,10 +1,10 @@ import type { DownloadItem } from 'electron'; import { t } from 'i18next'; -import { createNotification } from '../notifications/preload'; +import { createNotification } from '../../notifications/preload'; // Mock modules -jest.mock('../notifications/preload', () => ({ +jest.mock('../../notifications/preload', () => ({ createNotification: jest.fn(), })); diff --git a/src/downloads/main.spec.ts b/src/downloads/main.spec.ts index 05bbf7c276..a772c60266 100644 --- a/src/downloads/main.spec.ts +++ b/src/downloads/main.spec.ts @@ -1,5 +1,5 @@ import type { DownloadItem, Event, WebContents } from 'electron'; -import { clipboard, shell } from 'electron'; +import { BrowserWindow, clipboard, shell } from 'electron'; import { handle } from '../ipc/main'; import { dispatch, listen, select } from '../store'; @@ -20,8 +20,12 @@ jest.mock('electron', () => ({ }, shell: { showItemInFolder: jest.fn(), + openPath: jest.fn(), }, webContents: {}, + BrowserWindow: { + fromWebContents: jest.fn(), + }, })); // Mock IPC handler @@ -309,6 +313,101 @@ describe('downloads/main', () => { ); }); + describe('IPC handler: downloads/preview-file', () => { + const getPreviewHandler = () => + mockHandle.mock.calls.find( + ([channel]) => (channel as string) === 'downloads/preview-file' + )?.[1] as any; + + it('quick looks a completed download from its own window', async () => { + const previewFile = jest.fn(); + mockSelect.mockReturnValue({ + savePath: '/downloads/test-file.pdf', + fileName: 'test-file.pdf', + state: 'completed', + }); + (BrowserWindow.fromWebContents as jest.Mock).mockReturnValue({ + previewFile, + }); + + await getPreviewHandler()?.({} as any, 'test-item-id'); + + expect(previewFile).toHaveBeenCalledWith( + '/downloads/test-file.pdf', + 'test-file.pdf' + ); + }); + + it('does nothing for an unfinished download', async () => { + const previewFile = jest.fn(); + mockSelect.mockReturnValue({ + savePath: '/downloads/partial.pdf', + state: 'progressing', + }); + (BrowserWindow.fromWebContents as jest.Mock).mockReturnValue({ + previewFile, + }); + + await getPreviewHandler()?.({} as any, 'test-item-id'); + + expect(previewFile).not.toHaveBeenCalled(); + }); + }); + + describe('IPC handler: downloads/open-file', () => { + const getOpenFileHandler = () => + mockHandle.mock.calls.find( + ([channel]) => (channel as string) === 'downloads/open-file' + )?.[1] as any; + + it('opens a completed download with the OS handler', async () => { + mockSelect.mockReturnValue({ + savePath: '/downloads/test-file.pdf', + state: 'completed', + }); + (shell.openPath as jest.Mock).mockResolvedValue(''); + + await getOpenFileHandler()?.({} as any, 'test-item-id'); + + expect(shell.openPath).toHaveBeenCalledWith('/downloads/test-file.pdf'); + expect(shell.showItemInFolder).not.toHaveBeenCalled(); + }); + + it('reveals the folder when the file can no longer be opened', async () => { + mockSelect.mockReturnValue({ + savePath: '/downloads/moved.pdf', + state: 'completed', + }); + // openPath resolves with a message rather than rejecting. + (shell.openPath as jest.Mock).mockResolvedValue('No such file'); + + await getOpenFileHandler()?.({} as any, 'test-item-id'); + + expect(shell.showItemInFolder).toHaveBeenCalledWith( + '/downloads/moved.pdf' + ); + }); + + it('does nothing for an unfinished download', async () => { + mockSelect.mockReturnValue({ + savePath: '/downloads/partial.pdf', + state: 'progressing', + }); + + await getOpenFileHandler()?.({} as any, 'test-item-id'); + + expect(shell.openPath).not.toHaveBeenCalled(); + }); + + it('does nothing when the download does not exist', async () => { + mockSelect.mockReturnValue(undefined); + + await getOpenFileHandler()?.({} as any, 'nope'); + + expect(shell.openPath).not.toHaveBeenCalled(); + }); + }); + describe('IPC handler: downloads/show-in-folder', () => { it('should show download in folder when download exists', async () => { const mockDownload = { diff --git a/src/downloads/main.ts b/src/downloads/main.ts index caf4fc2092..a49e5d0311 100644 --- a/src/downloads/main.ts +++ b/src/downloads/main.ts @@ -1,5 +1,5 @@ import type { DownloadItem, Event, WebContents } from 'electron'; -import { clipboard, shell, webContents } from 'electron'; +import { BrowserWindow, clipboard, shell, webContents } from 'electron'; import { t } from 'i18next'; import { handle } from '../ipc/main'; @@ -248,6 +248,42 @@ export const setupDownloads = (): void => { startDownloadsSimulation(); }); + /** + * Opens the downloaded file with the OS default handler. The path is looked up + * from our own state by id rather than taken from the renderer, so a window + * cannot ask the main process to open an arbitrary file. + */ + handle('downloads/open-file', async (_webContents, itemId) => { + const download = select(({ downloads }) => downloads[itemId]); + + if (!download || download.state !== 'completed') { + return; + } + + const error = await shell.openPath(download.savePath); + + // openPath resolves with a message instead of throwing; a missing or moved + // file is the common case, so fall back to revealing its folder. + if (error) { + shell.showItemInFolder(download.savePath); + } + }); + + /** + * macOS Quick Look. There is no cross-platform equivalent, so the renderer + * only offers this on darwin; elsewhere the file opens in its default app. + */ + handle('downloads/preview-file', async (senderWebContents, itemId) => { + const download = select(({ downloads }) => downloads[itemId]); + + if (!download || download.state !== 'completed') { + return; + } + + const window = BrowserWindow.fromWebContents(senderWebContents); + window?.previewFile(download.savePath, download.fileName); + }); + handle('downloads/show-in-folder', async (_webContents, itemId) => { const download = select(({ downloads }) => downloads[itemId]); diff --git a/src/downloads/integration.spec.ts b/src/downloads/main/integration.main.spec.ts similarity index 96% rename from src/downloads/integration.spec.ts rename to src/downloads/main/integration.main.spec.ts index 2d2b06c14b..7ec83fd3cd 100644 --- a/src/downloads/integration.spec.ts +++ b/src/downloads/main/integration.main.spec.ts @@ -1,22 +1,23 @@ import type { DownloadItem, Event, WebContents } from 'electron'; import { clipboard, shell, webContents } from 'electron'; -import { handle as mockHandle } from '../ipc/main'; -import { createMainReduxStore, dispatch, select } from '../store'; +import { handle as mockHandle } from '../../ipc/main'; +import { createMainReduxStore, dispatch, select } from '../../store'; import { DOWNLOAD_CREATED, DOWNLOAD_REMOVED, DOWNLOAD_UPDATED, DOWNLOADS_CLEARED, -} from './actions'; -import type { Download } from './common'; -import { DownloadStatus } from './common'; -import { handleWillDownloadEvent, setupDownloads } from './main'; +} from '../actions'; +import type { Download } from '../common'; +import { DownloadStatus } from '../common'; +import { handleWillDownloadEvent, setupDownloads } from '../main'; -jest.mock('../store', () => ({ +jest.mock('../../store', () => ({ createMainReduxStore: jest.fn(), dispatch: jest.fn(), select: jest.fn(), + listen: jest.fn(), })); // Mock electron modules @@ -33,12 +34,12 @@ jest.mock('electron', () => ({ })); // Mock IPC handler -jest.mock('../ipc/main', () => ({ +jest.mock('../../ipc/main', () => ({ handle: jest.fn(), })); // Mock notifications -jest.mock('../notifications/preload', () => ({ +jest.mock('../../notifications/preload', () => ({ createNotification: jest.fn(), })); @@ -48,8 +49,8 @@ jest.mock('i18next', () => ({ })); // Mock main.ts to avoid circular dependencies -jest.mock('./main', () => { - const actual = jest.requireActual('./main'); +jest.mock('../main', () => { + const actual = jest.requireActual('../main'); return { ...actual, setupDownloads: actual.setupDownloads, diff --git a/src/downloadsWindow/DownloadRow.tsx b/src/downloadsWindow/DownloadRow.tsx new file mode 100644 index 0000000000..edc6f78b18 --- /dev/null +++ b/src/downloadsWindow/DownloadRow.tsx @@ -0,0 +1,322 @@ +import { Box, IconButton, Tag } from '@rocket.chat/fuselage'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { Download } from '../downloads/common'; +import { invoke } from '../ipc/renderer'; +import { formatServerTitle } from '../ui/components/utils/formatServerTitle'; +import { isDarwin } from '../ui/windowChrome/appearance'; +import { LIST_ROW_CLASS } from '../ui/windowChrome/styles'; +import { useCopiedFeedback } from '../ui/windowChrome/useCopiedFeedback'; +import { FileTypeIcon } from './FileTypeIcon'; +import { DOWNLOAD_NAME_CLASS } from './styles'; + +export type DownloadRowProps = { + download: Download; + /** + * The workspace a download came from. Worth saying in the downloads window, + * which filters by it; noise in the main window's panel, which lists a + * handful of downloads from the session at hand. + */ + showServer?: boolean; +}; + +/** + * One download as a list row rather than a card: icon, name and a single muted + * line of metadata, with the actions that apply to its current state. The + * progress bar only appears while a transfer is live. + * + * Shared by the downloads window and the main window's downloads panel, so a + * download reads the same wherever it is seen. + */ +export const DownloadRow = ({ + download, + showServer = true, +}: DownloadRowProps) => { + const { t, i18n } = useTranslation(); + const { + itemId, + state, + fileName, + receivedBytes, + totalBytes, + startTime, + endTime, + mimeType, + serverTitle, + } = download; + + const errored = state === 'interrupted' || state === 'cancelled'; + const expired = state === 'expired'; + const isActive = state === 'progressing' || state === 'paused'; + + const percentage = useMemo( + () => (totalBytes ? Math.floor((receivedBytes / totalBytes) * 100) : 0), + [receivedBytes, totalBytes] + ); + + /** Server, size and — while a transfer is live — its rate and time left. */ + const meta = useMemo(() => { + const parts: string[] = []; + + if (showServer && serverTitle) parts.push(formatServerTitle(serverTitle)); + + if (receivedBytes && totalBytes) { + // The "x of y" form only says something while bytes are still moving. + parts.push( + isActive + ? t('downloads.item.progressSize', { + receivedBytes, + totalBytes, + ratio: receivedBytes / totalBytes, + }) + : String(i18n.format(totalBytes, 'byteSize')) + ); + } + + if (state === 'progressing' && receivedBytes && startTime && endTime) { + const elapsed = endTime - startTime; + if (elapsed > 0) { + parts.push( + String(i18n.format((receivedBytes / elapsed) * 1000, 'byteSpeed')) + ); + if (totalBytes) { + parts.push( + String( + i18n.format( + (totalBytes - receivedBytes) / (receivedBytes / elapsed), + 'duration' + ) + ) + ); + } + } + } + + return parts.join(' · '); + }, [ + endTime, + i18n, + isActive, + receivedBytes, + serverTitle, + showServer, + startTime, + state, + t, + totalBytes, + ]); + + const [hasCopiedLink, acknowledgeCopy] = useCopiedFeedback(); + + const run = useCallback( + (channel: Parameters[0]) => () => { + invoke(channel as any, itemId as never); + }, + [itemId] + ); + + return ( + + {/* + Progress rides along the row's bottom edge instead of sitting under the + text: in the flow it added 12px the moment a transfer started, so every + row below it jumped. Drawn here rather than with Fuselage's ProgressBar, + whose animated shine is an absolutely positioned pseudo-element with no + containing block of its own — it escapes the bar and sweeps the whole + list. + */} + {isActive && ( + + + + )} + + + + + {state === 'completed' ? ( + + {fileName} + + ) : ( + + {fileName} + + )} + {errored && ( + + + {t('downloads.item.errored')} + + + )} + + {meta && ( + + {meta} + + )} + + + + {state === 'progressing' && ( + + )} + {state === 'paused' && ( + + )} + {/* Quick Look has no equivalent off macOS, so it is offered there only. */} + {isDarwin && state === 'completed' && ( + + )} + {state === 'completed' && ( + + )} + {errored && ( + + )} + {!expired && ( + { + run('downloads/copy-link')(); + acknowledgeCopy(); + }} + /> + )} + {/* + Whatever a row's state, its last action is a cross, so the button + nearest the edge never moves as a download progresses. + + They are not the same action: while a transfer is live the cross + cancels it; once it is over the cross drops the entry from the list + and leaves the downloaded file alone. + */} + {isActive && ( + + )} + {!isActive && ( + + )} + + + ); +}; diff --git a/src/downloadsWindow/DownloadsSidebar.tsx b/src/downloadsWindow/DownloadsSidebar.tsx new file mode 100644 index 0000000000..d3e5c679f7 --- /dev/null +++ b/src/downloadsWindow/DownloadsSidebar.tsx @@ -0,0 +1,167 @@ +import { Box, Icon, Scrollable, SearchInput } from '@rocket.chat/fuselage'; +import type { ChangeEvent } from 'react'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FilterRow } from '../ui/windowChrome/FilterRow'; +import { FilterSection } from '../ui/windowChrome/FilterSection'; +import { SidebarPlaceholder } from '../ui/windowChrome/SidebarPlaceholder'; +import { SIDEBAR_WIDTH } from '../ui/windowChrome/appearance'; +import type { FacetSelection } from '../ui/windowChrome/filters'; +import { isFacetNarrowed, isFacetSelected } from '../ui/windowChrome/filters'; +import { SEARCH_FIELD_CLASS } from '../ui/windowChrome/styles'; +import { useFindShortcut } from '../ui/windowChrome/useFindShortcut'; + +export type FacetOption = { + value: string; + label: string; + title?: string; +}; + +export type DownloadsSidebarProps = { + searchFilter: string; + onSearchFilterChange: (event: ChangeEvent) => void; + serverOptions: FacetOption[]; + serverFilters: FacetSelection; + serverCounts: Record; + onToggleServer: (server: string) => void; + typeOptions: FacetOption[]; + typeFilters: FacetSelection; + typeCounts: Record; + onToggleType: (mimeType: string) => void; + statusOptions: FacetOption[]; + statusFilters: FacetSelection; + statusCounts: Record; + onToggleStatus: (status: string) => void; + onSelectAllServers: () => void; + onSelectAllTypes: () => void; + onSelectAllStatuses: () => void; +}; + +export const DownloadsSidebar = ({ + searchFilter, + onSearchFilterChange, + serverOptions, + serverFilters, + serverCounts, + onToggleServer, + typeOptions, + typeFilters, + typeCounts, + onToggleType, + statusOptions, + statusFilters, + statusCounts, + onToggleStatus, + onSelectAllServers, + onSelectAllTypes, + onSelectAllStatuses, +}: DownloadsSidebarProps) => { + const { t } = useTranslation(); + const searchFieldRef = useRef(null); + useFindShortcut(searchFieldRef); + const selectAllLabel = t('downloads.filters.selectAll'); + const hasFacets = + statusOptions.length > 0 || + typeOptions.length > 0 || + serverOptions.length > 0; + + return ( + + + } + aria-label={t('downloads.filters.search')} + placeholder={t('downloads.filters.search')} + value={searchFilter} + onChange={onSearchFilterChange} + /> + + + + + + {!hasFacets && ( + + {t('downloads.filters.empty')} + + )} + + {statusOptions.length > 0 && ( + + {statusOptions.map((option) => ( + onToggleStatus(option.value)} + /> + ))} + + )} + + {typeOptions.length > 0 && ( + + {typeOptions.map((option) => ( + onToggleType(option.value)} + /> + ))} + + )} + + {serverOptions.length > 0 && ( + + {serverOptions.map((option) => ( + onToggleServer(option.value)} + /> + ))} + + )} + + + + + ); +}; diff --git a/src/downloadsWindow/DownloadsStatusBar.tsx b/src/downloadsWindow/DownloadsStatusBar.tsx new file mode 100644 index 0000000000..d968c2acab --- /dev/null +++ b/src/downloadsWindow/DownloadsStatusBar.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next'; + +import { StatusBar } from '../ui/windowChrome/StatusBar'; +import { StatusItem } from '../ui/windowChrome/StatusItem'; +import { TextButton } from '../ui/windowChrome/TextButton'; + +export type DownloadsStatusBarProps = { + shownCount: number; + totalCount: number; + canClear: boolean; + onClearAll: () => void; +}; + +export const DownloadsStatusBar = ({ + shownCount, + totalCount, + canClear, + onClearAll, +}: DownloadsStatusBarProps) => { + const { t } = useTranslation(); + + return ( + + {t('downloads.clearAll')} + + ) + } + > + + {shownCount === totalCount + ? t('downloads.count', { count: totalCount }) + : t('downloads.countOfTotal', { + count: shownCount, + total: totalCount, + })} + + + ); +}; diff --git a/src/downloadsWindow/DownloadsToolbar.tsx b/src/downloadsWindow/DownloadsToolbar.tsx new file mode 100644 index 0000000000..1b20ef16c9 --- /dev/null +++ b/src/downloadsWindow/DownloadsToolbar.tsx @@ -0,0 +1,26 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import { WindowToolbar } from '../ui/windowChrome/WindowToolbar'; + +/** + * Title only. Clearing the list lives in the status bar next to the count it + * affects, rather than as an unlabelled trash icon up here. + */ +export const DownloadsToolbar = () => { + const { t } = useTranslation(); + + return ( + + + + {t('downloads.title')} + + + ); +}; diff --git a/src/downloadsWindow/DownloadsWindow.tsx b/src/downloadsWindow/DownloadsWindow.tsx new file mode 100644 index 0000000000..674638329b --- /dev/null +++ b/src/downloadsWindow/DownloadsWindow.tsx @@ -0,0 +1,433 @@ +import { + Box, + Scrollable, + States, + StatesAction, + StatesActions, + StatesIcon, + StatesSubtitle, + StatesTitle, +} from '@rocket.chat/fuselage'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import type { ChangeEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { DOWNLOADS_CLEARED } from '../downloads/actions'; +import type { Download } from '../downloads/common'; +import { DownloadStatus } from '../downloads/common'; +import { invoke } from '../ipc/renderer'; +import { dispatch } from '../store'; +import type { RootState } from '../store/rootReducer'; +import TooltipProvider from '../ui/components/utils/TooltipProvider'; +import { formatServerTitle } from '../ui/components/utils/formatServerTitle'; +import { DayHeader } from '../ui/windowChrome/DayHeader'; +import type { PaletteTheme } from '../ui/windowChrome/appearance'; +import { getCardStyle, resolveSurfaces } from '../ui/windowChrome/appearance'; +import type { FacetSelection } from '../ui/windowChrome/filters'; +import { + isFacetNarrowed, + isFacetSelected, + readFacetSelection, + toggleFacet, +} from '../ui/windowChrome/filters'; +import { WindowChromeGlobalStyles } from '../ui/windowChrome/styles'; +import { useTransparency } from '../ui/windowChrome/useTransparency'; +import { DownloadRow } from './DownloadRow'; +import { DownloadsSidebar } from './DownloadsSidebar'; +import type { FacetOption } from './DownloadsSidebar'; +import { DownloadsStatusBar } from './DownloadsStatusBar'; +import { DownloadsToolbar } from './DownloadsToolbar'; +import { TRANSPARENCY_CHANNEL } from './constants'; +import { formatDayLabel, groupDownloadsByDay } from './grouping'; +import { DownloadsGlobalStyles } from './styles'; + +/** Top-level MIME groups worth offering as a facet. */ +const MIME_GROUPS = ['image', 'video', 'audio', 'text', 'application']; + +const getMimeGroup = (download: Download): string => + download.mimeType?.split('/')?.[0] || 'other'; + +const STATUSES = [DownloadStatus.PAUSED, DownloadStatus.CANCELLED] as const; + +type DownloadsWindowProps = { + paletteTheme: PaletteTheme; +}; + +export const DownloadsWindow = ({ paletteTheme }: DownloadsWindowProps) => { + const { t } = useTranslation(); + const isTransparent = useTransparency(TRANSPARENCY_CHANNEL); + const surfaces = useMemo( + () => resolveSurfaces(paletteTheme, isTransparent), + [paletteTheme, isTransparent] + ); + const cardStyle = useMemo( + () => getCardStyle(paletteTheme, surfaces), + [paletteTheme, surfaces] + ); + + const downloadsState = useSelector(({ downloads }: RootState) => downloads); + const allDownloads = useMemo( + () => + Object.values(downloadsState).sort( + (a: Download, b: Download) => b.itemId - a.itemId + ), + [downloadsState] + ); + + const [searchFilter, setSearchFilter] = useState(''); + const [storedServerFilters, setServerFilters] = + useLocalStorage('downloads-window/servers', null); + const [storedTypeFilters, setTypeFilters] = useLocalStorage( + 'downloads-window/types', + null + ); + const [storedStatusFilters, setStatusFilters] = + useLocalStorage('downloads-window/statuses', null); + + const serverFilters = useMemo( + () => readFacetSelection(storedServerFilters), + [storedServerFilters] + ); + const typeFilters = useMemo( + () => readFacetSelection(storedTypeFilters), + [storedTypeFilters] + ); + const statusFilters = useMemo( + () => readFacetSelection(storedStatusFilters), + [storedStatusFilters] + ); + + const handleSearchFilterChange = useCallback( + (event: ChangeEvent) => { + setSearchFilter(event.target.value); + }, + [] + ); + + const matchesSearch = useCallback( + (download: Download): boolean => + !searchFilter || + download.fileName.toLowerCase().includes(searchFilter.toLowerCase()), + [searchFilter] + ); + + const matchesServer = useCallback( + (download: Download): boolean => + isFacetSelected(serverFilters, download.serverUrl), + [serverFilters] + ); + + const matchesType = useCallback( + (download: Download): boolean => + isFacetSelected(typeFilters, getMimeGroup(download)), + [typeFilters] + ); + + const matchesStatus = useCallback( + (download: Download): boolean => + isFacetSelected(statusFilters, download.status, STATUSES), + [statusFilters] + ); + + /** Options come from the data, so a facet never offers an empty choice. */ + const serverOptions = useMemo(() => { + const seen = new Map(); + allDownloads.forEach(({ serverUrl, serverTitle }) => { + if (!serverUrl) return; + if (!seen.has(serverUrl)) { + seen.set(serverUrl, formatServerTitle(serverTitle ?? serverUrl)); + } + }); + return [...seen].map(([value, label]) => ({ + value, + label, + title: value, + })); + }, [allDownloads]); + + const typeOptions = useMemo(() => { + const present = new Set(allDownloads.map(getMimeGroup)); + const known = MIME_GROUPS.filter((group) => present.has(group)).map( + (group) => ({ + value: group, + label: t(`downloads.filters.mimes.${group}`, group), + }) + ); + return present.has('other') + ? [ + ...known, + { value: 'other', label: t('downloads.filters.mimes.other') }, + ] + : known; + }, [allDownloads, t]); + + const statusOptions = useMemo(() => { + const present = new Set(allDownloads.map(({ status }) => status)); + return STATUSES.filter((status) => present.has(status)).map((status) => ({ + value: status, + label: t(`downloads.filters.statuses.${status.toLowerCase()}`, status), + })); + }, [allDownloads, t]); + + /** + * Faceted counts: each list reports what selecting it would yield with the + * *other* filters applied, so a count is never a number you cannot reach. + */ + const searched = useMemo( + () => allDownloads.filter(matchesSearch), + [allDownloads, matchesSearch] + ); + + const countBy = (items: Download[], key: (item: Download) => string) => + items.reduce>((counts, item) => { + const value = key(item); + counts[value] = (counts[value] ?? 0) + 1; + return counts; + }, {}); + + const serverCounts = useMemo( + () => + countBy( + searched.filter((d) => matchesType(d) && matchesStatus(d)), + (d) => d.serverUrl + ), + [searched, matchesType, matchesStatus] + ); + + const typeCounts = useMemo( + () => + countBy( + searched.filter((d) => matchesServer(d) && matchesStatus(d)), + getMimeGroup + ), + [searched, matchesServer, matchesStatus] + ); + + const statusCounts = useMemo( + () => + countBy( + searched.filter((d) => matchesServer(d) && matchesType(d)), + (d) => d.status + ), + [searched, matchesServer, matchesType] + ); + + const downloads = useMemo( + () => + searched.filter( + (d) => matchesServer(d) && matchesType(d) && matchesStatus(d) + ), + [searched, matchesServer, matchesType, matchesStatus] + ); + + const activeFilterCount = + (searchFilter ? 1 : 0) + + (isFacetNarrowed(serverFilters) ? 1 : 0) + + (isFacetNarrowed(typeFilters) ? 1 : 0) + + (isFacetNarrowed(statusFilters) ? 1 : 0); + + const handleClearFilters = useCallback(() => { + setSearchFilter(''); + setServerFilters(null); + setTypeFilters(null); + setStatusFilters(null); + }, [setServerFilters, setTypeFilters, setStatusFilters]); + + const handleToggleServer = useCallback( + (server: string) => { + setServerFilters((previous) => + toggleFacet( + readFacetSelection(previous), + server, + serverOptions.map(({ value }) => value) + ) + ); + }, + [serverOptions, setServerFilters] + ); + + const handleToggleType = useCallback( + (mimeType: string) => { + setTypeFilters((previous) => + toggleFacet( + readFacetSelection(previous), + mimeType, + typeOptions.map(({ value }) => value) + ) + ); + }, + [typeOptions, setTypeFilters] + ); + + const handleToggleStatus = useCallback( + (status: string) => { + setStatusFilters((previous) => + toggleFacet( + readFacetSelection(previous), + status, + statusOptions.map(({ value }) => value) + ) + ); + }, + [statusOptions, setStatusFilters] + ); + + const handleClearAll = useCallback(async () => { + const confirmed = await invoke('downloads-window/confirm-clear-all'); + if (!confirmed) return; + dispatch({ type: DOWNLOADS_CLEARED }); + }, []); + + const dayGroups = useMemo(() => groupDownloadsByDay(downloads), [downloads]); + const dayLabels = useMemo( + () => ({ + today: t('downloads.day.today'), + yesterday: t('downloads.day.yesterday'), + unknown: t('downloads.day.unknown'), + }), + [t] + ); + + return ( + + + + + + + {/* + The sidebar runs to the bottom of the window; the status bar lives in + the right-hand column so it starts on the same line as the list rather + than cutting across the filters. + */} + + setServerFilters(null)} + onSelectAllTypes={() => setTypeFilters(null)} + onSelectAllStatuses={() => setStatusFilters(null)} + /> + + + + {downloads.length === 0 ? ( + + + 0 + ? 'magnifier' + : 'circle-arrow-down' + } + /> + + {activeFilterCount > 0 + ? t('downloads.noResults.title') + : t('downloads.empty.title')} + + + {activeFilterCount > 0 + ? t('downloads.noResults.subtitle') + : t('downloads.empty.subtitle')} + + {activeFilterCount > 0 && ( + + + {t('downloads.clearFilters')} + + + )} + + + ) : ( + + + {dayGroups.map((group) => ( + + + {group.items.map((download) => ( + + ))} + + ))} + + + )} + + + 0} + onClearAll={handleClearAll} + /> + + + + + ); +}; diff --git a/src/downloadsWindow/FileTypeIcon.tsx b/src/downloadsWindow/FileTypeIcon.tsx new file mode 100644 index 0000000000..bba4b901c0 --- /dev/null +++ b/src/downloadsWindow/FileTypeIcon.tsx @@ -0,0 +1,83 @@ +import { Box } from '@rocket.chat/fuselage'; +import { useMemo } from 'react'; + +import { getFileLabel } from './fileTypes'; + +const WIDTH = 36; +const HEIGHT = 44; + +/** Page outline with the top-right corner cut away, and the fold that fills it. */ +const PAGE_PATH = + 'M 4 1 H 22.5 L 35 13.5 V 40 Q 35 43 32 43 H 4 Q 1 43 1 40 V 4 Q 1 1 4 1 Z'; +const FOLD_PATH = 'M 22.5 1 L 35 13.5 H 25.5 Q 22.5 13.5 22.5 10.5 Z'; + +export type FileTypeIconProps = { + fileName: string; + mimeType?: string; +}; + +/** + * The file as a document with a folded corner. + * + * Drawn inline rather than loaded as an image so its fill, outline and label all + * come from the palette: the old asset was a fixed white page, which read as a + * bright block on the dark window. Deliberately monochrome — the icon is there + * to identify a row, not to compete with the file name for attention — and every + * colour is a neutral at low alpha over whatever is behind it, so one drawing + * works on both themes. + */ +const NEUTRAL = 'var(--rcx-color-font-annotation)'; +export const FileTypeIcon = ({ fileName, mimeType }: FileTypeIconProps) => { + const label = useMemo( + () => getFileLabel(fileName, mimeType), + [fileName, mimeType] + ); + + return ( + + + + + + + + {label} + + + ); +}; diff --git a/src/downloadsWindow/__tests__/DownloadsWindow.spec.tsx b/src/downloadsWindow/__tests__/DownloadsWindow.spec.tsx new file mode 100644 index 0000000000..6c0e96a080 --- /dev/null +++ b/src/downloadsWindow/__tests__/DownloadsWindow.spec.tsx @@ -0,0 +1,110 @@ +import { DOWNLOADS_CLEARED } from '../../downloads/actions'; +import type { Download } from '../../downloads/common'; +import { DownloadStatus } from '../../downloads/common'; +import { invoke } from '../../ipc/renderer'; +import { dispatch } from '../../store'; +import { + renderWithStore, + screen, + userEvent, + waitFor, +} from '../../ui/test-utils'; +import { DownloadsWindow } from '../DownloadsWindow'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => + options ? `${key}:${JSON.stringify(options)}` : key, + i18n: { + language: 'en', + changeLanguage: jest.fn(), + format: (value: unknown, format: string) => `${format}:${value}`, + }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, +})); + +jest.mock('../../store', () => ({ + dispatch: jest.fn(), +})); + +jest.mock('../../ipc/renderer', () => ({ + invoke: jest.fn(), +})); + +const invokeMock = invoke as jest.MockedFunction; +const dispatchMock = dispatch as jest.MockedFunction; + +const mockDownload: Download = { + itemId: 1, + state: 'completed', + status: DownloadStatus.ALL, + fileName: 'report.pdf', + receivedBytes: 1024, + totalBytes: 1024, + startTime: 1, + endTime: 2, + url: 'https://example.com/report.pdf', + serverUrl: 'https://open.rocket.chat', + serverTitle: 'Rocket.Chat', + mimeType: 'application/pdf', + savePath: '/tmp/report.pdf', +}; + +describe('DownloadsWindow clear-all confirm', () => { + beforeEach(() => { + jest.clearAllMocks(); + invokeMock.mockResolvedValue(false as never); + }); + + const renderWindow = () => + renderWithStore(, { + preloadedState: { + downloads: { [mockDownload.itemId]: mockDownload }, + }, + }); + + it('does not clear the list when the confirm dialog is cancelled', async () => { + const user = userEvent.setup(); + renderWindow(); + + invokeMock.mockResolvedValueOnce(false as never); + await user.click( + screen.getByRole('button', { name: 'downloads.clearAll' }) + ); + + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith( + 'downloads-window/confirm-clear-all' + ) + ); + expect(dispatchMock).not.toHaveBeenCalledWith({ + type: DOWNLOADS_CLEARED, + }); + }); + + it('clears the list when the confirm dialog is accepted', async () => { + const user = userEvent.setup(); + renderWindow(); + + invokeMock.mockResolvedValueOnce(true as never); + await user.click( + screen.getByRole('button', { name: 'downloads.clearAll' }) + ); + + await waitFor(() => + expect(dispatchMock).toHaveBeenCalledWith({ + type: DOWNLOADS_CLEARED, + }) + ); + }); +}); diff --git a/src/downloadsWindow/__tests__/fileTypes.spec.ts b/src/downloadsWindow/__tests__/fileTypes.spec.ts new file mode 100644 index 0000000000..82b501e1af --- /dev/null +++ b/src/downloadsWindow/__tests__/fileTypes.spec.ts @@ -0,0 +1,32 @@ +import { getFileLabel } from '../fileTypes'; + +describe('getFileLabel', () => { + it('uses the extension', () => { + expect(getFileLabel('demo-video.mp4', 'video/mp4')).toBe('mp4'); + expect(getFileLabel('archive.zip', 'application/zip')).toBe('zip'); + }); + + it('falls back to the MIME subtype when the name has none', () => { + expect(getFileLabel('screenshot', 'image/png')).toBe('png'); + }); + + it('drops a structured-syntax suffix', () => { + expect(getFileLabel('feed', 'application/rss+xml')).toBe('rss'); + }); + + it('caps the label so the badge stays readable', () => { + expect(getFileLabel('site.webmanifest')).toBe('webm'); + }); + + it('strips punctuation rather than drawing it', () => { + expect(getFileLabel('weird.t_a-r')).toBe('tar'); + }); + + it('returns nothing when there is neither name nor type', () => { + expect(getFileLabel('noextension')).toBe(''); + }); + + it('takes the last extension of a multi-dot name', () => { + expect(getFileLabel('backup.tar.gz')).toBe('gz'); + }); +}); diff --git a/src/downloadsWindow/__tests__/grouping.spec.ts b/src/downloadsWindow/__tests__/grouping.spec.ts new file mode 100644 index 0000000000..9a545f2c95 --- /dev/null +++ b/src/downloadsWindow/__tests__/grouping.spec.ts @@ -0,0 +1,88 @@ +import type { Download } from '../../downloads/common'; +import { formatDayLabel, groupDownloadsByDay } from '../grouping'; + +const at = (iso: string, itemId = 1): Download => + ({ + itemId, + startTime: new Date(iso).getTime(), + fileName: `f-${itemId}`, + }) as Download; + +const LABELS = { today: 'Today', yesterday: 'Yesterday', unknown: 'Earlier' }; + +describe('groupDownloadsByDay', () => { + it('returns nothing for an empty list', () => { + expect(groupDownloadsByDay([])).toEqual([]); + }); + + it('groups consecutive downloads sharing a day', () => { + const groups = groupDownloadsByDay([ + at('2026-08-08T10:00:00', 1), + at('2026-08-08T09:00:00', 2), + at('2026-08-07T23:00:00', 3), + ]); + + expect(groups).toHaveLength(2); + expect(groups[0].items.map((d) => d.itemId)).toEqual([1, 2]); + expect(groups[1].items.map((d) => d.itemId)).toEqual([3]); + }); + + it('preserves the incoming order rather than re-sorting', () => { + const groups = groupDownloadsByDay([ + at('2026-08-07T10:00:00', 1), + at('2026-08-08T10:00:00', 2), + ]); + + expect(groups.map((g) => g.items[0].itemId)).toEqual([1, 2]); + }); + + it('keeps downloads with an unusable start time instead of dropping them', () => { + const broken = { itemId: 9, startTime: NaN } as Download; + const groups = groupDownloadsByDay([broken]); + + expect(groups).toHaveLength(1); + expect(groups[0].day).toBe(''); + expect(groups[0].items).toEqual([broken]); + }); + + it('starts a new run when the day changes back and forth', () => { + const groups = groupDownloadsByDay([ + at('2026-08-08T10:00:00', 1), + at('2026-08-07T10:00:00', 2), + at('2026-08-08T08:00:00', 3), + ]); + + expect(groups).toHaveLength(3); + }); +}); + +describe('formatDayLabel', () => { + // Built from local parts on purpose: grouping is by local calendar day, and + // `new Date('2026-08-08')` would parse as UTC midnight and land on the + // previous day in any negative-offset timezone. + const now = new Date(2026, 7, 8, 12); + + it('names today and yesterday', () => { + expect( + formatDayLabel(new Date(2026, 7, 8).toDateString(), now, LABELS) + ).toBe('Today'); + expect( + formatDayLabel(new Date(2026, 7, 7).toDateString(), now, LABELS) + ).toBe('Yesterday'); + }); + + it('falls back to a full date for older days', () => { + const label = formatDayLabel( + new Date(2026, 7, 1).toDateString(), + now, + LABELS + ); + expect(label).not.toBe('Today'); + expect(label).not.toBe('Yesterday'); + expect(label).toContain('2026'); + }); + + it('labels downloads with no usable day', () => { + expect(formatDayLabel('', now, LABELS)).toBe('Earlier'); + }); +}); diff --git a/src/downloadsWindow/constants.ts b/src/downloadsWindow/constants.ts new file mode 100644 index 0000000000..5372ff5723 --- /dev/null +++ b/src/downloadsWindow/constants.ts @@ -0,0 +1,9 @@ +/** Channel the main process pushes transparency changes on. */ +export const TRANSPARENCY_CHANNEL = 'downloads-window/transparency-changed'; + +/** Window size as a multiplier of the screen it opens on. */ +export const WINDOW_SIZE_MULTIPLIER = 0.52; + +/** Below this the sidebar plus a download row no longer fit. */ +export const WINDOW_MIN_WIDTH = 640; +export const WINDOW_MIN_HEIGHT = 400; diff --git a/src/downloadsWindow/downloads-window.tsx b/src/downloadsWindow/downloads-window.tsx new file mode 100644 index 0000000000..c5fa541e28 --- /dev/null +++ b/src/downloadsWindow/downloads-window.tsx @@ -0,0 +1,69 @@ +import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import type { Themes } from '@rocket.chat/fuselage/dist/components/PaletteStyleTag/types/themes'; +import { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +import { setupI18n } from '../i18n/renderer'; +import '../logging/preload'; +import { createRendererReduxStore } from '../store'; +import { whenReady } from '../whenReady'; +import { DownloadsWindow } from './DownloadsWindow'; + +const useSystemTheme = (): Themes => { + const [theme, setTheme] = useState(() => + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + ); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (event: MediaQueryListEvent) => { + setTheme(event.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return theme; +}; + +const ThemedDownloadsWindow = () => { + const theme = useSystemTheme(); + + return ( + <> + + + + ); +}; + +const start = async (): Promise => { + // The downloads list is redux state synced from the main process, so this + // window joins the same store rather than reimplementing it over IPC. + const reduxStore = await createRendererReduxStore(); + + await whenReady(); + await setupI18n(); + + const container = document.getElementById('root'); + + if (!container) { + throw new Error('cannot find the container node for React'); + } + + const root = createRoot(container); + + root.render( + + + + ); + + window.addEventListener('beforeunload', () => { + root.unmount(); + }); +}; + +start().catch(console.error); diff --git a/src/downloadsWindow/fileTypes.ts b/src/downloadsWindow/fileTypes.ts new file mode 100644 index 0000000000..7273f02c06 --- /dev/null +++ b/src/downloadsWindow/fileTypes.ts @@ -0,0 +1,18 @@ +/** Longest label that still reads at icon size; "jpeg" and "webm" both fit. */ +const MAX_LABEL_LENGTH = 4; + +/** + * The short tag shown on the file icon — the file's extension, or the MIME + * subtype when a name has none (a download can arrive as "file" with + * `image/png`). + */ +export const getFileLabel = (fileName: string, mimeType?: string): string => { + const fromName = fileName.includes('.') + ? fileName.slice(fileName.lastIndexOf('.') + 1) + : ''; + + const subtype = mimeType?.split('/')?.[1] ?? ''; + const raw = fromName || subtype.split('+')[0]; + + return raw.replace(/[^a-z0-9]/gi, '').slice(0, MAX_LABEL_LENGTH); +}; diff --git a/src/downloadsWindow/grouping.ts b/src/downloadsWindow/grouping.ts new file mode 100644 index 0000000000..5c9c5e9dfa --- /dev/null +++ b/src/downloadsWindow/grouping.ts @@ -0,0 +1,60 @@ +import type { Download } from '../downloads/common'; + +export type DownloadDayGroup = { + /** Stable key for the day, from the start of the download. */ + day: string; + items: Download[]; +}; + +/** + * Groups downloads into runs sharing a calendar day, preserving the order they + * arrive in (newest first). Downloads without a usable `startTime` fall into an + * "unknown" run rather than being dropped or dated to the epoch. + */ +export const groupDownloadsByDay = ( + downloads: Download[] +): DownloadDayGroup[] => { + const groups: DownloadDayGroup[] = []; + + downloads.forEach((download) => { + const started = new Date(download.startTime); + const day = isNaN(started.getTime()) ? '' : started.toDateString(); + const current = groups[groups.length - 1]; + + if (current && current.day === day) { + current.items.push(download); + return; + } + + groups.push({ day, items: [download] }); + }); + + return groups; +}; + +/** `Today` / `Yesterday` where they apply, otherwise a full date. */ +export const formatDayLabel = ( + day: string, + now: Date, + labels: { today: string; yesterday: string; unknown: string } +): string => { + if (!day) return labels.unknown; + + const parsed = new Date(day); + if (isNaN(parsed.getTime())) return day; + + if (parsed.toDateString() === now.toDateString()) return labels.today; + + const yesterday = new Date(now); + yesterday.setDate(yesterday.getDate() - 1); + if (parsed.toDateString() === yesterday.toDateString()) { + return labels.yesterday; + } + + return parsed.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); +}; diff --git a/src/downloadsWindow/ipc.ts b/src/downloadsWindow/ipc.ts new file mode 100644 index 0000000000..8d7966ae2c --- /dev/null +++ b/src/downloadsWindow/ipc.ts @@ -0,0 +1,232 @@ +import path from 'path'; + +import type { Event } from 'electron'; +import { app, BrowserWindow, dialog, screen } from 'electron'; +import i18next from 'i18next'; + +import { packageJsonInformation } from '../app/main/app'; +import { handle } from '../ipc/main'; +import { dispatch, listen, select, watch } from '../store'; +import type { RootState } from '../store/rootReducer'; +import { + DOWNLOADS_WINDOW_OPEN_STATE_CHANGED, + SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, +} from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { watchWindowControls } from '../ui/main/secondaryWindowControls'; +import { focusSecondaryWindow } from '../ui/main/secondaryWindowFocus'; +import { + getSavedWindowBounds, + watchWindowBounds, +} from '../ui/main/secondaryWindowState'; +import { + NOT_FULL_SCREENABLE, + getTitleBarOptions, +} from '../ui/windowChrome/appearance'; +import { + TRANSPARENCY_CHANNEL, + WINDOW_MIN_HEIGHT, + WINDOW_MIN_WIDTH, + WINDOW_SIZE_MULTIPLIER, +} from './constants'; + +const t = i18next.t.bind(i18next); + +const isMac = process.platform === 'darwin'; + +let downloadsWindow: BrowserWindow | null = null; + +/** + * Set once quitting starts. The window's `closed` handler fires both when the + * reader closes it and when the app tears its windows down on quit; without this + * the quit path would record "closed" and defeat restoring it next launch. + */ +let isAppQuitting = false; + +const selectIsTransparencyEnabled = ({ + isTransparentWindowEnabled, +}: RootState): boolean => isTransparentWindowEnabled; + +/** Set while a window is being built; see `createDownloadsWindow`. */ +let pendingCreation: Promise | null = null; + +const buildDownloadsWindow = async (focusOnShow: boolean): Promise => { + const mainWindow = await getRootWindow(); + const winBounds = mainWindow.getNormalBounds(); + + const actualScreen = screen.getDisplayNearestPoint({ + x: winBounds.x + winBounds.width / 2, + y: winBounds.y + winBounds.height / 2, + }); + + const width = Math.round( + actualScreen.workAreaSize.width * WINDOW_SIZE_MULTIPLIER + ); + const height = Math.round( + actualScreen.workAreaSize.height * WINDOW_SIZE_MULTIPLIER + ); + const x = Math.round( + (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x + ); + const y = Math.round( + (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y + ); + + // Where the reader last left this window, falling back to centred on the + // display nearest the main window. + const savedBounds = getSavedWindowBounds('downloads'); + + // Seeds the first paint only; the renderer then follows the setting live. + const isTransparencyEnabled = isMac && select(selectIsTransparencyEnabled); + + downloadsWindow = new BrowserWindow({ + ...(savedBounds ?? { width, height, x, y }), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, + title: 'Downloads - Rocket.Chat', + // The toolbar doubles as the title bar wherever the platform allows it, so + // the window shows one header instead of a native title bar stacked on an + // in-app one. + ...getTitleBarOptions(), + ...NOT_FULL_SCREENABLE, + // `transparent` cannot be toggled after creation, so — like the root window — + // the window is always transparent with a vibrancy material on macOS and the + // setting only decides whether the renderer paints an opaque surface over it. + ...(isMac + ? { + transparent: true, + vibrancy: 'sidebar' as const, + visualEffectState: 'active' as const, + } + : {}), + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + show: false, + }); + + downloadsWindow.loadFile( + path.join(app.getAppPath(), 'app/downloads-window.html'), + { query: { transparent: String(isTransparencyEnabled) } } + ); + + downloadsWindow.once('ready-to-show', () => { + downloadsWindow?.setTitle( + `Downloads - ${packageJsonInformation.productName}` + ); + if (focusOnShow) { + downloadsWindow?.show(); + return; + } + // Restored at launch: show it without stealing focus from the main window. + downloadsWindow?.showInactive(); + }); + + dispatch({ type: DOWNLOADS_WINDOW_OPEN_STATE_CHANGED, payload: true }); + + downloadsWindow.on('closed', () => { + downloadsWindow = null; + if (!isAppQuitting) { + dispatch({ type: DOWNLOADS_WINDOW_OPEN_STATE_CHANGED, payload: false }); + } + }); + + downloadsWindow.webContents.on( + 'will-navigate', + (event: Event, url: string) => { + if (!url.startsWith('file://')) { + event.preventDefault(); + } + } + ); + + watchWindowBounds('downloads', downloadsWindow); + watchWindowControls(downloadsWindow); + + downloadsWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); +}; + +/** + * Opens the window, or focuses the one already open. + * + * Building one awaits the main window before the module variable is assigned, + * so two opens arriving in that gap would each build a window and the second + * would orphan the first — visible, untracked, and impossible to close from its + * own channel. Every caller comes through here, so only one is ever in flight. + */ +const createDownloadsWindow = async (focusOnShow: boolean): Promise => { + if (pendingCreation) await pendingCreation; + + if (downloadsWindow && !downloadsWindow.isDestroyed()) { + focusSecondaryWindow(downloadsWindow); + return; + } + + pendingCreation = buildDownloadsWindow(focusOnShow).finally(() => { + pendingCreation = null; + }); + await pendingCreation; +}; + +export const openDownloadsWindow = (): Promise => + createDownloadsWindow(true); + +/** + * Reopens the window at launch when it was open at shutdown, without taking + * focus from the main window. + */ +export const restoreDownloadsWindow = async (): Promise => { + if ( + !select(({ isDownloadsWindowOpen }: RootState) => isDownloadsWindowOpen) + ) { + return; + } + await createDownloadsWindow(false); +}; + +export const startDownloadsWindowHandler = (): void => { + app.on('before-quit', () => { + isAppQuitting = true; + }); + + handle('downloads-window/open-window', openDownloadsWindow); + + // Every entry point — the menu items, the titlebar indicator and the + // download-finished notification — already dispatches this, so hooking it + // here redirects all of them at once. + listen(SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, () => { + openDownloadsWindow(); + }); + + handle('downloads-window/close-requested', async () => { + downloadsWindow?.close(); + }); + + handle('downloads-window/confirm-clear-all', async () => { + if (!downloadsWindow || downloadsWindow.isDestroyed()) { + return false; + } + + const { response } = await dialog.showMessageBox(downloadsWindow, { + type: 'warning', + buttons: [ + t('dialog.clearDownloads.yes'), + t('dialog.clearDownloads.cancel'), + ], + defaultId: 1, + cancelId: 1, + title: t('dialog.clearDownloads.title'), + message: t('dialog.clearDownloads.message'), + }); + + return response === 0; + }); + + // Transparency is a renderer concern here, so a change only needs pushing to + // the open window — no reopen, no restart. + watch(selectIsTransparencyEnabled, (isEnabled) => { + if (!downloadsWindow || downloadsWindow.isDestroyed()) return; + downloadsWindow.webContents.send(TRANSPARENCY_CHANNEL, isEnabled); + }); +}; diff --git a/src/downloadsWindow/main/ipc.main.spec.ts b/src/downloadsWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..d95752d516 --- /dev/null +++ b/src/downloadsWindow/main/ipc.main.spec.ts @@ -0,0 +1,179 @@ +/** + * Confirm path for clearing the downloads list. Lives in main/ so jest's + * main-process project discovers it. + */ +export {}; + +const handleRegistry = new Map any>(); + +jest.mock('../../ipc/main', () => ({ + handle: jest.fn((channel: string, cb: (...args: any[]) => any) => { + handleRegistry.set(channel, cb); + return () => handleRegistry.delete(channel); + }), +})); + +jest.mock('../../store', () => ({ + dispatch: jest.fn(), + listen: jest.fn(), + select: jest.fn(() => false), + watch: jest.fn(), +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(() => + Promise.resolve({ + getNormalBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }), + }) + ), +})); + +jest.mock('../../ui/main/secondaryWindowControls', () => ({ + watchWindowControls: jest.fn(), +})); + +jest.mock('../../ui/main/secondaryWindowFocus', () => ({ + focusSecondaryWindow: jest.fn(), +})); + +jest.mock('../../ui/main/secondaryWindowState', () => ({ + getSavedWindowBounds: jest.fn(() => undefined), + watchWindowBounds: jest.fn(), +})); + +jest.mock('../../ui/windowChrome/appearance', () => ({ + NOT_FULL_SCREENABLE: {}, + getTitleBarOptions: jest.fn(() => ({})), +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +type FakeBW = { + isDestroyed: jest.Mock; + webContents: { + on: jest.Mock; + setWindowOpenHandler: jest.Mock; + send: jest.Mock; + }; + loadFile: jest.Mock; + once: jest.Mock; + on: jest.Mock; + setTitle: jest.Mock; + show: jest.Mock; + showInactive: jest.Mock; +}; + +const createdWindows: FakeBW[] = []; + +class FakeBrowserWindow { + webContents = { + on: jest.fn(), + setWindowOpenHandler: jest.fn(), + send: jest.fn(), + }; + + loadFile = jest.fn(() => Promise.resolve()); + + once = jest.fn(); + + on = jest.fn(); + + setTitle = jest.fn(); + + show = jest.fn(); + + showInactive = jest.fn(); + + isDestroyed = jest.fn(() => false); + + constructor() { + createdWindows.push(this as unknown as FakeBW); + } +} + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/app'), + on: jest.fn(), + }, + BrowserWindow: jest.fn().mockImplementation(() => new FakeBrowserWindow()), + dialog: { + showMessageBox: jest.fn(), + }, + screen: { + getDisplayNearestPoint: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), + }, +})); + +const loadIpc = async () => { + const ipc = await import('../ipc'); + const { dialog } = jest.requireMock('electron') as { + dialog: { showMessageBox: jest.Mock }; + }; + return { ...ipc, showMessageBox: dialog.showMessageBox }; +}; + +const confirmHandler = () => { + const handler = handleRegistry.get('downloads-window/confirm-clear-all'); + if (!handler) { + throw new Error('confirm-clear-all was not registered'); + } + return handler; +}; + +describe('downloads-window/confirm-clear-all', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + handleRegistry.clear(); + createdWindows.length = 0; + }); + + it('returns false and does not show a dialog when the window is gone', async () => { + const { startDownloadsWindowHandler, showMessageBox } = await loadIpc(); + startDownloadsWindowHandler(); + + await expect(confirmHandler()({} as never)).resolves.toBe(false); + expect(showMessageBox).not.toHaveBeenCalled(); + }); + + it('returns false on cancel so the list is not cleared', async () => { + const { openDownloadsWindow, startDownloadsWindowHandler, showMessageBox } = + await loadIpc(); + startDownloadsWindowHandler(); + await openDownloadsWindow(); + + showMessageBox.mockResolvedValue({ response: 1 }); + + await expect(confirmHandler()({} as never)).resolves.toBe(false); + expect(showMessageBox).toHaveBeenCalledWith( + createdWindows[0], + expect.objectContaining({ + type: 'warning', + defaultId: 1, + cancelId: 1, + title: 'dialog.clearDownloads.title', + }) + ); + }); + + it('returns true on accept so the list can be cleared', async () => { + const { openDownloadsWindow, startDownloadsWindowHandler, showMessageBox } = + await loadIpc(); + startDownloadsWindowHandler(); + await openDownloadsWindow(); + + showMessageBox.mockResolvedValue({ response: 0 }); + + await expect(confirmHandler()({} as never)).resolves.toBe(true); + }); +}); diff --git a/src/downloadsWindow/styles.tsx b/src/downloadsWindow/styles.tsx new file mode 100644 index 0000000000..ab3f5c5359 --- /dev/null +++ b/src/downloadsWindow/styles.tsx @@ -0,0 +1,17 @@ +import { Global, css } from '@emotion/react'; + +export const DOWNLOAD_NAME_CLASS = 'rcx-download-row__name'; + +/** + * Underlined only on hover: every finished download's name is clickable, and + * underlining them all would make the list look like link soup. + */ +export const DownloadsGlobalStyles = () => ( + +); diff --git a/src/i18n/__tests__/renderer.spec.ts b/src/i18n/__tests__/renderer.spec.ts new file mode 100644 index 0000000000..7d68ecfb06 --- /dev/null +++ b/src/i18n/__tests__/renderer.spec.ts @@ -0,0 +1,49 @@ +import i18next from 'i18next'; + +import { setupI18n } from '../renderer'; + +jest.mock('i18next', () => { + const init = jest.fn().mockResolvedValue(undefined); + const use = jest.fn(() => ({ init })); + return { + __esModule: true, + default: { use, init }, + __mockInit: init, + __mockUse: use, + }; +}); + +jest.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: jest.fn() }, +})); + +jest.mock('../../store', () => ({ + request: jest.fn(async () => 'en'), +})); + +jest.mock('../resources', () => ({ + __esModule: true, + default: { + en: jest.fn(async () => ({ hello: 'Hello' })), + }, +})); + +jest.mock('../common', () => ({ + interpolation: {}, + fallbackLng: 'en', +})); + +describe('i18n/renderer setupI18n', () => { + it('initializes i18next with requested language resources', async () => { + await setupI18n(); + expect(i18next.use).toHaveBeenCalled(); + const chain = (i18next.use as jest.Mock).mock.results[0]?.value; + expect(chain.init).toHaveBeenCalledWith( + expect.objectContaining({ + lng: 'en', + fallbackLng: 'en', + initImmediate: true, + }) + ); + }); +}); diff --git a/src/i18n/__tests__/resources.spec.ts b/src/i18n/__tests__/resources.spec.ts new file mode 100644 index 0000000000..46ddb78ecf --- /dev/null +++ b/src/i18n/__tests__/resources.spec.ts @@ -0,0 +1,44 @@ +import resources from '../resources'; + +describe('i18n/resources', () => { + it('exposes loaders for all supported languages', () => { + expect(Object.keys(resources).sort()).toEqual( + [ + 'de-DE', + 'en', + 'es', + 'fi', + 'fr', + 'hu', + 'it-IT', + 'ja', + 'no', + 'pl', + 'pt-BR', + 'ru', + 'sv', + 'tr-TR', + 'uk-UA', + 'zh-CN', + 'zh-TW', + ].sort() + ); + }); + + it('loads English resources as a non-empty object', async () => { + const en = await resources.en(); + expect(en).toEqual(expect.any(Object)); + expect(Object.keys(en as object).length).toBeGreaterThan(0); + }); + + it('loads a sample of non-English locales', async () => { + const [de, pt, ja] = await Promise.all([ + resources['de-DE'](), + resources['pt-BR'](), + resources.ja(), + ]); + expect(de).toEqual(expect.any(Object)); + expect(pt).toEqual(expect.any(Object)); + expect(ja).toEqual(expect.any(Object)); + }); +}); diff --git a/src/i18n/ar.i18n.json b/src/i18n/ar.i18n.json index cf136cd33b..772fb14b0c 100644 --- a/src/i18n/ar.i18n.json +++ b/src/i18n/ar.i18n.json @@ -46,5 +46,20 @@ "label": "تاريخ الانتهاء:", "expiresOn": "ينتهي في {{date}}" } + }, + "menus": { + "simulateDisconnected": "محاكاة قطع الاتصال" + }, + "tray": { + "presence": { + "status": "الحالة", + "online": "متصل", + "away": "غائب", + "busy": "مشغول", + "offline": "غير متصل", + "disconnected": "انقطع الاتصال — جارٍ إعادة المحاولة...", + "signIn": "تسجيل الدخول إلى {{- workspace}}", + "addWorkspace": "إضافة مساحة عمل" + } } } \ No newline at end of file diff --git a/src/i18n/de-DE.i18n.json b/src/i18n/de-DE.i18n.json index 00e1ac74eb..91abf59e07 100644 --- a/src/i18n/de-DE.i18n.json +++ b/src/i18n/de-DE.i18n.json @@ -339,7 +339,8 @@ "viewMenu": "Darstellung", "windowMenu": "&Fenster", "zoomIn": "Vergrößern", - "zoomOut": "Verkleinern" + "zoomOut": "Verkleinern", + "simulateDisconnected": "Verbindungsabbruch simulieren" }, "loadingError": { "title": "Server konnte nicht geladen werden", @@ -437,6 +438,16 @@ "title": "{{- appName}} läuft noch", "content": "{{- appName }} ist so eingestellt, dass es in der Taskleiste/im Benachrichtigungsbereich ausgeführt wird." } + }, + "presence": { + "status": "Status", + "online": "Online", + "away": "Abwesend", + "busy": "Beschäftigt", + "offline": "Offline", + "disconnected": "Verbindung getrennt — Verbindung wird erneut hergestellt...", + "signIn": "Bei {{- workspace}} anmelden", + "addWorkspace": "Arbeitsbereich hinzufügen" } }, "taskbar": { diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 3792d7633b..b50b420a5d 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -20,14 +20,17 @@ "about": { "title": "About {{- appName}}", "version": "Version: <1>{{-version}}", - "checkUpdates": "Check for Updates", - "checkUpdatesOnStart": "Check for Updates on Start", + "checkUpdates": "Check for updates now", + "checkUpdatesDescription": "Look for a newer version now.", + "checkUpdatesOnStart": "Check for updates on start", + "checkUpdatesOnStartDescription": "Look for a newer version every time the app launches.", "noUpdatesAvailable": "No updates are available.", "updateAvailable": "Version {{- version}} is available", "copyright": "Copyright {{copyright}}", "errorWhenLookingForUpdates": "An error has occurred when looking for updates", "updateChannel": { - "label": "Update Channel", + "label": "Update channel", + "description": "Which release stream this app updates from.", "latest": "Stable", "beta": "Beta", "alpha": "Alpha (Experimental)" @@ -76,6 +79,12 @@ "yes": "Yes", "cancel": "Cancel" }, + "saveLogFile": { + "title": "Save Log File", + "zipFiles": "ZIP Files", + "logFiles": "Log Files", + "allFiles": "All Files" + }, "screenshare": { "title": "Share Your Screen", "announcement": "Select a screen to share" @@ -86,6 +95,12 @@ "currentVersion": "Current version", "newVersion": "New version", "install": "Install update", + "openStore": { + "mas": "Open App Store", + "windows": "Open Microsoft Store", + "snap": "Open Snap Store", + "flatpak": "Open Flathub" + }, "skip": "Skip this version" }, "updateInstallLater": { @@ -104,6 +119,11 @@ "message": "We will notify you when the next update is available\nIf you change your mind you can check for updates from the About menu.", "ok": "OK" }, + "selectLogFile": { + "title": "Select Log File", + "logFiles": "Log Files", + "allFiles": "All Files" + }, "selectClientCertificate": { "announcement": "Select Certificate", "select": "Select", @@ -170,6 +190,18 @@ "detail": "This action cannot be undone. All current log entries will be permanently deleted.", "yes": "Clear", "cancel": "Cancel" + }, + "clearDownloads": { + "title": "Clear all downloads", + "message": "Remove every download from this list? Downloaded files stay on disk.", + "yes": "Clear all", + "cancel": "Cancel" + }, + "removeCertificate": { + "title": "Remove certificate", + "message": "Remove the stored decision for \"{{- domain}}\"? The next visit will ask you again.", + "yes": "Remove", + "cancel": "Cancel" } }, "documentViewer": { @@ -177,7 +209,12 @@ "pdf": "PDF Viewer", "markdown": "Markdown Viewer" }, - "back": "Back" + "back": "Back", + "download": "Download", + "downloadError": "The document could not be saved", + "dismissError": "Dismiss", + "viewSource": "View source", + "viewRendered": "View rendered" }, "downloads": { "title": "Downloads", @@ -186,7 +223,7 @@ "downloadInterrupted": "Download Interrupted", "downloadCancelled": "Download Cancelled", "downloadFailed": "Download Failed", - "downloadExpired": "Downlaod Expired", + "downloadExpired": "Download expired", "downloadExpiredMessage": "Please retry downloading from source." }, "filters": { @@ -194,30 +231,36 @@ "server": "Server", "mimeType": "Type", "status": "Status", + "empty": "Filters appear here once you have downloads.", "clear": "Clear filters", "all": "All", "mimes": { - "images": "Images", - "videos": "Videos", - "audios": "Audios", - "texts": "Texts", - "files": "Files" + "image": "Images", + "video": "Videos", + "audio": "Audio", + "text": "Documents", + "application": "Files", + "other": "Other" }, "statuses": { "paused": "Paused", "cancelled": "Cancelled" - } + }, + "selectAll": "All" }, "item": { "cancel": "Cancel", "copyLink": "Copy link", + "copied": "Copied", "errored": "Download cancelled", "pause": "Pause", "progressSize": "{{receivedBytes, byteSize}} of {{totalBytes, byteSize}} ({{ratio, percentage}})", "remove": "Remove from list", "resume": "Resume", "retry": "Retry", - "showInFolder": "Show in Folder" + "showInFolder": "Show in folder", + "openFile": "Open {{fileName}}", + "preview": "Quick look" }, "showingResults": "Showing results {{first}} - {{last}} of {{count}}", "empty": { @@ -227,6 +270,20 @@ "noResults": { "title": "No matching downloads", "subtitle": "Try adjusting your filters or search." + }, + "clearFilters": "Clear filters", + "clearFiltersCount": "Clear {{count}} filters", + "count_one": "{{count}} download", + "count_other": "{{count}} downloads", + "countOfTotal": "{{count}} of {{total}} downloads", + "sidebar": { + "title": "Download filters" + }, + "clearAll": "Clear all", + "day": { + "today": "Today", + "yesterday": "Yesterday", + "unknown": "Earlier" } }, "certificatesManager": { @@ -237,6 +294,21 @@ "domain": "Domain", "actions": "Actions", "remove": "Remove" + }, + "filter": "Filter domains", + "state": { + "trusted": "Trusted", + "notTrusted": "Not trusted" + }, + "summary_one": "{{count}} certificate · {{trusted}} trusted", + "summary_other": "{{count}} certificates · {{trusted}} trusted", + "empty": { + "title": "No certificates yet", + "subtitle": "Certificates you trust or reject will appear here." + }, + "noResults": { + "title": "No matching certificates", + "subtitle": "Try a different domain." } }, "settings": { @@ -253,7 +325,8 @@ "other": "Other & technical", "logging": "Logging", "telephony": "Telephony", - "videoCalls": "Video calls" + "videoCalls": "Video calls", + "advanced": "Advanced" }, "options": { "report": { @@ -390,7 +463,10 @@ "title": "PDF preview size limit in encrypted rooms (MB)", "description": "Encrypted files are loaded into memory for previews. Files larger than this limit will be downloaded directly." } - } + }, + "appearance": "Appearance", + "search": "Search settings", + "noResults": "No matching settings" }, "error": { "authNeeded": "Auth needed, try {{- auth}}", @@ -416,6 +492,7 @@ "developerMode": "Developer Mode", "simulateUpdate": "Simulate Update Flow", "simulateDownload": "Simulate Download", + "simulateDisconnected": "Simulate Disconnected", "disableGpu": "Disable GPU", "documentation": "Documentation", "downloads": "Downloads", @@ -581,6 +658,16 @@ "title": "{{- appName}} is still running", "content": "{{- appName }} is set to stay running in the system tray/notification area." } + }, + "presence": { + "status": "Status", + "online": "Online", + "away": "Away", + "busy": "Busy", + "offline": "Offline", + "disconnected": "Disconnected — trying to reconnect...", + "signIn": "Sign in to {{- workspace}}", + "addWorkspace": "Add workspace" } }, "taskbar": { @@ -645,58 +732,53 @@ }, "logViewer": { "title": "Log Viewer", - "aria": { - "logIcon": "Log viewer icon", - "entriesCount": "{{count}} log entries displayed" - }, "fileInfo": { "custom": "Custom", - "entries": "{{count}} entries", + "entries_one": "{{count}} entry", + "entries_other": "{{count}} entries", "entriesOfTotal": "{{count}} of {{total}} entries", + "matches": "{{count}} matches", "noEntries": "No entries" }, "buttons": { - "openLogFile": "Open Log File", - "defaultLog": "Default Log", + "openLogFile": "Open log file", + "defaultLog": "Default log", "refresh": "Refresh", - "autoRefresh": "Auto Refresh", - "stopAutoRefresh": "Stop Auto Refresh", + "autoRefresh": "Auto refresh", + "stopAutoRefresh": "Stop auto refresh", "copy": "Copy", + "copied": "Copied", "save": "Save", - "clear": "Clear", - "close": "Close", - "clearFilters": "Clear Filters" + "saved": "Saved", + "showInFolder": "Show in Folder", + "retry": "Retry", + "dismissError": "Dismiss error", + "clear": "Clear all", + "clearFilters": "Clear filters", + "clearFiltersCount": "Clear {{count}} filters" }, "controls": { "showContext": "Show Context", "showServer": "Show Server", - "autoScrollToTop": "Auto Scroll to Top" + "autoScrollToTop": "Auto Scroll to Top", + "wrapLines": "Wrap long lines", + "collapseMultiline": "Collapse multi-line entries", + "showTimeline": "Show timeline" }, "placeholders": { - "loadAmount": "Load amount", "searchLogs": "Search logs...", - "level": "Level", - "context": "Context", "exchangeDebug": "Exchange Debug Filter" }, "filters": { - "entryLimit": { - "last100": "Last 100 entries", - "last500": "Last 500 entries", - "last1000": "Last 1000 entries", - "last5000": "Last 5000 entries", - "all": "All entries" - }, "level": { - "all": "All Levels", "debug": "Debug", "info": "Info", "warn": "Warning", "error": "Error", - "verbose": "Verbose" + "verbose": "Verbose", + "silly": "Silly" }, "context": { - "all": "All Contexts", "main": "Main Process", "renderer": "Renderer", "webview": "Webview", @@ -708,10 +790,6 @@ "servers": "Servers", "ipc": "IPC Communication" }, - "server": { - "all": "All Servers", - "label": "Server" - }, "exchangeDebug": { "all": "All Logs", "success": "Success Only", @@ -722,11 +800,40 @@ "networkErrors": "Network Issues", "successFactors": "Success Factors", "outlookCalendar": "Outlook Calendar" - } + }, + "selectAll": "All" }, "messages": { "noLogsFound": "No logs found", - "adjustFilters": "Try adjusting your filters or refresh the logs" + "newEntriesPaused": "{{count}} new entries — click to resume", + "adjustFilters": "Try adjusting your filters or refresh the logs", + "loadFailed": "Failed to load logs", + "saveFailed": "Failed to save logs" + }, + "sidebar": { + "title": "Log filters", + "levels": "Levels", + "contexts": "Contexts", + "display": "Display", + "servers": "Servers" + }, + "entry": { + "expand_one": "+{{count}} more line", + "expand_other": "+{{count}} more lines", + "collapse": "Show less", + "copyEntry": "Copy entry", + "copied": "Copied" + }, + "status": { + "live": "Live", + "loading": "Loading…" + }, + "timeline": { + "label": "Distribution over time", + "peak_one": "peak {{count}} entry", + "peak_other": "peak {{count}} entries", + "hint": "Drag across the chart to select a time range", + "clearRange": "Clear time range" } }, "serverInfo": { @@ -802,4 +909,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/i18n/es.i18n.json b/src/i18n/es.i18n.json index dd32165f3c..6c1cadf8a0 100644 --- a/src/i18n/es.i18n.json +++ b/src/i18n/es.i18n.json @@ -363,7 +363,8 @@ "viewMenu": "&Ver", "windowMenu": "&Ventana", "zoomIn": "Acercar", - "zoomOut": "Alejar" + "zoomOut": "Alejar", + "simulateDisconnected": "Simular desconexión" }, "loadingError": { "title": "Fallo al cargar el servidor", @@ -467,6 +468,16 @@ "title": "{{- appName}} todavía está en ejecución", "content": "{{- appName}} está configurado para permanecer en ejecución en la bandeja del sistema/área de notificación." } + }, + "presence": { + "status": "Estado", + "online": "En línea", + "away": "Ausente", + "busy": "Ocupado", + "offline": "Desconectado", + "disconnected": "Desconectado — intentando reconectar...", + "signIn": "Iniciar sesión en {{- workspace}}", + "addWorkspace": "Agregar espacio de trabajo" } }, "taskbar": { diff --git a/src/i18n/fi.i18n.json b/src/i18n/fi.i18n.json index a38913fdb3..aaf3d46a85 100644 --- a/src/i18n/fi.i18n.json +++ b/src/i18n/fi.i18n.json @@ -337,7 +337,8 @@ "viewMenu": "&Näytä", "windowMenu": "&Ikkuna", "zoomIn": "Lähennä", - "zoomOut": "Loitonna" + "zoomOut": "Loitonna", + "simulateDisconnected": "Simuloi yhteyden katkeamista" }, "loadingError": { "title": "Palvelimen lataus epäonnistui", @@ -435,6 +436,16 @@ "title": "{{- appName}} on edelleen käynnissä", "content": "{{- appName }} on määritetty pysymään käynnissä ilmaisinalueella." } + }, + "presence": { + "status": "Tila", + "online": "Paikalla", + "away": "Poissa", + "busy": "Varattu", + "offline": "Poissa linjoilta", + "disconnected": "Yhteys katkesi — yritetään yhdistää uudelleen...", + "signIn": "Kirjaudu työtilaan {{- workspace}}", + "addWorkspace": "Lisää työtila" } }, "taskbar": { diff --git a/src/i18n/fr.i18n.json b/src/i18n/fr.i18n.json index f0ceeda3c7..14a2c850c8 100644 --- a/src/i18n/fr.i18n.json +++ b/src/i18n/fr.i18n.json @@ -338,7 +338,8 @@ "viewMenu": "&Voir", "windowMenu": "&Fenêtre", "zoomIn": "Zoomer", - "zoomOut": "Zoom arrière" + "zoomOut": "Zoom arrière", + "simulateDisconnected": "Simuler une déconnexion" }, "loadingError": { "title": "Echec du chargement du serveur", @@ -436,6 +437,16 @@ "title": "{{- appName}} est toujours en cours d'exécution", "content": "{{- appName }} est configuré pour continuer à fonctionner dans la barre d'état système / zone de notification." } + }, + "presence": { + "status": "Statut", + "online": "En ligne", + "away": "Absent", + "busy": "Occupé", + "offline": "Hors ligne", + "disconnected": "Déconnecté — tentative de reconnexion...", + "signIn": "Se connecter à {{- workspace}}", + "addWorkspace": "Ajouter un espace de travail" } }, "taskbar": { diff --git a/src/i18n/hu.i18n.json b/src/i18n/hu.i18n.json index dc7c939142..d6c2ff4c19 100644 --- a/src/i18n/hu.i18n.json +++ b/src/i18n/hu.i18n.json @@ -386,7 +386,8 @@ "viewMenu": "&Nézet", "windowMenu": "&Ablak", "zoomIn": "Nagyítás", - "zoomOut": "Kicsinyítés" + "zoomOut": "Kicsinyítés", + "simulateDisconnected": "Kapcsolat megszakadásának szimulálása" }, "loadingError": { "title": "Kiszolgáló betöltése sikertelen", @@ -501,6 +502,16 @@ "title": "A {{- appName}} továbbra is fut", "content": "A {{- appName }} úgy lett beállítva, hogy futva maradjon a rendszertálcán vagy az értesítési területen." } + }, + "presence": { + "status": "Állapot", + "online": "Elérhető", + "away": "Távol", + "busy": "Elfoglalt", + "offline": "Nem elérhető", + "disconnected": "Kapcsolat megszakadt — újracsatlakozás...", + "signIn": "Bejelentkezés ide: {{- workspace}}", + "addWorkspace": "Munkaterület hozzáadása" } }, "taskbar": { diff --git a/src/i18n/it-IT.i18n.json b/src/i18n/it-IT.i18n.json index d045d12794..cafac6023d 100644 --- a/src/i18n/it-IT.i18n.json +++ b/src/i18n/it-IT.i18n.json @@ -84,5 +84,20 @@ "label": "Scadenza:", "expiresOn": "Scade il {{date}}" } + }, + "menus": { + "simulateDisconnected": "Simula disconnessione" + }, + "tray": { + "presence": { + "status": "Stato", + "online": "Online", + "away": "Assente", + "busy": "Occupato", + "offline": "Offline", + "disconnected": "Disconnesso — tentativo di riconnessione...", + "signIn": "Accedi a {{- workspace}}", + "addWorkspace": "Aggiungi area di lavoro" + } } } \ No newline at end of file diff --git a/src/i18n/ja.i18n.json b/src/i18n/ja.i18n.json index 2662a8feea..ad93bb0465 100644 --- a/src/i18n/ja.i18n.json +++ b/src/i18n/ja.i18n.json @@ -176,7 +176,8 @@ "viewMenu": "表示 (&V)", "windowMenu": "ウィンドウ (&W)", "zoomIn": "拡大", - "zoomOut": "縮小" + "zoomOut": "縮小", + "simulateDisconnected": "切断をシミュレート" }, "loadingError": { "title": "サーバーの読み込みに失敗しました", @@ -258,6 +259,16 @@ "unreadMention": "{{- appName}}: 未読のメンション/ダイレクトメッセージがあります", "unreadMention_plural": "{{- appName}}: 未読のメンション/ダイレクトメッセージが {{- count}} 件あります", "unreadMessage": "{{- appName}}: 未読のメッセージがあります" + }, + "presence": { + "status": "ステータス", + "online": "オンライン", + "away": "退席中", + "busy": "取り込み中", + "offline": "オフライン", + "disconnected": "切断されました — 再接続を試みています...", + "signIn": "{{- workspace}} にサインイン", + "addWorkspace": "ワークスペースを追加" } }, "taskbar": { diff --git a/src/i18n/nb-NO.i18n.json b/src/i18n/nb-NO.i18n.json index b8d22ec0e8..05b1c807c6 100644 --- a/src/i18n/nb-NO.i18n.json +++ b/src/i18n/nb-NO.i18n.json @@ -46,5 +46,20 @@ "label": "Utløpsdato:", "expiresOn": "Utløper {{date}}" } + }, + "menus": { + "simulateDisconnected": "Simuler frakobling" + }, + "tray": { + "presence": { + "status": "Status", + "online": "Pålogget", + "away": "Borte", + "busy": "Opptatt", + "offline": "Frakoblet", + "disconnected": "Frakoblet — prøver å koble til på nytt...", + "signIn": "Logg på {{- workspace}}", + "addWorkspace": "Legg til arbeidsområde" + } } } \ No newline at end of file diff --git a/src/i18n/nn.i18n.json b/src/i18n/nn.i18n.json index 8af314a063..02ff45577e 100644 --- a/src/i18n/nn.i18n.json +++ b/src/i18n/nn.i18n.json @@ -46,5 +46,20 @@ "label": "Utløpsdato:", "expiresOn": "Utløper {{date}}" } + }, + "menus": { + "simulateDisconnected": "Simuler fråkopling" + }, + "tray": { + "presence": { + "status": "Status", + "online": "Pålogga", + "away": "Borte", + "busy": "Oppteken", + "offline": "Fråkopla", + "disconnected": "Fråkopla — prøver å kopla til på nytt...", + "signIn": "Logg på {{- workspace}}", + "addWorkspace": "Legg til arbeidsområde" + } } } \ No newline at end of file diff --git a/src/i18n/no.i18n.json b/src/i18n/no.i18n.json index 6d123a6847..5809142c57 100644 --- a/src/i18n/no.i18n.json +++ b/src/i18n/no.i18n.json @@ -399,7 +399,8 @@ "viewMenu": "Visning", "windowMenu": "Vindu", "zoomIn": "Zoom inn", - "zoomOut": "Zoom ut" + "zoomOut": "Zoom ut", + "simulateDisconnected": "Simuler frakobling" }, "loadingError": { "title": "Serveren kunne ikke lastes inn", @@ -514,6 +515,16 @@ "title": "{{- appName}} kjører fortsatt", "content": "{{- appName }} er satt til å fortsette å kjøre i systemstatusfeltet/meldingsområdet." } + }, + "presence": { + "status": "Status", + "online": "Pålogget", + "away": "Borte", + "busy": "Opptatt", + "offline": "Frakoblet", + "disconnected": "Frakoblet — prøver å koble til på nytt...", + "signIn": "Logg på {{- workspace}}", + "addWorkspace": "Legg til arbeidsområde" } }, "taskbar": { diff --git a/src/i18n/pl.i18n.json b/src/i18n/pl.i18n.json index ac0b312b9e..48cd2b903e 100644 --- a/src/i18n/pl.i18n.json +++ b/src/i18n/pl.i18n.json @@ -205,7 +205,8 @@ "viewMenu": "&Widok", "windowMenu": "&Okno", "zoomIn": "Przybliż", - "zoomOut": "Oddal" + "zoomOut": "Oddal", + "simulateDisconnected": "Symuluj rozłączenie" }, "loadingError": { "title": "Błąd ładowania serwera", @@ -299,6 +300,16 @@ "title": "{{- appName}} wciąż działa", "content": "{{- appName }} jest ustawiony aby działał w zasobniku systemowym/obszarze powiadomień." } + }, + "presence": { + "status": "Status", + "online": "Dostępny", + "away": "Zaraz wracam", + "busy": "Zajęty", + "offline": "Offline", + "disconnected": "Rozłączono — próba ponownego połączenia...", + "signIn": "Zaloguj się do {{- workspace}}", + "addWorkspace": "Dodaj obszar roboczy" } }, "taskbar": { diff --git a/src/i18n/pt-BR.i18n.json b/src/i18n/pt-BR.i18n.json index e906cebde7..cdb841b04b 100644 --- a/src/i18n/pt-BR.i18n.json +++ b/src/i18n/pt-BR.i18n.json @@ -359,7 +359,8 @@ "viewMenu": "&Exibir", "windowMenu": "&Janela", "zoomIn": "Aumentar zoom", - "zoomOut": "Diminuir zoom" + "zoomOut": "Diminuir zoom", + "simulateDisconnected": "Simular desconexão" }, "loadingError": { "title": "Servidor Falhou em Carregar", @@ -472,6 +473,16 @@ "title": "{{- appName}} ainda está rodando", "content": "{{- appName }} está configurado para permanecer em funcionamento na bandeja do sistema/área de notificação." } + }, + "presence": { + "status": "Status", + "online": "Online", + "away": "Ausente", + "busy": "Ocupado", + "offline": "Offline", + "disconnected": "Desconectado — tentando reconectar...", + "signIn": "Entrar em {{- workspace}}", + "addWorkspace": "Adicionar espaço de trabalho" } }, "taskbar": { diff --git a/src/i18n/ru.i18n.json b/src/i18n/ru.i18n.json index f24ce3e30c..a5cb27566c 100644 --- a/src/i18n/ru.i18n.json +++ b/src/i18n/ru.i18n.json @@ -341,7 +341,8 @@ "viewMenu": "&Вид", "windowMenu": "&Окна", "zoomIn": "Увеличить", - "zoomOut": "Уменьшить" + "zoomOut": "Уменьшить", + "simulateDisconnected": "Симулировать отключение" }, "loadingError": { "title": "Не удалось загрузить сервер", @@ -439,6 +440,16 @@ "title": "{{- appName}} все еще работает", "content": "{{- appName }} настроен на постоянное размещение в системном трее/области уведомлений." } + }, + "presence": { + "status": "Статус", + "online": "В сети", + "away": "Отошёл", + "busy": "Занят", + "offline": "Не в сети", + "disconnected": "Отключено — попытка переподключения...", + "signIn": "Войти в {{- workspace}}", + "addWorkspace": "Добавить рабочее пространство" } }, "taskbar": { diff --git a/src/i18n/se.i18n.json b/src/i18n/se.i18n.json index 45f4a57bd7..d4b32ba0a8 100644 --- a/src/i18n/se.i18n.json +++ b/src/i18n/se.i18n.json @@ -46,5 +46,20 @@ "label": "Utgångsdatum:", "expiresOn": "Upphör {{date}}" } + }, + "menus": { + "simulateDisconnected": "Simuler čađaheami" + }, + "tray": { + "presence": { + "status": "Stáhtus", + "online": "Fárrolaš", + "away": "Eret", + "busy": "Bargguid alde", + "offline": "Ii fárrolaš", + "disconnected": "Čatnasat gaskkalduvvon — geahččalit ođđasit čatnasit...", + "signIn": "Čálihuvvo {{- workspace}} bargobáikái", + "addWorkspace": "Lasit bargobáikki" + } } } \ No newline at end of file diff --git a/src/i18n/sv.i18n.json b/src/i18n/sv.i18n.json index 81afbbdd5c..5e2cb158e4 100644 --- a/src/i18n/sv.i18n.json +++ b/src/i18n/sv.i18n.json @@ -377,7 +377,8 @@ "viewMenu": "&Visa", "windowMenu": "&Fönster", "zoomIn": "Zooma in", - "zoomOut": "Zooma ut" + "zoomOut": "Zooma ut", + "simulateDisconnected": "Simulera frånkoppling" }, "loadingError": { "title": "Servern kunde inte laddas", @@ -492,6 +493,16 @@ "title": "{{- appName}} är fortfarande igång", "content": "{{- appName }} är inställd på att fortsätta köras i systemfältet/notifikationsområdet." } + }, + "presence": { + "status": "Status", + "online": "Online", + "away": "Borta", + "busy": "Upptagen", + "offline": "Offline", + "disconnected": "Frånkopplad — försöker återansluta...", + "signIn": "Logga in på {{- workspace}}", + "addWorkspace": "Lägg till arbetsyta" } }, "taskbar": { diff --git a/src/i18n/tr-TR.i18n.json b/src/i18n/tr-TR.i18n.json index 69085ea5d7..57d6343327 100644 --- a/src/i18n/tr-TR.i18n.json +++ b/src/i18n/tr-TR.i18n.json @@ -189,7 +189,8 @@ "viewMenu": "&Görünüm", "windowMenu": "&Pencereler", "zoomIn": "Yakınlaştır", - "zoomOut": "Uzaklaştır" + "zoomOut": "Uzaklaştır", + "simulateDisconnected": "Bağlantı Kesilmesini Simüle Et" }, "loadingError": { "title": "Sunucu yüklenirken hata oluştu", @@ -277,6 +278,16 @@ "unreadMention": "{{- appName}}: okunmamış bahsetme/direkt mesajınız var", "unreadMention_plural": "{{- appName}}: {{- count}} okunmamış bahsetme/direkt mesajınız var", "unreadMessage": "{{- appName}}: okunmamış mesajınız var" + }, + "presence": { + "status": "Durum", + "online": "Çevrimiçi", + "away": "Uzakta", + "busy": "Meşgul", + "offline": "Çevrimdışı", + "disconnected": "Bağlantı kesildi — yeniden bağlanmaya çalışılıyor...", + "signIn": "{{- workspace}} alanına giriş yap", + "addWorkspace": "Çalışma alanı ekle" } }, "taskbar": { diff --git a/src/i18n/uk-UA.i18n.json b/src/i18n/uk-UA.i18n.json index 40e0d66d0c..21d8e2d0f0 100644 --- a/src/i18n/uk-UA.i18n.json +++ b/src/i18n/uk-UA.i18n.json @@ -174,7 +174,8 @@ "viewMenu": "&Вигляд", "windowMenu": "В&ікно", "zoomIn": "Збільшити", - "zoomOut": "Зменшити" + "zoomOut": "Зменшити", + "simulateDisconnected": "Симулювати відключення" }, "loadingError": { "title": "Не вдалося завантажити сервер", @@ -268,6 +269,16 @@ "title": "{{- appName}} досі працює", "content": "{{- appName }} налаштовано, щоб продовжувати працювати в системному треї/області сповіщень." } + }, + "presence": { + "status": "Статус", + "online": "В мережі", + "away": "Відійшов", + "busy": "Зайнятий", + "offline": "Не в мережі", + "disconnected": "Відключено — спроба повторного підключення...", + "signIn": "Увійти до {{- workspace}}", + "addWorkspace": "Додати робочий простір" } }, "taskbar": { diff --git a/src/i18n/zh-CN.i18n.json b/src/i18n/zh-CN.i18n.json index eab875ca4d..80a8c026ec 100644 --- a/src/i18n/zh-CN.i18n.json +++ b/src/i18n/zh-CN.i18n.json @@ -231,7 +231,8 @@ "viewMenu": "显示 (&V)", "windowMenu": "窗口 (&W)", "zoomIn": "放大", - "zoomOut": "缩小" + "zoomOut": "缩小", + "simulateDisconnected": "模拟断开连接" }, "loadingError": { "title": "服务器载入失败", @@ -317,6 +318,16 @@ "unreadMention": "{{- appName}}: 您有未读的直接或提及到您的信息", "unreadMention_plural": "{{- appName}}: 您有 {{- count}} 封未读的直接或提及到您的信息", "unreadMessage": "{{- appName}}: 您有未读的信息" + }, + "presence": { + "status": "状态", + "online": "在线", + "away": "离开", + "busy": "忙碌", + "offline": "离线", + "disconnected": "已断开连接 — 正在尝试重新连接...", + "signIn": "登录 {{- workspace}}", + "addWorkspace": "添加工作区" } }, "taskbar": { diff --git a/src/i18n/zh-TW.i18n.json b/src/i18n/zh-TW.i18n.json index 580244e57c..c0e1806080 100644 --- a/src/i18n/zh-TW.i18n.json +++ b/src/i18n/zh-TW.i18n.json @@ -168,7 +168,8 @@ "viewMenu": "檢視 (&V)", "windowMenu": "視窗 (&W)", "zoomIn": "放大", - "zoomOut": "縮小" + "zoomOut": "縮小", + "simulateDisconnected": "模擬中斷連線" }, "loadingError": { "title": "伺服器載入失敗", @@ -235,6 +236,16 @@ "unreadMention": "{{- appName}}: 您有未讀的直接或提及到您的訊息", "unreadMention_plural": "{{- appName}}: 您有 {{- count}} 封未讀的直接或提及到您的訊息", "unreadMessage": "{{- appName}}: 您有未讀的訊息" + }, + "presence": { + "status": "狀態", + "online": "線上", + "away": "離開", + "busy": "忙碌", + "offline": "離線", + "disconnected": "已中斷連線 — 正在嘗試重新連線...", + "signIn": "登入 {{- workspace}}", + "addWorkspace": "新增工作區" } }, "taskbar": { diff --git a/src/i18n/zh.i18n.json b/src/i18n/zh.i18n.json index c5dee48e08..e92b6d0503 100644 --- a/src/i18n/zh.i18n.json +++ b/src/i18n/zh.i18n.json @@ -46,5 +46,20 @@ "label": "过期时间:", "expiresOn": "将于 {{date}} 过期" } + }, + "menus": { + "simulateDisconnected": "模拟断开连接" + }, + "tray": { + "presence": { + "status": "状态", + "online": "在线", + "away": "离开", + "busy": "忙碌", + "offline": "离线", + "disconnected": "已断开连接 — 正在尝试重新连接...", + "signIn": "登录 {{- workspace}}", + "addWorkspace": "添加工作区" + } } } \ No newline at end of file diff --git a/src/injected.ts b/src/injected.ts index c23a83eeb7..ba3d1851ec 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -1,5 +1,10 @@ import type { NotificationAction } from 'electron'; +import { createPresenceRateLimiter } from './servers/preload/presenceDebounce'; +import type { PresenceStoreEntry } from './servers/preload/presenceSnapshot'; +import { buildPresenceSnapshot } from './servers/preload/presenceSnapshot'; +import { hasUsablePresenceApi } from './servers/preload/presenceSupport'; + console.log('[Rocket.Chat Desktop] Injected.ts'); const resolveWithExponentialBackoff = ( @@ -28,6 +33,26 @@ const resolveWithExponentialBackoff = ( const tryRequire = (path: string) => resolveWithExponentialBackoff(() => window.require(path)); +// Some webapp modules are reachable under more than one specifier depending on +// the Rocket.Chat version, and there is no registry we can enumerate. Probing +// each candidate with `tryRequire` in turn would multiply the exponential +// backoff per candidate, so the whole candidate list is probed in a single +// synchronous pass and it is that pass which gets retried with backoff. +const tryRequireFirstOf = (paths: string[]) => + resolveWithExponentialBackoff(async () => { + for (const path of paths) { + try { + const module = window.require(path); + if (module) { + return module as T; + } + } catch (error) { + // Wrong specifier for this server version; try the next candidate. + } + } + throw new Error(`None of the module paths resolved: ${paths.join(', ')}`); + }); + const BOOT_RECOVERY_ATTEMPTS_KEY = 'rocketChatDesktopBootRecoveryAttempts'; const MAX_BOOT_RECOVERY_ATTEMPTS = 2; @@ -211,6 +236,23 @@ const start = async () => { return '/app/utils'; })(); + // The webapp's presence store (`apps/meteor/client/lib/presence.ts`) is the + // ONLY source of a user's presence: it is fed by the `stream-user-presence` + // DDP stream and is deliberately not published into the `Meteor.users` + // minimongo collection. The exact `window.require` specifier is not stable + // across Rocket.Chat versions and could not be confirmed at runtime, so a + // short candidate list is probed and the first hit wins. All misses are a + // supported outcome: presence is reported as unsupported and hidden. + // `/client/lib/presence.ts` is the specifier confirmed against a live + // Rocket.Chat 8.8 workspace — the extension is required there, and the + // extensionless form fails with "Cannot find module". The others are + // fallbacks for versions that resolve it differently. + const presenceModulePaths = [ + '/client/lib/presence.ts', + '/client/lib/presence', + 'client/lib/presence', + ]; + // Load core modules with individual error handling (non-blocking) let Meteor: any = null; let Session: any = null; @@ -218,6 +260,11 @@ const start = async () => { let settings: any = null; let getUserPreference: any = null; let UserPresence: any = null; + let Presence: any = null; + // Distinguishes "still loading" (null) from "this workspace does not expose + // the presence module" (false), so the tray can degrade to hiding presence + // instead of showing a permanently blank state. + let presenceModuleResolved: boolean | null = null; // Load modules asynchronously without blocking const loadModule = async ( @@ -261,9 +308,36 @@ const start = async () => { UserPresence = value.UserPresence; }); + tryRequireFirstOf(presenceModulePaths) + .then((module) => { + if (!hasUsablePresenceApi(module)) { + console.warn( + '[Rocket.Chat Desktop] Presence module resolved without the expected store/listen API; presence will be reported as unsupported' + ); + presenceModuleResolved = false; + return; + } + Presence = (module as { Presence: unknown }).Presence; + presenceModuleResolved = true; + console.log('[Rocket.Chat Desktop] Presence module loaded successfully'); + }) + .catch(() => { + console.warn( + `[Rocket.Chat Desktop] Failed to load the presence module (tried: ${presenceModulePaths.join( + ', ' + )}); presence will be reported as unsupported` + ); + presenceModuleResolved = false; + }); + // Initialize non-module dependent features immediately - navigator.clipboard.writeText = async (...args) => - window.RocketChatDesktop.writeTextToClipboard(...args); + // navigator.clipboard is undefined outside secure contexts (plain HTTP + // workspaces), so guard the patch or the whole start() chain throws here + // and never reaches the Notification shim below. + if (navigator.clipboard) { + navigator.clipboard.writeText = async (...args) => + window.RocketChatDesktop.writeTextToClipboard(...args); + } console.log('[Rocket.Chat Desktop] Injected.ts replaced Notification'); @@ -468,6 +542,7 @@ const start = async () => { gitCommitHash: false, themeAppearance: false, userPresence: false, + userPresenceStatus: false, }; // Per-subscription unread state, accumulated from the @@ -749,6 +824,141 @@ const start = async () => { setupFlags.userLoginDetection = true; } + if ( + Tracker && + Meteor && + presenceModuleResolved !== null && + !setupFlags.userPresenceStatus + ) { + // Presence and the custom status message both come from the webapp's + // presence store, which is fed by the `stream-user-presence` DDP stream. + // `Presence.listen` is an Emitter subscription, NOT a Tracker reactive + // dependency, so it cannot live inside the autorun below — it is + // subscribed once per uid outside it. Only `Meteor.userId()` and + // `Meteor.status()` are genuinely reactive. + const presenceSupported = presenceModuleResolved === true; + + let currentUid: string | null = null; + let latestStoreEntry: PresenceStoreEntry | null = null; + let latestConnectionStatus: string | undefined; + + const pushPresence = (): void => { + window.RocketChatDesktop.setUserPresence( + buildPresenceSnapshot({ + storeEntry: latestStoreEntry, + connectionStatus: latestConnectionStatus, + supported: presenceSupported, + }) + ); + }; + + const handlePresenceUpdate = (entry?: PresenceStoreEntry): void => { + latestStoreEntry = entry ?? null; + pushPresence(); + }; + + // `listen` only fires on subsequent stream updates, so the current value + // is seeded from the store; `stop` on uid change keeps handlers from + // accumulating across account switches. + const subscribeToPresence = (uid: string | null): void => { + if (uid === currentUid) return; + + if (currentUid && Presence) { + try { + Presence.stop(currentUid, handlePresenceUpdate); + } catch (error) { + console.warn( + '[Rocket.Chat Desktop] Failed to unsubscribe from presence updates', + error + ); + } + } + + currentUid = uid; + latestStoreEntry = null; + + if (!uid || !Presence) return; + + try { + Presence.listen(uid, handlePresenceUpdate); + + // The store is populated lazily: it stays empty until something + // asks for a uid, so reading `store.get` here returns nothing on a + // fresh session and `listen` alone never backfills it. `get` + // resolves the current presence and registers the subscription that + // keeps it up to date, so it is what seeds the first value. + Promise.resolve(Presence.get(uid)) + .then((entry: PresenceStoreEntry | undefined) => { + if (currentUid !== uid) return; + handlePresenceUpdate(entry ?? Presence.store?.get(uid)); + }) + .catch((error: unknown) => { + console.warn( + '[Rocket.Chat Desktop] Failed to read the initial presence', + error + ); + }); + } catch (error) { + console.warn( + '[Rocket.Chat Desktop] Failed to subscribe to presence updates', + error + ); + } + }; + + Tracker.autorun(() => { + const uid: string | null = Meteor.userId(); + const conn = Meteor.status(); + + latestConnectionStatus = conn?.status; + + subscribeToPresence(uid); + pushPresence(); + }); + + const SET_USER_STATUS_MIN_INTERVAL_MS = 1000; + + const presenceRateLimiter = createPresenceRateLimiter({ + minIntervalMs: SET_USER_STATUS_MIN_INTERVAL_MS, + onDeferred: () => { + console.warn( + '[Rocket.Chat Desktop] setUserStatus request deferred: rate limit (1/s) not yet elapsed, will send latest status once elapsed' + ); + }, + send: ({ status, statusText }) => { + if (!Meteor.call) return; + + Meteor.call('setUserStatus', status, statusText, (err: any) => { + if (!err) return; + + if ( + err.error === 'error-not-allowed' || + err.error === 'error-status-not-allowed' + ) { + console.warn( + `[Rocket.Chat Desktop] setUserStatus rejected: ${err.error}` + ); + return; + } + + console.error('[Rocket.Chat Desktop] setUserStatus failed', err); + }); + }, + }); + + // No local refresh needed after a change request: the server broadcasts + // the new presence (and statusText) back over `stream-user-presence`, + // which reaches `handlePresenceUpdate` on its own — including changes + // made on another device. + window.RocketChatDesktop.onPresenceChangeRequested( + (status, statusText) => { + presenceRateLimiter.request({ status, statusText }); + } + ); + + setupFlags.userPresenceStatus = true; + } + if (Tracker && Meteor && !setupFlags.gitCommitHash) { Tracker.autorun(() => { const { gitCommitHash } = Meteor; diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index 0d5cd66c59..52477f6b49 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -14,6 +14,8 @@ type ChannelToArgsMap = { 'power-monitor/get-system-idle-state': ( idleThreshold: number ) => SystemIdleState; + 'downloads/open-file': (itemId: Download['itemId']) => void; + 'downloads/preview-file': (itemId: Download['itemId']) => void; 'downloads/show-in-folder': (itemId: Download['itemId']) => void; 'downloads/copy-link': (itemId: Download['itemId']) => void; 'downloads/pause': (itemId: Download['itemId']) => void; @@ -121,6 +123,10 @@ type ChannelToArgsMap = { lastModifiedTime?: number; error?: string; }; + 'log-viewer-window/reveal-log-file': (options?: { filePath?: string }) => { + success: boolean; + error?: string; + }; 'log-viewer-window/confirm-clear-logs': () => boolean; 'log-viewer-window/clear-logs': () => { success: boolean; error?: string }; 'log-viewer-window/save-logs': (options: { @@ -136,11 +142,32 @@ type ChannelToArgsMap = { success: boolean; mapping: Record; }; + 'settings-window/open-window': () => void; + 'settings-window/close-requested': () => void; + 'settings-window/confirm-remove-certificate': (domain: string) => boolean; + 'downloads-window/open-window': () => void; + 'downloads-window/close-requested': () => void; + 'downloads-window/confirm-clear-all': () => boolean; + 'document-viewer-window/close-requested': () => void; + 'document-viewer-window/save-document': (request: { + url: string; + partition: string; + server: string; + format: string; + }) => { success: boolean; canceled?: boolean; error?: string }; + 'secondary-window/minimize': () => void; + 'secondary-window/toggle-maximize': () => void; + 'secondary-window/close': () => void; + 'secondary-window/is-maximized': () => boolean; 'screen-picker/open': () => void; 'screen-picker/source-responded': (sourceId: string | null) => void; 'screen-picker/screen-recording-is-permission-granted': () => boolean; 'screen-picker/open-url': (url: string) => void; 'telephony/get-diagnostics': () => TelephonyDiagnostics; + 'presence/change-requested': ( + status: NonNullable, + statusText?: string + ) => void; }; export type Channel = keyof ChannelToArgsMap; diff --git a/src/logViewerWindow/LogEntry.tsx b/src/logViewerWindow/LogEntry.tsx index 4b26b83562..7e3fa4be6d 100644 --- a/src/logViewerWindow/LogEntry.tsx +++ b/src/logViewerWindow/LogEntry.tsx @@ -1,72 +1,76 @@ -import { Box, Badge, Chip } from '@rocket.chat/fuselage'; +import { Badge, Box, IconButton, Tag } from '@rocket.chat/fuselage'; +import type { MouseEvent, ReactNode } from 'react'; import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; -import { type LogLevel, type LogEntryType } from './types'; +import type { Surfaces } from '../ui/windowChrome/appearance'; +import { useCopiedFeedback } from '../ui/windowChrome/useCopiedFeedback'; +import { LEVEL_ACCENT, LEVEL_BADGE_VARIANT } from './appearance'; +import { getEntryTime } from './parseLogs'; +import { LOG_MARK_CLASS, LOG_ROW_ACTIONS_CLASS, LOG_ROW_CLASS } from './styles'; +import type { LogEntryType } from './types'; /** - * Find the server hostname tag in a context string by matching against - * known hostnames from the server mapping. + * Tags sit on their own line above the message and wrap, so the only cap needed + * is one that stops a single very long server title from filling the line. */ -const findServerTag = ( - context: string, - serverMapping: Record -): string => { - const contextTags = context.split(/\s+/); - const hostnames = Object.keys(serverMapping); - return contextTags.find((tag) => hostnames.includes(tag)) || ''; -}; +const TRUNCATED_TAG_STYLE = { + display: 'block', + maxWidth: '360px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} as const; -const getLevelColor = ( - level: LogLevel -): 'danger' | 'warning' | 'primary' | 'secondary' | 'ghost' => { - switch (level) { - case 'error': - return 'danger'; - case 'warn': - return 'warning'; - case 'info': - return 'primary'; - case 'debug': - return 'secondary'; - case 'verbose': - return 'ghost'; - default: - return 'ghost'; +/** Wraps every case-insensitive occurrence of `term` in a . */ +const highlightMatches = (text: string, term: string): ReactNode => { + if (!term) { + return text; } -}; -const getLevelTextColor = (level: LogLevel): string => { - switch (level) { - case 'error': - return 'danger'; - case 'warn': - return 'default'; - case 'info': - return 'info'; - case 'debug': - return 'default'; - case 'verbose': - return 'default'; - default: - return 'default'; + const haystack = text.toLowerCase(); + const needle = term.toLowerCase(); + const parts: ReactNode[] = []; + let cursor = 0; + let match = haystack.indexOf(needle); + + while (match !== -1) { + if (match > cursor) { + parts.push(text.slice(cursor, match)); + } + parts.push( + + {text.slice(match, match + needle.length)} + + ); + cursor = match + needle.length; + match = haystack.indexOf(needle, cursor); + } + + if (parts.length === 0) { + return text; } -}; -const getLevelBorderColor = (level: LogLevel): string => { - switch (level) { - case 'error': - return 'var(--rcx-color-stroke-highlight)'; - case 'warn': - return 'var(--rcx-color-stroke-highlight)'; - case 'info': - return 'var(--rcx-color-stroke-highlight)'; - case 'debug': - return 'var(--rcx-color-stroke-light)'; - case 'verbose': - return 'var(--rcx-color-stroke-light)'; - default: - return 'var(--rcx-color-stroke-light)'; + if (cursor < text.length) { + parts.push(text.slice(cursor)); } + + return parts; +}; + +export type LogEntryProps = { + entry: LogEntryType; + showContext: boolean; + showServer: boolean; + serverMapping: Record; + searchTerm: string; + wrapLines: boolean; + /** Whether multi-line entries fold at all — the "Collapse multi-line" toggle. */ + collapseEnabled: boolean; + isExpanded: boolean; + onToggleExpanded: (id: string) => void; + onCopy: (entry: LogEntryType) => void; + surfaces: Surfaces; }; export const LogEntry = ({ @@ -74,75 +78,166 @@ export const LogEntry = ({ showContext, showServer, serverMapping, -}: { - entry: LogEntryType; - showContext: boolean; - showServer: boolean; - serverMapping: Record; -}) => { - const { serverTag, serverDisplayName, contextWithoutServer } = useMemo(() => { - const tag = findServerTag(entry.context, serverMapping); - const displayName = tag ? serverMapping[tag] || tag : ''; - const ctxWithout = tag - ? entry.context - .split(/\s+/) - .filter((token) => token !== tag) - .join(' ') - .trim() - : entry.context; + searchTerm, + wrapLines, + collapseEnabled, + isExpanded, + onToggleExpanded, + onCopy, + surfaces, +}: LogEntryProps) => { + const { t } = useTranslation(); + + /** + * The first non-server tag is the process (`main`, `renderer:webview`) and + * gets a fixed column, so the message always starts at the same offset. The + * remaining tags trail the message instead of shoving it rightwards. + */ + const { serverName, processTag, trailingTags } = useMemo(() => { + const serverTag = entry.contextTags.find((tag) => tag in serverMapping); + const contextTags = entry.contextTags.filter((tag) => tag !== serverTag); + const [process, ...rest] = contextTags; return { - serverTag: tag, - serverDisplayName: displayName, - contextWithoutServer: ctxWithout, + serverName: serverTag ? serverMapping[serverTag] || serverTag : '', + processTag: process ?? '', + trailingTags: rest, }; - }, [entry.context, serverMapping]); + }, [entry.contextTags, serverMapping]); + + const hasTags = + (showContext && (processTag !== '' || trailingTags.length > 0)) || + (showServer && serverName !== ''); + + const lines = useMemo(() => entry.message.split('\n'), [entry.message]); + const hiddenLineCount = lines.length - 1; + const isFoldable = collapseEnabled && hiddenLineCount > 0; + const isFolded = isFoldable && !isExpanded; + const visibleMessage = isFolded ? lines[0] : entry.message; + + const [hasCopied, acknowledgeCopy] = useCopiedFeedback(); + + const handleCopy = (event: MouseEvent): void => { + event.stopPropagation(); + onCopy(entry); + acknowledgeCopy(); + }; return ( - {entry.timestamp} + {getEntryTime(entry.timestamp)} - - + + {entry.level.toUpperCase()} - {showServer && serverTag && ( - - {serverDisplayName} - - )} - {showContext && contextWithoutServer && ( - - {contextWithoutServer} + + {hasTags && ( + + {showContext && processTag && ( + + {processTag} + + )} + {showServer && serverName && ( + + {serverName} + + )} + {showContext && + trailingTags.map((tag) => ( + + {tag} + + ))} + + )} + + {highlightMatches(visibleMessage, searchTerm)} - )} + {isFoldable && ( + onToggleExpanded(entry.id)} + > + {isFolded + ? t('logViewer.entry.expand', { count: hiddenLineCount }) + : t('logViewer.entry.collapse')} + + )} + - {entry.message} + ); diff --git a/src/logViewerWindow/LogStatusBar.tsx b/src/logViewerWindow/LogStatusBar.tsx new file mode 100644 index 0000000000..c76c2ad13c --- /dev/null +++ b/src/logViewerWindow/LogStatusBar.tsx @@ -0,0 +1,72 @@ +import { Box, StatusBullet } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import { StatusBar } from '../ui/windowChrome/StatusBar'; +import { StatusItem } from '../ui/windowChrome/StatusItem'; +import { TextButton } from '../ui/windowChrome/TextButton'; + +export type LogStatusBarProps = { + shownCount: number; + loadedCount: number; + fileSize?: string; + dateRange?: string; + filePath?: string; + isStreaming: boolean; + isLoading: boolean; + canClear: boolean; + onClearLogs: () => void; +}; + +export const LogStatusBar = ({ + shownCount, + loadedCount, + fileSize, + dateRange, + filePath, + isStreaming, + isLoading, + canClear, + onClearLogs, +}: LogStatusBarProps) => { + const { t } = useTranslation(); + + return ( + + {t('logViewer.buttons.clear')} + + ) + } + > + + {shownCount === loadedCount + ? t('logViewer.fileInfo.entries', { count: shownCount }) + : t('logViewer.fileInfo.entriesOfTotal', { + count: shownCount, + total: loadedCount, + })} + + {fileSize && {fileSize}} + {dateRange && {dateRange}} + {isLoading && ( + {t('logViewer.status.loading')} + )} + {isStreaming && ( + + + + + {t('logViewer.status.live')} + + )} + + ); +}; diff --git a/src/logViewerWindow/LogTimeline.tsx b/src/logViewerWindow/LogTimeline.tsx new file mode 100644 index 0000000000..e92aaf596e --- /dev/null +++ b/src/logViewerWindow/LogTimeline.tsx @@ -0,0 +1,323 @@ +import { Box, IconButton } from '@rocket.chat/fuselage'; +import type { + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, +} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { Surfaces } from '../ui/windowChrome/appearance'; +import { TimelineBar } from './TimelineBar'; +import { TIMELINE_BUCKET_COUNT, TIMELINE_PLOT_HEIGHT } from './constants'; +import { TIMELINE_PLOT_CLASS, TIMELINE_SELECTION_CLASS } from './styles'; +import type { TimeRange, TimelineBucket } from './timeline'; +import { buildTimeline, resolveRange } from './timeline'; +import type { LogEntryType } from './types'; + +const formatTime = (time: number): string => + new Date(time).toLocaleTimeString(); + +export type LogTimelineProps = { + /** + * Entries matching every filter *except* the time range. Excluding the range + * is what lets the chart keep showing the whole span while a slice of it is + * selected — filtering it too would collapse the chart onto the selection and + * leave no way back. + */ + entries: LogEntryType[]; + surfaces: Surfaces; + selectedRange: TimeRange | null; + onSelectRange: (range: TimeRange | null) => void; +}; + +/** + * Distribution of matching entries over the file's time span, oldest on the + * left. Dragging across the plot selects a time range; a plain click selects the + * single slice under the pointer. + */ +export const LogTimeline = ({ + entries, + surfaces, + selectedRange, + onSelectRange, +}: LogTimelineProps) => { + const { t } = useTranslation(); + const plotRef = useRef(null); + + const timeline = useMemo( + () => buildTimeline(entries, TIMELINE_BUCKET_COUNT), + [entries] + ); + + /** Range being dragged right now, drawn on top of any committed selection. */ + const [dragRange, setDragRange] = useState(null); + + // Handlers are bound imperatively on mousedown and read the live buckets from + // a ref, so they never need rebinding and never capture a stale span. + const bucketsRef = useRef(timeline.buckets); + useEffect(() => { + bucketsRef.current = timeline.buckets; + }, [timeline.buckets]); + + const releaseDragRef = useRef<(() => void) | null>(null); + useEffect(() => () => releaseDragRef.current?.(), []); + + /** + * Anchor bucket for Shift+Arrow range extension. Reset whenever the + * selection is replaced by something the keyboard didn't just build, so a + * fresh mouse drag or an external change doesn't extend from a stale anchor. + */ + const keyAnchorRef = useRef(null); + + /** + * Bucket whose startTime a given time belongs to. Bucket boundaries are + * shared (one bucket's endTime is the next one's startTime), so this has to + * favour the later bucket on an exact boundary hit — matching how + * `buildTimeline` slots entries — or the anchor of a range ending exactly on + * a boundary would resolve one bucket too early. + */ + const bucketIndexAtStart = useCallback((time: number): number => { + const buckets = bucketsRef.current; + for (let index = buckets.length - 1; index >= 0; index -= 1) { + if (time >= buckets[index].startTime) return index; + } + return 0; + }, []); + + /** Bucket whose endTime a given time belongs to, favouring the earlier bucket. */ + const bucketIndexAtEnd = useCallback((time: number): number => { + const buckets = bucketsRef.current; + for (let index = 0; index < buckets.length; index += 1) { + if (time <= buckets[index].endTime) return index; + } + return Math.max(0, buckets.length - 1); + }, []); + + const bucketFromClientX = useCallback((clientX: number): number => { + const rect = plotRef.current?.getBoundingClientRect(); + const count = bucketsRef.current.length; + if (!rect || rect.width === 0 || count === 0) return 0; + const ratio = (clientX - rect.left) / rect.width; + return Math.min(count - 1, Math.max(0, Math.floor(ratio * count))); + }, []); + + /** + * Binding move/up listeners here rather than in an effect keyed on drag state + * matters: an effect only runs after the next render, so a drag fast enough to + * finish within the same task would lose its own mouseup and stay stuck. + */ + const handleMouseDown = useCallback( + (event: ReactMouseEvent) => { + if (event.button !== 0 || bucketsRef.current.length === 0) return; + event.preventDefault(); + keyAnchorRef.current = null; + + const anchor = bucketFromClientX(event.clientX); + setDragRange(resolveRange(bucketsRef.current, anchor, anchor)); + + const release = (): void => { + window.removeEventListener('mousemove', handleMove); + window.removeEventListener('mouseup', handleUp); + window.removeEventListener('keydown', handleCancel); + releaseDragRef.current = null; + }; + + function handleMove(moveEvent: MouseEvent): void { + const cursor = bucketFromClientX(moveEvent.clientX); + setDragRange(resolveRange(bucketsRef.current, anchor, cursor)); + } + + function handleUp(upEvent: MouseEvent): void { + release(); + setDragRange(null); + const cursor = bucketFromClientX(upEvent.clientX); + const range = resolveRange(bucketsRef.current, anchor, cursor); + if (range) onSelectRange(range); + } + + function handleCancel(keyEvent: KeyboardEvent): void { + if (keyEvent.key !== 'Escape') return; + release(); + setDragRange(null); + } + + window.addEventListener('mousemove', handleMove); + window.addEventListener('mouseup', handleUp); + window.addEventListener('keydown', handleCancel); + releaseDragRef.current = release; + }, + [bucketFromClientX, onSelectRange] + ); + + const handleClearRange = useCallback(() => { + keyAnchorRef.current = null; + onSelectRange(null); + }, [onSelectRange]); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const buckets = bucketsRef.current; + const lastIndex = buckets.length - 1; + if (lastIndex < 0) return; + + if (event.key === 'Escape') { + keyAnchorRef.current = null; + onSelectRange(null); + return; + } + + const activeIndex = + selectedRange !== null + ? bucketIndexAtEnd(selectedRange.endTime) + : lastIndex; + const anchorIndex = + selectedRange !== null + ? keyAnchorRef.current ?? bucketIndexAtStart(selectedRange.startTime) + : activeIndex; + + let nextCursor = activeIndex; + switch (event.key) { + case 'ArrowLeft': + nextCursor = Math.max(0, activeIndex - 1); + break; + case 'ArrowRight': + nextCursor = Math.min(lastIndex, activeIndex + 1); + break; + case 'Home': + nextCursor = 0; + break; + case 'End': + nextCursor = lastIndex; + break; + default: + return; + } + + event.preventDefault(); + + const nextAnchor = event.shiftKey ? anchorIndex : nextCursor; + keyAnchorRef.current = nextAnchor; + + const range = resolveRange(buckets, nextAnchor, nextCursor); + if (range) onSelectRange(range); + }, + [bucketIndexAtEnd, bucketIndexAtStart, onSelectRange, selectedRange] + ); + + if (timeline.buckets.length === 0) { + return null; + } + + const span = timeline.endTime - timeline.startTime || 1; + const toPercent = (time: number): number => + Math.min(100, Math.max(0, ((time - timeline.startTime) / span) * 100)); + + const highlight = dragRange ?? selectedRange; + const highlightLeft = highlight ? toPercent(highlight.startTime) : 0; + const highlightWidth = highlight + ? Math.max(0.6, toPercent(highlight.endTime) - highlightLeft) + : 0; + + const activeBucketIndex = highlight + ? bucketIndexAtEnd(highlight.endTime) + : timeline.buckets.length - 1; + + return ( + + + {timeline.buckets.map((bucket) => ( + = highlight.endTime) + } + title={`${formatTime(bucket.startTime)} – ${formatTime( + bucket.endTime + )} · ${t('logViewer.fileInfo.entries', { count: bucket.total })}`} + /> + ))} + {highlight && ( + + )} + + + + {formatTime(timeline.startTime)} + {selectedRange ? ( + + + {formatTime(selectedRange.startTime)} –{' '} + {formatTime(selectedRange.endTime)} + + + + + + ) : ( + {t('logViewer.timeline.peak', { count: timeline.peak })} + )} + {formatTime(timeline.endTime)} + + + ); +}; diff --git a/src/logViewerWindow/LogViewerSidebar.tsx b/src/logViewerWindow/LogViewerSidebar.tsx new file mode 100644 index 0000000000..b0bdc304e1 --- /dev/null +++ b/src/logViewerWindow/LogViewerSidebar.tsx @@ -0,0 +1,251 @@ +import { + Box, + Icon, + Scrollable, + SearchInput, + ToggleSwitch, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent } from 'react'; +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FilterRow } from '../ui/windowChrome/FilterRow'; +import { FilterSection } from '../ui/windowChrome/FilterSection'; +import { SIDEBAR_WIDTH } from '../ui/windowChrome/appearance'; +import type { FacetSelection } from '../ui/windowChrome/filters'; +import { isFacetNarrowed, isFacetSelected } from '../ui/windowChrome/filters'; +import { SEARCH_FIELD_CLASS } from '../ui/windowChrome/styles'; +import { useFindShortcut } from '../ui/windowChrome/useFindShortcut'; +import { LEVEL_ACCENT, LOG_LEVELS } from './appearance'; +import type { LogLevel } from './types'; + +export type LogViewerSidebarProps = { + searchFilter: string; + onSearchFilterChange: (event: ChangeEvent) => void; + levelFilters: FacetSelection; + /** Levels present in the loaded file — the only ones worth offering. */ + availableLevels: LogLevel[]; + levelCounts: Record; + onToggleLevel: (level: LogLevel) => void; + contextOptions: string[]; + contextLabels: Record; + contextFilters: FacetSelection; + contextCounts: Record; + onToggleContext: (context: string) => void; + serverOptions: string[]; + serverLabels: Record; + serverFilters: FacetSelection; + serverCounts: Record; + onToggleServer: (server: string) => void; + showContext: boolean; + onToggleShowContext: () => void; + showServer: boolean; + onToggleShowServer: () => void; + wrapLines: boolean; + onToggleWrapLines: () => void; + collapseMultiline: boolean; + onToggleCollapseMultiline: () => void; + autoScroll: boolean; + onToggleAutoScroll: () => void; + showTimeline: boolean; + onToggleShowTimeline: () => void; + onSelectAllLevels: () => void; + onSelectAllContexts: () => void; + onSelectAllServers: () => void; +}; + +export const LogViewerSidebar = ({ + searchFilter, + onSearchFilterChange, + levelFilters, + availableLevels, + levelCounts, + onToggleLevel, + contextOptions, + contextLabels, + contextFilters, + contextCounts, + onToggleContext, + serverOptions, + serverLabels, + serverFilters, + serverCounts, + onToggleServer, + showContext, + onToggleShowContext, + showServer, + onToggleShowServer, + wrapLines, + onToggleWrapLines, + collapseMultiline, + onToggleCollapseMultiline, + autoScroll, + onToggleAutoScroll, + showTimeline, + onToggleShowTimeline, + onSelectAllLevels, + onSelectAllContexts, + onSelectAllServers, +}: LogViewerSidebarProps) => { + const { t } = useTranslation(); + const searchFieldRef = useRef(null); + useFindShortcut(searchFieldRef); + const selectAllLabel = t('logViewer.filters.selectAll'); + + return ( + + + } + aria-label={t('logViewer.placeholders.searchLogs')} + placeholder={t('logViewer.placeholders.searchLogs')} + value={searchFilter} + onChange={onSearchFilterChange} + /> + + + + + + + {(availableLevels.length > 0 ? availableLevels : LOG_LEVELS).map( + (level) => ( + onToggleLevel(level)} + /> + ) + )} + + + {contextOptions.length > 0 && ( + + {contextOptions.map((context) => ( + onToggleContext(context)} + /> + ))} + + )} + + {serverOptions.length > 0 && ( + + {serverOptions.map((server) => ( + onToggleServer(server)} + /> + ))} + + )} + + + {( + [ + [ + t('logViewer.controls.showContext'), + showContext, + onToggleShowContext, + ], + [ + t('logViewer.controls.showServer'), + showServer, + onToggleShowServer, + ], + [ + t('logViewer.controls.wrapLines'), + wrapLines, + onToggleWrapLines, + ], + [ + t('logViewer.controls.collapseMultiline'), + collapseMultiline, + onToggleCollapseMultiline, + ], + [ + t('logViewer.controls.autoScrollToTop'), + autoScroll, + onToggleAutoScroll, + ], + [ + t('logViewer.controls.showTimeline'), + showTimeline, + onToggleShowTimeline, + ], + ] as const + ).map(([label, checked, onToggle]) => ( + + + {label} + + + + ))} + + + + + + ); +}; diff --git a/src/logViewerWindow/LogViewerToolbar.tsx b/src/logViewerWindow/LogViewerToolbar.tsx new file mode 100644 index 0000000000..ebc2f84eac --- /dev/null +++ b/src/logViewerWindow/LogViewerToolbar.tsx @@ -0,0 +1,151 @@ +import { Box, ButtonGroup, Icon, IconButton, Tag } from '@rocket.chat/fuselage'; +import { useTranslation } from 'react-i18next'; + +import { WindowToolbar } from '../ui/windowChrome/WindowToolbar'; +import { NO_DRAG_REGION_CLASS } from '../ui/windowChrome/styles'; +import { useCopiedFeedback } from '../ui/windowChrome/useCopiedFeedback'; + +export type LogViewerToolbarProps = { + fileName: string; + filePath?: string; + isDefaultLog: boolean; + isLoading: boolean; + isStreaming: boolean; + onOpenLogFile: () => void; + onOpenDefaultLog: () => void; + onRevealLogFile: () => void; + onRefresh: () => void; + onToggleStreaming: () => void; + onCopy: () => void; + onSave: () => void; + hasSaved: boolean; +}; + +export const LogViewerToolbar = ({ + fileName, + filePath, + isDefaultLog, + isLoading, + isStreaming, + onOpenLogFile, + onOpenDefaultLog, + onRevealLogFile, + onRefresh, + onToggleStreaming, + onCopy, + onSave, + hasSaved, +}: LogViewerToolbarProps) => { + const { t } = useTranslation(); + const [hasCopied, acknowledgeCopy] = useCopiedFeedback(); + + return ( + + + + {!isDefaultLog && ( + + )} + + + { + onCopy(); + acknowledgeCopy(); + }} + /> + + + } + > + + + {fileName} + + {!isDefaultLog && ( + + {t('logViewer.fileInfo.custom')} + + )} + + ); +}; diff --git a/src/logViewerWindow/TimelineBar.tsx b/src/logViewerWindow/TimelineBar.tsx new file mode 100644 index 0000000000..0322c2a8e1 --- /dev/null +++ b/src/logViewerWindow/TimelineBar.tsx @@ -0,0 +1,72 @@ +import { Box } from '@rocket.chat/fuselage'; + +import { LEVEL_ACCENT, LOG_LEVELS } from './appearance'; +import { TIMELINE_PLOT_HEIGHT } from './constants'; +import type { TimelineBucket } from './timeline'; +import { getBarHeightPercent } from './timeline'; + +export type TimelineBarProps = { + bucket: TimelineBucket; + peak: number; + title: string; + isDimmed: boolean; +}; + +/** + * One time slice, stacked by level so a burst of errors stays visible inside an + * otherwise busy period. + * + * Bars tile the plot exactly — the 1px gutter is padding inside each bar rather + * than a flex gap — so the plot's width maps linearly onto bucket indices and + * drag selection can resolve a pointer position without measuring each bar. + */ +export const TimelineBar = ({ + bucket, + peak, + title, + isDimmed, +}: TimelineBarProps) => { + const heightPercent = getBarHeightPercent(bucket.total, peak); + + return ( + + + {LOG_LEVELS.map((level) => { + const count = bucket.countsByLevel[level] ?? 0; + if (count === 0) return null; + return ( + + ); + })} + + + ); +}; diff --git a/src/logViewerWindow/__tests__/LogEntry.spec.tsx b/src/logViewerWindow/__tests__/LogEntry.spec.tsx new file mode 100644 index 0000000000..a8731c0907 --- /dev/null +++ b/src/logViewerWindow/__tests__/LogEntry.spec.tsx @@ -0,0 +1,90 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; + +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { LogEntry } from '../LogEntry'; +import type { LogEntryType } from '../types'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const surfaces: Surfaces = { + panel: '#fff', + card: '#fff', + sticky: '#fff', + field: '#eee', + hover: '#ddd', + selected: '#ccc', + divider: '#000', +}; + +const baseEntry = (overrides: Partial = {}): LogEntryType => ({ + id: '1', + timestamp: '2026-01-01T00:00:00.000Z', + level: 'info', + message: 'hello world', + contextTags: ['main', 'open.rocket.chat'], + context: 'main open.rocket.chat', + raw: 'raw line', + searchText: 'hello world main open.rocket.chat', + rawLower: 'raw line', + ...overrides, +}); + +const renderEntry = ( + entry: LogEntryType, + overrides: Partial> = {} +) => + render( + + ); + +describe('LogEntry', () => { + it('renders message and level', () => { + renderEntry(baseEntry()); + expect(screen.getByText('hello world')).toBeInTheDocument(); + expect(screen.getByText(/info/i)).toBeInTheDocument(); + }); + + it('renders context when enabled', () => { + renderEntry( + baseEntry({ contextTags: ['main', 'app'], context: 'main app' }), + { + showContext: true, + } + ); + expect(screen.getByText(/main/)).toBeInTheDocument(); + }); + + it('renders server chip when mapping matches context tag', () => { + renderEntry(baseEntry(), { + showContext: true, + showServer: true, + serverMapping: { 'open.rocket.chat': 'Community' }, + }); + expect(screen.getByText('Community')).toBeInTheDocument(); + }); + + it.each(['error', 'warn', 'info', 'debug', 'verbose'] as const)( + 'renders %s level badge', + (level) => { + renderEntry(baseEntry({ level })); + expect(screen.getByText(new RegExp(level, 'i'))).toBeInTheDocument(); + } + ); +}); diff --git a/src/logViewerWindow/__tests__/LogTimeline.spec.tsx b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx new file mode 100644 index 0000000000..7badc20e29 --- /dev/null +++ b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx @@ -0,0 +1,112 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { LogTimeline } from '../LogTimeline'; +import { buildTimeline, resolveRange } from '../timeline'; +import type { TimeRange } from '../timeline'; +import type { LogEntryType } from '../types'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const surfaces = { + card: '#000', + divider: '#000', +} as unknown as Surfaces; + +const makeEntry = (offsetMs: number): LogEntryType => ({ + id: `entry-${offsetMs}`, + timestamp: new Date(offsetMs).toISOString(), + level: 'info', + contextTags: [], + context: '', + message: 'message', + raw: 'raw', + searchText: 'message ', + rawLower: 'raw', +}); + +const entries: LogEntryType[] = Array.from({ length: 20 }, (_unused, index) => + makeEntry(index * 1000) +); + +const renderTimeline = (selectedRange: TimeRange | null = null) => { + const onSelectRange = jest.fn(); + render( + + ); + return { onSelectRange }; +}; + +describe('LogTimeline keyboard interaction', () => { + it('exposes range slider semantics', () => { + renderTimeline(); + const slider = screen.getByRole('slider'); + expect(slider).toHaveAttribute('aria-valuemin', '0'); + expect(slider).toHaveAttribute('aria-valuemax'); + expect(slider).toHaveAttribute('aria-valuenow'); + expect(slider).toHaveAttribute('tabindex', '0'); + }); + + it('selects a single bucket on ArrowLeft from the default (last bucket) position', () => { + const { onSelectRange } = renderTimeline(); + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowLeft' }); + + expect(onSelectRange).toHaveBeenCalledTimes(1); + const range = onSelectRange.mock.calls[0][0] as TimeRange; + expect(range.startTime).toBeLessThan(range.endTime); + }); + + it('extends the range with Shift+ArrowLeft from an existing selection', () => { + const { buckets } = buildTimeline(entries, 96); + const anchorRange = resolveRange(buckets, 10, 10) as TimeRange; + const { onSelectRange } = renderTimeline(anchorRange); + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'ArrowLeft', shiftKey: true }); + + expect(onSelectRange).toHaveBeenCalledTimes(1); + const range = onSelectRange.mock.calls[0][0] as TimeRange; + expect(range.startTime).toBeLessThanOrEqual(anchorRange.startTime); + expect(range.endTime).toBe(anchorRange.endTime); + }); + + it('jumps to the first bucket on Home and the last bucket on End', () => { + const { onSelectRange } = renderTimeline(); + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'Home' }); + const homeRange = onSelectRange.mock.calls[0][0] as TimeRange; + expect(homeRange.startTime).toBeLessThanOrEqual( + new Date(entries[0].timestamp).getTime() + ); + + fireEvent.keyDown(slider, { key: 'End' }); + const endRange = onSelectRange.mock.calls[1][0] as TimeRange; + expect(endRange.endTime).toBeGreaterThanOrEqual( + new Date(entries[entries.length - 1].timestamp).getTime() + ); + }); + + it('clears the selection on Escape', () => { + const anchorRange: TimeRange = { + startTime: new Date(entries[5].timestamp).getTime(), + endTime: new Date(entries[8].timestamp).getTime(), + }; + const { onSelectRange } = renderTimeline(anchorRange); + const slider = screen.getByRole('slider'); + + fireEvent.keyDown(slider, { key: 'Escape' }); + + expect(onSelectRange).toHaveBeenCalledWith(null); + }); +}); diff --git a/src/logViewerWindow/__tests__/displayControls.spec.tsx b/src/logViewerWindow/__tests__/displayControls.spec.tsx new file mode 100644 index 0000000000..dbff4261f1 --- /dev/null +++ b/src/logViewerWindow/__tests__/displayControls.spec.tsx @@ -0,0 +1,126 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { ChangeEvent } from 'react'; + +import type { FacetSelection } from '../../ui/windowChrome/filters'; +import { LogStatusBar } from '../LogStatusBar'; +import { LogViewerSidebar } from '../LogViewerSidebar'; +import { LEVEL_ACCENT } from '../appearance'; +import type { LogLevel } from '../types'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const sidebarProps = { + searchFilter: '', + onSearchFilterChange: (_event: ChangeEvent) => undefined, + levelFilters: null as FacetSelection, + availableLevels: [] as LogLevel[], + levelCounts: {}, + onToggleLevel: () => undefined, + contextOptions: [] as string[], + contextLabels: {}, + contextFilters: null as FacetSelection, + contextCounts: {}, + onToggleContext: () => undefined, + serverOptions: [] as string[], + serverLabels: {}, + serverFilters: null as FacetSelection, + serverCounts: {}, + onToggleServer: () => undefined, + showContext: true, + onToggleShowContext: () => undefined, + showServer: true, + onToggleShowServer: () => undefined, + wrapLines: false, + onToggleWrapLines: () => undefined, + collapseMultiline: true, + onToggleCollapseMultiline: () => undefined, + autoScroll: false, + onToggleAutoScroll: () => undefined, + showTimeline: true, + onToggleShowTimeline: () => undefined, + onSelectAllLevels: () => undefined, + onSelectAllContexts: () => undefined, + onSelectAllServers: () => undefined, +}; + +describe('LEVEL_ACCENT fill tokens', () => { + it('uses status-bullet / badge fills, not status-font-on-* text tokens', () => { + expect(LEVEL_ACCENT.error).toBe('var(--rcx-color-status-bullet-busy)'); + expect(LEVEL_ACCENT.warn).toBe('var(--rcx-color-status-bullet-away)'); + expect(LEVEL_ACCENT.info).toBe('var(--rcx-color-badge-background-level-2)'); + expect(LEVEL_ACCENT.error).not.toMatch(/status-font-on-/); + expect(LEVEL_ACCENT.warn).not.toMatch(/status-font-on-/); + expect(LEVEL_ACCENT.info).not.toMatch(/status-font-on-/); + }); +}); + +describe('LogViewerSidebar display toggles', () => { + it('renders the six display controls as ToggleSwitches and preserves callbacks', () => { + const onToggleShowContext = jest.fn(); + const onToggleWrapLines = jest.fn(); + + render( + + ); + + const showContext = screen.getByRole('checkbox', { + name: 'logViewer.controls.showContext', + }); + const showServer = screen.getByRole('checkbox', { + name: 'logViewer.controls.showServer', + }); + const wrapLines = screen.getByRole('checkbox', { + name: 'logViewer.controls.wrapLines', + }); + const collapse = screen.getByRole('checkbox', { + name: 'logViewer.controls.collapseMultiline', + }); + const autoScroll = screen.getByRole('checkbox', { + name: 'logViewer.controls.autoScrollToTop', + }); + const showTimeline = screen.getByRole('checkbox', { + name: 'logViewer.controls.showTimeline', + }); + + expect(showContext).toBeChecked(); + expect(showServer).toBeChecked(); + expect(wrapLines).not.toBeChecked(); + expect(collapse).toBeChecked(); + expect(autoScroll).not.toBeChecked(); + expect(showTimeline).toBeChecked(); + + fireEvent.click(showContext); + fireEvent.click(wrapLines); + + expect(onToggleShowContext).toHaveBeenCalledTimes(1); + expect(onToggleWrapLines).toHaveBeenCalledTimes(1); + }); +}); + +describe('LogStatusBar live indicator', () => { + it('renders a StatusBullet online mark while streaming', () => { + const { container } = render( + undefined} + /> + ); + + expect( + container.querySelector('.rcx-status-bullet--online') + ).toBeInTheDocument(); + expect(screen.getByText('logViewer.status.live')).toBeInTheDocument(); + }); +}); diff --git a/src/logViewerWindow/__tests__/ipc.spec.ts b/src/logViewerWindow/__tests__/ipc.spec.ts new file mode 100644 index 0000000000..5af4732dc2 --- /dev/null +++ b/src/logViewerWindow/__tests__/ipc.spec.ts @@ -0,0 +1,115 @@ +import { + countLogEntries, + getLastNEntries, + trimBufferToLastNewline, +} from '../ipc'; + +describe('trimBufferToLastNewline', () => { + it('returns nothing consumed when the buffer has no newline', () => { + const buf = Buffer.from('partial line without newline'); + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + expect(bytesConsumed).toBe(0); + expect(consumed.length).toBe(0); + }); + + it('consumes up to and including the last newline mid-buffer', () => { + const buf = Buffer.from('line one\nline two\npartial line three'); + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + + expect(consumed.toString('utf-8')).toBe('line one\nline two\n'); + expect(bytesConsumed).toBe(Buffer.byteLength('line one\nline two\n')); + expect(buf.subarray(bytesConsumed).toString('utf-8')).toBe( + 'partial line three' + ); + }); + + it('consumes the entire buffer when it ends with a newline', () => { + const buf = Buffer.from('line one\nline two\n'); + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + + expect(bytesConsumed).toBe(buf.length); + expect(consumed.equals(buf)).toBe(true); + }); + + it('does not split a multi-byte UTF-8 character across the cut point', () => { + // "café" ends with a 2-byte UTF-8 char (é = 0xC3 0xA9); make sure the + // cut happens strictly after a newline, never inside a multi-byte char. + const completeLine = 'café logged\n'; + const partialMultiByteTail = 'café'; // "café" without trailing newline + const buf = Buffer.concat([ + Buffer.from(completeLine, 'utf-8'), + Buffer.from(partialMultiByteTail, 'utf-8'), + ]); + + const { consumed, bytesConsumed } = trimBufferToLastNewline(buf); + + expect(bytesConsumed).toBe(Buffer.byteLength(completeLine, 'utf-8')); + expect(consumed.toString('utf-8')).toBe(completeLine); + + const remainder = buf.subarray(bytesConsumed); + expect(remainder.toString('utf-8')).toBe(partialMultiByteTail); + }); + + it('returns an empty consumed buffer for an empty input', () => { + const { consumed, bytesConsumed } = trimBufferToLastNewline( + Buffer.alloc(0) + ); + expect(bytesConsumed).toBe(0); + expect(consumed.length).toBe(0); + }); +}); + +describe('getLastNEntries', () => { + const buildLog = (count: number): string => + Array.from( + { length: count }, + (_, i) => + `[2024-01-01 10:00:${String(i).padStart(2, '0')}.000] [info] [main] Entry ${i}` + ).join('\n'); + + it('returns all content and correct total when limit exceeds entry count', () => { + const log = buildLog(3); + const { content, totalEntries } = getLastNEntries(log, 10); + expect(totalEntries).toBe(3); + expect(content).toBe(log); + }); + + it('returns only the last N entries', () => { + const log = buildLog(5); + const { content, totalEntries } = getLastNEntries(log, 2); + expect(totalEntries).toBe(5); + expect(content.split('\n')).toHaveLength(2); + expect(content).toContain('Entry 3'); + expect(content).toContain('Entry 4'); + expect(content).not.toContain('Entry 2'); + }); + + it('returns empty content and zero entries for a non-positive limit', () => { + const { content, totalEntries } = getLastNEntries(buildLog(3), 0); + expect(content).toBe(''); + expect(totalEntries).toBe(0); + }); + + it('falls back to the last N lines when no entries match the log pattern', () => { + const content = 'plain line 1\nplain line 2\nplain line 3'; + const result = getLastNEntries(content, 2); + expect(result.totalEntries).toBe(0); + expect(result.content).toBe('plain line 2\nplain line 3'); + }); +}); + +describe('countLogEntries', () => { + it('counts entry-start lines and ignores continuation lines', () => { + const content = [ + '[2024-01-01 10:00:00.000] [info] [main] First', + ' continuation line', + '[2024-01-01 10:00:01.000] [warn] [main] Second', + ].join('\n'); + + expect(countLogEntries(content)).toBe(2); + }); + + it('returns 0 for content with no matching entries', () => { + expect(countLogEntries('just some text\nanother line')).toBe(0); + }); +}); diff --git a/src/logViewerWindow/__tests__/logFormatters.spec.ts b/src/logViewerWindow/__tests__/logFormatters.spec.ts new file mode 100644 index 0000000000..48eea93672 --- /dev/null +++ b/src/logViewerWindow/__tests__/logFormatters.spec.ts @@ -0,0 +1,45 @@ +import { formatFileSize, parseLogLines } from '../logFormatters'; + +describe('logFormatters', () => { + describe('formatFileSize', () => { + it('formats bytes, KB and MB', () => { + expect(formatFileSize(0)).toBe('0 B'); + expect(formatFileSize(512)).toBe('512 B'); + expect(formatFileSize(2048)).toBe('2.0 KB'); + expect(formatFileSize(2 * 1024 * 1024)).toBe('2.0 MB'); + }); + }); + + describe('parseLogLines', () => { + it('returns empty array for blank input', () => { + expect(parseLogLines('')).toEqual([]); + expect(parseLogLines(' \n ')).toEqual([]); + }); + + it('parses timestamp, level, context and message', () => { + const logs = [ + '[2026-01-01T00:00:00.000Z] [info] [main] [app] hello', + '[2026-01-01T00:00:01.000Z] [error] boom', + ].join('\n'); + const entries = parseLogLines(logs); + expect(entries).toHaveLength(2); + // reversed so newest first + expect(entries[0].level).toBe('error'); + expect(entries[0].message).toContain('boom'); + expect(entries[1].level).toBe('info'); + expect(entries[1].message).toContain('hello'); + }); + + it('appends continuation lines to the current entry', () => { + const logs = [ + '[2026-01-01T00:00:00.000Z] [info] first line', + 'stack frame 1', + 'stack frame 2', + ].join('\n'); + const [entry] = parseLogLines(logs); + expect(entry.message).toContain('first line'); + expect(entry.message).toContain('stack frame 1'); + expect(entry.message).toContain('stack frame 2'); + }); + }); +}); diff --git a/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx b/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx new file mode 100644 index 0000000000..5b4693fc1e --- /dev/null +++ b/src/logViewerWindow/__tests__/logViewerWindow.spec.tsx @@ -0,0 +1,261 @@ +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// eslint-disable-next-line import/first +import LogViewerWindow from '../logViewerWindow'; + +const invoke = jest.fn(); +const on = jest.fn(); +const removeListener = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: any[]) => invoke(...args), + on: (...args: any[]) => on(...args), + removeListener: (...args: any[]) => removeListener(...args), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { defaultValue?: string }) => + opts?.defaultValue || key, + }), +})); + +jest.mock('@rocket.chat/fuselage-hooks', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + return { + useLocalStorage: (_key: string, initial: any) => React.useState(initial), + useDebouncedValue: (value: any) => value, + useMergedRefs: () => jest.fn(), + useResizeObserver: () => ({ ref: jest.fn(), contentBoxSize: {} }), + useUniqueId: () => 'id', + useEffectEvent: (fn: any) => fn, + useAutoFocus: () => jest.fn(), + }; +}); + +// Light fuselage stubs so Select/SearchInput don't need full hooks +jest.mock('@rocket.chat/fuselage', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const passthrough = + (tag = 'div') => + ({ children, ...props }: any) => + React.createElement(tag, props, children); + + return { + Box: passthrough('div'), + SearchInput: ({ value, onChange, placeholder, ...rest }: any) => + React.createElement('input', { + value, + onChange, + placeholder, + 'aria-label': placeholder || 'search', + ...rest, + }), + Icon: () => null, + Button: ({ children, onClick, ...rest }: any) => + React.createElement( + 'button', + { type: 'button', onClick, ...rest }, + children + ), + ButtonGroup: passthrough('div'), + Select: ({ value, onChange, options = [] }: any) => + React.createElement( + 'select', + { + value, + 'onChange': (e: any) => onChange?.(e.target.value), + 'aria-label': 'select', + }, + options.map(([val, label]: [string, string]) => + React.createElement('option', { key: val, value: val }, label) + ) + ), + Tile: passthrough('div'), + Throbber: () => React.createElement('div', { role: 'progressbar' }), + CheckBox: ({ checked, onChange, ...rest }: any) => + React.createElement('input', { + type: 'checkbox', + checked, + onChange, + ...rest, + }), + Callout: passthrough('div'), + IconButton: ({ onClick, title, ...rest }: any) => + React.createElement('button', { + 'type': 'button', + onClick, + 'aria-label': title, + ...rest, + }), + States: passthrough('div'), + StatesAction: ({ children, onClick, ...rest }: any) => + React.createElement( + 'button', + { type: 'button', onClick, ...rest }, + children + ), + StatesActions: passthrough('div'), + StatesIcon: () => null, + StatesSubtitle: passthrough('div'), + StatesTitle: passthrough('div'), + }; +}); + +jest.mock('react-virtuoso', () => ({ + GroupedVirtuoso: ({ data, itemContent }: any) => ( +

+ {(data || []).map((item: any, index: number) => ( +
{itemContent(index, 0, item)}
+ ))} +
+ ), +})); + +jest.mock('../LogEntry', () => ({ + LogEntry: ({ entry }: any) => ( +
{entry.message}
+ ), +})); + +jest.mock('../LogViewerToolbar', () => ({ + LogViewerToolbar: ({ onRefresh }: { onRefresh: () => void }) => ( + + ), +})); + +jest.mock('../LogViewerSidebar', () => ({ + LogViewerSidebar: ({ + searchFilter, + onSearchFilterChange, + }: { + searchFilter: string; + onSearchFilterChange: (event: { target: { value: string } }) => void; + }) => ( + + ), +})); + +jest.mock('../LogTimeline', () => ({ + LogTimeline: () => null, +})); + +jest.mock('../LogStatusBar', () => ({ + LogStatusBar: () => null, +})); + +jest.mock('../../ui/windowChrome/styles', () => ({ + WindowChromeGlobalStyles: () => null, +})); + +jest.mock('../styles', () => ({ + LogViewerGlobalStyles: () => null, +})); + +jest.mock('../../ui/windowChrome/useTransparency', () => ({ + useTransparency: () => false, +})); + +jest.mock('../../ui/windowChrome/useCopiedFeedback', () => ({ + useCopiedFeedback: () => [false, jest.fn()], +})); + +jest.mock('../../ui/components/utils/TooltipProvider', () => ({ + __esModule: true, + default: ({ children }: { children: unknown }) => children, +})); + +const sampleLog = [ + '[2026-01-01T00:00:00.000Z] [info] first message', + '[2026-01-01T00:01:00.000Z] [error] boom', +].join('\n'); + +describe('LogViewerWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + invoke.mockImplementation(async (channel: string) => { + if (channel === 'log-viewer-window/read-logs') { + return { + success: true, + logs: sampleLog, + filePath: '/tmp/main.log', + fileName: 'main.log', + isDefaultLog: true, + totalEntriesInFile: 2, + lastModifiedTime: Date.now(), + }; + } + if (channel === 'log-viewer-window/get-server-mapping') { + return { success: true, mapping: { 'open.rocket.chat': 'Community' } }; + } + if (channel === 'log-viewer-window/select-log-file') { + return { + success: true, + filePath: '/tmp/other.log', + fileName: 'other.log', + canceled: false, + }; + } + if (channel === 'log-viewer-window/save-logs') { + return { success: true }; + } + if (channel === 'log-viewer-window/clear-logs') { + return { success: true }; + } + if (channel === 'log-viewer-window/read-logs-tail') { + return { success: true, logs: '', hasNew: false }; + } + return { success: true }; + }); + }); + + it('loads and renders log entries', async () => { + render(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'log-viewer-window/read-logs', + expect.any(Object) + ); + }); + }); + + it('filters rendered entries by the search term', async () => { + render(); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + await waitFor(() => expect(screen.getAllByTestId(/^log-/)).toHaveLength(2)); + + const searchInput = screen.getByRole('textbox', { name: /search/i }); + fireEvent.change(searchInput, { target: { value: 'boom' } }); + + await waitFor(() => expect(screen.getAllByTestId(/^log-/)).toHaveLength(1)); + expect(screen.getByTestId(/^log-/)).toHaveTextContent('boom'); + }); + + it('invokes the refresh IPC channel when the refresh button is clicked', async () => { + render(); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + invoke.mockClear(); + + fireEvent.click( + screen.getByRole('button', { name: 'logViewer.buttons.refresh' }) + ); + + await waitFor(() => + expect(invoke).toHaveBeenCalledWith( + 'log-viewer-window/read-logs', + expect.any(Object) + ) + ); + }); +}); diff --git a/src/logViewerWindow/__tests__/pagination.spec.ts b/src/logViewerWindow/__tests__/pagination.spec.ts new file mode 100644 index 0000000000..a156f13fc8 --- /dev/null +++ b/src/logViewerWindow/__tests__/pagination.spec.ts @@ -0,0 +1,31 @@ +import { advanceVisibleCount } from '../pagination'; + +describe('advanceVisibleCount', () => { + it('advances by a page while entries remain', () => { + expect(advanceVisibleCount(100, 1000, 100)).toBe(200); + }); + + it('never overshoots the total', () => { + expect(advanceVisibleCount(100, 130, 100)).toBe(130); + }); + + it('settles once everything is shown, so endReached cannot loop', () => { + expect(advanceVisibleCount(130, 130, 100)).toBe(130); + expect(advanceVisibleCount(500, 130, 100)).toBe(500); + }); + + it('settles on an empty list', () => { + expect(advanceVisibleCount(100, 0, 100)).toBe(100); + }); + + it('reaches the end in a bounded number of steps', () => { + let count = 100; + let steps = 0; + while (advanceVisibleCount(count, 1000, 100) !== count) { + count = advanceVisibleCount(count, 1000, 100); + steps += 1; + expect(steps).toBeLessThan(20); + } + expect(count).toBe(1000); + }); +}); diff --git a/src/logViewerWindow/__tests__/parseLogs.spec.ts b/src/logViewerWindow/__tests__/parseLogs.spec.ts new file mode 100644 index 0000000000..ddb1e9bc6c --- /dev/null +++ b/src/logViewerWindow/__tests__/parseLogs.spec.ts @@ -0,0 +1,143 @@ +import { + countBy, + getEntryDay, + getEntryTime, + parseLogLines, +} from '../parseLogs'; + +describe('parseLogLines', () => { + it('returns nothing for empty input', () => { + expect(parseLogLines('')).toEqual([]); + expect(parseLogLines(' \n ')).toEqual([]); + }); + + it('parses timestamp, level and context tags', () => { + const [entry] = parseLogLines( + '[2026-08-07 18:28:38.848] [warn] [main] Update check failed' + ); + + expect(entry.timestamp).toBe('2026-08-07 18:28:38.848'); + expect(entry.level).toBe('warn'); + expect(entry.contextTags).toEqual(['main']); + expect(entry.context).toBe('main'); + expect(entry.message).toBe('Update check failed'); + }); + + it('keeps every bracketed tag as its own context tag', () => { + const [entry] = parseLogLines( + '[2026-08-07 18:28:38.804] [warn] [renderer:webview] [localhost:3000] [preload] Insecure CSP' + ); + + expect(entry.contextTags).toEqual([ + 'renderer:webview', + 'localhost:3000', + 'preload', + ]); + expect(entry.message).toBe('Insecure CSP'); + }); + + it('treats a message without context tags as all message', () => { + const [entry] = parseLogLines('[2026-08-07 18:28:38.848] [info] hello'); + + expect(entry.contextTags).toEqual([]); + expect(entry.message).toBe('hello'); + }); + + it('folds continuation lines into the previous entry', () => { + const entries = parseLogLines( + [ + '[2026-08-07 18:28:38.847] [warn] [main] Error: No published versions', + ' at newError (error.js:5:19)', + ' at GitHubProvider.getLatestVersion (GitHubProvider.js:100:55)', + ].join('\n') + ); + + expect(entries).toHaveLength(1); + expect(entries[0].message.split('\n')).toHaveLength(3); + expect(entries[0].raw).toContain('at newError'); + }); + + it('orders entries newest first', () => { + const entries = parseLogLines( + [ + '[2026-08-07 10:00:00.000] [info] [main] first', + '[2026-08-07 11:00:00.000] [info] [main] second', + ].join('\n') + ); + + expect(entries.map((entry) => entry.message)).toEqual(['second', 'first']); + }); + + it('namespaces ids per call so merged reads never collide', () => { + const head = parseLogLines('[2026-08-07 10:00:00.000] [info] [main] a'); + const tail = parseLogLines( + '[2026-08-07 11:00:00.000] [info] [main] b', + 'g2' + ); + + expect(head[0].id).not.toBe(tail[0].id); + expect(tail[0].id).toBe('g2-0'); + }); + + it('falls back to info for unknown levels', () => { + const [entry] = parseLogLines( + '[2026-08-07 10:00:00.000] [shout] [main] loud' + ); + + expect(entry.level).toBe('info'); + }); + + it('precomputes searchText and rawLower', () => { + const [entry] = parseLogLines( + '[2026-08-07 10:00:00.000] [info] [Main] [Server-1] HELLO World' + ); + + expect(entry.searchText).toBe( + `${entry.message} ${entry.context}`.toLowerCase() + ); + expect(entry.rawLower).toBe(entry.raw.toLowerCase()); + expect(entry.contextTags).toEqual(['Main', 'Server-1']); + }); + + it('updates searchText when continuation lines are folded in', () => { + const [entry] = parseLogLines( + [ + '[2026-08-07 10:00:00.000] [error] [main] Boom', + ' at foo (foo.js:1:1)', + ].join('\n') + ); + + expect(entry.searchText).toContain('at foo'); + expect(entry.rawLower).toContain('at foo'); + }); +}); + +describe('timestamp helpers', () => { + it('extracts the wall clock time', () => { + expect(getEntryTime('2026-08-07 18:28:38.848')).toBe('18:28:38.848'); + expect(getEntryTime('nonsense')).toBe('nonsense'); + }); + + it('groups entries by calendar day', () => { + expect(getEntryDay('2026-08-07 18:28:38.848')).toBe( + new Date('2026-08-07 18:28:38.848').toDateString() + ); + expect(getEntryDay('2026-08-07 18:28:38.848')).toBe( + getEntryDay('2026-08-07 01:00:00.000') + ); + expect(getEntryDay('2026-08-07 18:28:38.848')).not.toBe( + getEntryDay('2026-08-08 18:28:38.848') + ); + }); +}); + +describe('countBy', () => { + it('counts every key an item contributes', () => { + expect( + countBy( + [{ tags: ['main', 'ipc'] }, { tags: ['main'] }, { tags: [] }], + (item) => item.tags + ) + ).toEqual({ main: 2, ipc: 1 }); + }); +}); diff --git a/src/logViewerWindow/__tests__/textHighlight.spec.ts b/src/logViewerWindow/__tests__/textHighlight.spec.ts new file mode 100644 index 0000000000..e070fb96a4 --- /dev/null +++ b/src/logViewerWindow/__tests__/textHighlight.spec.ts @@ -0,0 +1,83 @@ +import { + MAX_COLLAPSED_MESSAGE_LINES, + splitMessageForCollapse, + splitTextForHighlight, +} from '../textHighlight'; + +describe('splitTextForHighlight', () => { + it('returns a single unmatched segment when query is empty', () => { + expect(splitTextForHighlight('hello world', '')).toEqual([ + { text: 'hello world', matched: false }, + ]); + }); + + it('returns a single unmatched segment when there is no match', () => { + expect(splitTextForHighlight('hello world', 'xyz')).toEqual([ + { text: 'hello world', matched: false }, + ]); + }); + + it('splits and marks a single case-insensitive match', () => { + const result = splitTextForHighlight('Hello World', 'world'); + expect(result).toEqual([ + { text: 'Hello ', matched: false }, + { text: 'World', matched: true }, + ]); + }); + + it('marks all occurrences of the query', () => { + const result = splitTextForHighlight('foo bar foo', 'foo'); + expect(result).toEqual([ + { text: 'foo', matched: true }, + { text: ' bar ', matched: false }, + { text: 'foo', matched: true }, + ]); + }); + + it('escapes regex special characters in the query', () => { + const result = splitTextForHighlight('a.b*c', '.b*'); + expect(result).toEqual([ + { text: 'a', matched: false }, + { text: '.b*', matched: true }, + { text: 'c', matched: false }, + ]); + }); +}); + +describe('splitMessageForCollapse', () => { + it('returns all lines with zero hidden count when under the threshold', () => { + const message = Array.from({ length: 3 }, (_, i) => `line ${i}`).join('\n'); + const { visibleLines, hiddenLineCount } = splitMessageForCollapse(message); + expect(visibleLines).toHaveLength(3); + expect(hiddenLineCount).toBe(0); + }); + + it('returns exactly the threshold with zero hidden count at the boundary', () => { + const message = Array.from( + { length: MAX_COLLAPSED_MESSAGE_LINES }, + (_, i) => `line ${i}` + ).join('\n'); + const { visibleLines, hiddenLineCount } = splitMessageForCollapse(message); + expect(visibleLines).toHaveLength(MAX_COLLAPSED_MESSAGE_LINES); + expect(hiddenLineCount).toBe(0); + }); + + it('truncates to the threshold and reports the hidden count when over it', () => { + const totalLines = MAX_COLLAPSED_MESSAGE_LINES + 4; + const message = Array.from( + { length: totalLines }, + (_, i) => `line ${i}` + ).join('\n'); + const { visibleLines, hiddenLineCount } = splitMessageForCollapse(message); + expect(visibleLines).toHaveLength(MAX_COLLAPSED_MESSAGE_LINES); + expect(visibleLines).toEqual([ + 'line 0', + 'line 1', + 'line 2', + 'line 3', + 'line 4', + 'line 5', + ]); + expect(hiddenLineCount).toBe(4); + }); +}); diff --git a/src/logViewerWindow/__tests__/timeline.spec.ts b/src/logViewerWindow/__tests__/timeline.spec.ts new file mode 100644 index 0000000000..0aaf08e14b --- /dev/null +++ b/src/logViewerWindow/__tests__/timeline.spec.ts @@ -0,0 +1,221 @@ +import { + buildTimeline, + getBarHeightPercent, + isWithinRange, + resolveRange, +} from '../timeline'; +import type { LogEntryType, LogLevel } from '../types'; + +const entry = (timestamp: string, level: LogLevel = 'info'): LogEntryType => ({ + id: `e-${timestamp}-${level}`, + timestamp, + level, + contextTags: ['main'], + context: 'main', + message: 'message', + raw: `[${timestamp}] [${level}] [main] message`, + searchText: 'message main', + rawLower: `[${timestamp}] [${level}] [main] message`.toLowerCase(), +}); + +describe('buildTimeline', () => { + it('returns nothing for no entries or a bad bucket count', () => { + expect(buildTimeline([], 10).buckets).toEqual([]); + expect( + buildTimeline([entry('2026-08-07 10:00:00.000')], 0).buckets + ).toEqual([]); + }); + + it('spans oldest to newest regardless of input order', () => { + // Entries arrive newest-first, as the viewer holds them. + const timeline = buildTimeline( + [entry('2026-08-07 12:00:00.000'), entry('2026-08-07 10:00:00.000')], + 4 + ); + + expect(new Date(timeline.startTime).getHours()).toBe(10); + expect(new Date(timeline.endTime).getHours()).toBe(12); + expect(timeline.buckets).toHaveLength(4); + }); + + it('distributes entries across buckets oldest first', () => { + const timeline = buildTimeline( + [ + entry('2026-08-07 10:40:00.000'), + entry('2026-08-07 10:20:00.000'), + entry('2026-08-07 10:00:00.000'), + ], + 2 + ); + + // Span is 40min over 2 buckets: [10:00,10:20) holds 10:00, and + // [10:20,10:40] holds both 10:20 and the closing 10:40. + expect(timeline.buckets.map((bucket) => bucket.total)).toEqual([1, 2]); + }); + + it('keeps every entry, including the newest which lands on the edge', () => { + const timeline = buildTimeline( + [ + entry('2026-08-07 11:00:00.000'), + entry('2026-08-07 10:30:00.000'), + entry('2026-08-07 10:00:00.000'), + ], + 3 + ); + + const counted = timeline.buckets.reduce( + (sum, bucket) => sum + bucket.total, + 0 + ); + expect(counted).toBe(3); + }); + + it('collapses a single instant into one bucket', () => { + const timeline = buildTimeline( + [entry('2026-08-07 10:00:00.000'), entry('2026-08-07 10:00:00.000')], + 50 + ); + + expect(timeline.buckets).toHaveLength(1); + expect(timeline.buckets[0].total).toBe(2); + expect(timeline.peak).toBe(2); + }); + + it('counts levels per bucket', () => { + const timeline = buildTimeline( + [ + entry('2026-08-07 10:00:00.100', 'error'), + entry('2026-08-07 10:00:00.050', 'error'), + entry('2026-08-07 10:00:00.000', 'warn'), + ], + 1 + ); + + expect(timeline.buckets[0].countsByLevel).toEqual({ error: 2, warn: 1 }); + }); + + it('records the newest entry index of each bucket for scroll-to', () => { + const entries = [ + entry('2026-08-07 12:00:00.000'), + entry('2026-08-07 11:00:00.000'), + entry('2026-08-07 10:00:00.000'), + ]; + const timeline = buildTimeline(entries, 2); + + // Oldest bucket holds only 10:00 (index 2); the newer bucket holds 11:00 + // and 12:00, whose newest is index 0. + expect(timeline.buckets[0].newestIndex).toBe(2); + expect(timeline.buckets[1].newestIndex).toBe(0); + }); + + it('skips unparseable timestamps instead of stretching the span', () => { + const timeline = buildTimeline( + [ + entry('2026-08-07 10:00:01.000'), + entry('not a timestamp'), + entry('2026-08-07 10:00:00.000'), + ], + 2 + ); + + expect(timeline.endTime - timeline.startTime).toBe(1000); + const counted = timeline.buckets.reduce( + (sum, bucket) => sum + bucket.total, + 0 + ); + expect(counted).toBe(2); + }); + + it('reports nothing when no timestamp can be parsed', () => { + expect(buildTimeline([entry('nonsense')], 5).buckets).toEqual([]); + }); + + it('reports the peak bucket total for scaling', () => { + const timeline = buildTimeline( + [ + entry('2026-08-07 10:00:00.900'), + entry('2026-08-07 10:00:00.800'), + entry('2026-08-07 10:00:00.700'), + entry('2026-08-07 10:00:00.000'), + ], + 2 + ); + + expect(timeline.peak).toBe(3); + }); +}); + +describe('getBarHeightPercent', () => { + it('is zero for an empty bucket', () => { + expect(getBarHeightPercent(0, 10)).toBe(0); + }); + + it('scales against the peak', () => { + expect(getBarHeightPercent(10, 10)).toBe(100); + expect(getBarHeightPercent(5, 10)).toBe(50); + }); + + it('keeps a sparse bucket visible', () => { + expect(getBarHeightPercent(1, 1000)).toBe(6); + }); + + it('handles a peak of zero without dividing by it', () => { + expect(getBarHeightPercent(3, 0)).toBe(0); + }); +}); + +describe('resolveRange', () => { + const timeline = buildTimeline( + [ + entry('2026-08-07 10:00:03.000'), + entry('2026-08-07 10:00:02.000'), + entry('2026-08-07 10:00:01.000'), + entry('2026-08-07 10:00:00.000'), + ], + 4 + ); + const { buckets } = timeline; + + it('spans the two indices', () => { + const range = resolveRange(buckets, 1, 2); + expect(range).toEqual({ + startTime: buckets[1].startTime, + endTime: buckets[2].endTime, + }); + }); + + it('is the same range dragged in either direction', () => { + expect(resolveRange(buckets, 3, 0)).toEqual(resolveRange(buckets, 0, 3)); + }); + + it('covers a single bucket when the drag never moved', () => { + expect(resolveRange(buckets, 2, 2)).toEqual({ + startTime: buckets[2].startTime, + endTime: buckets[2].endTime, + }); + }); + + it('refuses indices outside the timeline', () => { + expect(resolveRange(buckets, 0, 99)).toBeNull(); + expect(resolveRange(buckets, -1, 1)).toBeNull(); + expect(resolveRange([], 0, 0)).toBeNull(); + }); +}); + +describe('isWithinRange', () => { + const range = { startTime: 1000, endTime: 2000 }; + + it('includes both ends', () => { + expect(isWithinRange(1000, range)).toBe(true); + expect(isWithinRange(2000, range)).toBe(true); + }); + + it('excludes times outside', () => { + expect(isWithinRange(999, range)).toBe(false); + expect(isWithinRange(2001, range)).toBe(false); + }); + + it('excludes an unparseable timestamp', () => { + expect(isWithinRange(NaN, range)).toBe(false); + }); +}); diff --git a/src/logViewerWindow/__tests__/types.spec.ts b/src/logViewerWindow/__tests__/types.spec.ts index c015a31a71..bb581dc657 100644 --- a/src/logViewerWindow/__tests__/types.spec.ts +++ b/src/logViewerWindow/__tests__/types.spec.ts @@ -1,4 +1,4 @@ -import { isLogLevel, parseLogLevel } from '../types'; +import { isAtLeastLevel, isLogLevel, parseLogLevel } from '../types'; describe('logViewerWindow types', () => { it('recognizes valid log levels', () => { @@ -20,3 +20,30 @@ describe('logViewerWindow types', () => { expect(parseLogLevel(42)).toBe('info'); }); }); + +describe('isAtLeastLevel', () => { + it('orders levels silly < verbose < debug < info < warn < error', () => { + expect(isAtLeastLevel('silly', 'silly')).toBe(true); + expect(isAtLeastLevel('verbose', 'silly')).toBe(true); + expect(isAtLeastLevel('debug', 'verbose')).toBe(true); + expect(isAtLeastLevel('info', 'debug')).toBe(true); + expect(isAtLeastLevel('warn', 'info')).toBe(true); + expect(isAtLeastLevel('error', 'warn')).toBe(true); + }); + + it('returns false when level is below the minimum', () => { + expect(isAtLeastLevel('silly', 'error')).toBe(false); + expect(isAtLeastLevel('debug', 'warn')).toBe(false); + expect(isAtLeastLevel('info', 'error')).toBe(false); + }); + + it('returns true when level equals the minimum', () => { + expect(isAtLeastLevel('warn', 'warn')).toBe(true); + expect(isAtLeastLevel('error', 'error')).toBe(true); + }); + + it('treats error as the highest severity, matching only itself and above', () => { + expect(isAtLeastLevel('error', 'error')).toBe(true); + expect(isAtLeastLevel('warn', 'error')).toBe(false); + }); +}); diff --git a/src/logViewerWindow/appearance.ts b/src/logViewerWindow/appearance.ts new file mode 100644 index 0000000000..8cadfb61a9 --- /dev/null +++ b/src/logViewerWindow/appearance.ts @@ -0,0 +1,31 @@ +import type { LogLevel } from './types'; + +export const LEVEL_ACCENT: Record = { + error: 'var(--rcx-color-status-bullet-busy)', + warn: 'var(--rcx-color-status-bullet-away)', + info: 'var(--rcx-color-badge-background-level-2)', + debug: 'var(--rcx-color-font-hint)', + verbose: 'var(--rcx-color-font-annotation)', + silly: 'var(--rcx-color-font-annotation)', +}; + +export const LEVEL_BADGE_VARIANT: Record< + LogLevel, + 'danger' | 'warning' | 'primary' | 'secondary' | 'ghost' +> = { + error: 'danger', + warn: 'warning', + info: 'primary', + debug: 'secondary', + verbose: 'ghost', + silly: 'ghost', +}; + +export const LOG_LEVELS: LogLevel[] = [ + 'error', + 'warn', + 'info', + 'debug', + 'verbose', + 'silly', +]; diff --git a/src/logViewerWindow/constants.ts b/src/logViewerWindow/constants.ts index de3d4cf6a5..d2d6e71eee 100644 --- a/src/logViewerWindow/constants.ts +++ b/src/logViewerWindow/constants.ts @@ -16,3 +16,23 @@ export const SEARCH_DEBOUNCE_MS = 300; /** Virtuoso overscan count for smooth scrolling */ export const VIRTUOSO_OVERSCAN = 50; + +/** Channel the main process pushes transparency changes on. */ +export const TRANSPARENCY_CHANNEL = 'log-viewer-window/transparency-changed'; + +/** Minimum window size — below this the sidebar plus a log line no longer fit */ +export const WINDOW_MIN_WIDTH = 760; +export const WINDOW_MIN_HEIGHT = 480; + +/** Lines shown for a multi-line entry before it has to be expanded */ +export const COLLAPSED_MESSAGE_LINES = 1; + +/** Entries handed to the list per page; scrolling to the end adds another */ +export const PAGE_SIZE = 100; + +/** Time slices drawn in the distribution timeline, and the plot's height */ +export const TIMELINE_BUCKET_COUNT = 96; +export const TIMELINE_PLOT_HEIGHT = 40; + +/** Window during which scroll events are ignored after a programmatic scroll */ +export const AUTO_SCROLL_GUARD_MS = 150; diff --git a/src/logViewerWindow/ipc.ts b/src/logViewerWindow/ipc.ts index 14990149ac..bb158e4349 100644 --- a/src/logViewerWindow/ipc.ts +++ b/src/logViewerWindow/ipc.ts @@ -1,28 +1,67 @@ import fs, { createWriteStream } from 'fs'; import path from 'path'; -import { promisify } from 'util'; import archiver from 'archiver'; import type { Event } from 'electron'; -import { app, BrowserWindow, screen, dialog } from 'electron'; +import { app, BrowserWindow, screen, dialog, shell } from 'electron'; import i18next from 'i18next'; import { packageJsonInformation } from '../app/main/app'; import { handle } from '../ipc/main'; import { getHost } from '../logging/context'; -import { select } from '../store'; +import { dispatch, select, watch } from '../store'; import type { RootState } from '../store/rootReducer'; +import { LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED } from '../ui/actions'; import { getRootWindow } from '../ui/main/rootWindow'; -import { WINDOW_SIZE_MULTIPLIER } from './constants'; +import { watchWindowControls } from '../ui/main/secondaryWindowControls'; +import { focusSecondaryWindow } from '../ui/main/secondaryWindowFocus'; +import { + getSavedWindowBounds, + watchWindowBounds, +} from '../ui/main/secondaryWindowState'; +import { + NOT_FULL_SCREENABLE, + getTitleBarOptions, +} from '../ui/windowChrome/appearance'; +import { + TRANSPARENCY_CHANNEL, + WINDOW_MIN_HEIGHT, + WINDOW_MIN_WIDTH, + WINDOW_SIZE_MULTIPLIER, +} from './constants'; const t = i18next.t.bind(i18next); -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); +const isMac = process.platform === 'darwin'; + +const { readFile, writeFile, mkdir, stat } = fs.promises; + +const pathExists = async (targetPath: string): Promise => { + try { + await stat(targetPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +}; let logViewerWindow: BrowserWindow | null = null; const allowedLogPaths = new Set(); +/** + * Set once quitting starts. The window's `closed` handler fires both when the + * reader closes it and when the app tears its windows down on quit; without this + * the quit path would record "closed" and defeat restoring it next launch. + */ +let isAppQuitting = false; + +const selectIsTransparencyEnabled = ({ + isTransparentWindowEnabled, +}: RootState): boolean => isTransparentWindowEnabled; + const getLogFilePath = (): string => { const logsPath = app.getPath('logs'); return path.join(logsPath, 'main.log'); @@ -51,7 +90,18 @@ const validateLogFilePath = ( const LOG_ENTRY_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]/; -const getLastNEntries = ( +export const countLogEntries = (content: string): number => { + const lines = content.split(/\r?\n/); + let count = 0; + lines.forEach((line) => { + if (LOG_ENTRY_REGEX.test(line)) { + count += 1; + } + }); + return count; +}; + +export const getLastNEntries = ( content: string, limit: number ): { content: string; totalEntries: number } => { @@ -83,12 +133,25 @@ const getLastNEntries = ( }; }; -export const openLogViewerWindow = async (): Promise => { - if (logViewerWindow && !logViewerWindow.isDestroyed()) { - logViewerWindow.focus(); - return; +const NEWLINE_BYTE = 0x0a; + +export const trimBufferToLastNewline = ( + buf: Buffer +): { consumed: Buffer; bytesConsumed: number } => { + const lastNewlineIndex = buf.lastIndexOf(NEWLINE_BYTE); + + if (lastNewlineIndex === -1) { + return { consumed: Buffer.alloc(0), bytesConsumed: 0 }; } + const bytesConsumed = lastNewlineIndex + 1; + return { consumed: buf.subarray(0, bytesConsumed), bytesConsumed }; +}; + +/** Set while a window is being built; see `createLogViewerWindow`. */ +let pendingCreation: Promise | null = null; + +const buildLogViewerWindow = async (focusOnShow: boolean): Promise => { const mainWindow = await getRootWindow(); const winBounds = await mainWindow.getNormalBounds(); @@ -115,12 +178,34 @@ export const openLogViewerWindow = async (): Promise => { (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y ); + // Where the reader last left this window, falling back to centred on the + // display nearest the main window. + const savedBounds = getSavedWindowBounds('logViewer'); + + // Seeds the first paint only; the renderer then follows the setting live. + const isTransparencyEnabled = isMac && select(selectIsTransparencyEnabled); + logViewerWindow = new BrowserWindow({ - width, - height, - x, - y, + ...(savedBounds ?? { width, height, x, y }), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, title: 'Log Viewer - Rocket.Chat', + // The toolbar doubles as the title bar wherever the platform allows it, so + // the window shows one header instead of a native title bar stacked on an + // in-app one. + ...getTitleBarOptions(), + ...NOT_FULL_SCREENABLE, + // `transparent` cannot be toggled after creation, so — like the root window — + // the window is always transparent with a vibrancy material on macOS and the + // setting only decides whether the renderer paints an opaque surface over it. + // That is what lets the setting apply without reopening the window. + ...(isMac + ? { + transparent: true, + vibrancy: 'sidebar' as const, + visualEffectState: 'active' as const, + } + : {}), webPreferences: { nodeIntegration: true, contextIsolation: false, @@ -129,19 +214,33 @@ export const openLogViewerWindow = async (): Promise => { }); logViewerWindow.loadFile( - path.join(app.getAppPath(), 'app/log-viewer-window.html') + path.join(app.getAppPath(), 'app/log-viewer-window.html'), + { query: { transparent: String(isTransparencyEnabled) } } ); logViewerWindow.once('ready-to-show', () => { logViewerWindow?.setTitle( `Log Viewer - ${packageJsonInformation.productName}` ); - logViewerWindow?.show(); + if (focusOnShow) { + logViewerWindow?.show(); + return; + } + // Restored at launch: show it without stealing focus from the main window. + logViewerWindow?.showInactive(); }); + dispatch({ type: LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED, payload: true }); + logViewerWindow.on('closed', () => { logViewerWindow = null; allowedLogPaths.clear(); + if (!isAppQuitting) { + dispatch({ + type: LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED, + payload: false, + }); + } }); logViewerWindow.webContents.on( @@ -153,12 +252,64 @@ export const openLogViewerWindow = async (): Promise => { } ); + watchWindowBounds('logViewer', logViewerWindow); + watchWindowControls(logViewerWindow); + logViewerWindow.webContents.setWindowOpenHandler(() => { return { action: 'deny' }; }); }; +/** + * Opens the window, or focuses the one already open. + * + * Building one awaits the main window before the module variable is assigned, + * so two opens arriving in that gap would each build a window and the second + * would orphan the first — visible, untracked, and impossible to close from its + * own channel. Every caller comes through here, so only one is ever in flight. + */ +const createLogViewerWindow = async (focusOnShow: boolean): Promise => { + if (pendingCreation) await pendingCreation; + + if (logViewerWindow && !logViewerWindow.isDestroyed()) { + focusSecondaryWindow(logViewerWindow); + return; + } + + pendingCreation = buildLogViewerWindow(focusOnShow).finally(() => { + pendingCreation = null; + }); + await pendingCreation; +}; + +export const openLogViewerWindow = (): Promise => + createLogViewerWindow(true); + +/** + * Reopens the window at launch when it was open at shutdown, without taking + * focus from the main window. + */ +export const restoreLogViewerWindow = async (): Promise => { + if ( + !select(({ isLogViewerWindowOpen }: RootState) => isLogViewerWindowOpen) + ) { + return; + } + await createLogViewerWindow(false); +}; + export const startLogViewerWindowHandler = (): void => { + app.on('before-quit', () => { + isAppQuitting = true; + }); + + // Transparency is a renderer concern here, so a change only needs pushing to + // the open window — no reopen, no restart. + watch(selectIsTransparencyEnabled, (isEnabled) => { + if (!logViewerWindow || logViewerWindow.isDestroyed()) return; + logViewerWindow.webContents.send(TRANSPARENCY_CHANNEL, isEnabled); + }); + handle('log-viewer-window/open-window', openLogViewerWindow); handle('log-viewer-window/close-requested', async () => { @@ -172,10 +323,13 @@ export const startLogViewerWindowHandler = (): void => { } const result = await dialog.showOpenDialog(logViewerWindow, { - title: 'Select Log File', + title: t('dialog.selectLogFile.title'), filters: [ - { name: 'Log Files', extensions: ['log', 'txt'] }, - { name: 'All Files', extensions: ['*'] }, + { + name: t('dialog.selectLogFile.logFiles'), + extensions: ['log', 'txt'], + }, + { name: t('dialog.selectLogFile.allFiles'), extensions: ['*'] }, ], properties: ['openFile'], }); @@ -230,11 +384,11 @@ export const startLogViewerWindowHandler = (): void => { } const limit = options?.limit; - if (!fs.existsSync(logPath)) { + if (!(await pathExists(logPath))) { if (!options?.filePath) { const logDir = path.dirname(logPath); - if (!fs.existsSync(logDir)) { - fs.mkdirSync(logDir, { recursive: true }); + if (!(await pathExists(logDir))) { + await mkdir(logDir, { recursive: true }); } await writeFile(logPath, ''); } else { @@ -251,13 +405,14 @@ export const startLogViewerWindowHandler = (): void => { if (limit === 'all' || !limit) { logContent = fileContent; + totalEntries = countLogEntries(fileContent); } else { const result = getLastNEntries(fileContent, limit); logContent = result.content; totalEntries = result.totalEntries; } - const stats = fs.statSync(logPath); + const stats = await stat(logPath); const lastModifiedTime = stats.mtime.getTime(); return { @@ -304,11 +459,11 @@ export const startLogViewerWindowHandler = (): void => { logPath = getLogFilePath(); } - if (!fs.existsSync(logPath)) { + if (!(await pathExists(logPath))) { return { success: false, error: 'Log file does not exist' }; } - const stats = fs.statSync(logPath); + const stats = await stat(logPath); return { success: true, lastModifiedTime: stats.mtime.getTime(), @@ -348,11 +503,11 @@ export const startLogViewerWindowHandler = (): void => { logPath = getLogFilePath(); } - if (!fs.existsSync(logPath)) { + if (!(await pathExists(logPath))) { return { success: false, error: 'Log file does not exist' }; } - const stats = fs.statSync(logPath); + const stats = await stat(logPath); const rawFromByte = Number(options.fromByte); const fromByte = Number.isFinite(rawFromByte) && rawFromByte >= 0 @@ -363,7 +518,7 @@ export const startLogViewerWindowHandler = (): void => { return { success: true, logs: '', - newSize: stats.size, + newSize: fromByte, lastModifiedTime: stats.mtime.getTime(), }; } @@ -379,12 +534,24 @@ export const startLogViewerWindowHandler = (): void => { stream.on('error', (err) => reject(err)); }); - const newContent = Buffer.concat(chunks).toString('utf-8'); + const rawChunk = Buffer.concat(chunks); + const { consumed, bytesConsumed } = trimBufferToLastNewline(rawChunk); + + if (bytesConsumed === 0) { + return { + success: true, + logs: '', + newSize: fromByte, + lastModifiedTime: stats.mtime.getTime(), + }; + } + + const newContent = consumed.toString('utf-8'); return { success: true, logs: newContent, - newSize: stats.size, + newSize: fromByte + bytesConsumed, lastModifiedTime: stats.mtime.getTime(), }; } catch (error) { @@ -394,6 +561,46 @@ export const startLogViewerWindowHandler = (): void => { } ); + handle( + 'log-viewer-window/reveal-log-file', + async (_, options?: { filePath?: string }) => { + try { + let logPath: string; + if (options?.filePath) { + const validation = validateLogFilePath(options.filePath); + if (!validation.valid) { + return { success: false, error: validation.error }; + } + const normalizedPath = path.normalize(options.filePath); + const defaultLogPath = path.normalize(getLogFilePath()); + if ( + normalizedPath !== defaultLogPath && + !allowedLogPaths.has(normalizedPath) + ) { + return { + success: false, + error: + 'Log file not authorized. Please select it via the file dialog first.', + }; + } + logPath = normalizedPath; + } else { + logPath = getLogFilePath(); + } + + if (!(await pathExists(logPath))) { + return { success: false, error: 'Log file does not exist' }; + } + + shell.showItemInFolder(logPath); + return { success: true }; + } catch (error) { + console.error('Failed to reveal log file:', error); + return { success: false, error: (error as Error).message }; + } + } + ); + handle('log-viewer-window/confirm-clear-logs', async () => { if (!logViewerWindow || logViewerWindow.isDestroyed()) { return false; @@ -431,11 +638,12 @@ export const startLogViewerWindowHandler = (): void => { } const result = await dialog.showSaveDialog(logViewerWindow, { - title: 'Save Log File', + title: t('dialog.saveLogFile.title'), defaultPath: options.defaultFileName, filters: [ - { name: 'ZIP Files', extensions: ['zip'] }, - { name: 'All Files', extensions: ['*'] }, + { name: t('dialog.saveLogFile.zipFiles'), extensions: ['zip'] }, + { name: t('dialog.saveLogFile.logFiles'), extensions: ['log'] }, + { name: t('dialog.saveLogFile.allFiles'), extensions: ['*'] }, ], }); @@ -443,6 +651,14 @@ export const startLogViewerWindowHandler = (): void => { return { success: false, canceled: true }; } + if (result.filePath.toLowerCase().endsWith('.log')) { + await writeFile(result.filePath, options.content, 'utf-8'); + return { + success: true, + filePath: result.filePath, + }; + } + await new Promise((resolve, reject) => { const output = createWriteStream(result.filePath!); const archive = archiver('zip', { diff --git a/src/logViewerWindow/log-viewer-window.tsx b/src/logViewerWindow/log-viewer-window.tsx index 6dc7326bab..09f1e0fe11 100644 --- a/src/logViewerWindow/log-viewer-window.tsx +++ b/src/logViewerWindow/log-viewer-window.tsx @@ -34,7 +34,7 @@ const ThemedLogViewerWindow = () => { return ( <> - + ); }; diff --git a/src/logViewerWindow/logFormatters.ts b/src/logViewerWindow/logFormatters.ts new file mode 100644 index 0000000000..e02f94fa58 --- /dev/null +++ b/src/logViewerWindow/logFormatters.ts @@ -0,0 +1,79 @@ +import type { LogEntryType } from './types'; +import { parseLogLevel } from './types'; + +export const LOG_LINE_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s*(.*)$/; +export const CONTEXT_REGEX = /^(\[[^\]]+\](?:\s*\[[^\]]+\])*)\s*(.*)$/; + +export const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + return `${mb.toFixed(1)} MB`; +}; + +const buildEntryDerivedFields = ( + entry: Pick +): Pick => ({ + searchText: `${entry.message} ${entry.context}`.toLowerCase(), + rawLower: entry.raw.toLowerCase(), +}); + +export const parseLogLines = (logText: string): LogEntryType[] => { + if (!logText || logText.trim() === '') { + return []; + } + const lines = logText.split(/\r?\n/).filter((line: string) => line.trim()); + const entries: LogEntryType[] = []; + let currentEntry: LogEntryType | null = null; + + lines.forEach((line) => { + const match = line.match(LOG_LINE_REGEX); + + if (match) { + const [, timestamp, level, rest] = match; + + const contextMatch = rest.match(CONTEXT_REGEX); + const contextTags = Array.from( + (contextMatch?.[1] || '').matchAll(/\[([^\]]*)\]/g), + ([, tag]) => tag.trim() + ).filter(Boolean); + const message = contextMatch?.[2] || rest; + + if (currentEntry) { + entries.push(currentEntry); + } + + const parsedMessage = message.trim(); + const context = contextTags.join(' '); + + currentEntry = { + id: `log-${entries.length}`, + timestamp, + level: parseLogLevel(level), + contextTags, + context, + message: parsedMessage, + raw: line, + ...buildEntryDerivedFields({ + message: parsedMessage, + context, + raw: line, + }), + }; + } else if (currentEntry && line.trim()) { + currentEntry.message += `\n${line}`; + currentEntry.raw += `\n${line}`; + const derived = buildEntryDerivedFields(currentEntry); + currentEntry.searchText = derived.searchText; + currentEntry.rawLower = derived.rawLower; + } + }); + + if (currentEntry) { + entries.push(currentEntry); + } + + return entries.reverse(); +}; diff --git a/src/logViewerWindow/logViewerWindow.tsx b/src/logViewerWindow/logViewerWindow.tsx index e6705de401..ed30e4c5cf 100644 --- a/src/logViewerWindow/logViewerWindow.tsx +++ b/src/logViewerWindow/logViewerWindow.tsx @@ -1,32 +1,63 @@ import { Box, - SearchInput, - Icon, Button, - ButtonGroup, - Select, - Tile, + Callout, + Icon, + IconButton, + States, + StatesAction, + StatesActions, + StatesIcon, + StatesSubtitle, + StatesTitle, Throbber, - CheckBox, } from '@rocket.chat/fuselage'; import { useLocalStorage, useDebouncedValue, } from '@rocket.chat/fuselage-hooks'; import { ipcRenderer } from 'electron'; -import type { ChangeEvent, Key } from 'react'; +import type { ChangeEvent } from 'react'; import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import type { VirtuosoHandle } from 'react-virtuoso'; -import { Virtuoso } from 'react-virtuoso'; - +import { GroupedVirtuoso } from 'react-virtuoso'; + +import TooltipProvider from '../ui/components/utils/TooltipProvider'; +import { formatServerTitle } from '../ui/components/utils/formatServerTitle'; +import { DayHeader } from '../ui/windowChrome/DayHeader'; +import type { PaletteTheme } from '../ui/windowChrome/appearance'; +import { getCardStyle, resolveSurfaces } from '../ui/windowChrome/appearance'; +import type { FacetSelection } from '../ui/windowChrome/filters'; +import { + isFacetNarrowed, + isFacetSelected, + readFacetSelection, + toggleFacet, +} from '../ui/windowChrome/filters'; +import { WindowChromeGlobalStyles } from '../ui/windowChrome/styles'; +import { useCopiedFeedback } from '../ui/windowChrome/useCopiedFeedback'; +import { useTransparency } from '../ui/windowChrome/useTransparency'; import { LogEntry } from './LogEntry'; +import { LogStatusBar } from './LogStatusBar'; +import { LogTimeline } from './LogTimeline'; +import { LogViewerSidebar } from './LogViewerSidebar'; +import { LogViewerToolbar } from './LogViewerToolbar'; +import { LOG_LEVELS } from './appearance'; import { + TRANSPARENCY_CHANNEL, AUTO_REFRESH_INTERVAL_MS, + AUTO_SCROLL_GUARD_MS, + PAGE_SIZE, SCROLL_DELAY_MS, SEARCH_DEBOUNCE_MS, VIRTUOSO_OVERSCAN, } from './constants'; +import { advanceVisibleCount } from './pagination'; +import { countBy, getEntryDay, parseLogLines } from './parseLogs'; +import { LogViewerGlobalStyles } from './styles'; +import type { TimeRange } from './timeline'; +import { isWithinRange } from './timeline'; import { type LogLevel, type LogEntryType, @@ -35,11 +66,22 @@ import { type SaveLogsResponse, type SelectFileResponse, type ClearLogsResponse, - parseLogLevel, } from './types'; -const LOG_LINE_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s*(.*)$/; -const CONTEXT_REGEX = /^(\[[^\]]+\](?:\s*\[[^\]]+\])*)\s*(.*)$/; +/** Context tags that have a friendlier translated name than the raw tag. */ +const CONTEXT_LABEL_KEYS: Record = { + 'main': 'logViewer.filters.context.main', + 'renderer:root': 'logViewer.filters.context.renderer', + 'renderer:webview': 'logViewer.filters.context.webview', + 'renderer:videocall': 'logViewer.filters.context.videocall', + 'videocall': 'logViewer.filters.context.videocall', + 'outlook': 'logViewer.filters.context.outlook', + 'auth': 'logViewer.filters.context.auth', + 'updates': 'logViewer.filters.context.updates', + 'notifications': 'logViewer.filters.context.notifications', + 'servers': 'logViewer.filters.context.servers', + 'ipc': 'logViewer.filters.context.ipc', +}; const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 B'; @@ -50,8 +92,33 @@ const formatFileSize = (bytes: number): string => { return `${mb.toFixed(1)} MB`; }; -function LogViewerWindow() { +const formatDayLabel = (day: string): string => { + const parsed = new Date(day); + if (isNaN(parsed.getTime())) return day; + return parsed.toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }); +}; + +type LogViewerWindowProps = { + paletteTheme: PaletteTheme; +}; + +function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const { t } = useTranslation(); + const isTransparent = useTransparency(TRANSPARENCY_CHANNEL); + const surfaces = useMemo( + () => resolveSurfaces(paletteTheme, isTransparent), + [paletteTheme, isTransparent] + ); + const cardStyle = useMemo( + () => getCardStyle(paletteTheme, surfaces), + [paletteTheme, surfaces] + ); + const [searchFilter, setSearchFilter] = useState(''); const debouncedSearchFilter = useDebouncedValue( searchFilter, @@ -59,15 +126,24 @@ function LogViewerWindow() { ); const [logEntries, setLogEntries] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [hasSaved, acknowledgeSave] = useCopiedFeedback( + t('logViewer.buttons.saved') + ); const [isStreaming, setIsStreaming] = useState(false); const virtuosoRef = useRef(null); const lastModifiedTimeRef = useRef(undefined); const lastKnownSizeRef = useRef(0); const isAutoScrollingRef = useRef(false); - const [autoScroll, setAutoScroll] = useState(true); - const [userHasScrolled, setUserHasScrolled] = useState(false); - const [showContext, setShowContext] = useState(true); - const [showServer, setShowServer] = useState(true); + const lastAutoScrollAtRef = useRef(0); + const isSuspendedRef = useRef(false); + const parseGenerationRef = useRef(0); + const loadRequestIdRef = useRef(0); + const [pendingNewEntryCount, setPendingNewEntryCount] = useState(0); + const [expandedEntryIds, setExpandedEntryIds] = useState>( + () => new Set() + ); const [serverMapping, setServerMapping] = useState>( {} ); @@ -88,175 +164,108 @@ function LogViewerWindow() { isDefaultLog: true, }); - const entryLimitOptions = useMemo<[string, string][]>( - () => [ - ['100', t('logViewer.filters.entryLimit.last100')], - ['500', t('logViewer.filters.entryLimit.last500')], - ['1000', t('logViewer.filters.entryLimit.last1000')], - ['5000', t('logViewer.filters.entryLimit.last5000')], - ['all', t('logViewer.filters.entryLimit.all')], - ], - [t] + const [showContext, setShowContext] = useLocalStorage( + 'log-viewer/show-context', + true + ); + const [showServer, setShowServer] = useLocalStorage( + 'log-viewer/show-server', + true + ); + const [wrapLines, setWrapLines] = useLocalStorage( + 'log-viewer/wrap-lines', + true + ); + const [collapseMultiline, setCollapseMultiline] = useLocalStorage( + 'log-viewer/collapse-multiline', + true ); + const [autoScroll, setAutoScroll] = useLocalStorage( + 'log-viewer/auto-scroll', + true + ); + const [showTimeline, setShowTimeline] = useLocalStorage( + 'log-viewer/show-timeline', + true + ); + // Deliberately not persisted: a time range belongs to the file you are reading + // now, and restoring one silently would hide entries on the next open. + const [timeRange, setTimeRange] = useState(null); + const [userHasScrolled, setUserHasScrolled] = useState(false); - const [entryLimit, setEntryLimit] = - useState<(typeof entryLimitOptions)[number][0]>('100'); + // How many matching entries are handed to the list. Scrolling to the end + // raises it a page at a time, so reading further back is a scroll rather than + // a filter the reader has to find and change. + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + const [storedLevelFilters, setLevelFilters] = useLocalStorage< + FacetSelection + >('log-viewer/levels', null); + const [storedContextFilters, setContextFilters] = + useLocalStorage('log-viewer/contexts', null); + const [storedServerFilters, setServerFilters] = + useLocalStorage('log-viewer/servers', null); + + const levelFilters = useMemo( + () => readFacetSelection(storedLevelFilters), + [storedLevelFilters] + ); + const contextFilters = useMemo( + () => readFacetSelection(storedContextFilters), + [storedContextFilters] + ); + const serverFilters = useMemo( + () => readFacetSelection(storedServerFilters), + [storedServerFilters] + ); const handleSearchFilterChange = useCallback( (event: ChangeEvent) => { setSearchFilter(event.target.value); }, - [setSearchFilter] + [] ); - const levelFilterOptions = useMemo<[LogLevel | 'all', string][]>( - () => [ - ['all', t('logViewer.filters.level.all')], - ['debug', t('logViewer.filters.level.debug')], - ['info', t('logViewer.filters.level.info')], - ['warn', t('logViewer.filters.level.warn')], - ['error', t('logViewer.filters.level.error')], - ['verbose', t('logViewer.filters.level.verbose')], - ], - [t] + const serverOptions = useMemo( + () => Object.keys(serverMapping), + [serverMapping] ); - const [levelFilter, setLevelFilter] = useLocalStorage< - (typeof levelFilterOptions)[number][0] - >('log-level', 'all'); - - const handleLevelFilterChange = useCallback( - (value: Key) => { - setLevelFilter(String(value) as LogLevel | 'all'); - }, - [setLevelFilter] - ); - - const contextFilterOptions = useMemo<[string, string][]>( - () => [ - ['all', t('logViewer.filters.context.all')], - ['main', t('logViewer.filters.context.main')], - ['renderer', t('logViewer.filters.context.renderer')], - ['webview', t('logViewer.filters.context.webview')], - ['videocall', t('logViewer.filters.context.videocall')], - ['outlook', t('logViewer.filters.context.outlook')], - ['auth', t('logViewer.filters.context.auth')], - ['updates', t('logViewer.filters.context.updates')], - ['notifications', t('logViewer.filters.context.notifications')], - ['servers', t('logViewer.filters.context.servers')], - ['ipc', t('logViewer.filters.context.ipc')], - ], - [t] - ); - - const [contextFilter, setContextFilter] = useLocalStorage< - (typeof contextFilterOptions)[number][0] - >('log-context', 'all'); - - const handleContextFilterChange = useCallback( - (value: Key) => { - setContextFilter(String(value)); - }, - [setContextFilter] - ); - - const serverFilterOptions = useMemo<[string, string][]>(() => { - const options: [string, string][] = [ - ['all', t('logViewer.filters.server.all')], - ]; + const serverLabels = useMemo(() => { + const labels: Record = {}; Object.entries(serverMapping).forEach(([hostname, name]) => { - options.push([hostname, name || hostname]); + labels[hostname] = name || hostname; }); - return options; - }, [serverMapping, t]); - - const [serverFilter, setServerFilter] = useLocalStorage( - 'log-server', - 'all' - ); + return labels; + }, [serverMapping]); - // Reset persisted server filter if the stored value no longer exists + // Drop persisted hosts that are no longer configured, so a removed workspace + // cannot leave the list filtered down to nothing. useEffect(() => { - const validKeys = serverFilterOptions.map(([key]) => key); - if (!validKeys.includes(serverFilter)) { - setServerFilter('all'); - } - }, [serverFilter, serverFilterOptions, setServerFilter]); - - const handleServerFilterChange = useCallback( - (value: Key) => { - setServerFilter(String(value)); - }, - [setServerFilter] - ); + if (serverOptions.length === 0 || serverFilters === null) return; + const pruned = serverFilters.filter((host) => serverOptions.includes(host)); + if (pruned.length === serverFilters.length) return; - const handleEntryLimitChange = useCallback( - (value: Key) => { - setEntryLimit(String(value)); - }, - [setEntryLimit] - ); + // Back to untouched when nothing survives: an empty selection now means + // "no server selected", which would filter the list down to nothing — the + // very thing this is here to prevent. + setServerFilters(pruned.length > 0 ? pruned : null); + }, [serverFilters, serverOptions, setServerFilters]); - const handleClearAll = useCallback((): void => { + const handleClearFilters = useCallback((): void => { setSearchFilter(''); - setLevelFilter('all'); - setContextFilter('all'); - setServerFilter('all'); - setEntryLimit('100'); - }, [ - setSearchFilter, - setLevelFilter, - setContextFilter, - setServerFilter, - setEntryLimit, - ]); - - const parseLogLines = useCallback((logText: string): LogEntryType[] => { - if (!logText || logText.trim() === '') { - return []; - } - const lines = logText.split(/\r?\n/).filter((line: string) => line.trim()); - const entries: LogEntryType[] = []; - let currentEntry: LogEntryType | null = null; - - lines.forEach((line, _index) => { - const match = line.match(LOG_LINE_REGEX); - - if (match) { - const [, timestamp, level, rest] = match; - - const contextMatch = rest.match(CONTEXT_REGEX); - const context = contextMatch?.[1] || ''; - const message = contextMatch?.[2] || rest; - - if (currentEntry) { - entries.push(currentEntry); - } - - currentEntry = { - id: `log-${entries.length}`, - timestamp, - level: parseLogLevel(level), - context: context.replace(/[\[\]]/g, ' ').trim(), - message: message.trim(), - raw: line, - }; - } else if (currentEntry && line.trim()) { - currentEntry.message += `\n${line}`; - currentEntry.raw += `\n${line}`; - } - }); - - if (currentEntry) { - entries.push(currentEntry); - } - - return entries.reverse(); - }, []); + setLevelFilters(null); + setContextFilters(null); + setServerFilters(null); + setTimeRange(null); + }, [setLevelFilters, setContextFilters, setServerFilters]); const loadLogs = useCallback(async () => { + const requestId = loadRequestIdRef.current + 1; + loadRequestIdRef.current = requestId; setIsLoading(true); try { + setLoadError(null); const response = (await ipcRenderer.invoke( 'log-viewer-window/read-logs', { @@ -266,9 +275,15 @@ function LogViewerWindow() { : currentLogFile.filePath, } )) as ReadLogsResponse; + if (requestId !== loadRequestIdRef.current) return; if (response?.success && response.logs !== undefined) { - const parsedLogs = parseLogLines(response.logs); + parseGenerationRef.current += 1; + const parsedLogs = parseLogLines( + response.logs, + `g${parseGenerationRef.current}` + ); setLogEntries(parsedLogs); + setExpandedEntryIds(new Set()); setCurrentLogFile({ filePath: response.filePath, @@ -315,61 +330,282 @@ function LogViewerWindow() { }); } else { console.error('Failed to load logs:', response?.error); + setLogEntries([]); setFileInfo(null); + setLoadError(response?.error || t('logViewer.messages.loadFailed')); } } catch (error) { + if (requestId !== loadRequestIdRef.current) return; console.error('Failed to load logs:', error); + setLogEntries([]); setFileInfo(null); + setLoadError( + error instanceof Error + ? error.message + : t('logViewer.messages.loadFailed') + ); } finally { - setIsLoading(false); + if (requestId === loadRequestIdRef.current) { + setIsLoading(false); + } } - }, [parseLogLines, currentLogFile.filePath, currentLogFile.isDefaultLog, t]); + }, [currentLogFile.filePath, currentLogFile.isDefaultLog, t]); - const filteredLogs = useMemo(() => { - const filtered = logEntries.filter((entry) => { - const matchesSearch = - !debouncedSearchFilter || - entry.message - .toLowerCase() - .includes(debouncedSearchFilter.toLowerCase()) || - entry.context - .toLowerCase() - .includes(debouncedSearchFilter.toLowerCase()); + const matchesSearch = useCallback( + (entry: LogEntryType): boolean => { + if (!debouncedSearchFilter) return true; + return entry.searchText.includes(debouncedSearchFilter.toLowerCase()); + }, + [debouncedSearchFilter] + ); - const matchesLevel = levelFilter === 'all' || entry.level === levelFilter; + const matchesServer = useCallback( + (entry: LogEntryType): boolean => { + if (serverFilters === null) return true; + return serverFilters.some( + (host) => + entry.contextTags.includes(host) || entry.message.includes(host) + ); + }, + [serverFilters] + ); - const contextTags = entry.context.toLowerCase().split(/\s+/); + const matchesLevel = useCallback( + (entry: LogEntryType): boolean => + isFacetSelected(levelFilters, entry.level), + [levelFilters] + ); - const matchesContext = - contextFilter === 'all' || - contextTags.some((tag) => tag.startsWith(contextFilter.toLowerCase())); + const matchesContext = useCallback( + (entry: LogEntryType): boolean => + contextFilters === null || + entry.contextTags.some((tag) => contextFilters.includes(tag)), + [contextFilters] + ); + + const matchesTimeRange = useCallback( + (entry: LogEntryType): boolean => + timeRange === null || + isWithinRange(new Date(entry.timestamp).getTime(), timeRange), + [timeRange] + ); + + /** + * Counts are faceted: each list reports what selecting it would yield with the + * *other* filters applied, so a count is never a number the reader cannot get. + */ + const searchedEntries = useMemo( + () => logEntries.filter(matchesSearch), + [logEntries, matchesSearch] + ); + + const levelCounts = useMemo( + () => + countBy( + searchedEntries.filter( + (entry) => + matchesContext(entry) && + matchesServer(entry) && + matchesTimeRange(entry) + ), + (entry) => [entry.level] + ), + [searchedEntries, matchesContext, matchesServer, matchesTimeRange] + ); + + const contextCounts = useMemo( + () => + countBy( + searchedEntries.filter( + (entry) => + matchesLevel(entry) && + matchesServer(entry) && + matchesTimeRange(entry) + ), + (entry) => entry.contextTags.filter((tag) => !(tag in serverMapping)) + ), + [ + searchedEntries, + matchesLevel, + matchesServer, + matchesTimeRange, + serverMapping, + ] + ); - const matchesServer = - serverFilter === 'all' || - contextTags.some((tag) => tag === serverFilter.toLowerCase()) || - entry.raw.toLowerCase().includes(serverFilter.toLowerCase()); + const serverCounts = useMemo( + () => + countBy( + searchedEntries.filter( + (entry) => + matchesLevel(entry) && + matchesContext(entry) && + matchesTimeRange(entry) + ), + (entry) => + serverOptions.filter( + (host) => + entry.contextTags.includes(host) || entry.message.includes(host) + ) + ), + [ + searchedEntries, + matchesLevel, + matchesContext, + matchesTimeRange, + serverOptions, + ] + ); - return matchesSearch && matchesLevel && matchesContext && matchesServer; + /** Levels and context tags that actually occur in the loaded file. */ + const availableLevels = useMemo(() => { + const present = new Set(logEntries.map((entry) => entry.level)); + return LOG_LEVELS.filter((level) => present.has(level)); + }, [logEntries]); + + const contextOptions = useMemo(() => { + const totals = countBy(logEntries, (entry) => + entry.contextTags.filter((tag) => !(tag in serverMapping)) + ); + return Object.keys(totals).sort( + (a, b) => totals[b] - totals[a] || a.localeCompare(b) + ); + }, [logEntries, serverMapping]); + + const contextLabels = useMemo(() => { + const labels: Record = {}; + contextOptions.forEach((tag) => { + const key = CONTEXT_LABEL_KEYS[tag]; + labels[tag] = key ? t(key) : tag; }); + return labels; + }, [contextOptions, t]); + + /** + * Matches of every filter except the time range — the chart's data, so it can + * keep showing the whole span with the selection marked on top of it. + */ + const timelineEntries = useMemo( + () => + searchedEntries.filter( + (entry) => + matchesLevel(entry) && matchesContext(entry) && matchesServer(entry) + ), + [searchedEntries, matchesLevel, matchesContext, matchesServer] + ); - // Apply entry limit as a display cap on filtered results - if (entryLimit !== 'all') { - const limit = parseInt(entryLimit); - if (filtered.length > limit) { - return filtered.slice(0, limit); - } - } + /** Every entry matching the filters — what Copy and Save act on. */ + const filteredLogs = useMemo( + () => timelineEntries.filter(matchesTimeRange), + [timelineEntries, matchesTimeRange] + ); + + /** The page of matches currently handed to the list. */ + const visibleLogs = useMemo( + () => filteredLogs.slice(0, visibleCount), + [filteredLogs, visibleCount] + ); + + const handleEndReached = useCallback(() => { + setVisibleCount((previous) => + advanceVisibleCount(previous, filteredLogs.length, PAGE_SIZE) + ); + }, [filteredLogs.length]); - return filtered; + // Changing what matches restarts paging; appended entries must not, or a + // reader scrolled into history would be yanked back to the first page. + useEffect(() => { + setVisibleCount(PAGE_SIZE); }, [ - logEntries, debouncedSearchFilter, - levelFilter, - contextFilter, - serverFilter, - entryLimit, + levelFilters, + contextFilters, + serverFilters, + timeRange, ]); + /** + * Runs of entries sharing a calendar day. Fed to the list as groups so their + * headers stick to the top of the viewport while that day scrolls past. + */ + const dayGroups = useMemo(() => { + const groups: { label: string; count: number }[] = []; + let previousDay: string | undefined; + visibleLogs.forEach((entry) => { + const day = getEntryDay(entry.timestamp); + if (day !== previousDay) { + groups.push({ label: formatDayLabel(day), count: 0 }); + previousDay = day; + } + groups[groups.length - 1].count += 1; + }); + return groups; + }, [visibleLogs]); + + const dayGroupCounts = useMemo( + () => dayGroups.map((group) => group.count), + [dayGroups] + ); + + const activeFilterCount = useMemo( + () => + (debouncedSearchFilter ? 1 : 0) + + (isFacetNarrowed(levelFilters) ? 1 : 0) + + (isFacetNarrowed(contextFilters) ? 1 : 0) + + (isFacetNarrowed(serverFilters) ? 1 : 0) + + (timeRange !== null ? 1 : 0), + [ + debouncedSearchFilter, + levelFilters, + contextFilters, + serverFilters, + timeRange, + ] + ); + + const handleToggleLevel = useCallback( + (level: LogLevel) => { + setLevelFilters((previous) => + toggleFacet( + readFacetSelection(previous), + level, + availableLevels.length > 0 ? availableLevels : LOG_LEVELS + ) + ); + }, + [availableLevels, setLevelFilters] + ); + + const handleToggleContext = useCallback( + (context: string) => { + setContextFilters((previous) => + toggleFacet(readFacetSelection(previous), context, contextOptions) + ); + }, + [contextOptions, setContextFilters] + ); + + const handleToggleServer = useCallback( + (server: string) => { + setServerFilters((previous) => + toggleFacet(readFacetSelection(previous), server, serverOptions) + ); + }, + [serverOptions, setServerFilters] + ); + + const handleToggleExpanded = useCallback((id: string) => { + setExpandedEntryIds((previous) => { + const next = new Set(previous); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + useEffect(() => { lastModifiedTimeRef.current = fileInfo?.lastModifiedTime; }, [fileInfo?.lastModifiedTime]); @@ -382,7 +618,17 @@ function LogViewerWindow() { 'log-viewer-window/get-server-mapping' )) as { success: boolean; mapping: Record }; if (response?.success) { - setServerMapping(response.mapping); + // Workspace titles are often the raw address; strip the scheme and + // trailing slashes here so the sidebar labels and the tag on every + // row read the same way the workspace tabs do. + setServerMapping( + Object.fromEntries( + Object.entries(response.mapping).map(([host, title]) => [ + host, + formatServerTitle(title || host), + ]) + ) + ); } } catch { // Non-critical: mapping not available yet @@ -434,7 +680,11 @@ function LogViewerWindow() { )) as ReadLogsTailResponse; if (tailResponse?.success && tailResponse.logs) { - const newEntries = parseLogLines(tailResponse.logs); + parseGenerationRef.current += 1; + const newEntries = parseLogLines( + tailResponse.logs, + `g${parseGenerationRef.current}` + ); if (newEntries.length > 0) { setLogEntries((prev) => { // newEntries are already reversed (newest first) @@ -442,6 +692,10 @@ function LogViewerWindow() { return [...newEntries, ...prev]; }); + if (isSuspendedRef.current) { + setPendingNewEntryCount((prev) => prev + newEntries.length); + } + setFileInfo((prev) => prev ? { @@ -464,7 +718,7 @@ function LogViewerWindow() { } catch (error) { console.error('Failed to check for updates:', error); } - }, [isStreaming, currentLogFile.isDefaultLog, loadLogs, parseLogLines]); + }, [isStreaming, currentLogFile.isDefaultLog, loadLogs]); useEffect(() => { if (!isStreaming || !currentLogFile.isDefaultLog) return; @@ -479,6 +733,13 @@ function LogViewerWindow() { } }, [autoScroll]); + useEffect(() => { + isSuspendedRef.current = autoScroll && userHasScrolled; + if (!isSuspendedRef.current) { + setPendingNewEntryCount(0); + } + }, [autoScroll, userHasScrolled]); + useEffect(() => { if ( autoScroll && @@ -488,6 +749,7 @@ function LogViewerWindow() { ) { const timeoutId = setTimeout(() => { isAutoScrollingRef.current = true; + lastAutoScrollAtRef.current = Date.now(); if (virtuosoRef.current && autoScroll && !userHasScrolled) { virtuosoRef.current.scrollToIndex({ index: 0, @@ -504,24 +766,69 @@ function LogViewerWindow() { const handleScroll = useCallback(() => { if (isAutoScrollingRef.current) return; + if (Date.now() - lastAutoScrollAtRef.current < AUTO_SCROLL_GUARD_MS) return; if (autoScroll && !userHasScrolled) { setUserHasScrolled(true); } }, [autoScroll, userHasScrolled]); + const handleResumeAutoScroll = useCallback(() => { + setUserHasScrolled(false); + setPendingNewEntryCount(0); + if (virtuosoRef.current) { + isAutoScrollingRef.current = true; + lastAutoScrollAtRef.current = Date.now(); + virtuosoRef.current.scrollToIndex({ index: 0, behavior: 'smooth' }); + isAutoScrollingRef.current = false; + } + }, []); + + const handleCopyEntry = useCallback((entry: LogEntryType) => { + navigator.clipboard.writeText(entry.raw).catch((error) => { + console.error('Failed to copy entry to clipboard:', error); + }); + }, []); + + const renderDayGroup = useCallback( + (groupIndex: number) => ( + + ), + [dayGroups, surfaces, isTransparent] + ); + const renderLogEntry = useCallback( - (_index: number, entry: LogEntryType) => { - return ( - - ); - }, - [showContext, showServer, serverMapping] + (_index: number, _groupIndex: number, entry: LogEntryType) => ( + + ), + [ + showContext, + showServer, + serverMapping, + debouncedSearchFilter, + wrapLines, + collapseMultiline, + expandedEntryIds, + handleToggleExpanded, + handleCopyEntry, + surfaces, + ] ); const handleOpenLogFile = useCallback(async () => { @@ -532,6 +839,8 @@ function LogViewerWindow() { if (response?.success && response.filePath) { setLogEntries([]); setFileInfo(null); + setTimeRange(null); + setLoadError(null); setIsStreaming(false); @@ -549,6 +858,8 @@ function LogViewerWindow() { const handleOpenDefaultLog = useCallback(() => { setLogEntries([]); setFileInfo(null); + setTimeRange(null); + setLoadError(null); setCurrentLogFile({ filePath: undefined, @@ -557,6 +868,24 @@ function LogViewerWindow() { }); }, []); + const handleRevealLogFile = useCallback(async () => { + try { + const response = (await ipcRenderer.invoke( + 'log-viewer-window/reveal-log-file', + { + filePath: currentLogFile.isDefaultLog + ? undefined + : currentLogFile.filePath, + } + )) as { success: boolean; error?: string }; + if (!response?.success) { + console.error('Failed to reveal log file:', response?.error); + } + } catch (error) { + console.error('Failed to reveal log file:', error); + } + }, [currentLogFile.filePath, currentLogFile.isDefaultLog]); + const handleRefresh = useCallback(() => { loadLogs(); }, [loadLogs]); @@ -584,8 +913,8 @@ function LogViewerWindow() { }, [currentLogFile.isDefaultLog, loadLogs]); const handleToggleStreaming = useCallback(() => { - setIsStreaming(!isStreaming); - }, [isStreaming]); + setIsStreaming((previous) => !previous); + }, []); const handleCopyLogs = useCallback(() => { const logText = filteredLogs.map((entry) => entry.raw).join('\n'); @@ -595,6 +924,7 @@ function LogViewerWindow() { }, [filteredLogs]); const handleSaveLogs = useCallback(async () => { + setSaveError(null); try { const logText = filteredLogs.map((entry) => entry.raw).join('\n'); const timestamp = new Date() @@ -611,23 +941,38 @@ function LogViewerWindow() { if (response?.success) { console.log('Logs saved successfully to:', response.filePath); + acknowledgeSave(); + } else if (response?.canceled) { + // User dismissed the save dialog — not an error, stay silent. } else if (response?.error) { console.error('Failed to save logs:', response.error); + setSaveError(response.error); } } catch (error) { console.error('Failed to save logs:', error); + setSaveError( + error instanceof Error + ? error.message + : t('logViewer.messages.saveFailed') + ); } - }, [filteredLogs]); + }, [filteredLogs, acknowledgeSave, t]); + + const handleToggleAutoScroll = useCallback(() => { + setAutoScroll((previous) => { + const next = !previous; + if (next) { + setUserHasScrolled(false); + setTimeout(() => { + virtuosoRef.current?.scrollToIndex({ index: 0, behavior: 'smooth' }); + }, SCROLL_DELAY_MS); + } + return next; + }); + }, [setAutoScroll]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 'f') { - e.preventDefault(); - const searchInput = document.querySelector( - 'input[type="search"]' - ) as HTMLInputElement; - searchInput?.focus(); - } if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSaveLogs(); @@ -639,326 +984,246 @@ function LogViewerWindow() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [searchFilter, setSearchFilter, handleSaveLogs]); - - const handleClose = useCallback(() => { - ipcRenderer.invoke('log-viewer-window/close-requested'); - }, []); + }, [searchFilter, handleSaveLogs]); return ( - + + + + + + {saveError && ( + + setSaveError(null)} + /> + } + > + {saveError} + + + )} + - setShowContext(!showContext)} + showServer={showServer} + onToggleShowServer={() => setShowServer(!showServer)} + wrapLines={wrapLines} + onToggleWrapLines={() => setWrapLines(!wrapLines)} + collapseMultiline={collapseMultiline} + onToggleCollapseMultiline={() => + setCollapseMultiline(!collapseMultiline) + } + autoScroll={autoScroll} + onToggleAutoScroll={handleToggleAutoScroll} + showTimeline={showTimeline} + onToggleShowTimeline={() => setShowTimeline(!showTimeline)} + onSelectAllLevels={() => setLevelFilters(null)} + onSelectAllContexts={() => setContextFilters(null)} + onSelectAllServers={() => setServerFilters(null)} /> - - {t('logViewer.title')} - + - - - - {currentLogFile.fileName} - {!currentLogFile.isDefaultLog && - ` (${t('logViewer.fileInfo.custom')})`} - - - {fileInfo && ( - - - - - {fileInfo.totalEntriesInFile && - fileInfo.totalEntriesInFile !== fileInfo.totalEntries - ? t('logViewer.fileInfo.entriesOfTotal', { - count: fileInfo.totalEntries, - total: fileInfo.totalEntriesInFile, - }) - : t('logViewer.fileInfo.entries', { - count: fileInfo.totalEntries, - })} - + + {showTimeline && !loadError && timelineEntries.length > 0 && ( + + )} + {isLoading && logEntries.length === 0 && ( + + - - - {fileInfo.size} + )} + {!isLoading && loadError && ( + + + {loadError} + + - - - {fileInfo.dateRange} + )} + {!isLoading && !loadError && visibleLogs.length === 0 && ( + + + + + {t('logViewer.messages.noLogsFound')} + + + {t('logViewer.messages.adjustFilters')} + + {activeFilterCount > 0 && ( + + + {t('logViewer.buttons.clearFilters')} + + + )} + - - )} - - - - - {!currentLogFile.isDefaultLog && ( - - )} - - - - - - - - - - - - - setShowContext(!showContext)} - /> - - {t('logViewer.controls.showContext')} - - - - setShowServer(!showServer)} - /> - - {t('logViewer.controls.showServer')} - - - - { - const newAutoScroll = !autoScroll; - setAutoScroll(newAutoScroll); - if (newAutoScroll) { - setUserHasScrolled(false); - if (filteredLogs.length > 0 && virtuosoRef.current) { - setTimeout(() => { - virtuosoRef.current?.scrollToIndex({ - index: 0, - behavior: 'smooth', - }); - }, 100); - } - } - }} - /> - - {t('logViewer.controls.autoScrollToTop')} + )} + {!loadError && visibleLogs.length > 0 && ( + + {autoScroll && + userHasScrolled && + pendingNewEntryCount > 0 && ( + + + + {t('logViewer.messages.newEntriesPaused', { + count: pendingNewEntryCount, + })} + + + )} + + entry?.id ?? `day-${index}` + } + itemContent={renderLogEntry} + overscan={VIRTUOSO_OVERSCAN} + style={{ height: '100%', width: '100%' }} + onScroll={handleScroll} + endReached={handleEndReached} + /> + + )} - - - - - - - - - - )} - - - - - {isLoading && ( - - - - )} - {!isLoading && filteredLogs.length === 0 && ( - - - - {t('logViewer.messages.noLogsFound')} - - - {t('logViewer.messages.adjustFilters')} - - - )} - {!isLoading && filteredLogs.length > 0 && ( - - - {t('logViewer.fileInfo.entries', { - count: filteredLogs.length, - })} - - - - )} - - - + ); } diff --git a/src/logViewerWindow/main/ipc.main.spec.ts b/src/logViewerWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..cbf74afba0 --- /dev/null +++ b/src/logViewerWindow/main/ipc.main.spec.ts @@ -0,0 +1,265 @@ +import fs from 'fs'; + +import { openLogViewerWindow, startLogViewerWindowHandler } from '../ipc'; + +const handlers = new Map(); +const select = jest.fn(); +const dispatch = jest.fn(); +const getRootWindow = jest.fn(); + +const logContent = [ + '[2026-01-01T00:00:00.000Z] [info] [main] first', + '[2026-01-01T00:00:01.000Z] [info] [main] second', + '[2026-01-01T00:00:02.000Z] [error] [main] third', +].join('\n'); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => true), + mkdirSync: jest.fn(), + statSync: jest.fn(() => ({ + size: 128, + mtime: new Date('2026-01-01T00:00:02.000Z'), + })), + promises: { + readFile: jest.fn(async () => logContent), + writeFile: jest.fn(async () => undefined), + mkdir: jest.fn(async () => undefined), + stat: jest.fn(async () => ({ + size: 128, + mtime: new Date('2026-01-01T00:00:02.000Z'), + mtimeMs: Date.parse('2026-01-01T00:00:02.000Z'), + })), + }, + createWriteStream: jest.fn(() => ({ + on: jest.fn(), + })), + readFile: jest.fn( + ( + _p: string, + enc: string | ((err: null, data: string) => void), + cb?: (err: null, data: string) => void + ) => { + if (typeof enc === 'function') { + enc(null, logContent); + } else { + cb?.(null, logContent); + } + } + ), + writeFile: jest.fn((_p: string, _data: string, cb?: (err: null) => void) => + cb?.(null) + ), + }; +}); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn((name: string) => + name === 'logs' ? '/tmp/logs' : '/tmp' + ), + getAppPath: jest.fn(() => '/app'), + on: jest.fn(), + }, + BrowserWindow: jest.fn().mockImplementation(() => ({ + loadFile: jest.fn().mockResolvedValue(undefined), + once: jest.fn(), + on: jest.fn(), + show: jest.fn(), + focus: jest.fn(), + isMinimized: jest.fn(() => false), + restore: jest.fn(), + addListener: jest.fn(), + isDestroyed: jest.fn(() => false), + webContents: { + openDevTools: jest.fn(), + send: jest.fn(), + setWindowOpenHandler: jest.fn(), + on: jest.fn(), + once: jest.fn(), + removeAllListeners: jest.fn(), + }, + getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 800, height: 600 })), + setBounds: jest.fn(), + close: jest.fn(), + })), + screen: { + getDisplayNearestPoint: jest.fn(() => ({ + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + workAreaSize: { width: 1920, height: 1080 }, + })), + getCursorScreenPoint: jest.fn(() => ({ x: 10, y: 10 })), + getPrimaryDisplay: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + })), + }, + dialog: { + showSaveDialog: jest.fn(), + showOpenDialog: jest.fn(), + showMessageBox: jest.fn().mockResolvedValue({ response: 1 }), + }, +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('archiver', () => + jest.fn(() => ({ + pipe: jest.fn(), + append: jest.fn(), + finalize: jest.fn(), + on: jest.fn(), + })) +); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../logging/context', () => ({ + getHost: jest.fn((url: string) => new URL(url).hostname), +})); + +jest.mock('../../store', () => ({ + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + watch: jest.fn(), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +describe('logViewerWindow/ipc', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + getRootWindow.mockResolvedValue({ + getNormalBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + isDestroyed: () => false, + }); + select.mockImplementation((selector: any) => + selector({ + servers: [{ url: 'https://open.rocket.chat', title: 'Community' }], + }) + ); + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('opens a log viewer window', async () => { + await openLogViewerWindow(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { BrowserWindow } = require('electron'); + expect(BrowserWindow).toHaveBeenCalled(); + }); + + it('registers handlers and reads default log with limit', async () => { + startLogViewerWindowHandler(); + expect(handlers.has('log-viewer-window/read-logs')).toBe(true); + + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { + limit: 2, + } + ); + expect(result.success).toBe(true); + expect(result.logs).toEqual(expect.any(String)); + expect(result.fileName).toBe('main.log'); + expect(result.isDefaultLog).toBe(true); + }); + + it('rejects unauthorized custom log paths', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { filePath: '/tmp/custom.log', limit: 10 } + ); + expect(result.success).toBe(false); + expect(result.error).toMatch(/not authorized|Path traversal|absolute/i); + }); + + it('rejects path traversal', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { filePath: '/tmp/../etc/passwd.log', limit: 10 } + ); + expect(result.success).toBe(false); + }); + + it('returns server mapping', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/get-server-mapping')?.( + {} + ); + expect(result.success).toBe(true); + expect(result.mapping['open.rocket.chat']).toBe('Community'); + }); + + it('clears logs', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/clear-logs')?.({}); + expect(result.success).toBe(true); + }); + + it('reads all logs when limit is all', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/read-logs')?.( + {}, + { + limit: 'all', + } + ); + expect(result.success).toBe(true); + expect(result.logs).toContain('first'); + expect(result.logs).toContain('third'); + }); + + it('stats the default log file', async () => { + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/stat-log')?.({}); + expect(result.success).toBe(true); + }); + + it('select-log-file returns canceled when dialog cancels', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dialog } = require('electron'); + dialog.showOpenDialog.mockResolvedValue({ canceled: true, filePaths: [] }); + await openLogViewerWindow(); + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/select-log-file')?.( + {} + ); + expect(result.canceled || result.success === false).toBe(true); + }); + + it('close-requested is safe when window exists', async () => { + await openLogViewerWindow(); + startLogViewerWindowHandler(); + await expect( + handlers.get('log-viewer-window/close-requested')?.({}) + ).resolves.not.toThrow(); + }); + + it('confirm-clear-logs uses dialog', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dialog } = require('electron'); + dialog.showMessageBox.mockResolvedValue({ response: 1 }); + await openLogViewerWindow(); + startLogViewerWindowHandler(); + const result = await handlers.get('log-viewer-window/confirm-clear-logs')?.( + {} + ); + expect(typeof result === 'boolean' || result === undefined).toBe(true); + }); +}); diff --git a/src/logViewerWindow/pagination.ts b/src/logViewerWindow/pagination.ts new file mode 100644 index 0000000000..a2ab3984cf --- /dev/null +++ b/src/logViewerWindow/pagination.ts @@ -0,0 +1,18 @@ +/** + * Next page size for the log list. + * + * This has to settle: the virtual list keeps firing `endReached` while the last + * row stays in view, so returning a larger number once everything is already + * rendered re-renders forever and wedges the renderer. Returning `visibleCount` + * unchanged lets React bail out of the update instead. + */ +export const advanceVisibleCount = ( + visibleCount: number, + totalCount: number, + pageSize: number +): number => { + if (visibleCount >= totalCount) { + return visibleCount; + } + return Math.min(visibleCount + pageSize, totalCount); +}; diff --git a/src/logViewerWindow/parseLogs.ts b/src/logViewerWindow/parseLogs.ts new file mode 100644 index 0000000000..3d838fe729 --- /dev/null +++ b/src/logViewerWindow/parseLogs.ts @@ -0,0 +1,115 @@ +import { type LogEntryType, parseLogLevel } from './types'; + +const LOG_LINE_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s*(.*)$/; +const CONTEXT_RUN_REGEX = /^((?:\[[^\]]*\]\s*)+)(.*)$/; +const CONTEXT_TAG_REGEX = /\[([^\]]*)\]/g; + +const buildEntryDerivedFields = ( + entry: Pick +): Pick => ({ + searchText: `${entry.message} ${entry.context}`.toLowerCase(), + rawLower: entry.raw.toLowerCase(), +}); + +/** + * Parse `[timestamp] [level] [tag] [tag] message` lines into entries, newest + * first. Lines that do not open a new entry are folded into the previous + * message, which is how stack traces stay attached to the error that threw. + * + * Context tags are kept as a list rather than a joined string: tags such as + * `renderer:webview` or `localhost:3000` are meaningful units, and splitting the + * joined form on whitespace would shred any tag that contains a space. + * + * `idPrefix` keeps ids unique across calls — tail reads are parsed separately + * and prepended, so a shared prefix would collide with the initial read's ids. + */ +export const parseLogLines = ( + logText: string, + idPrefix = 'log' +): LogEntryType[] => { + if (!logText || logText.trim() === '') { + return []; + } + + const lines = logText.split(/\r?\n/); + const entries: LogEntryType[] = []; + let currentEntry: LogEntryType | null = null; + + lines.forEach((line) => { + const match = line.match(LOG_LINE_REGEX); + + if (match) { + const [, timestamp, level, rest] = match; + + const contextMatch = rest.match(CONTEXT_RUN_REGEX); + const contextTags = contextMatch + ? Array.from(contextMatch[1].matchAll(CONTEXT_TAG_REGEX), ([, tag]) => + tag.trim() + ).filter(Boolean) + : []; + const message = contextMatch ? contextMatch[2] : rest; + + if (currentEntry) { + entries.push(currentEntry); + } + + const parsedMessage = message.trim(); + const context = contextTags.join(' '); + + currentEntry = { + id: `${idPrefix}-${entries.length}`, + timestamp, + level: parseLogLevel(level), + contextTags, + context, + message: parsedMessage, + raw: line, + ...buildEntryDerivedFields({ + message: parsedMessage, + context, + raw: line, + }), + }; + } else if (currentEntry && line.trim()) { + currentEntry.message += `\n${line}`; + currentEntry.raw += `\n${line}`; + const derived = buildEntryDerivedFields(currentEntry); + currentEntry.searchText = derived.searchText; + currentEntry.rawLower = derived.rawLower; + } + }); + + if (currentEntry) { + entries.push(currentEntry); + } + + return entries.reverse(); +}; + +/** Calendar day of an entry, used to group the list under date dividers. */ +export const getEntryDay = (timestamp: string): string => { + const parsed = new Date(timestamp); + if (!isNaN(parsed.getTime())) { + return parsed.toDateString(); + } + return timestamp.slice(0, 10); +}; + +/** Drops the date from `2026-08-07 18:28:43.920`, keeping the wall clock time. */ +export const getEntryTime = (timestamp: string): string => { + const [, time] = timestamp.split(' '); + return time || timestamp; +}; + +export const countBy = ( + items: T[], + getKeys: (item: T) => string[] +): Record => { + const counts: Record = {}; + items.forEach((item) => { + getKeys(item).forEach((key) => { + counts[key] = (counts[key] ?? 0) + 1; + }); + }); + return counts; +}; diff --git a/src/logViewerWindow/styles.tsx b/src/logViewerWindow/styles.tsx new file mode 100644 index 0000000000..efc1cb8696 --- /dev/null +++ b/src/logViewerWindow/styles.tsx @@ -0,0 +1,74 @@ +import { Global, css } from '@emotion/react'; + +import type { Surfaces } from '../ui/windowChrome/appearance'; + +export const LOG_ROW_CLASS = 'rcx-log-row'; +export const LOG_ROW_ACTIONS_CLASS = 'rcx-log-row__actions'; +export const LOG_MARK_CLASS = 'rcx-log-mark'; +export const TIMELINE_SELECTION_CLASS = 'rcx-log-timeline-selection'; +export const TIMELINE_PLOT_CLASS = 'rcx-log-timeline-plot'; + +type LogViewerGlobalStylesProps = { + surfaces: Surfaces; +}; + +/** + * Log-specific rules only; the window background, drag regions, filter hover and + * scrollbars come from the shared window chrome. + * + * Row hover lives in a stylesheet rather than React state because the list runs + * to thousands of rows and per-row state would re-render on every pointer move. + */ +export const LogViewerGlobalStyles = ({ + surfaces, +}: LogViewerGlobalStylesProps) => ( + +); diff --git a/src/logViewerWindow/textHighlight.ts b/src/logViewerWindow/textHighlight.ts new file mode 100644 index 0000000000..5d589c14b2 --- /dev/null +++ b/src/logViewerWindow/textHighlight.ts @@ -0,0 +1,49 @@ +export interface ITextSegment { + text: string; + matched: boolean; +} + +export type TextSegment = ITextSegment; + +const escapeRegExp = (value: string): string => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const splitTextForHighlight = ( + text: string, + query: string +): TextSegment[] => { + if (!query) { + return [{ text, matched: false }]; + } + + const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi'); + const parts = text.split(regex); + + if (parts.length === 1) { + return [{ text, matched: false }]; + } + + const lowerQuery = query.toLowerCase(); + return parts + .filter((part) => part !== '') + .map((part) => ({ + text: part, + matched: part.toLowerCase() === lowerQuery, + })); +}; + +export const MAX_COLLAPSED_MESSAGE_LINES = 6; + +export const splitMessageForCollapse = ( + message: string +): { visibleLines: string[]; hiddenLineCount: number } => { + const lines = message.split('\n'); + if (lines.length <= MAX_COLLAPSED_MESSAGE_LINES) { + return { visibleLines: lines, hiddenLineCount: 0 }; + } + + return { + visibleLines: lines.slice(0, MAX_COLLAPSED_MESSAGE_LINES), + hiddenLineCount: lines.length - MAX_COLLAPSED_MESSAGE_LINES, + }; +}; diff --git a/src/logViewerWindow/timeline.ts b/src/logViewerWindow/timeline.ts new file mode 100644 index 0000000000..ccf9e19b40 --- /dev/null +++ b/src/logViewerWindow/timeline.ts @@ -0,0 +1,136 @@ +import type { LogEntryType, LogLevel } from './types'; + +export type TimelineBucket = { + /** Inclusive start of the bucket's time range, epoch ms. */ + startTime: number; + /** Exclusive end of the range, except for the last bucket. */ + endTime: number; + total: number; + countsByLevel: Partial>; + /** + * Index of this bucket's newest entry in the source array. The list is + * newest-first, so this is the smallest index that landed here — clicking a + * bucket can scroll straight to it. + */ + newestIndex: number; +}; + +export type Timeline = { + buckets: TimelineBucket[]; + startTime: number; + endTime: number; + /** Highest bucket total, for scaling bar heights. */ + peak: number; +}; + +export const EMPTY_TIMELINE: Timeline = { + buckets: [], + startTime: 0, + endTime: 0, + peak: 0, +}; + +/** + * Buckets entries into equal time slices, oldest first, so they can be drawn as + * a distribution over the file's time span. + * + * Entries arrive newest-first and their timestamps are log text, so anything + * unparseable is skipped rather than dragged to the epoch — one bad line would + * otherwise stretch the span across decades and flatten every real bar. + */ +export const buildTimeline = ( + entries: LogEntryType[], + bucketCount: number +): Timeline => { + if (entries.length === 0 || bucketCount < 1) { + return EMPTY_TIMELINE; + } + + const times = new Array(entries.length); + let startTime = Infinity; + let endTime = -Infinity; + + entries.forEach((entry, index) => { + const time = new Date(entry.timestamp).getTime(); + times[index] = time; + if (isNaN(time)) return; + if (time < startTime) startTime = time; + if (time > endTime) endTime = time; + }); + + if (!isFinite(startTime) || !isFinite(endTime)) { + return EMPTY_TIMELINE; + } + + // A single instant still deserves one bar rather than a division by zero. + const span = endTime - startTime; + const resolvedCount = span === 0 ? 1 : bucketCount; + const bucketSpan = span === 0 ? 1 : span / resolvedCount; + + const buckets: TimelineBucket[] = Array.from( + { length: resolvedCount }, + (_unused, index) => ({ + startTime: startTime + index * bucketSpan, + endTime: startTime + (index + 1) * bucketSpan, + total: 0, + countsByLevel: {}, + newestIndex: -1, + }) + ); + + let peak = 0; + + entries.forEach((entry, index) => { + const time = times[index]; + if (isNaN(time)) return; + + // The newest entry lands exactly on endTime, which is one past the last + // bucket's start, so clamp it back into that bucket. + const slot = Math.min( + resolvedCount - 1, + Math.floor((time - startTime) / bucketSpan) + ); + const bucket = buckets[slot]; + + bucket.total += 1; + bucket.countsByLevel[entry.level] = + (bucket.countsByLevel[entry.level] ?? 0) + 1; + if (bucket.newestIndex === -1 || index < bucket.newestIndex) { + bucket.newestIndex = index; + } + if (bucket.total > peak) peak = bucket.total; + }); + + return { buckets, startTime, endTime, peak }; +}; + +export type TimeRange = { + startTime: number; + endTime: number; +}; + +/** + * Time range covering two bucket indices, in either drag direction. Returns null + * when the indices fall outside the timeline, so a stale drag cannot commit a + * range built from undefined buckets. + */ +export const resolveRange = ( + buckets: TimelineBucket[], + anchorIndex: number, + cursorIndex: number +): TimeRange | null => { + const first = buckets[Math.min(anchorIndex, cursorIndex)]; + const last = buckets[Math.max(anchorIndex, cursorIndex)]; + if (!first || !last) return null; + return { startTime: first.startTime, endTime: last.endTime }; +}; + +/** Whether a timestamp falls inside a selected range, ends inclusive. */ +export const isWithinRange = (time: number, range: TimeRange): boolean => + !isNaN(time) && time >= range.startTime && time <= range.endTime; + +/** Bar height as a percentage, keeping any non-empty bucket visible. */ +export const getBarHeightPercent = (total: number, peak: number): number => { + if (total === 0 || peak <= 0) return 0; + return Math.max(6, Math.round((total / peak) * 100)); +}; diff --git a/src/logViewerWindow/types.ts b/src/logViewerWindow/types.ts index e408b01585..a778667f1d 100644 --- a/src/logViewerWindow/types.ts +++ b/src/logViewerWindow/types.ts @@ -10,9 +10,16 @@ export interface ILogEntryType { id: string; timestamp: string; level: LogLevel; + /** Bracketed context tags in the order they were logged. */ + contextTags: string[]; + /** `contextTags` joined with spaces, for free-text search. */ context: string; message: string; raw: string; + /** Lowercased `message` + `context` for filter matching without reallocating. */ + searchText: string; + /** Lowercased `raw` for full-entry search without reallocating. */ + rawLower: string; } export interface IReadLogsResponse { @@ -81,3 +88,15 @@ export const parseLogLevel = (value: unknown): LogLevel => { const trimmed = value.trim().toLowerCase(); return isLogLevel(trimmed) ? trimmed : 'info'; }; + +const LOG_LEVEL_ORDER: Record = { + silly: 0, + verbose: 1, + debug: 2, + info: 3, + warn: 4, + error: 5, +}; + +export const isAtLeastLevel = (level: LogLevel, minLevel: LogLevel): boolean => + LOG_LEVEL_ORDER[level] >= LOG_LEVEL_ORDER[minLevel]; diff --git a/src/logging/cleanup.spec.ts b/src/logging/__tests__/cleanup.spec.ts similarity index 94% rename from src/logging/cleanup.spec.ts rename to src/logging/__tests__/cleanup.spec.ts index d6fde6f600..4ec3667dcd 100644 --- a/src/logging/cleanup.spec.ts +++ b/src/logging/__tests__/cleanup.spec.ts @@ -1,8 +1,9 @@ import type * as fs from 'fs'; +import path from 'path'; import { app } from 'electron'; -import { cleanupOldLogs } from './cleanup'; +import { cleanupOldLogs } from '../cleanup'; const { existsSync, readdirSync, statSync, unlinkSync } = jest.requireMock('fs'); @@ -81,7 +82,7 @@ describe('logging/cleanup', () => { cleanupOldLogs(1); expect(unlinkSync).toHaveBeenCalledTimes(1); - expect(unlinkSync).toHaveBeenCalledWith(`${logsPath}/old.log`); + expect(unlinkSync).toHaveBeenCalledWith(path.join(logsPath, 'old.log')); expect(infoMock).toHaveBeenCalledWith( '[logging] Cleaned up 1 old log file(s)' ); @@ -125,7 +126,7 @@ describe('logging/cleanup', () => { cleanupOldLogs(); - expect(unlinkSync).toHaveBeenCalledWith(`${logsPath}/old.log`); + expect(unlinkSync).toHaveBeenCalledWith(path.join(logsPath, 'old.log')); expect(infoMock).toHaveBeenCalledWith( '[logging] Cleaned up 1 old log file(s)' ); diff --git a/src/logging/fallback.spec.ts b/src/logging/__tests__/fallback.spec.ts similarity index 97% rename from src/logging/fallback.spec.ts rename to src/logging/__tests__/fallback.spec.ts index 6da5894b41..faf770e0d5 100644 --- a/src/logging/fallback.spec.ts +++ b/src/logging/__tests__/fallback.spec.ts @@ -1,4 +1,4 @@ -import { fallbackLog, logLoggingFailure } from './fallback'; +import { fallbackLog, logLoggingFailure } from '../fallback'; describe('logging/fallback', () => { const writeSpy = jest.spyOn(process.stderr, 'write'); diff --git a/src/logging/scopes.spec.ts b/src/logging/__tests__/scopes.spec.ts similarity index 94% rename from src/logging/scopes.spec.ts rename to src/logging/__tests__/scopes.spec.ts index 165f637027..9045b7d14d 100644 --- a/src/logging/scopes.spec.ts +++ b/src/logging/__tests__/scopes.spec.ts @@ -1,4 +1,4 @@ -import type * as ScopesModule from './scopes'; +import type * as ScopesModule from '../scopes'; const logDebug = jest.fn(); const logInfo = jest.fn(); @@ -13,7 +13,7 @@ jest.mock('electron-log', () => ({ })); const getProcessContext = jest.fn(); -jest.mock('./context', () => ({ +jest.mock('../context', () => ({ getProcessContext, })); @@ -22,7 +22,7 @@ const loadScopes = (processContext: string) => { let scopesModule: typeof ScopesModule; jest.isolateModules(() => { // eslint-disable-next-line @typescript-eslint/no-var-requires - scopesModule = require('./scopes'); + scopesModule = require('../scopes'); }); return scopesModule!; }; diff --git a/src/logging/utils.spec.ts b/src/logging/__tests__/utils.spec.ts similarity index 97% rename from src/logging/utils.spec.ts rename to src/logging/__tests__/utils.spec.ts index fa183287ab..9ec1863ae9 100644 --- a/src/logging/utils.spec.ts +++ b/src/logging/__tests__/utils.spec.ts @@ -1,4 +1,4 @@ -import { logExecutionTime } from './utils'; +import { logExecutionTime } from '../utils'; const debugMock = jest.fn(); const errorMock = jest.fn(); diff --git a/src/main.ts b/src/main.ts index 29823b64ef..cf4e7ae7ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,15 +12,24 @@ import { watchAndPersistChanges, } from './app/main/data'; import { setUserDataDirectory } from './app/main/dev'; +import { flushPersistedValues } from './app/main/persistence'; import { startBrowserHandler } from './browser/ipc'; import { setupDeepLinks, processDeepLinksInArgs } from './deepLinks/main'; import { startDocumentViewerHandler } from './documentViewer/ipc'; +import { startDocumentViewerWindowHandler } from './documentViewerWindow/ipc'; import { setupDownloads } from './downloads/main'; import { setupElectronDlWithTracking } from './downloads/main/setup'; +import { + restoreDownloadsWindow, + startDownloadsWindowHandler, +} from './downloadsWindow/ipc'; import { setupMainErrorHandling } from './errors'; import i18n from './i18n/main'; import { handleJitsiDesktopCapturerGetSources } from './jitsi/ipc'; -import { startLogViewerWindowHandler } from './logViewerWindow/ipc'; +import { + restoreLogViewerWindow, + startLogViewerWindowHandler, +} from './logViewerWindow/ipc'; import { logger, setupWebContentsLogging, @@ -45,6 +54,10 @@ import { } from './servers/cache'; import { setupServers } from './servers/main'; import { checkSupportedVersionServers } from './servers/supportedVersions/main'; +import { + restoreSettingsWindow, + startSettingsWindowHandler, +} from './settingsWindow/ipc'; import { setupSpellChecking } from './spellChecking/main'; import { createMainReduxStore } from './store'; import { applySystemCertificates } from './systemCertificates'; @@ -63,6 +76,7 @@ import { exportLocalStorage, watchMachineTheme, } from './ui/main/rootWindow'; +import { startSecondaryWindowControlsHandler } from './ui/main/secondaryWindowControls'; import { attachGuestWebContentsEvents } from './ui/main/serverView'; import touchBar from './ui/main/touchBar'; import trayIcon from './ui/main/trayIcon'; @@ -130,7 +144,11 @@ const start = async (): Promise => { setupScreenSharing(); startServerViewScreenSharingHandler(); startVideoCallWindowHandler(); + startSecondaryWindowControlsHandler(); + startDocumentViewerWindowHandler(); startLogViewerWindowHandler(); + startDownloadsWindowHandler(); + startSettingsWindowHandler(); await setupSpellChecking(); @@ -157,6 +175,7 @@ const start = async (): Promise => { attentionDrawing.tearDown(); stopOutlookCalendarSync(); cleanupVideoCallResources(); + flushPersistedValues(); }); watchAndPersistChanges(); @@ -168,6 +187,10 @@ const start = async (): Promise => { startBrowserHandler(); checkSupportedVersionServers(); + await restoreLogViewerWindow(); + await restoreDownloadsWindow(); + await restoreSettingsWindow(); + await processDeepLinksInArgs(); console.info('Application initialization completed successfully'); diff --git a/src/notifications/__tests__/attentionDrawing.spec.ts b/src/notifications/__tests__/attentionDrawing.spec.ts new file mode 100644 index 0000000000..6bce331df6 --- /dev/null +++ b/src/notifications/__tests__/attentionDrawing.spec.ts @@ -0,0 +1,93 @@ +import attentionDrawing from '../attentionDrawing'; + +const getRootWindow = jest.fn(); +const select = jest.fn(); + +jest.mock('../../store', () => { + class Service { + protected destroy(): void {} + } + return { + Service, + select: (...args: unknown[]) => select(...args), + }; +}); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +jest.mock('electron', () => ({ + app: { + dock: { + bounce: jest.fn(() => 7), + cancelBounce: jest.fn(), + }, + }, +})); + +describe('attentionDrawing', () => { + const flashFrame = jest.fn(); + const browserWindow = { + isDestroyed: jest.fn(() => false), + flashFrame, + }; + const originalPlatform = process.platform; + + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(() => { + jest.clearAllMocks(); + getRootWindow.mockResolvedValue(browserWindow); + select.mockImplementation((selector: (s: any) => any) => + selector({ isFlashFrameEnabled: true }) + ); + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + it('no-ops when flash frame disabled', async () => { + select.mockImplementation((selector: (s: any) => any) => + selector({ isFlashFrameEnabled: false }) + ); + await attentionDrawing.drawAttention('n1'); + expect(getRootWindow).not.toHaveBeenCalled(); + }); + + it('flashes frame on non-darwin platforms', async () => { + setPlatform('linux'); + + await attentionDrawing.drawAttention('n-linux'); + expect(flashFrame).toHaveBeenCalledWith(true); + + await attentionDrawing.stopAttention('n-linux'); + expect(flashFrame).toHaveBeenCalledWith(false); + }); + + it('ignores duplicate drawAttention for same notification id', async () => { + setPlatform('linux'); + await attentionDrawing.drawAttention('dup'); + flashFrame.mockClear(); + await attentionDrawing.drawAttention('dup'); + expect(flashFrame).not.toHaveBeenCalled(); + await attentionDrawing.stopAttention('dup'); + }); + + it('keeps attention while other notifications remain active', async () => { + setPlatform('linux'); + await attentionDrawing.drawAttention('a'); + await attentionDrawing.drawAttention('b'); + flashFrame.mockClear(); + await attentionDrawing.stopAttention('a'); + expect(flashFrame).not.toHaveBeenCalled(); + await attentionDrawing.stopAttention('b'); + expect(flashFrame).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/notifications/__tests__/parseActivationArguments.spec.ts b/src/notifications/__tests__/parseActivationArguments.spec.ts new file mode 100644 index 0000000000..cf3ffb1aa5 --- /dev/null +++ b/src/notifications/__tests__/parseActivationArguments.spec.ts @@ -0,0 +1,39 @@ +import { parseActivationArguments } from '../parseActivationArguments'; + +describe('parseActivationArguments', () => { + it('parses type and tag from a raw activation arguments string', () => { + expect(parseActivationArguments('type=reply&tag=abc123')).toEqual({ + type: 'reply', + tag: 'abc123', + }); + }); + + it('URL-decodes the tag value', () => { + expect(parseActivationArguments('type=reply&tag=abc%20123%3D456')).toEqual({ + type: 'reply', + tag: 'abc 123=456', + }); + }); + + it('returns an empty object for an empty string', () => { + expect(parseActivationArguments('')).toEqual({}); + }); + + it('returns an object without a tag for garbage input without a tag', () => { + expect(parseActivationArguments('some=garbage&without=tag')).toEqual({ + type: undefined, + tag: undefined, + }); + }); + + it('returns tag without type when type is missing', () => { + expect(parseActivationArguments('tag=abc123')).toEqual({ + type: undefined, + tag: 'abc123', + }); + }); + + it('returns an empty object for undefined input', () => { + expect(parseActivationArguments(undefined)).toEqual({}); + }); +}); diff --git a/src/notifications/main.spec.ts b/src/notifications/main.spec.ts new file mode 100644 index 0000000000..2b3ba655e2 --- /dev/null +++ b/src/notifications/main.spec.ts @@ -0,0 +1,390 @@ +import { Notification } from 'electron'; + +import { + NOTIFICATIONS_NOTIFICATION_ACTIONED, + NOTIFICATIONS_NOTIFICATION_CLICKED, + NOTIFICATIONS_NOTIFICATION_REPLIED, +} from './actions'; +import { handleNotificationActivation, setupNotifications } from './main'; + +const originalPlatform = process.platform; + +let notificationInstances: any[] = []; + +jest.mock('electron', () => { + class MockNotification { + static handleActivation = jest.fn(); + + options: any; + + listeners: Record void)[]> = {}; + + constructor(options: any) { + this.options = options; + // eslint-disable-next-line @typescript-eslint/no-use-before-define + notificationInstances.push(this); + } + + addListener(event: string, handler: (...args: any[]) => void): void { + this.listeners[event] = this.listeners[event] || []; + this.listeners[event].push(handler); + } + + show = jest.fn(); + + close = jest.fn(); + } + + return { + Notification: MockNotification, + nativeImage: { createFromDataURL: jest.fn() }, + }; +}); + +jest.mock('../ipc/main', () => ({ + invoke: jest.fn(), +})); + +jest.mock('../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(), +})); + +jest.mock('../ui/main/serverView', () => ({ + getServerUrlByWebContentsId: jest.fn(() => 'https://example.com'), +})); + +jest.mock('./attentionDrawing', () => ({ + __esModule: true, + default: { drawAttention: jest.fn(), stopAttention: jest.fn() }, +})); + +const mockNotificationsWarn = jest.fn(); + +jest.mock('../logging/scopes', () => ({ + loggers: { + notifications: { warn: (...args: any[]) => mockNotificationsWarn(...args) }, + }, +})); + +const listeners = new Map void | Promise>(); + +const mockDispatch = jest.fn(); +const mockDispatchSingle = jest.fn(); +const mockSelect = jest.fn((_selector?: any) => true); + +jest.mock('../store', () => ({ + dispatch: (action: any) => mockDispatch(action), + dispatchSingle: (action: any) => mockDispatchSingle(action), + select: (selector: any) => mockSelect(selector), + listen: (type: string, listener: (action: any) => void) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +describe('notifications/main win32 activation routing', () => { + beforeAll(() => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true, + configurable: true, + }); + }); + + afterAll(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + writable: true, + configurable: true, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + listeners.clear(); + notificationInstances = []; + mockSelect.mockReturnValue(true); + setupNotifications(); + }); + + const createTestNotification = async ( + id: string, + ipcMeta: any = { type: 'single', webContentsId: 7 } + ): Promise => { + const createRequested = listeners.get('notifications/create-requested'); + expect(createRequested).toBeDefined(); + await createRequested!({ + type: 'notifications/create-requested', + payload: { title: 'Hello', tag: id, canReply: true }, + meta: { id: 'req-1' }, + ipcMeta, + }); + }; + + it('registers Notification.handleActivation on win32', () => { + expect(Notification.handleActivation).toHaveBeenCalledWith( + handleNotificationActivation + ); + }); + + it('dispatches NOTIFICATIONS_NOTIFICATION_REPLIED for a reply activation', async () => { + await createTestNotification('reply-abc123'); + + handleNotificationActivation({ + type: 'reply', + arguments: 'type=reply&tag=reply-abc123', + reply: 'hello there', + } as any); + + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id: 'reply-abc123', reply: 'hello there' }, + }) + ); + }); + + it('dispatches NOTIFICATIONS_NOTIFICATION_ACTIONED for an action activation', async () => { + await createTestNotification('action-abc123'); + + handleNotificationActivation({ + type: 'action', + arguments: 'type=action&tag=action-abc123', + actionIndex: 2, + } as any); + + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_ACTIONED, + payload: { id: 'action-abc123', index: 2 }, + }) + ); + }); + + it('attaches the instance click listener on win32 with handleActivation available, and firing it dispatches NOTIFICATIONS_NOTIFICATION_CLICKED', async () => { + await createTestNotification('click-abc123'); + + const instance = notificationInstances[0]; + expect(instance.listeners.click).toBeDefined(); + + instance.listeners.click[0](); + + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_CLICKED, + payload: expect.objectContaining({ + id: 'click-abc123', + serverUrl: 'https://example.com', + }), + }) + ); + }); + + it('preserves the category for a root click on a card retained past close', async () => { + const createRequested = listeners.get('notifications/create-requested'); + expect(createRequested).toBeDefined(); + await createRequested!({ + type: 'notifications/create-requested', + payload: { + title: 'Hello', + tag: 'category-abc123', + canReply: true, + category: 'DOWNLOADS', + }, + meta: { id: 'req-1' }, + ipcMeta: { type: 'single', webContentsId: 7 }, + }); + + const instance = notificationInstances[0]; + + // Windows auto-hides the banner into the Action Center: Electron emits + // `close`, which drops `notificationCategories`, before the root card is + // clicked. + instance.listeners.close[0](); + + mockDispatchSingle.mockClear(); + + instance.listeners.click[0](); + + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_CLICKED, + payload: expect.objectContaining({ + id: 'category-abc123', + category: 'DOWNLOADS', + }), + }) + ); + }); + + it('warns and does not dispatch when the tag cannot be parsed', () => { + handleNotificationActivation({ + type: 'reply', + arguments: 'garbage', + reply: 'hi', + } as any); + + expect(mockDispatchSingle).not.toHaveBeenCalled(); + expect(mockNotificationsWarn).toHaveBeenCalled(); + }); + + it('warns but still broadcasts the reply when routing metadata is missing', () => { + handleNotificationActivation({ + type: 'reply', + arguments: 'type=reply&tag=unknown-id', + reply: 'hi', + } as any); + + expect(mockDispatchSingle).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id: 'unknown-id', reply: 'hi' }, + }); + expect(mockNotificationsWarn).toHaveBeenCalled(); + }); + + it('warns but still broadcasts the action when routing metadata is missing', () => { + handleNotificationActivation({ + type: 'action', + arguments: 'type=action&tag=unknown-id', + actionIndex: 1, + } as any); + + expect(mockDispatchSingle).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ + type: NOTIFICATIONS_NOTIFICATION_ACTIONED, + payload: { id: 'unknown-id', index: 1 }, + }); + expect(mockNotificationsWarn).toHaveBeenCalled(); + }); + + it('still delivers a reply from an Action Center card after the banner timed out and the web client auto-closed it', async () => { + await createTestNotification('timeout-abc123'); + const instance = notificationInstances[0]; + + // Windows auto-hides the banner into the Action Center: Electron emits + // `close` while the card stays actionable. + instance.listeners.close[0](); + + // The web client's `setTimeout(() => n.close())` then dismisses it. + const dismissed = listeners.get('notifications/notification-dismissed'); + expect(dismissed).toBeDefined(); + dismissed!({ + type: 'notifications/notification-dismissed', + payload: { id: 'timeout-abc123' }, + }); + + // The dismissal must reach the instance even though `close` already + // removed it from the live map, or the card is never taken down. + expect(instance.close).toHaveBeenCalled(); + + mockDispatch.mockClear(); + mockDispatchSingle.mockClear(); + + handleNotificationActivation({ + type: 'reply', + arguments: 'type=reply&tag=timeout-abc123', + reply: 'late reply', + } as any); + + // The reply lands, and win32 keeps the routing metadata through the + // dismissal so it is still addressed to the originating view. + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id: 'timeout-abc123', reply: 'late reply' }, + ipcMeta: { type: 'single', webContentsId: 7 }, + }) + ); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('retains routing metadata through a dismissal on win32 so a later activation keeps its original ipcMeta', async () => { + await createTestNotification('retain-abc123', { + type: 'single', + webContentsId: 42, + }); + + const dismissed = listeners.get('notifications/notification-dismissed'); + expect(dismissed).toBeDefined(); + dismissed!({ + type: 'notifications/notification-dismissed', + payload: { id: 'retain-abc123' }, + }); + + mockDispatch.mockClear(); + mockDispatchSingle.mockClear(); + + handleNotificationActivation({ + type: 'reply', + arguments: 'type=reply&tag=retain-abc123', + reply: 'after dismissal', + } as any); + + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id: 'retain-abc123', reply: 'after dismissal' }, + ipcMeta: { type: 'single', webContentsId: 42 }, + }) + ); + // Precise routing survived, so the broadcast fallback stays untouched. + expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockNotificationsWarn).not.toHaveBeenCalled(); + }); + + it('keeps routing metadata across a banner timeout so the reply is still addressed to its view', async () => { + await createTestNotification('closed-abc123'); + const instance = notificationInstances[0]; + + instance.listeners.close[0](); + + mockDispatch.mockClear(); + mockDispatchSingle.mockClear(); + + handleNotificationActivation({ + type: 'reply', + arguments: 'type=reply&tag=closed-abc123', + reply: 'from action center', + } as any); + + expect(mockDispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id: 'closed-abc123', reply: 'from action center' }, + ipcMeta: { type: 'single', webContentsId: 7 }, + }) + ); + expect(mockDispatch).not.toHaveBeenCalled(); + }); + + it('attaches the instance click listener but not reply/action listeners on win32', async () => { + await createTestNotification('listeners-abc123'); + + const instance = notificationInstances[0]; + expect(instance.listeners.click).toBeDefined(); + expect(instance.listeners.reply).toBeUndefined(); + expect(instance.listeners.action).toBeUndefined(); + expect(instance.listeners.show).toBeDefined(); + expect(instance.listeners.close).toBeDefined(); + }); + + it('falls back to instance click/reply/action listeners on win32 when handleActivation is unavailable', async () => { + const originalHandleActivation = Notification.handleActivation; + // @ts-expect-error simulating an older Electron without handleActivation + Notification.handleActivation = undefined; + + // Re-register with handleActivation unavailable so setupNotifications + // takes the fallback branch too. + setupNotifications(); + + await createTestNotification('fallback-abc123'); + + const instance = notificationInstances[0]; + expect(instance.listeners.click).toBeDefined(); + expect(instance.listeners.reply).toBeDefined(); + expect(instance.listeners.action).toBeDefined(); + + Notification.handleActivation = originalHandleActivation; + }); +}); diff --git a/src/notifications/main.ts b/src/notifications/main.ts index aa44d079cc..3c5fcbfcb1 100644 --- a/src/notifications/main.ts +++ b/src/notifications/main.ts @@ -2,7 +2,8 @@ import type { NativeImage } from 'electron'; import { Notification, nativeImage } from 'electron'; import { invoke } from '../ipc/main'; -import { dispatch, dispatchSingle, listen } from '../store'; +import { loggers } from '../logging/scopes'; +import { dispatch, dispatchSingle, listen, select } from '../store'; import type { ActionIPCMeta } from '../store/actions'; import { hasMeta } from '../store/fsa'; import { getRootWindow } from '../ui/main/rootWindow'; @@ -19,6 +20,18 @@ import { } from './actions'; import attentionDrawing from './attentionDrawing'; import type { ExtendedNotificationOptions } from './common'; +import { parseActivationArguments } from './parseActivationArguments'; + +type NotificationRoutingMeta = { + ipcMeta?: ActionIPCMeta; + category?: 'DOWNLOADS' | 'SERVER'; +}; + +const MAX_ROUTING_ENTRIES = 200; + +const shouldUseActivationRouting = (): boolean => + process.platform === 'win32' && + typeof Notification.handleActivation === 'function'; const resolveIcon = async ( iconUrl: string | undefined @@ -46,8 +59,29 @@ const resolveIcon = async ( }; const notifications = new Map(); +// Windows only: instances whose banner already closed but whose Action Center +// card may still be showing, so an explicit dismissal can still remove it. +const closedNotifications = new Map(); const notificationTypes = new Map(); const notificationCategories = new Map(); +const notificationRoutingMeta = new Map(); +// Electron can fire a Windows toast's `reply` event twice for one submission; +// gate on this until the next `show` resets it (SUP-1097 forward-port, #3467). +const repliedNotifications = new Set(); + +const setNotificationRoutingMeta = ( + id: string, + meta: NotificationRoutingMeta +): void => { + notificationRoutingMeta.set(id, meta); + + if (notificationRoutingMeta.size > MAX_ROUTING_ENTRIES) { + const oldestId = notificationRoutingMeta.keys().next().value; + if (oldestId !== undefined) { + notificationRoutingMeta.delete(oldestId); + } + } +}; const createNotification = async ( id: string, @@ -64,13 +98,18 @@ const createNotification = async ( }: ExtendedNotificationOptions, ipcMeta?: ActionIPCMeta ): Promise => { + const isQuickReplyEnabled = select( + (state) => state.isNotificationQuickReplyEnabled + ); + const notification = new Notification({ + id, title, subtitle, body: body ?? '', icon: await resolveIcon(icon), silent: silent ?? undefined, - hasReply: canReply, + hasReply: canReply && isQuickReplyEnabled, actions: actions?.map((action) => ({ type: 'button', text: action.title, @@ -79,6 +118,8 @@ const createNotification = async ( }); notification.addListener('show', () => { + repliedNotifications.delete(id); + dispatchSingle({ type: NOTIFICATIONS_NOTIFICATION_SHOWN, payload: { id }, @@ -97,8 +138,26 @@ const createNotification = async ( payload: { id }, ipcMeta, }); + + const notification = notifications.get(id); notifications.delete(id); + // On Windows a toast that times out raises `close` while its Action Center + // card stays visible and still accepts quick replies. Keep the instance + // reachable so an explicit dismissal can actually remove that card, but + // out of `notifications` so a later create with the same tag still builds a + // fresh notification instead of taking the update path. + if (shouldUseActivationRouting() && notification) { + closedNotifications.set(id, notification); + + if (closedNotifications.size > MAX_ROUTING_ENTRIES) { + const oldestId = closedNotifications.keys().next().value; + if (oldestId !== undefined) { + closedNotifications.delete(oldestId); + } + } + } + const notificationType = notificationTypes.get(id); if (notificationType === 'voice') { attentionDrawing.stopAttention(id); @@ -112,7 +171,9 @@ const createNotification = async ( ipcMeta?.webContentsId !== undefined ? getServerUrlByWebContentsId(ipcMeta.webContentsId) : undefined; - const notificationCategory = notificationCategories.get(id); + const notificationCategory = + notificationCategories.get(id) ?? + notificationRoutingMeta.get(id)?.category; dispatchSingle({ type: NOTIFICATIONS_NOTIFICATION_CLICKED, payload: { @@ -125,26 +186,34 @@ const createNotification = async ( }); }); - notification.addListener('reply', (_event, reply) => { - dispatchSingle({ - type: NOTIFICATIONS_NOTIFICATION_REPLIED, - payload: { id, reply }, - ipcMeta, + if (!shouldUseActivationRouting()) { + notification.addListener('reply', (_event, reply) => { + if (repliedNotifications.has(id)) { + return; + } + repliedNotifications.add(id); + + dispatchSingle({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id, reply }, + ipcMeta, + }); }); - }); - notification.addListener('action', (_event, index) => { - dispatchSingle({ - type: NOTIFICATIONS_NOTIFICATION_ACTIONED, - payload: { id, index }, - ipcMeta, + notification.addListener('action', (_event, index) => { + dispatchSingle({ + type: NOTIFICATIONS_NOTIFICATION_ACTIONED, + payload: { id, index }, + ipcMeta, + }); }); - }); + } notifications.set(id, notification); if (category) { notificationCategories.set(id, category); } + setNotificationRoutingMeta(id, { ipcMeta, category }); notification.show(); @@ -226,6 +295,64 @@ const handleCreateEvent = async ( return createNotification(id, options, ipcMeta); }; +export const handleNotificationActivation = ( + details: Electron.ActivationArguments +): void => { + const { tag, type } = parseActivationArguments(details.arguments); + + if (!tag) { + loggers.notifications.warn( + 'could not parse notification id from activation arguments', + details.arguments + ); + return; + } + + // An Action Center card outlives its banner and the app-side `close()`, so a + // reply can arrive after the routing metadata was evicted (LRU overflow, or + // the web client's auto-close timer). Losing the metadata only costs us the + // target webContents, so fall back to a broadcast: both renderer handlers key + // off the notification id, and only the view that created it has an entry. + const routingMeta = notificationRoutingMeta.get(tag); + if (!routingMeta) { + loggers.notifications.warn( + `no routing metadata for notification ${tag}; broadcasting activation` + ); + } + + const ipcMeta = routingMeta?.ipcMeta; + + const dispatchActivation = [0]>( + action: Action + ): void => { + if (ipcMeta) { + dispatchSingle({ ...action, ipcMeta }); + return; + } + dispatch(action); + }; + + if (type === 'reply' && details.reply !== undefined) { + if (repliedNotifications.has(tag)) { + return; + } + repliedNotifications.add(tag); + + dispatchActivation({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: { id: tag, reply: details.reply }, + }); + return; + } + + if (type === 'action') { + dispatchActivation({ + type: NOTIFICATIONS_NOTIFICATION_ACTIONED, + payload: { id: tag, index: details.actionIndex ?? 0 }, + }); + } +}; + export const setupNotifications = (): void => { listen(NOTIFICATIONS_CREATE_REQUESTED, async (action) => { if (!hasMeta(action)) { @@ -245,11 +372,30 @@ export const setupNotifications = (): void => { const notificationId = String(action.payload.id); const notificationType = notificationTypes.get(notificationId); - notifications.get(notificationId)?.close(); + // The banner may already have timed out, which removes the instance from + // `notifications` but leaves the Action Center card in place; fall back to + // the closed instance so this dismissal actually removes that card. + ( + notifications.get(notificationId) ?? + closedNotifications.get(notificationId) + )?.close(); + closedNotifications.delete(notificationId); if (notificationType === 'voice') { attentionDrawing.stopAttention(notificationId); } notificationTypes.delete(notificationId); + + // Keep the routing metadata on Windows: the web client auto-closes every + // notification a few seconds after showing it, yet its Action Center card + // stays repliable, so dropping the metadata here is what silently lost + // late replies. The bounded LRU in setNotificationRoutingMeta reclaims it. + if (!shouldUseActivationRouting()) { + notificationRoutingMeta.delete(notificationId); + } }); + + if (shouldUseActivationRouting()) { + Notification.handleActivation(handleNotificationActivation); + } }; diff --git a/src/notifications/main/setup.main.spec.ts b/src/notifications/main/setup.main.spec.ts new file mode 100644 index 0000000000..0e5b0e052b --- /dev/null +++ b/src/notifications/main/setup.main.spec.ts @@ -0,0 +1,271 @@ +import { + NOTIFICATIONS_CREATE_REQUESTED, + NOTIFICATIONS_CREATE_RESPONDED, + NOTIFICATIONS_NOTIFICATION_ACTIONED, + NOTIFICATIONS_NOTIFICATION_CLICKED, + NOTIFICATIONS_NOTIFICATION_CLOSED, + NOTIFICATIONS_NOTIFICATION_DISMISSED, + NOTIFICATIONS_NOTIFICATION_REPLIED, + NOTIFICATIONS_NOTIFICATION_SHOWN, +} from '../actions'; +import { setupNotifications } from '../main'; + +const listeners = new Map(); +const dispatch = jest.fn(); +const dispatchSingle = jest.fn(); +const getRootWindow = jest.fn(); +const getServerUrlByWebContentsId = jest.fn(); +const invoke = jest.fn(); +const drawAttention = jest.fn(); +const stopAttention = jest.fn(); +const notificationInstances: any[] = []; + +jest.mock('electron', () => { + class MockNotification { + title = ''; + + body = ''; + + silent = false; + + icon: unknown; + + requireInteraction = false; + + listeners: Record = {}; + + constructor(opts: any) { + Object.assign(this, opts); + notificationInstances.push(this); + } + + addListener(event: string, cb: Function) { + this.listeners[event] = this.listeners[event] || []; + this.listeners[event].push(cb); + } + + show = jest.fn(); + + close = jest.fn(); + + emit(event: string, ...args: unknown[]) { + for (const cb of this.listeners[event] || []) { + cb(...args); + } + } + } + + return { + Notification: MockNotification, + nativeImage: { + createFromDataURL: jest.fn(() => ({ isEmpty: () => false })), + createEmpty: jest.fn(() => ({ isEmpty: () => true })), + }, + }; +}); + +jest.mock('../../store', () => ({ + dispatch: (...args: unknown[]) => dispatch(...args), + dispatchSingle: (...args: unknown[]) => dispatchSingle(...args), + select: () => true, + listen: (type: string, listener: Function) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +jest.mock('../../store/fsa', () => ({ + hasMeta: (action: any) => Boolean(action?.meta?.id), +})); + +jest.mock('../../ipc/main', () => ({ + invoke: (...args: unknown[]) => invoke(...args), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getServerUrlByWebContentsId: (...args: unknown[]) => + getServerUrlByWebContentsId(...args), +})); + +jest.mock('../attentionDrawing', () => ({ + __esModule: true, + default: { + drawAttention: (...args: unknown[]) => drawAttention(...args), + stopAttention: (...args: unknown[]) => stopAttention(...args), + }, +})); + +describe('notifications/main setupNotifications', () => { + beforeEach(() => { + jest.clearAllMocks(); + listeners.clear(); + notificationInstances.length = 0; + getRootWindow.mockResolvedValue({ + webContents: { id: 1 }, + }); + invoke.mockResolvedValue('data:image/png;base64,abc'); + getServerUrlByWebContentsId.mockReturnValue('https://open.rocket.chat'); + setupNotifications(); + }); + + const create = async (payload: Record, metaId = 'req-1') => { + const listener = listeners.get(NOTIFICATIONS_CREATE_REQUESTED); + await listener?.({ + type: NOTIFICATIONS_CREATE_REQUESTED, + payload, + ipcMeta: { webContentsId: 9 }, + meta: { id: metaId, response: false }, + }); + }; + + const repliedCalls = () => + dispatchSingle.mock.calls.filter( + ([action]) => action.type === NOTIFICATIONS_NOTIFICATION_REPLIED + ); + + it('creates a notification and wires show/click/close/reply/action', async () => { + await create({ + title: 'Hello', + body: 'World', + canReply: true, + actions: [{ title: 'Open' }], + category: 'SERVER', + notificationType: 'text', + tag: 'n1', + }); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_CREATE_RESPONDED, + payload: 'n1', + }) + ); + expect(notificationInstances).toHaveLength(1); + const n = notificationInstances[0]; + expect(n.show).toHaveBeenCalled(); + + n.emit('show'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ type: NOTIFICATIONS_NOTIFICATION_SHOWN }) + ); + + n.emit('click'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_CLICKED, + payload: expect.objectContaining({ + title: 'Hello', + serverUrl: 'https://open.rocket.chat', + category: 'SERVER', + }), + }) + ); + + n.emit('reply', {}, 'thanks'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_REPLIED, + payload: expect.objectContaining({ reply: 'thanks' }), + }) + ); + + n.emit('action', {}, 0); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ + type: NOTIFICATIONS_NOTIFICATION_ACTIONED, + payload: expect.objectContaining({ index: 0 }), + }) + ); + + n.emit('close'); + expect(dispatchSingle).toHaveBeenCalledWith( + expect.objectContaining({ type: NOTIFICATIONS_NOTIFICATION_CLOSED }) + ); + }); + + it('dispatches a duplicated reply event exactly once', async () => { + await create({ title: 'Hello', tag: 'reply-once' }); + + const notification = notificationInstances[0]; + notification.emit('reply', {}, 'hi there'); + notification.emit('reply', {}, 'hi there'); + + expect(repliedCalls()).toHaveLength(1); + expect(repliedCalls()[0][0].payload).toEqual({ + id: 'reply-once', + reply: 'hi there', + }); + }); + + it('accepts another reply after the notification is shown again', async () => { + await create({ title: 'Hello', tag: 'reply-again' }); + + const notification = notificationInstances[0]; + notification.emit('reply', {}, 'first reply'); + notification.emit('show'); + notification.emit('reply', {}, 'second reply'); + + expect(repliedCalls()).toHaveLength(2); + expect(repliedCalls()[1][0].payload).toEqual({ + id: 'reply-again', + reply: 'second reply', + }); + }); + + it('draws attention for voice notifications', async () => { + await create({ + title: 'Call', + body: 'Incoming', + notificationType: 'voice', + tag: 'voice-1', + }); + notificationInstances[0].emit('show'); + expect(drawAttention).toHaveBeenCalledWith('voice-1'); + notificationInstances[0].emit('close'); + expect(stopAttention).toHaveBeenCalledWith('voice-1'); + }); + + it('updates an existing tagged notification', async () => { + await create({ + title: 'First', + body: 'A', + tag: 'same', + notificationType: 'text', + }); + await create({ + title: 'Second', + body: 'B', + tag: 'same', + silent: true, + notificationType: 'voice', + }); + expect(notificationInstances).toHaveLength(1); + expect(notificationInstances[0].title).toBe('Second'); + expect(notificationInstances[0].body).toBe('B'); + expect(drawAttention).toHaveBeenCalledWith('same'); + }); + + it('dismisses a notification via action listener', async () => { + await create({ title: 'X', body: 'Y', tag: 'dismiss-me' }); + const dismiss = listeners.get(NOTIFICATIONS_NOTIFICATION_DISMISSED); + dismiss?.({ + type: NOTIFICATIONS_NOTIFICATION_DISMISSED, + payload: { id: 'dismiss-me' }, + }); + expect(notificationInstances[0].close).toHaveBeenCalled(); + }); + + it('ignores create requests without meta', async () => { + const listener = listeners.get(NOTIFICATIONS_CREATE_REQUESTED); + await listener?.({ + type: NOTIFICATIONS_CREATE_REQUESTED, + payload: { title: 'No meta', body: 'x' }, + }); + expect(dispatch).not.toHaveBeenCalled(); + expect(notificationInstances).toHaveLength(0); + }); +}); diff --git a/src/notifications/parseActivationArguments.ts b/src/notifications/parseActivationArguments.ts new file mode 100644 index 0000000000..9cd3a8aa4a --- /dev/null +++ b/src/notifications/parseActivationArguments.ts @@ -0,0 +1,22 @@ +export type ParsedActivationArguments = { + type?: string; + tag?: string; +}; + +export const parseActivationArguments = ( + rawArguments: string | undefined +): ParsedActivationArguments => { + if (!rawArguments) { + return {}; + } + + try { + const params = new URLSearchParams(rawArguments); + return { + type: params.get('type') ?? undefined, + tag: params.get('tag') ?? undefined, + }; + } catch (error) { + return {}; + } +}; diff --git a/src/notifications/preload.ts b/src/notifications/preload.ts index 9c3026d6ba..e6ffd95a2a 100644 --- a/src/notifications/preload.ts +++ b/src/notifications/preload.ts @@ -66,6 +66,13 @@ export const createNotification = async ({ onEvent?.({ type: event.type, detail: event.detail }) ); + if (eventHandlers.size > 500) { + const oldestId = eventHandlers.keys().next().value; + if (oldestId !== undefined) { + eventHandlers.delete(oldestId); + } + } + return id; }; @@ -108,7 +115,6 @@ export const listenToNotificationsRequests = (): void => { } = action; const eventHandler = eventHandlers.get(id); eventHandler?.({ type: 'close' }); - eventHandlers.delete(id); }); listen(NOTIFICATIONS_NOTIFICATION_CLICKED, (action) => { diff --git a/src/notifications/reducers/isNotificationQuickReplyEnabled.ts b/src/notifications/reducers/isNotificationQuickReplyEnabled.ts new file mode 100644 index 0000000000..c079803f92 --- /dev/null +++ b/src/notifications/reducers/isNotificationQuickReplyEnabled.ts @@ -0,0 +1,32 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; + +type IsNotificationQuickReplyEnabledAction = ActionOf< + typeof APP_SETTINGS_LOADED +>; + +/** + * Controls whether notifications are created with an inline reply field + * (Windows toast reply box / macOS notification reply). + * + * Configurable via overridden-settings.json: + * { "isNotificationQuickReplyEnabled": false } + * + * Defaults to true (quick reply enabled). + */ +export const isNotificationQuickReplyEnabled: Reducer< + boolean, + IsNotificationQuickReplyEnabledAction +> = (state = true, action) => { + switch (action.type) { + case APP_SETTINGS_LOADED: { + const { isNotificationQuickReplyEnabled = state } = action.payload; + return isNotificationQuickReplyEnabled; + } + + default: + return state; + } +}; diff --git a/src/outlookCalendar/main/ipc.main.spec.ts b/src/outlookCalendar/main/ipc.main.spec.ts new file mode 100644 index 0000000000..b506410af1 --- /dev/null +++ b/src/outlookCalendar/main/ipc.main.spec.ts @@ -0,0 +1,455 @@ +export {}; + +const handlers = new Map(); +const watchFns: Array<(curr: any, prev: any) => void> = []; +const dispatch = jest.fn(); +const request = jest.fn(); +const getOutlookEvents = jest.fn( + async (..._args: any[]) => [] as any[] +) as jest.Mock; + +const servers = [ + { + url: 'https://open.rocket.chat', + webContentsId: 7, + version: '7.6.0', + outlookCredentials: { + userId: 'u1', + login: 'user@example.com', + password: 'secret', + serverUrl: 'https://exchange.example', + }, + }, +]; + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../store', () => ({ + select: jest.fn((selector: any) => + selector({ + servers, + outlookCalendarSyncInterval: 60, + outlookCalendarSyncIntervalOverride: undefined, + allowInsecureOutlookConnections: false, + }) + ), + dispatch: (...args: any[]) => dispatch(...args), + request: (...args: any[]) => request(...args), + listen: jest.fn(), + watch: jest.fn((_sel: any, fn: any) => { + watchFns.push(fn); + return jest.fn(); + }), +})); + +jest.mock('../../app/selectors', () => ({ + selectPersistableValues: (state: any) => state, +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(() => ({ + id: 7, + isDestroyed: () => false, + send: jest.fn(), + })), +})); + +jest.mock('../logger', () => ({ + outlookLog: jest.fn(), + outlookError: jest.fn(), + outlookWarn: jest.fn(), + outlookEventDetail: jest.fn(), +})); + +jest.mock('../getOutlookEvents', () => ({ + getOutlookEvents: (...args: any[]) => getOutlookEvents(...args), +})); + +jest.mock('../errorClassification', () => ({ + createClassifiedError: (e: Error) => e, + formatErrorForLogging: (e: Error) => String(e), + generateUserFriendlyMessage: () => 'friendly', +})); + +jest.mock('../../urls', () => ({ + server: (base: string) => ({ + calendarEvents: { + list: `${base}/api/v1/calendar-events.list`, + import: `${base}/api/v1/calendar-events.import`, + update: `${base}/api/v1/calendar-events.update`, + delete: `${base}/api/v1/calendar-events.delete`, + }, + }), +})); + +jest.mock('../../utils', () => ({ + meetsMinimumVersion: () => true, +})); + +const axiosGet = jest.fn(async (..._args: any[]) => ({ + status: 200, + data: { data: [] }, +})) as jest.Mock; +const axiosPost = jest.fn(async (..._args: any[]) => ({ + status: 200, + data: {}, +})) as jest.Mock; +const axiosDelete = jest.fn(async (..._args: any[]) => ({ + status: 200, + data: {}, +})) as jest.Mock; + +jest.mock('axios', () => { + const axios: any = { + get: (...args: any[]) => axiosGet(...args), + post: (...args: any[]) => axiosPost(...args), + delete: (...args: any[]) => axiosDelete(...args), + isAxiosError: (e: any) => !!e?.isAxiosError, + }; + return { __esModule: true, default: axios, ...axios }; +}); + +const encryptString = jest.fn((s: string) => + Buffer.from(`enc:${s}`) +) as jest.Mock; +const decryptString = jest.fn((b: Buffer) => + b.toString().replace(/^enc:/, '') +) as jest.Mock; + +jest.mock('electron', () => ({ + net: { fetch: jest.fn() }, + session: { fromPartition: jest.fn() }, + safeStorage: { + isEncryptionAvailable: jest.fn(() => false), + encryptString: (s: string) => encryptString(s), + decryptString: (b: Buffer) => decryptString(b), + }, + webContents: { + fromId: jest.fn(() => ({ + executeJavaScript: jest.fn(async () => 'token-from-webview'), + })), + }, +})); + +describe('outlookCalendar/ipc', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + watchFns.length = 0; + jest.useFakeTimers(); + getOutlookEvents.mockResolvedValue([]); + axiosGet.mockResolvedValue({ status: 200, data: { data: [] } }); + axiosPost.mockResolvedValue({ status: 200, data: {} }); + axiosDelete.mockResolvedValue({ status: 200, data: {} }); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../ipc').startOutlookCalendarUrlHandler(); + }); + + afterEach(() => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../ipc').stopOutlookCalendarSync(); + } catch { + // ignore + } + jest.useRealTimers(); + }); + + it('registers outlook calendar IPC handlers', () => { + expect(handlers.has('outlook-calendar/set-user-token')).toBe(true); + expect(handlers.has('outlook-calendar/has-credentials')).toBe(true); + expect(handlers.has('outlook-calendar/clear-credentials')).toBe(true); + expect(handlers.has('outlook-calendar/set-exchange-url')).toBe(true); + expect(handlers.has('outlook-calendar/get-events')).toBe(true); + }); + + it('has-credentials returns false when server missing', async () => { + const result = await handlers.get('outlook-calendar/has-credentials')?.({ + id: 999, + }); + expect(result).toBeFalsy(); + }); + + it('has-credentials returns true for filled credentials', async () => { + const result = await handlers.get('outlook-calendar/has-credentials')?.({ + id: 7, + }); + expect(result).toBe(true); + }); + + it('set-user-token rejects invalid token payloads', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + null, + 'u1' + ); + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'token', + null + ); + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 999 }, + 'token', + 'u1' + ); + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'token', + 'other-user' + ); + + expect(dispatch).not.toHaveBeenCalled(); + expect(getOutlookEvents).not.toHaveBeenCalled(); + }); + + it('set-user-token starts recurring sync and initial debounce', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + // flush initial sync debounce + await jest.advanceTimersByTimeAsync(200); + expect(getOutlookEvents).toHaveBeenCalled(); + }); + + it('clear-credentials dispatches empty password credentials', async () => { + await handlers.get('outlook-calendar/clear-credentials')?.({ id: 7 }); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'outlook-calendar/save-credentials', + }) + ); + }); + + it('set-exchange-url saves when url or user changes', async () => { + await handlers.get('outlook-calendar/set-exchange-url')?.( + { id: 7 }, + 'https://new-exchange.example', + 'u2' + ); + expect(dispatch).toHaveBeenCalled(); + }); + + it('get-events rejects without credentials shape', async () => { + // server with empty credentials fields via select override is hard; + // use missing server path + await expect( + handlers.get('outlook-calendar/get-events')?.({ id: 999 }, new Date()) + ).rejects.toThrow('No credentials'); + }); + + it('get-events syncs when token already set via set-user-token', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + await jest.advanceTimersByTimeAsync(200); + + getOutlookEvents.mockResolvedValue([ + { + id: 'evt-1', + subject: 'Standup', + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + description: 'daily', + reminderMinutesBeforeStart: 5, + busy: true, + }, + ]); + axiosGet.mockResolvedValue({ + status: 200, + data: { data: [] }, + }); + axiosPost.mockResolvedValue({ status: 200, data: {} }); + + const result = await handlers.get('outlook-calendar/get-events')?.( + { id: 7 }, + new Date() + ); + expect(result).toEqual({ status: 'success' }); + expect(axiosPost).toHaveBeenCalled(); + }); + + it('get-events creates, updates, and deletes events during sync', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + await jest.advanceTimersByTimeAsync(200); + + const now = new Date().toISOString(); + getOutlookEvents.mockResolvedValue([ + { + id: 'keep', + subject: 'Updated', + startTime: now, + endTime: now, + description: 'd', + reminderMinutesBeforeStart: 10, + busy: false, + }, + { + id: 'new', + subject: 'New', + startTime: now, + endTime: now, + description: '', + reminderMinutesBeforeStart: 0, + busy: true, + }, + ]); + axiosGet.mockResolvedValue({ + status: 200, + data: { + data: [ + { + _id: 'rc-keep', + externalId: 'keep', + subject: 'Old', + startTime: now, + description: 'old', + }, + { + _id: 'rc-gone', + externalId: 'gone', + subject: 'Gone', + startTime: now, + }, + ], + }, + }); + axiosPost.mockClear(); + axiosPost.mockResolvedValue({ status: 200, data: {} }); + // Deletes are performed via axios.post to the delete endpoint in this + // module — there is no dedicated axios.delete call. + axiosDelete.mockResolvedValue({ status: 200, data: {} }); + + const result = await handlers.get('outlook-calendar/get-events')?.( + { id: 7 }, + new Date() + ); + expect(result).toEqual({ status: 'success' }); + + expect(axiosPost).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/calendar-events.import', + expect.objectContaining({ + externalId: 'new', + subject: 'New', + startTime: now, + description: '', + reminderMinutesBeforeStart: 0, + endTime: now, + busy: true, + }), + expect.anything() + ); + + expect(axiosPost).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/calendar-events.update', + expect.objectContaining({ + eventId: 'rc-keep', + subject: 'Updated', + startTime: now, + description: 'd', + reminderMinutesBeforeStart: 10, + endTime: now, + busy: false, + }), + expect.anything() + ); + + expect(axiosPost).toHaveBeenCalledWith( + 'https://open.rocket.chat/api/v1/calendar-events.delete', + { eventId: 'rc-gone' }, + expect.anything() + ); + }); + + it('get-events fetches token from webContents when missing', async () => { + // Don't call set-user-token; get-events should try webContents path + getOutlookEvents.mockResolvedValue([]); + axiosGet.mockResolvedValue({ status: 200, data: { data: [] } }); + + const result = await handlers.get('outlook-calendar/get-events')?.( + { id: 7 }, + new Date() + ); + expect(result).toEqual({ status: 'success' }); + }); + + it('syncEventsWithRocketChatServer rejects empty token and queues concurrent', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { syncEventsWithRocketChatServer } = require('../ipc'); + const creds = { + userId: 'u1', + login: 'user@example.com', + password: 'secret', + serverUrl: 'https://exchange.example', + }; + + await expect( + syncEventsWithRocketChatServer( + 'https://open.rocket.chat', + creds, + '', + false + ) + ).rejects.toThrow(/Authentication required/); + + getOutlookEvents.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve([]), 50); + }) + ); + axiosGet.mockResolvedValue({ status: 200, data: { data: [] } }); + + const p1 = syncEventsWithRocketChatServer( + 'https://open.rocket.chat', + creds, + 'tok', + false + ); + const p2 = syncEventsWithRocketChatServer( + 'https://open.rocket.chat', + creds, + 'tok', + false + ); + await jest.advanceTimersByTimeAsync(100); + await expect(Promise.all([p1, p2])).resolves.toBeDefined(); + }); + + it('stopOutlookCalendarSync clears state', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { stopOutlookCalendarSync } = require('../ipc'); + expect(() => stopOutlookCalendarSync()).not.toThrow(); + }); + + it('interval watch reschedules when value changes', async () => { + await handlers.get('outlook-calendar/set-user-token')?.( + { id: 7 }, + 'user-token', + 'u1' + ); + await jest.advanceTimersByTimeAsync(200); + getOutlookEvents.mockClear(); + + expect(watchFns.length).toBeGreaterThan(0); + for (const fn of watchFns) { + fn(30, 60); + } + await jest.advanceTimersByTimeAsync(11000); + + expect(getOutlookEvents).toHaveBeenCalled(); + }); +}); diff --git a/src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts b/src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts new file mode 100644 index 0000000000..85e6f75858 --- /dev/null +++ b/src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts @@ -0,0 +1,61 @@ +import { APP_SETTINGS_LOADED } from '../../../app/actions'; +import { SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED } from '../../../ui/actions'; +import { allowInsecureOutlookConnections } from '../allowInsecureOutlookConnections'; +import { outlookCalendarSyncInterval } from '../outlookCalendarSyncInterval'; +import { outlookCalendarSyncIntervalOverride } from '../outlookCalendarSyncIntervalOverride'; + +describe('outlookCalendar reducers', () => { + describe('outlookCalendarSyncInterval', () => { + it('returns default and updates from settings action', () => { + expect(outlookCalendarSyncInterval(undefined, { type: 'x' } as any)).toBe( + 60 + ); + expect( + outlookCalendarSyncInterval(60, { + type: SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED, + payload: 30, + } as any) + ).toBe(30); + expect( + outlookCalendarSyncInterval(60, { + type: APP_SETTINGS_LOADED, + payload: { outlookCalendarSyncInterval: 5 }, + } as any) + ).toBe(5); + }); + }); + + describe('outlookCalendarSyncIntervalOverride', () => { + it('loads override from settings', () => { + expect( + outlookCalendarSyncIntervalOverride(undefined, { type: 'x' } as any) + ).toBeNull(); + expect( + outlookCalendarSyncIntervalOverride(null, { + type: APP_SETTINGS_LOADED, + payload: { outlookCalendarSyncIntervalOverride: 20 }, + } as any) + ).toBe(20); + expect( + outlookCalendarSyncIntervalOverride(20, { + type: APP_SETTINGS_LOADED, + payload: { outlookCalendarSyncIntervalOverride: null }, + } as any) + ).toBeNull(); + }); + }); + + describe('allowInsecureOutlookConnections', () => { + it('loads from settings', () => { + expect( + allowInsecureOutlookConnections(undefined, { type: 'x' } as any) + ).toBe(false); + expect( + allowInsecureOutlookConnections(false, { + type: APP_SETTINGS_LOADED, + payload: { allowInsecureOutlookConnections: true }, + } as any) + ).toBe(true); + }); + }); +}); diff --git a/src/preload.ts b/src/preload.ts index 32bc35e906..e21e43180d 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -7,6 +7,7 @@ import { listenToNotificationsRequests } from './notifications/preload'; import { listenToScreenSharingRequests } from './screenSharing/preload'; import { RocketChatDesktop } from './servers/preload/api'; import { listenToNavigateToRouteRequests } from './servers/preload/navigateToRoute'; +import { listenToPresenceChangeRequests } from './servers/preload/presence'; import { setServerUrl } from './servers/preload/urls'; import { createRendererReduxStore, listen } from './store'; import { listenToTelephonyRequests } from './telephony/preload'; @@ -68,6 +69,7 @@ const start = async (): Promise => { listenToTelephonyRequests(); listenToNavigateToRouteRequests(); + listenToPresenceChangeRequests(); console.log('[Rocket.Chat Desktop] waiting for RocketChatDesktop.onReady'); RocketChatDesktop.onReady(() => { diff --git a/src/public/document-viewer-window.html b/src/public/document-viewer-window.html new file mode 100644 index 0000000000..5d5a730f85 --- /dev/null +++ b/src/public/document-viewer-window.html @@ -0,0 +1,42 @@ + + + + + + Document - Rocket.Chat + + + + + + +
+ + + diff --git a/src/public/downloads-window.html b/src/public/downloads-window.html new file mode 100644 index 0000000000..7614dfa203 --- /dev/null +++ b/src/public/downloads-window.html @@ -0,0 +1,42 @@ + + + + + + Downloads - Rocket.Chat + + + + + + +
+ + + diff --git a/src/public/images/presence/away.png b/src/public/images/presence/away.png new file mode 100644 index 0000000000..652c407344 Binary files /dev/null and b/src/public/images/presence/away.png differ diff --git a/src/public/images/presence/away@2x.png b/src/public/images/presence/away@2x.png new file mode 100644 index 0000000000..dd1731bcc6 Binary files /dev/null and b/src/public/images/presence/away@2x.png differ diff --git a/src/public/images/presence/busy.png b/src/public/images/presence/busy.png new file mode 100644 index 0000000000..ce7d615111 Binary files /dev/null and b/src/public/images/presence/busy.png differ diff --git a/src/public/images/presence/busy@2x.png b/src/public/images/presence/busy@2x.png new file mode 100644 index 0000000000..cb16ab3feb Binary files /dev/null and b/src/public/images/presence/busy@2x.png differ diff --git a/src/public/images/presence/offline.png b/src/public/images/presence/offline.png new file mode 100644 index 0000000000..2f567771a7 Binary files /dev/null and b/src/public/images/presence/offline.png differ diff --git a/src/public/images/presence/offline@2x.png b/src/public/images/presence/offline@2x.png new file mode 100644 index 0000000000..97fb7fd044 Binary files /dev/null and b/src/public/images/presence/offline@2x.png differ diff --git a/src/public/images/presence/online.png b/src/public/images/presence/online.png new file mode 100644 index 0000000000..12c45a7114 Binary files /dev/null and b/src/public/images/presence/online.png differ diff --git a/src/public/images/presence/online@2x.png b/src/public/images/presence/online@2x.png new file mode 100644 index 0000000000..68b028203e Binary files /dev/null and b/src/public/images/presence/online@2x.png differ diff --git a/src/public/images/tray/darwin/disconnected.png b/src/public/images/tray/darwin/disconnected.png new file mode 100644 index 0000000000..e7022040f6 Binary files /dev/null and b/src/public/images/tray/darwin/disconnected.png differ diff --git a/src/public/images/tray/darwin/disconnected@2x.png b/src/public/images/tray/darwin/disconnected@2x.png new file mode 100644 index 0000000000..3f8ac64064 Binary files /dev/null and b/src/public/images/tray/darwin/disconnected@2x.png differ diff --git a/src/public/images/tray/darwin/notificationTemplate.png b/src/public/images/tray/darwin/notificationTemplate.png deleted file mode 100644 index 9114cb0e94..0000000000 Binary files a/src/public/images/tray/darwin/notificationTemplate.png and /dev/null differ diff --git a/src/public/images/tray/darwin/notificationTemplate@2x.png b/src/public/images/tray/darwin/notificationTemplate@2x.png deleted file mode 100644 index 9dfc165e4b..0000000000 Binary files a/src/public/images/tray/darwin/notificationTemplate@2x.png and /dev/null differ diff --git a/src/public/images/tray/darwin/presence-away.png b/src/public/images/tray/darwin/presence-away.png new file mode 100644 index 0000000000..3b4cce26b0 Binary files /dev/null and b/src/public/images/tray/darwin/presence-away.png differ diff --git a/src/public/images/tray/darwin/presence-away@2x.png b/src/public/images/tray/darwin/presence-away@2x.png new file mode 100644 index 0000000000..e6365f2e33 Binary files /dev/null and b/src/public/images/tray/darwin/presence-away@2x.png differ diff --git a/src/public/images/tray/darwin/presence-busy.png b/src/public/images/tray/darwin/presence-busy.png new file mode 100644 index 0000000000..2e76143888 Binary files /dev/null and b/src/public/images/tray/darwin/presence-busy.png differ diff --git a/src/public/images/tray/darwin/presence-busy@2x.png b/src/public/images/tray/darwin/presence-busy@2x.png new file mode 100644 index 0000000000..974343baad Binary files /dev/null and b/src/public/images/tray/darwin/presence-busy@2x.png differ diff --git a/src/public/images/tray/darwin/presence-offline.png b/src/public/images/tray/darwin/presence-offline.png new file mode 100644 index 0000000000..8da3482127 Binary files /dev/null and b/src/public/images/tray/darwin/presence-offline.png differ diff --git a/src/public/images/tray/darwin/presence-offline@2x.png b/src/public/images/tray/darwin/presence-offline@2x.png new file mode 100644 index 0000000000..afff39c79f Binary files /dev/null and b/src/public/images/tray/darwin/presence-offline@2x.png differ diff --git a/src/public/images/tray/darwin/presence-online.png b/src/public/images/tray/darwin/presence-online.png new file mode 100644 index 0000000000..3beda61543 Binary files /dev/null and b/src/public/images/tray/darwin/presence-online.png differ diff --git a/src/public/images/tray/darwin/presence-online@2x.png b/src/public/images/tray/darwin/presence-online@2x.png new file mode 100644 index 0000000000..3a2f3c5012 Binary files /dev/null and b/src/public/images/tray/darwin/presence-online@2x.png differ diff --git a/src/public/images/tray/linux/disconnected.png b/src/public/images/tray/linux/disconnected.png new file mode 100644 index 0000000000..13283b0f82 Binary files /dev/null and b/src/public/images/tray/linux/disconnected.png differ diff --git a/src/public/images/tray/linux/disconnected@2x.png b/src/public/images/tray/linux/disconnected@2x.png new file mode 100644 index 0000000000..05f998ad3a Binary files /dev/null and b/src/public/images/tray/linux/disconnected@2x.png differ diff --git a/src/public/images/tray/linux/notification-1.png b/src/public/images/tray/linux/notification-1.png deleted file mode 100644 index cffcdc0639..0000000000 Binary files a/src/public/images/tray/linux/notification-1.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-1@2x.png b/src/public/images/tray/linux/notification-1@2x.png deleted file mode 100644 index 24943fb860..0000000000 Binary files a/src/public/images/tray/linux/notification-1@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-2.png b/src/public/images/tray/linux/notification-2.png deleted file mode 100644 index 406db5c7dd..0000000000 Binary files a/src/public/images/tray/linux/notification-2.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-2@2x.png b/src/public/images/tray/linux/notification-2@2x.png deleted file mode 100644 index aa687e3541..0000000000 Binary files a/src/public/images/tray/linux/notification-2@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-3.png b/src/public/images/tray/linux/notification-3.png deleted file mode 100644 index 7c56ea2464..0000000000 Binary files a/src/public/images/tray/linux/notification-3.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-3@2x.png b/src/public/images/tray/linux/notification-3@2x.png deleted file mode 100644 index 032968d3a0..0000000000 Binary files a/src/public/images/tray/linux/notification-3@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-4.png b/src/public/images/tray/linux/notification-4.png deleted file mode 100644 index 443c2325ca..0000000000 Binary files a/src/public/images/tray/linux/notification-4.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-4@2x.png b/src/public/images/tray/linux/notification-4@2x.png deleted file mode 100644 index 32c03f957d..0000000000 Binary files a/src/public/images/tray/linux/notification-4@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-5.png b/src/public/images/tray/linux/notification-5.png deleted file mode 100644 index cc63cc785c..0000000000 Binary files a/src/public/images/tray/linux/notification-5.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-5@2x.png b/src/public/images/tray/linux/notification-5@2x.png deleted file mode 100644 index 7987d5b407..0000000000 Binary files a/src/public/images/tray/linux/notification-5@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-6.png b/src/public/images/tray/linux/notification-6.png deleted file mode 100644 index 0dd8d60874..0000000000 Binary files a/src/public/images/tray/linux/notification-6.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-6@2x.png b/src/public/images/tray/linux/notification-6@2x.png deleted file mode 100644 index 7851f8aaca..0000000000 Binary files a/src/public/images/tray/linux/notification-6@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-7.png b/src/public/images/tray/linux/notification-7.png deleted file mode 100644 index 3c2d40031c..0000000000 Binary files a/src/public/images/tray/linux/notification-7.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-7@2x.png b/src/public/images/tray/linux/notification-7@2x.png deleted file mode 100644 index a6a7c18dc3..0000000000 Binary files a/src/public/images/tray/linux/notification-7@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-8.png b/src/public/images/tray/linux/notification-8.png deleted file mode 100644 index f8f895b140..0000000000 Binary files a/src/public/images/tray/linux/notification-8.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-8@2x.png b/src/public/images/tray/linux/notification-8@2x.png deleted file mode 100644 index d72a6c04b8..0000000000 Binary files a/src/public/images/tray/linux/notification-8@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-9.png b/src/public/images/tray/linux/notification-9.png deleted file mode 100644 index 909f25633a..0000000000 Binary files a/src/public/images/tray/linux/notification-9.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-9@2x.png b/src/public/images/tray/linux/notification-9@2x.png deleted file mode 100644 index 7e6e249553..0000000000 Binary files a/src/public/images/tray/linux/notification-9@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-dot.png b/src/public/images/tray/linux/notification-dot.png deleted file mode 100644 index c3878afb92..0000000000 Binary files a/src/public/images/tray/linux/notification-dot.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-dot@2x.png b/src/public/images/tray/linux/notification-dot@2x.png deleted file mode 100644 index b13c205019..0000000000 Binary files a/src/public/images/tray/linux/notification-dot@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-plus-9.png b/src/public/images/tray/linux/notification-plus-9.png deleted file mode 100644 index 5d8dad56be..0000000000 Binary files a/src/public/images/tray/linux/notification-plus-9.png and /dev/null differ diff --git a/src/public/images/tray/linux/notification-plus-9@2x.png b/src/public/images/tray/linux/notification-plus-9@2x.png deleted file mode 100644 index b03717045b..0000000000 Binary files a/src/public/images/tray/linux/notification-plus-9@2x.png and /dev/null differ diff --git a/src/public/images/tray/linux/presence-away.png b/src/public/images/tray/linux/presence-away.png new file mode 100644 index 0000000000..95e51f04a4 Binary files /dev/null and b/src/public/images/tray/linux/presence-away.png differ diff --git a/src/public/images/tray/linux/presence-away@2x.png b/src/public/images/tray/linux/presence-away@2x.png new file mode 100644 index 0000000000..d50e7b0144 Binary files /dev/null and b/src/public/images/tray/linux/presence-away@2x.png differ diff --git a/src/public/images/tray/linux/presence-busy.png b/src/public/images/tray/linux/presence-busy.png new file mode 100644 index 0000000000..8e95ddc6bb Binary files /dev/null and b/src/public/images/tray/linux/presence-busy.png differ diff --git a/src/public/images/tray/linux/presence-busy@2x.png b/src/public/images/tray/linux/presence-busy@2x.png new file mode 100644 index 0000000000..e8d72767da Binary files /dev/null and b/src/public/images/tray/linux/presence-busy@2x.png differ diff --git a/src/public/images/tray/linux/presence-offline.png b/src/public/images/tray/linux/presence-offline.png new file mode 100644 index 0000000000..225315ff51 Binary files /dev/null and b/src/public/images/tray/linux/presence-offline.png differ diff --git a/src/public/images/tray/linux/presence-offline@2x.png b/src/public/images/tray/linux/presence-offline@2x.png new file mode 100644 index 0000000000..a850519c64 Binary files /dev/null and b/src/public/images/tray/linux/presence-offline@2x.png differ diff --git a/src/public/images/tray/linux/presence-online.png b/src/public/images/tray/linux/presence-online.png new file mode 100644 index 0000000000..7057f4717c Binary files /dev/null and b/src/public/images/tray/linux/presence-online.png differ diff --git a/src/public/images/tray/linux/presence-online@2x.png b/src/public/images/tray/linux/presence-online@2x.png new file mode 100644 index 0000000000..fd50ebda15 Binary files /dev/null and b/src/public/images/tray/linux/presence-online@2x.png differ diff --git a/src/public/images/tray/win32/notification-plus-9.ico b/src/public/images/tray/win32/disconnected.ico similarity index 71% rename from src/public/images/tray/win32/notification-plus-9.ico rename to src/public/images/tray/win32/disconnected.ico index 0c76b1ae00..94ad437023 100644 Binary files a/src/public/images/tray/win32/notification-plus-9.ico and b/src/public/images/tray/win32/disconnected.ico differ diff --git a/src/public/images/tray/win32/notification-1.ico b/src/public/images/tray/win32/notification-1.ico deleted file mode 100644 index ac8bc85b8e..0000000000 Binary files a/src/public/images/tray/win32/notification-1.ico and /dev/null differ diff --git a/src/public/images/tray/win32/notification-2.ico b/src/public/images/tray/win32/notification-2.ico deleted file mode 100644 index 6531525bb9..0000000000 Binary files a/src/public/images/tray/win32/notification-2.ico and /dev/null differ diff --git a/src/public/images/tray/win32/notification-5.ico b/src/public/images/tray/win32/notification-5.ico deleted file mode 100644 index 40cec4e66c..0000000000 Binary files a/src/public/images/tray/win32/notification-5.ico and /dev/null differ diff --git a/src/public/images/tray/win32/notification-6.ico b/src/public/images/tray/win32/notification-6.ico deleted file mode 100644 index 0197563582..0000000000 Binary files a/src/public/images/tray/win32/notification-6.ico and /dev/null differ diff --git a/src/public/images/tray/win32/notification-8.ico b/src/public/images/tray/win32/notification-8.ico deleted file mode 100644 index 104a5abd07..0000000000 Binary files a/src/public/images/tray/win32/notification-8.ico and /dev/null differ diff --git a/src/public/images/tray/win32/notification-9.ico b/src/public/images/tray/win32/notification-9.ico deleted file mode 100644 index 666328c519..0000000000 Binary files a/src/public/images/tray/win32/notification-9.ico and /dev/null differ diff --git a/src/public/images/tray/win32/notification-4.ico b/src/public/images/tray/win32/presence-away.ico similarity index 70% rename from src/public/images/tray/win32/notification-4.ico rename to src/public/images/tray/win32/presence-away.ico index f2fe150906..6e645f5563 100644 Binary files a/src/public/images/tray/win32/notification-4.ico and b/src/public/images/tray/win32/presence-away.ico differ diff --git a/src/public/images/tray/win32/notification-dot.ico b/src/public/images/tray/win32/presence-busy.ico similarity index 77% rename from src/public/images/tray/win32/notification-dot.ico rename to src/public/images/tray/win32/presence-busy.ico index d4b83d0f19..d47ebffeec 100644 Binary files a/src/public/images/tray/win32/notification-dot.ico and b/src/public/images/tray/win32/presence-busy.ico differ diff --git a/src/public/images/tray/win32/notification-3.ico b/src/public/images/tray/win32/presence-offline.ico similarity index 70% rename from src/public/images/tray/win32/notification-3.ico rename to src/public/images/tray/win32/presence-offline.ico index e7235287ae..1dcbdc5d35 100644 Binary files a/src/public/images/tray/win32/notification-3.ico and b/src/public/images/tray/win32/presence-offline.ico differ diff --git a/src/public/images/tray/win32/notification-7.ico b/src/public/images/tray/win32/presence-online.ico similarity index 72% rename from src/public/images/tray/win32/notification-7.ico rename to src/public/images/tray/win32/presence-online.ico index a33e0b8dfb..b49645b5fd 100644 Binary files a/src/public/images/tray/win32/notification-7.ico and b/src/public/images/tray/win32/presence-online.ico differ diff --git a/src/public/log-viewer-window.html b/src/public/log-viewer-window.html index e6c579b0b4..80f9c87486 100644 --- a/src/public/log-viewer-window.html +++ b/src/public/log-viewer-window.html @@ -6,6 +6,12 @@ Log Viewer - Rocket.Chat + + + + +
+ + + diff --git a/src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts b/src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts new file mode 100644 index 0000000000..cc260f424c --- /dev/null +++ b/src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts @@ -0,0 +1,69 @@ +import { BrowserWindow, webContents as electronWebContents } from 'electron'; + +import { resolveStandaloneOriginWindow } from '../serverViewScreenSharing'; + +jest.mock('electron', () => ({ + BrowserWindow: { + fromWebContents: jest.fn(), + }, + webContents: { + fromFrame: jest.fn(), + }, +})); + +jest.mock('../../ipc/main', () => ({ handle: jest.fn() })); +jest.mock('../../navigation/main', () => ({ + isProtocolAllowed: jest.fn(), +})); +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(), +})); +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); +jest.mock('../ScreenSharingRequestTracker', () => ({ + ScreenSharingRequestTracker: jest.fn().mockImplementation(() => ({ + createRequest: jest.fn(), + })), +})); +jest.mock('../desktopCapturerCache', () => ({ + prewarmDesktopCapturerCache: jest.fn(), +})); +jest.mock('../popoutPickerRequest', () => ({ + requestViaPickerWindow: jest.fn(), +})); +jest.mock('../screenRecordingPermission', () => ({ + checkScreenRecordingPermission: jest.fn(), +})); + +describe('resolveStandaloneOriginWindow', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns null when frame is missing', () => { + expect(resolveStandaloneOriginWindow(null)).toBeNull(); + expect(resolveStandaloneOriginWindow(undefined)).toBeNull(); + }); + + it('returns null when frame has no webContents', () => { + (electronWebContents.fromFrame as jest.Mock).mockReturnValue(null); + expect(resolveStandaloneOriginWindow({} as any)).toBeNull(); + }); + + it('returns null for guest webview contents (hostWebContents set)', () => { + (electronWebContents.fromFrame as jest.Mock).mockReturnValue({ + hostWebContents: {}, + }); + expect(resolveStandaloneOriginWindow({} as any)).toBeNull(); + }); + + it('returns BrowserWindow for standalone origin contents', () => { + const wc = { hostWebContents: undefined }; + const win = { id: 1 }; + (electronWebContents.fromFrame as jest.Mock).mockReturnValue(wc); + (BrowserWindow.fromWebContents as jest.Mock).mockReturnValue(win); + expect(resolveStandaloneOriginWindow({} as any)).toBe(win); + expect(BrowserWindow.fromWebContents).toHaveBeenCalledWith(wc); + }); +}); diff --git a/src/screenSharing/__tests__/screenSharePicker.spec.tsx b/src/screenSharing/__tests__/screenSharePicker.spec.tsx new file mode 100644 index 0000000000..9d8fcb2ef3 --- /dev/null +++ b/src/screenSharing/__tests__/screenSharePicker.spec.tsx @@ -0,0 +1,221 @@ +import '@testing-library/jest-dom'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; + +import { ScreenSharePicker } from '../screenSharePicker'; + +const invoke = jest.fn(); +const send = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: any[]) => invoke(...args), + send: (...args: any[]) => send(...args), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../ui/components/Dialog', () => ({ + Dialog: ({ isVisible, onClose, children }: any) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +jest.mock('@rocket.chat/fuselage', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const React = require('react'); + const passthrough = + (tag = 'div') => + ({ children, onClick, ...props }: any) => + React.createElement(tag, { onClick, ...props }, children); + const Tabs = ({ children }: any) =>
{children}
; + Tabs.Item = ({ children, onClick, selected }: any) => ( + + ); + return { + Box: passthrough('div'), + Button: ({ children, onClick, disabled, ...rest }: any) => ( + + ), + Callout: ({ children, title }: any) => ( +
+ {title} + {children} +
+ ), + Label: passthrough('label'), + Tabs, + Scrollable: passthrough('div'), + PaletteStyleTag: () => null, + }; +}); + +const sources = [ + { + id: 'screen:0:0', + name: 'Entire Screen', + thumbnail: { + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,aaa', + }, + }, + { + id: 'window:1:0', + name: 'Chrome', + thumbnail: { + isEmpty: () => false, + toDataURL: () => 'data:image/png;base64,bbb', + }, + }, +]; + +describe('ScreenSharePicker', () => { + beforeEach(() => { + jest.clearAllMocks(); + invoke.mockImplementation(async (channel: string) => { + if (channel === 'desktop-capturer-get-sources') return sources; + if (channel.includes('permission')) return true; + return undefined; + }); + }); + + afterEach(() => { + jest.clearAllTimers(); + }); + + it('mounts, shows sources, selects, and shares', async () => { + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + /> + ); + + await act(async () => { + setVisible?.(true); + }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'desktop-capturer-get-sources', + expect.any(Array) + ); + }); + + fireEvent.click(screen.getByText('screenSharing.applicationWindow')); + fireEvent.click(screen.getByText('screenSharing.entireScreen')); + + const screenTile = await screen.findByText('Entire Screen'); + fireEvent.click(screenTile); + + const share = screen.getByRole('button', { name: 'screenSharing.share' }); + expect(share).toBeEnabled(); + fireEvent.click(share); + expect(send).toHaveBeenCalledWith( + 'video-call-window/screen-sharing-source-responded', + 'screen:0:0' + ); + + // Always close to exercise cancel path cleanup + await act(async () => { + setVisible?.(false); + }); + }); + + it('sends null on dialog close without selection', async () => { + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + responseChannel='custom-response' + /> + ); + await act(async () => { + setVisible?.(true); + }); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + fireEvent.click(screen.getByText('dialog-close')); + expect(send).toHaveBeenCalledWith('custom-response', null); + }); + + it('shows permission denied callout when permission false', async () => { + invoke.mockImplementation(async (channel: string) => { + if (channel.includes('permission')) return false; + if (channel === 'desktop-capturer-get-sources') return sources; + return undefined; + }); + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + /> + ); + await act(async () => { + setVisible?.(true); + }); + await waitFor(() => { + expect( + screen.getByText('screenSharing.permissionDenied') + ).toBeInTheDocument(); + }); + await act(async () => { + setVisible?.(false); + }); + }); + + it('handles fetchSources failure without crashing', async () => { + invoke.mockImplementation(async (channel: string) => { + if (channel.includes('permission')) return true; + if (channel === 'desktop-capturer-get-sources') { + throw new Error('enum failed'); + } + return undefined; + }); + let setVisible: ((v: boolean) => void) | undefined; + render( + { + setVisible = fn; + }} + /> + ); + await act(async () => { + setVisible?.(true); + }); + await waitFor(() => expect(invoke).toHaveBeenCalled()); + expect(screen.getByTestId('dialog')).toBeInTheDocument(); + expect(send).not.toHaveBeenCalled(); + + await act(async () => { + setVisible?.(false); + }); + expect(send).toHaveBeenCalledWith( + 'video-call-window/screen-sharing-source-responded', + null + ); + }); +}); diff --git a/src/servers/common.spec.ts b/src/servers/__tests__/common.spec.ts similarity index 98% rename from src/servers/common.spec.ts rename to src/servers/__tests__/common.spec.ts index be04a10789..7b6c150eec 100644 --- a/src/servers/common.spec.ts +++ b/src/servers/__tests__/common.spec.ts @@ -1,7 +1,7 @@ import { isServerUrlResolutionResult, ServerUrlResolutionStatus, -} from './common'; +} from '../common'; describe('servers/common', () => { it('returns false for non-array objects', () => { diff --git a/src/servers/__tests__/fetchInfo.spec.ts b/src/servers/__tests__/fetchInfo.spec.ts new file mode 100644 index 0000000000..dd763ef5de --- /dev/null +++ b/src/servers/__tests__/fetchInfo.spec.ts @@ -0,0 +1,80 @@ +import { fetchInfo } from '../renderer'; + +describe('servers/renderer fetchInfo', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('returns resolved url and version from api/info', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + url: 'https://open.rocket.chat/', + }) + .mockResolvedValueOnce({ + ok: true, + url: 'https://open.rocket.chat/api/info', + json: async () => ({ success: true, version: '6.5.0' }), + }) as any; + + const [url, version] = await fetchInfo('https://open.rocket.chat'); + expect(version).toBe('6.5.0'); + expect(url).toBe('https://open.rocket.chat/'); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('sends basic auth when credentials are in the URL', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + url: 'https://user:pass@open.rocket.chat/', + }) + .mockResolvedValueOnce({ + ok: true, + url: 'https://user:pass@open.rocket.chat/api/info', + json: async () => ({ success: true, version: '6.0.0' }), + }) as any; + + await fetchInfo('https://user:pass@open.rocket.chat'); + const firstCall = (global.fetch as jest.Mock).mock.calls[0]; + const { headers }: { headers: Headers } = firstCall[1]; + expect(headers.get('Authorization')).toMatch(/^Basic /); + }); + + it('throws when home response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValueOnce({ + ok: false, + statusText: 'Not Found', + }) as any; + await expect(fetchInfo('https://missing.example')).rejects.toThrow( + 'Not Found' + ); + }); + + it('throws when api/info response is not ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, url: 'https://open.rocket.chat/' }) + .mockResolvedValueOnce({ ok: false, statusText: 'Server Error' }) as any; + await expect(fetchInfo('https://open.rocket.chat')).rejects.toThrow( + 'Server Error' + ); + }); + + it('throws when api/info success is false', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, url: 'https://open.rocket.chat/' }) + .mockResolvedValueOnce({ + ok: true, + url: 'https://open.rocket.chat/api/info', + json: async () => ({ success: false }), + }) as any; + await expect(fetchInfo('https://open.rocket.chat')).rejects.toThrow(); + }); +}); diff --git a/src/servers/common.ts b/src/servers/common.ts index 06f618b153..64944f51ed 100644 --- a/src/servers/common.ts +++ b/src/servers/common.ts @@ -1,6 +1,8 @@ import type { OutlookCredentials } from '../outlookCalendar/type'; import type { SupportedVersions } from './supportedVersions/types'; +export type UserPresence = 'online' | 'away' | 'busy' | 'offline'; + export type Server = { url: string; title?: string; @@ -31,6 +33,10 @@ export type Server = { supportedVersionsValidatedAt?: Date; documentViewerOpenUrl?: string; documentViewerFormat?: string; + presence?: UserPresence; + presenceStatusText?: string; + presenceConnection?: 'connected' | 'connecting' | 'disconnected'; + presenceSupported?: boolean; }; export const enum ServerUrlResolutionStatus { diff --git a/src/servers/main.spec.ts b/src/servers/main.spec.ts index ae492e31f4..9f2f5e4a43 100644 --- a/src/servers/main.spec.ts +++ b/src/servers/main.spec.ts @@ -1,55 +1,60 @@ -import { ServerUrlResolutionStatus } from './common'; -import { convertToURL, resolveServerUrl } from './main'; - -describe('convertToUrl', () => { - it.each([ - ['localhost', 'https://localhost/'], - ['localhost:3000', 'https://localhost:3000/'], - ['https://localhost', 'https://localhost/'], - ['http://localhost', 'http://localhost/'], - ['https://localhost/', 'https://localhost/'], - ['http://localhost/', 'http://localhost/'], - ['https://localhost/subdir', 'https://localhost/subdir/'], - ['http://localhost:80', 'http://localhost/'], - ['https://localhost:443', 'https://localhost/'], - ])('transforms %s into URL(%s)', (input, expected) => { - const result = convertToURL(input); - - expect(result.href).toBe(expected); - }); -}); +import { convertToURL } from './main'; -jest.mock('../ui/main/rootWindow', () => ({ - __esModule: true, - getRootWindow: jest.fn(() => ({ webContents: null })), +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp'), + }, +})); + +jest.mock('../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat', version: '4.0.0' }, })); jest.mock('../ipc/main', () => ({ - __esModule: true, - invoke: jest.fn(async (_webContents, channel, ...args) => { - if (channel === 'servers/fetch-info') { - return [args[0], '3.8.0']; - } - - return null; - }), + invoke: jest.fn(), +})); + +jest.mock('../store', () => ({ + select: jest.fn(), + dispatch: jest.fn(), + listen: jest.fn(() => jest.fn()), + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(async () => ({ + webContents: {}, + })), +})); + +jest.mock('../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(), })); -describe('resolveServerUrl', () => { - it.each([ - ['localhost', 'https://localhost/'], - ['localhost:3000', 'https://localhost:3000/'], - ['https://localhost', 'https://localhost/'], - ['http://localhost', 'http://localhost/'], - ['https://localhost/', 'https://localhost/'], - ['http://localhost/', 'http://localhost/'], - ['https://localhost/subdir', 'https://localhost/subdir/'], - ['http://localhost:80', 'http://localhost/'], - ['https://localhost:443', 'https://localhost/'], - ])('resolves %s as %s', async (input, expected) => { - const [serverUrl, status, error] = await resolveServerUrl(input); - expect(serverUrl).toBe(expected); - expect(status).toBe(ServerUrlResolutionStatus.OK); - expect(error).toBe(undefined); +describe('servers/main convertToURL', () => { + it('parses absolute http and https urls', () => { + expect(convertToURL('https://open.rocket.chat').href).toBe( + 'https://open.rocket.chat/' + ); + expect(convertToURL('http://localhost:3000').href).toBe( + 'http://localhost:3000/' + ); + }); + + it('prefixes bare hostnames with https', () => { + expect(convertToURL('open.rocket.chat').protocol).toBe('https:'); + expect(convertToURL('open.rocket.chat').hostname).toBe('open.rocket.chat'); + }); + + it('preserves host and path for credentialed urls', () => { + const url = convertToURL('https://user:pass@open.rocket.chat/path'); + expect(url.hostname).toBe('open.rocket.chat'); + expect(url.pathname).toBe('/path/'); + }); + + it('ensures trailing slash on pathname', () => { + expect( + convertToURL('https://open.rocket.chat/channel/general').pathname + ).toBe('/channel/general/'); }); }); diff --git a/src/servers/main/preloadCoverage.main.spec.ts b/src/servers/main/preloadCoverage.main.spec.ts new file mode 100644 index 0000000000..8f96864151 --- /dev/null +++ b/src/servers/main/preloadCoverage.main.spec.ts @@ -0,0 +1,732 @@ +/** + * Exercise preload modules from the main/node Jest project so Istanbul + * coverage counts them under `yarn test:coverage` (renderer preload specs are + * skipped when --coverage is set due to EvalError). + */ +/* eslint-disable @typescript-eslint/no-var-requires -- modules are required + inline per-test, after jest.resetModules(), rather than statically imported */ +import { NOTIFICATIONS_NOTIFICATION_CLICKED } from '../../notifications/actions'; +import { + WEBVIEW_UNREAD_CHANGED, + WEBVIEW_SERVER_VERSION_UPDATED, + WEBVIEW_SERVER_UNIQUE_ID_UPDATED, + WEBVIEW_TITLE_CHANGED, + WEBVIEW_GIT_COMMIT_HASH_CHECK, + WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR, + WEBVIEW_USER_LOGGED_IN, + SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, + WEBVIEW_FOCUS_REQUESTED, +} from '../../ui/actions'; + +const dispatch = jest.fn(); +const request = jest.fn(); +const listen = jest.fn(() => jest.fn()); +const watch = jest.fn(() => jest.fn()); +const select = jest.fn((sel: any) => { + try { + return sel({ + e2ePdfPreviewSizeLimit: 10, + servers: [{ url: 'https://open.rocket.chat' }], + isInternalVideoChatWindowEnabled: true, + navigationLayout: 'sideBar', + }); + } catch { + return undefined; + } +}); + +jest.mock('../../store', () => ({ + dispatch: (...args: any[]) => (dispatch as any)(...args), + request: (...args: any[]) => (request as any)(...args), + listen: (...args: any[]) => (listen as any)(...args), + select: (...args: any[]) => (select as any)(...args), + safeSelect: (sel: any) => { + try { + return select(sel); + } catch { + return undefined; + } + }, + watch: (...args: any[]) => (watch as any)(...args), +})); + +jest.mock('../preload/urls', () => ({ + getServerUrl: jest.fn(() => 'https://open.rocket.chat'), + getAbsoluteUrl: jest.fn((p: string) => + p?.startsWith('http') ? p : `https://open.rocket.chat${p || ''}` + ), +})); + +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); + +jest.mock('../../ipc/renderer', () => ({ + invoke: jest.fn(async () => 'active'), + invokeWithRetry: jest.fn(async () => ({ language: 'en' })), +})); + +const ipcInvoke = jest.fn(async (..._args: any[]) => undefined) as jest.Mock; +const ipcSend = jest.fn(); +const ipcSendSync = jest.fn((..._args: any[]) => 'jitsi'); +const ipcOn = jest.fn(); +const ipcOnce = jest.fn(); +const ipcRemoveListener = jest.fn(); +const ipcRemoveAllListeners = jest.fn(); +const exposeInMainWorld = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: any[]) => (ipcInvoke as any)(...args), + send: (...args: any[]) => (ipcSend as any)(...args), + sendSync: (...args: any[]) => (ipcSendSync as any)(...args), + on: (...args: any[]) => (ipcOn as any)(...args), + once: (...args: any[]) => (ipcOnce as any)(...args), + removeListener: (...args: any[]) => (ipcRemoveListener as any)(...args), + removeAllListeners: (...args: any[]) => + (ipcRemoveAllListeners as any)(...args), + }, + contextBridge: { + exposeInMainWorld: (...args: any[]) => (exposeInMainWorld as any)(...args), + }, + webFrame: { setZoomFactor: jest.fn() }, + clipboard: { + writeText: jest.fn(), + readText: jest.fn(() => 'clip'), + }, + nativeImage: { + createFromDataURL: jest.fn(() => ({})), + createFromPath: jest.fn(() => ({})), + }, +})); + +const installDomGlobals = (): void => { + const styleEl = { + id: '', + style: {} as Record, + classList: { add: jest.fn() }, + remove: jest.fn(), + innerHTML: '', + }; + const body = { + append: jest.fn(), + appendChild: jest.fn(), + removeChild: jest.fn(), + }; + const head = { + append: jest.fn(), + appendChild: jest.fn(), + }; + const canvasCtx = { + clearRect: jest.fn(), + drawImage: jest.fn(), + }; + const canvas = { + width: 0, + height: 0, + getContext: jest.fn(() => canvasCtx), + toDataURL: jest.fn(() => 'data:image/png;base64,abc'), + }; + const imageListeners: Record = {}; + const image = { + src: '', + addEventListener: jest.fn((event: string, cb: Function) => { + imageListeners[event] = imageListeners[event] || []; + imageListeners[event].push(cb); + }), + }; + + (global as any).document = { + body, + head: { + ...head, + appendChild: jest.fn((el: any) => { + // Resolve script loads immediately so loadJitsiScript does not hang + if (el && typeof el.onload === 'function') { + queueMicrotask(() => el.onload()); + } + return el; + }), + }, + readyState: 'complete', + createElement: jest.fn((tag: string) => { + if (tag === 'canvas') return canvas; + if (tag === 'img') return image; + if (tag === 'style') return { ...styleEl, style: {} }; + if (tag === 'script') { + return { + src: '', + async: false, + onload: null as null | Function, + onerror: null as null | Function, + }; + } + if (tag === 'div') { + return { + style: {} as Record, + classList: { add: jest.fn(), contains: jest.fn(() => false) }, + remove: jest.fn(), + closest: jest.fn(() => null), + querySelector: jest.fn(() => null), + }; + } + return { style: {}, classList: { add: jest.fn() }, remove: jest.fn() }; + }), + createElementNS: jest.fn(), + getElementById: jest.fn(() => null), + querySelectorAll: jest.fn(() => []), + querySelector: jest.fn(() => null), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + (global as any).window = { + location: { + origin: 'https://open.rocket.chat', + href: 'https://open.rocket.chat/home', + hostname: 'open.rocket.chat', + pathname: '/home', + protocol: 'https:', + }, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + getComputedStyle: jest.fn(() => ({ + background: 'rgb(0,0,0)', + color: 'rgb(255,255,255)', + border: '1px solid #000', + })), + top: { postMessage: jest.fn() }, + localStorage: { + getItem: jest.fn((key: string) => { + if (key === 'Meteor.loginToken') return 'token'; + if (key === 'Meteor.userId') return 'user-1'; + return null; + }), + setItem: jest.fn(), + }, + // Pre-install so initializeJitsiApi skips script load path when set + JitsiMeetExternalAPI: function MockJitsi() { + return { + executeCommand: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + dispose: jest.fn(), + }; + }, + }; + + (global as any).localStorage = (global as any).window.localStorage; + (global as any).Image = function ImageMock(this: any) { + Object.assign(this, image); + return this; + }; + (global as any).MutationObserver = class { + observe = jest.fn(); + + disconnect = jest.fn(); + + constructor(public cb: Function) {} + }; + (global as any).fetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ roles: ['user', 'admin'] }), + })); +}; + +describe('preload modules coverage (node env)', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + installDomGlobals(); + request.mockResolvedValue('notif-id'); + ipcInvoke.mockResolvedValue(undefined); + }); + + it('covers small servers/preload setters', () => { + const SERVER_URL = 'https://open.rocket.chat'; + + const { setBadge } = require('../preload/badge'); + setBadge(3); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_UNREAD_CHANGED, + payload: { url: SERVER_URL, badge: 3 }, + }); + setBadge('•'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_UNREAD_CHANGED, + payload: { url: SERVER_URL, badge: '•' }, + }); + setBadge(undefined); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_UNREAD_CHANGED, + payload: { url: SERVER_URL, badge: undefined }, + }); + + const { writeTextToClipboard } = require('../preload/clipboard'); + writeTextToClipboard('hello'); + const { clipboard } = require('electron'); + expect(clipboard.writeText).toHaveBeenCalledWith('hello'); + + const { setVersion } = require('../preload/version'); + setVersion('6.5.0'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_SERVER_VERSION_UPDATED, + payload: { url: SERVER_URL, version: '6.5.0' }, + }); + + const { setWorkspaceUID } = require('../preload/uniqueID'); + setWorkspaceUID('uid-1'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_SERVER_UNIQUE_ID_UPDATED, + payload: { url: SERVER_URL, uniqueID: 'uid-1' }, + }); + + const { setTitle } = require('../preload/title'); + setTitle('Community'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_TITLE_CHANGED, + payload: { url: SERVER_URL, title: 'Community' }, + }); + // non-string titles are ignored + dispatch.mockClear(); + setTitle(undefined as unknown as string); + expect(dispatch).not.toHaveBeenCalled(); + + const { setGitCommitHash } = require('../preload/gitCommitHash'); + setGitCommitHash('deadbeef'); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_GIT_COMMIT_HASH_CHECK, + payload: { url: SERVER_URL, gitCommitHash: 'deadbeef' }, + }); + + const { reloadServer } = require('../preload/reloadServer'); + reloadServer(); + expect(dispatch).toHaveBeenLastCalledWith({ + type: WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR, + payload: SERVER_URL, + }); + + const { openInBrowser } = require('../preload/openInBrowser'); + openInBrowser('https://example.com'); + expect(ipcInvoke).toHaveBeenCalledWith( + 'browser/open-url', + 'https://example.com/' + ); + ipcInvoke.mockClear(); + openInBrowser('javascript:alert(1)'); + expect(ipcInvoke).not.toHaveBeenCalled(); + + const { setUserLoggedIn } = require('../preload/userLoggedIn'); + dispatch.mockClear(); + setUserLoggedIn(true); + // userLoggedIn=true also kicks off the async updateUserRoles() REST + // fallback (fire-and-forget), so only the synchronous dispatch is asserted here. + expect(dispatch).toHaveBeenCalledWith({ + type: WEBVIEW_USER_LOGGED_IN, + payload: { url: SERVER_URL, userLoggedIn: true }, + }); + dispatch.mockClear(); + setUserLoggedIn(false); + // userLoggedIn=false also synchronously calls clearUserRoles(), which + // dispatches a second WEBVIEW_USER_ROLES_CHANGED action right after. + expect(dispatch).toHaveBeenNthCalledWith(1, { + type: WEBVIEW_USER_LOGGED_IN, + payload: { url: SERVER_URL, userLoggedIn: false }, + }); + + const { setUserThemeAppearance } = require('../preload/themeAppearance'); + // No-op kept for desktop-api backwards compatibility: must not throw or dispatch. + dispatch.mockClear(); + expect(() => setUserThemeAppearance('dark' as any)).not.toThrow(); + expect(dispatch).not.toHaveBeenCalled(); + + const { + getE2ePdfPreviewSizeLimit, + } = require('../preload/e2ePdfPreviewSizeLimit'); + expect(getE2ePdfPreviewSizeLimit()).toBe(10); + + const { + openDocumentViewer, + supportedDocumentViewerFormats, + } = require('../preload/documentViewer'); + expect(supportedDocumentViewerFormats()).toEqual(['pdf', 'markdown']); + openDocumentViewer('https://open.rocket.chat/file.pdf', 'pdf', { + page: 1, + }); + expect(ipcInvoke).toHaveBeenCalledWith( + 'document-viewer/open-window', + 'https://open.rocket.chat/file.pdf', + 'pdf', + { page: 1 } + ); + }); + + it('covers favicon and sidebar with DOM mocks', () => { + const { setFavicon } = require('../preload/favicon'); + setFavicon('https://open.rocket.chat/favicon.ico'); + setFavicon(null as any); + + const { + setServerVersionToSidebar, + setBackground, + setSidebarCustomTheme, + } = require('../preload/sidebar'); + setServerVersionToSidebar('6.0.0'); + setBackground('https://open.rocket.chat/bg.png'); + setBackground(''); + setServerVersionToSidebar('6.3.0'); + setBackground('https://open.rocket.chat/bg2.png'); + setSidebarCustomTheme('{"color":"#000"}'); + expect(dispatch).toHaveBeenCalled(); + }); + + it('covers userRoles bridge and REST fallback', async () => { + const { + setUserRoles, + updateUserRoles, + clearUserRoles, + } = require('../preload/userRoles'); + + setUserRoles(['admin', 1, 'user'] as any); + await updateUserRoles(); // bridge already provided → skip + clearUserRoles(); + + // After clear, REST path can run + await updateUserRoles(); + setUserRoles('not-array' as any); + expect(dispatch).toHaveBeenCalled(); + }); + + it('covers internal video chat window open paths', () => { + const { + openInternalVideoChatWindow, + getInternalVideoChatWindowEnabled, + } = require('../preload/internalVideoChatWindow'); + + expect(getInternalVideoChatWindowEnabled()).toBe(true); + + openInternalVideoChatWindow('https://meet.example/room', { + providerName: 'jitsi', + }); + openInternalVideoChatWindow('https://meet.google.com/abc', { + providerName: 'googlemeet', + }); + openInternalVideoChatWindow('https://pexip.example/room', { + providerName: 'pexip', + }); + openInternalVideoChatWindow('https://other.example/room', undefined); + openInternalVideoChatWindow('ftp://bad.example/room', undefined); + + // MAS / disabled path falls back to external + const originalMas = (process as any).mas; + try { + (process as any).mas = true; + openInternalVideoChatWindow('https://meet.example/room', { + providerName: 'jitsi', + }); + } finally { + (process as any).mas = originalMas; + } + + expect(ipcInvoke).toHaveBeenCalled(); + }); + + it('covers notifications/preload createNotification paths', async () => { + const { + createNotification, + destroyNotification, + dispatchCustomNotification, + closeCustomNotification, + listenToNotificationsRequests, + } = require('../../notifications/preload'); + + const onEvent = jest.fn(); + const id = await createNotification({ + title: 'Hello', + body: 'World', + icon: '/static/icon.png', + notificationType: 'text', + category: 'SERVER', + onEvent, + }); + expect(id).toBe('notif-id'); + + await createNotification({ + title: 'Voice', + body: 'Call', + icon: 'data:image/png;base64,abc', + notificationType: 'voice', + category: 'DOWNLOADS', + }); + + await createNotification({ + title: 'No icon', + body: 'x', + }); + + await dispatchCustomNotification({ + type: 'text', + payload: { + title: 'Custom', + body: 'Body', + avatar: 'https://cdn.example/a.png', + requireInteraction: true, + }, + }); + + destroyNotification(id); + closeCustomNotification(id); + listenToNotificationsRequests(); + + const clickedHandler = (listen.mock.calls as any[]).find( + ([matcher]) => matcher === NOTIFICATIONS_NOTIFICATION_CLICKED + )?.[1] as Function; + expect(clickedHandler).toBeDefined(); + + dispatch.mockClear(); + clickedHandler({ + payload: { + id, + serverUrl: 'https://open.rocket.chat', + category: 'DOWNLOADS', + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, + }); + + dispatch.mockClear(); + clickedHandler({ + payload: { + id, + serverUrl: 'https://open.rocket.chat', + category: 'SERVER', + }, + }); + expect(dispatch).toHaveBeenCalledWith({ + type: WEBVIEW_FOCUS_REQUESTED, + payload: { url: 'https://open.rocket.chat', view: 'server' }, + }); + + expect(request).toHaveBeenCalled(); + }); + + it('covers navigateToRoute buffering and delivery', () => { + const { + onNavigateToRoute, + listenToNavigateToRouteRequests, + } = require('../preload/navigateToRoute'); + + listenToNavigateToRouteRequests(); + listenToNavigateToRouteRequests(); // idempotent + + expect(ipcOn).toHaveBeenCalledWith( + 'navigate-to-route', + expect.any(Function) + ); + const handler = ipcOn.mock.calls.find( + ([ch]) => ch === 'navigate-to-route' + )?.[1] as Function; + + // Path arrives before callback is registered → buffered + handler({}, '/channel/general'); + const cb = jest.fn(); + onNavigateToRoute(cb); + expect(cb).toHaveBeenCalledWith('/channel/general'); + + // Subsequent path delivered immediately + handler({}, '/group/ops'); + expect(cb).toHaveBeenCalledWith('/group/ops'); + }); + + it('covers outlookCalendar preload success, failure, and fire-and-forget paths', async () => { + const outlook = require('../../outlookCalendar/preload'); + ipcInvoke.mockResolvedValueOnce({ status: 'success', events: [] }); + await outlook.getOutlookEvents(new Date('2026-01-01')); + ipcInvoke.mockRejectedValueOnce(new Error('net')); + await expect(outlook.getOutlookEvents(new Date())).rejects.toThrow(); + ipcInvoke.mockResolvedValue(true); + outlook.setOutlookExchangeUrl('https://exchange.example', 'u1'); + await outlook.hasOutlookCredentials(); + ipcInvoke.mockRejectedValueOnce(new Error('fail')); + await expect(outlook.hasOutlookCredentials()).resolves.toBe(false); + outlook.clearOutlookCredentials(); + outlook.setUserToken('tok', 'u1'); + // rejection paths for fire-and-forget + ipcInvoke.mockRejectedValueOnce(new Error('x')); + outlook.setOutlookExchangeUrl('https://exchange.example', 'u1'); + ipcInvoke.mockRejectedValueOnce(new Error('x')); + outlook.clearOutlookCredentials(); + ipcInvoke.mockRejectedValueOnce(new Error('x')); + outlook.setUserToken('tok', 'u1'); + }); + + describe('userPresence preload', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('covers setUserPresenceDetection and power-monitor handlers', () => { + const { + setUserPresenceDetection, + } = require('../../userPresence/preload'); + jest.useFakeTimers(); + setUserPresenceDetection({ + isAutoAwayEnabled: true, + idleThreshold: 60, + setUserOnline: jest.fn(), + }); + setUserPresenceDetection({ + isAutoAwayEnabled: false, + idleThreshold: null, + setUserOnline: jest.fn(), + }); + // fire power-monitor listen handlers + for (const call of listen.mock.calls as any[]) { + const matcher = call[0]; + const handler = call[1] as Function | undefined; + try { + if (typeof matcher === 'function' && handler) { + handler({ type: 'SYSTEM_SUSPENDING' }); + } + } catch { + // ignore + } + } + jest.runOnlyPendingTimers(); + }); + }); + + it('covers listenToMessageBoxEvents registering a DOM listener', () => { + const { listenToMessageBoxEvents } = require('../../ui/preload/messageBox'); + listenToMessageBoxEvents(); + expect(document.addEventListener).toHaveBeenCalled(); + }); + + it('covers handleTrafficLightsSpacing on darwin and non-darwin platforms', () => { + const { handleTrafficLightsSpacing } = require('../../ui/preload/sidebar'); + const originalPlatform = process.platform; + try { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + handleTrafficLightsSpacing(); + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + handleTrafficLightsSpacing(); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + } + }); + + it('covers listenToScreenSharingRequests registering a window listener', () => { + const { + listenToScreenSharingRequests, + } = require('../../screenSharing/preload'); + listenToScreenSharingRequests(); + expect(window.addEventListener).toHaveBeenCalledWith( + 'get-sourceId', + expect.any(Function) + ); + }); + + it('covers jitsi preload and video-call preload index', async () => { + const jitsi = require('../../jitsi/preload'); + expect(jitsi.desktopCapturer).toBeDefined(); + expect(jitsi.JitsiMeetElectron).toBeDefined(); + + ipcInvoke.mockResolvedValueOnce([ + { + id: 's1', + name: 'Screen', + display_id: '1', + thumbnail: { toDataURL: () => 'data:thumb' }, + appIcon: { toDataURL: () => 'data:icon' }, + }, + ]); + await jitsi.JitsiMeetElectron.obtainDesktopStreams(jest.fn(), jest.fn(), { + types: ['screen'], + }); + ipcInvoke.mockRejectedValueOnce(new Error('denied')); + await jitsi.JitsiMeetElectron.obtainDesktopStreams(jest.fn(), jest.fn(), { + types: ['window'], + }); + + jest.resetModules(); + installDomGlobals(); + ipcSendSync.mockReturnValue('jitsi'); + // Auto-resolve screen-share once listener so requestScreenSharing does not hang + ipcOnce.mockImplementation((channel: string, handler: Function) => { + if (channel === 'video-call-window/screen-sharing-source-responded') { + queueMicrotask(() => handler({}, 'source-1')); + } + }); + require('../../videoCallWindow/preload/index'); + const api = exposeInMainWorld.mock.calls.find( + ([name]) => name === 'videoCallWindow' + )?.[1]; + expect(api).toBeDefined(); + api.openInMainWindow('/channel/general'); + api.openInMainWindow('https://evil.example'); + api.openInMainWindow('//host'); + api.close(); + ipcInvoke.mockResolvedValue(undefined); + await api.requestScreenSharing(); + await api.getAuthCredentials(); + }); + + it('covers jitsiBridge initialize and helpers', async () => { + ipcSendSync.mockReturnValue('jitsi'); + require('../../videoCallWindow/preload/jitsiBridge'); + const b = (window as any).jitsiBridge; + expect(b).toBeTruthy(); + await b.initializeJitsiApi({ domain: '', roomName: '' }); + await b.initializeJitsiApi({ + domain: 'meet.jit.si', + roomName: 'RoomName', + }); + await b.initializeJitsiApi({ + domain: 'meet.jit.si', + roomName: 'RoomName', + }); + expect(b.isInitialized()).toBe(true); + expect(b.getCurrentDomain()).toBe('meet.jit.si'); + expect(b.getCurrentRoomName()).toBe('RoomName'); + await b.startScreenSharing(); + await b.getJitsiVersion(); + + const obtainer = (window as any).JitsiMeetScreenObtainer; + if (obtainer?.openDesktopPicker) { + const success = jest.fn(); + const error = jest.fn(); + obtainer.openDesktopPicker({}, success, error); + const onHandler = (ipcOn.mock.calls as any[]).find( + ([ch]) => ch === 'video-call-window/screen-sharing-source-responded' + )?.[1]; + onHandler?.({}, 'screen:0:0'); + obtainer.openDesktopPicker({}, success, error); + obtainer.openDesktopPicker({}, success, error); + const onHandler2 = (ipcOn.mock.calls as any[]) + .filter( + ([ch]) => ch === 'video-call-window/screen-sharing-source-responded' + ) + .pop()?.[1]; + onHandler2?.({}, null); + } + b.endCall(); + b.dispose(); + expect(window.addEventListener).toHaveBeenCalled(); + }); + + it('skips jitsiBridge when provider is not jitsi', () => { + ipcSendSync.mockReturnValue('pexip'); + const mod = require('../../videoCallWindow/preload/jitsiBridge'); + expect(mod.default).toBeNull(); + }); +}); diff --git a/src/servers/main/resolveServerUrl.main.spec.ts b/src/servers/main/resolveServerUrl.main.spec.ts new file mode 100644 index 0000000000..99a938d652 --- /dev/null +++ b/src/servers/main/resolveServerUrl.main.spec.ts @@ -0,0 +1,88 @@ +import { ServerUrlResolutionStatus } from '../common'; +import { convertToURL, resolveServerUrl } from '../main'; + +jest.mock('electron', () => ({ + app: { getPath: jest.fn(() => '/tmp'), getAppPath: jest.fn(() => '/app') }, +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat', version: '4.0.0' }, +})); + +const invoke = jest.fn(); +jest.mock('../../ipc/main', () => ({ + invoke: (...args: any[]) => invoke(...args), +})); + +jest.mock('../../store', () => ({ + select: jest.fn(), + dispatch: jest.fn(), + listen: jest.fn(() => jest.fn()), + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(async () => ({ webContents: { id: 1 } })), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: jest.fn(), +})); + +describe('resolveServerUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns INVALID_URL for garbage input', async () => { + const [input, status, error] = await resolveServerUrl('://bad'); + expect(status).toBe(ServerUrlResolutionStatus.INVALID_URL); + expect(error).toBeTruthy(); + expect(input).toBe('://bad'); + }); + + it('returns OK for compatible server version', async () => { + invoke.mockResolvedValue(['https://open.rocket.chat/', '6.5.0']); + const [url, status] = await resolveServerUrl('https://open.rocket.chat'); + expect(status).toBe(ServerUrlResolutionStatus.OK); + expect(url).toContain('open.rocket.chat'); + }); + + it('returns INVALID for incompatible server version', async () => { + invoke.mockResolvedValue(['https://open.rocket.chat/', '1.0.0']); + const [, status, error] = await resolveServerUrl( + 'https://open.rocket.chat' + ); + expect(status).toBe(ServerUrlResolutionStatus.INVALID); + expect(String(error?.message || error)).toMatch(/incompatible/i); + }); + + it('returns INVALID when fetch fails for absolute urls', async () => { + invoke.mockRejectedValue(new Error('network')); + const [, status] = await resolveServerUrl('https://down.example'); + expect(status).toBe(ServerUrlResolutionStatus.INVALID); + }); + + it('returns TIMEOUT when fetch aborts', async () => { + const err = new Error('aborted'); + err.name = 'AbortError'; + invoke.mockRejectedValue(err); + const [, status] = await resolveServerUrl('https://slow.example'); + expect(status).toBe(ServerUrlResolutionStatus.TIMEOUT); + }); + + it('convertToURL is used for bare hostnames', () => { + expect(convertToURL('chat.example').hostname).toBe('chat.example'); + }); + + it('retries as a rocket.chat subdomain for bare-word input and returns INVALID on failure', async () => { + invoke.mockRejectedValue(new Error('network')); + const [, status] = await resolveServerUrl('myworkspace'); + expect(status).toBe(ServerUrlResolutionStatus.INVALID); + expect(invoke).toHaveBeenCalledWith( + expect.anything(), + 'servers/fetch-info', + 'https://myworkspace.rocket.chat/' + ); + }); +}); diff --git a/src/servers/main/setupServers.main.spec.ts b/src/servers/main/setupServers.main.spec.ts new file mode 100644 index 0000000000..0b0c2ad9c1 --- /dev/null +++ b/src/servers/main/setupServers.main.spec.ts @@ -0,0 +1,202 @@ +import { + WEBVIEW_GIT_COMMIT_HASH_CHECK, + WEBVIEW_GIT_COMMIT_HASH_CHANGED, +} from '../../ui/actions'; +import { + SERVER_URL_RESOLUTION_REQUESTED, + SERVER_URL_RESOLVED, +} from '../actions'; + +const listenHandlers = new Map(); +const dispatch = jest.fn(); +const select = jest.fn(); +const invoke = jest.fn(); +const getWebContentsByServerUrl = jest.fn(); + +jest.mock('fs', () => { + const actual = jest.requireActual('fs'); + return { + ...actual, + existsSync: jest.fn(() => false), + promises: { + readFile: jest.fn(async () => { + throw new Error('missing'); + }), + unlink: jest.fn(async () => undefined), + }, + }; +}); + +jest.mock('electron', () => ({ + app: { + getPath: jest.fn(() => '/tmp/user'), + getAppPath: jest.fn(() => '/app'), + }, +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat', version: '4.0.0' }, +})); + +jest.mock('../../ipc/main', () => ({ + invoke: (...args: any[]) => invoke(...args), +})); + +jest.mock('../../store', () => ({ + select: (...args: any[]) => select(...args), + dispatch: (...args: any[]) => dispatch(...args), + listen: (typeOrPredicate: any, handler?: Function) => { + if (handler) { + listenHandlers.set(typeOrPredicate, handler); + } else { + listenHandlers.set('predicate', typeOrPredicate); + } + return jest.fn(); + }, + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(async () => ({ webContents: { id: 1 } })), +})); + +jest.mock('../../ui/main/serverView', () => ({ + getWebContentsByServerUrl: (...args: any[]) => + getWebContentsByServerUrl(...args), +})); + +describe('setupServers', () => { + beforeEach(() => { + jest.clearAllMocks(); + listenHandlers.clear(); + select.mockImplementation((sel: any) => + sel({ + servers: [ + { + url: 'https://open.rocket.chat', + title: 'Community', + gitCommitHash: 'abc', + }, + ], + currentView: { url: 'https://open.rocket.chat' }, + }) + ); + invoke.mockResolvedValue(['https://open.rocket.chat/', '6.5.0']); + getWebContentsByServerUrl.mockReturnValue({ + session: { + clearStorageData: jest.fn(async () => undefined), + clearCache: jest.fn(async () => undefined), + }, + reload: jest.fn(), + }); + jest.resetModules(); + }); + + it('registers listeners and loads hosts from localStorage string', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({ + 'rocket.chat.hosts': JSON.stringify('https://extra.rocket.chat'), + 'rocket.chat.currentHost': 'https://extra.rocket.chat', + }); + + expect(listenHandlers.size).toBeGreaterThan(0); + expect(dispatch).toHaveBeenCalled(); + }); + + it('loads hosts from localStorage array JSON', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({ + 'rocket.chat.hosts': JSON.stringify( + JSON.stringify(['https://a.example/', 'https://b.example/']) + ), + }); + expect(dispatch).toHaveBeenCalled(); + }); + + it('loads app servers when map empty', async () => { + select.mockImplementation((sel: any) => + sel({ + servers: [], + currentView: 'downloads', + }) + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const freshFs = require('fs'); + (freshFs.promises.readFile as jest.Mock).mockResolvedValueOnce( + JSON.stringify({ Community: 'https://open.rocket.chat' }) + ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({}); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + servers: [{ url: 'https://open.rocket.chat', title: 'Community' }], + }), + }) + ); + }); + + it('handles SERVER_URL_RESOLUTION_REQUESTED with meta', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({}); + + const handler = listenHandlers.get(SERVER_URL_RESOLUTION_REQUESTED); + expect(handler).toBeDefined(); + + await handler?.({ + type: SERVER_URL_RESOLUTION_REQUESTED, + payload: 'https://open.rocket.chat', + meta: { id: '1', response: false }, + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: SERVER_URL_RESOLVED, + payload: ['https://open.rocket.chat/', 'ok'], + meta: { response: true, id: '1' }, + }); + }); + + it('handles git commit hash change by clearing guest storage', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({}); + + const handler = listenHandlers.get(WEBVIEW_GIT_COMMIT_HASH_CHECK); + expect(handler).toBeDefined(); + + const session = getWebContentsByServerUrl(); + await handler?.({ + type: WEBVIEW_GIT_COMMIT_HASH_CHECK, + payload: { + url: 'https://open.rocket.chat', + gitCommitHash: 'def', + }, + }); + + expect(dispatch).toHaveBeenCalledWith({ + type: WEBVIEW_GIT_COMMIT_HASH_CHANGED, + payload: { url: 'https://open.rocket.chat', gitCommitHash: 'def' }, + }); + expect(getWebContentsByServerUrl).toHaveBeenCalledWith( + 'https://open.rocket.chat' + ); + expect(session.session.clearStorageData).toHaveBeenCalledWith({ + storages: ['indexdb'], + }); + expect(session.session.clearCache).toHaveBeenCalled(); + expect(session.reload).toHaveBeenCalled(); + }); + + it('ignores malformed hosts JSON', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setupServers } = require('../main'); + await setupServers({ + 'rocket.chat.hosts': '{not-json', + }); + expect(dispatch).toHaveBeenCalled(); + }); +}); diff --git a/src/servers/preload/__tests__/presence.spec.ts b/src/servers/preload/__tests__/presence.spec.ts new file mode 100644 index 0000000000..9242be11cf --- /dev/null +++ b/src/servers/preload/__tests__/presence.spec.ts @@ -0,0 +1,130 @@ +/** @jest-environment jsdom */ +import { dispatch } from '../../../store'; +import { WEBVIEW_USER_PRESENCE_CHANGED } from '../../../ui/actions'; +import { getServerUrl } from '../urls'; + +jest.mock('electron', () => ({ + ipcRenderer: { + on: jest.fn(), + }, +})); + +jest.mock('../../../store', () => ({ + dispatch: jest.fn(), +})); + +jest.mock('../urls', () => ({ + getServerUrl: jest.fn(() => 'https://server.local'), +})); + +const dispatchMock = dispatch as jest.MockedFunction; +const getServerUrlMock = getServerUrl as jest.MockedFunction< + typeof getServerUrl +>; + +describe('servers/preload/presence', () => { + beforeEach(() => { + dispatchMock.mockClear(); + getServerUrlMock.mockReturnValue('https://server.local'); + }); + + describe('setUserPresence', () => { + it('dispatches WEBVIEW_USER_PRESENCE_CHANGED with the current server url', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { setUserPresence } = require('../presence'); + + setUserPresence({ + presence: 'online', + presenceStatusText: 'Working', + presenceConnection: 'connected', + presenceSupported: true, + }); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: WEBVIEW_USER_PRESENCE_CHANGED, + payload: { + url: 'https://server.local', + presence: 'online', + presenceStatusText: 'Working', + presenceConnection: 'connected', + presenceSupported: true, + }, + }); + }); + }); + + describe('onPresenceChangeRequested / listenToPresenceChangeRequests', () => { + const fireIpcEvent = ( + ipcRendererOn: jest.Mock, + status: string, + statusText?: string + ): void => { + const entry = ipcRendererOn.mock.calls.find( + ([channel]: [string]) => channel === 'presence/change-requested' + ); + if (!entry) { + throw new Error('No handler registered for presence/change-requested'); + } + const handler = entry[1] as ( + _event: unknown, + status: string, + statusText?: string + ) => void; + handler({}, status, statusText); + }; + + it('registers ONE ipc listener even if listen is called twice', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ipcRenderer } = require('electron') as { + ipcRenderer: { on: jest.Mock }; + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { listenToPresenceChangeRequests } = require('../presence'); + + listenToPresenceChangeRequests(); + listenToPresenceChangeRequests(); + + expect(ipcRenderer.on).toHaveBeenCalledTimes(1); + expect(ipcRenderer.on).toHaveBeenCalledWith( + 'presence/change-requested', + expect.any(Function) + ); + }); + + it('invokes the registered callback when the ipc event fires', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ipcRenderer } = require('electron') as { + ipcRenderer: { on: jest.Mock }; + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const presence = require('../presence'); + + presence.listenToPresenceChangeRequests(); + + const cb = jest.fn(); + presence.onPresenceChangeRequested(cb); + + fireIpcEvent(ipcRenderer.on, 'away', 'Lunch'); + + expect(cb).toHaveBeenCalledTimes(1); + expect(cb).toHaveBeenCalledWith('away', 'Lunch'); + }); + + it('does nothing when the ipc event fires before a callback is registered', () => { + // Reset module state so a freshly required `../presence` has no + // registered callback left over from a prior test in this file. + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { ipcRenderer } = require('electron') as { + ipcRenderer: { on: jest.Mock }; + }; + ipcRenderer.on.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const presence = require('../presence'); + + presence.listenToPresenceChangeRequests(); + + expect(() => fireIpcEvent(ipcRenderer.on, 'busy')).not.toThrow(); + }); + }); +}); diff --git a/src/servers/preload/__tests__/presenceDebounce.spec.ts b/src/servers/preload/__tests__/presenceDebounce.spec.ts new file mode 100644 index 0000000000..6c51c12e02 --- /dev/null +++ b/src/servers/preload/__tests__/presenceDebounce.spec.ts @@ -0,0 +1,225 @@ +import { + createPresenceRateLimiter, + shouldDebouncePresenceCall, +} from '../presenceDebounce'; + +describe('shouldDebouncePresenceCall', () => { + it('drops a call arriving within the minimum interval of the previous call', () => { + expect(shouldDebouncePresenceCall(1500, 1000, 1000)).toBe(true); + }); + + it('allows a call arriving exactly at the minimum interval', () => { + expect(shouldDebouncePresenceCall(2000, 1000, 1000)).toBe(false); + }); + + it('allows a call arriving after the minimum interval', () => { + expect(shouldDebouncePresenceCall(2500, 1000, 1000)).toBe(false); + }); + + it('allows the first call when no previous call has happened', () => { + expect(shouldDebouncePresenceCall(Date.now(), 0, 1000)).toBe(false); + }); +}); + +describe('createPresenceRateLimiter', () => { + const createFakeClock = (initialTime = 0) => { + let currentTime = initialTime; + const timers: { fn: () => void; at: number }[] = []; + + return { + now: () => currentTime, + schedule: (fn: () => void, ms: number) => { + timers.push({ fn, at: currentTime + ms }); + }, + advance: (ms: number) => { + currentTime += ms; + const due = timers.filter((timer) => timer.at <= currentTime); + due.forEach((timer) => { + const index = timers.indexOf(timer); + if (index !== -1) timers.splice(index, 1); + timer.fn(); + }); + }, + pendingTimerCount: () => timers.length, + }; + }; + + it('sends the first request immediately', () => { + const send = jest.fn(); + const clock = createFakeClock(); + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + now: clock.now, + schedule: clock.schedule, + }); + + limiter.request({ status: 'online' }); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith({ status: 'online' }); + }); + + it('coalesces two rapid requests into one deferred send carrying the last status', () => { + const send = jest.fn(); + const clock = createFakeClock(); + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + now: clock.now, + schedule: clock.schedule, + }); + + limiter.request({ status: 'online' }); + send.mockClear(); + + clock.advance(200); + limiter.request({ status: 'away' }); + clock.advance(200); + limiter.request({ status: 'busy' }); + + expect(send).not.toHaveBeenCalled(); + + clock.advance(600); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith({ status: 'busy' }); + }); + + it('schedules only one timer for a burst of requests within the window', () => { + const send = jest.fn(); + const clock = createFakeClock(); + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + now: clock.now, + schedule: clock.schedule, + }); + + limiter.request({ status: 'online' }); + + clock.advance(100); + limiter.request({ status: 'away' }); + expect(clock.pendingTimerCount()).toBe(1); + + clock.advance(100); + limiter.request({ status: 'busy' }); + expect(clock.pendingTimerCount()).toBe(1); + + clock.advance(100); + limiter.request({ status: 'offline' }); + expect(clock.pendingTimerCount()).toBe(1); + }); + + it('sends immediately after the window has elapsed with nothing pending', () => { + const send = jest.fn(); + const clock = createFakeClock(); + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + now: clock.now, + schedule: clock.schedule, + }); + + limiter.request({ status: 'online' }); + send.mockClear(); + + clock.advance(1000); + limiter.request({ status: 'away' }); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith({ status: 'away' }); + }); + + it('results in exactly 2 sends for a burst of 3+ requests: first and last', () => { + const send = jest.fn(); + const clock = createFakeClock(); + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + now: clock.now, + schedule: clock.schedule, + }); + + limiter.request({ status: 'online' }); + clock.advance(100); + limiter.request({ status: 'away' }); + clock.advance(100); + limiter.request({ status: 'busy' }); + clock.advance(100); + limiter.request({ status: 'offline' }); + + clock.advance(700); + + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenNthCalledWith(1, { status: 'online' }); + expect(send).toHaveBeenNthCalledWith(2, { status: 'offline' }); + }); + + // REGRESSION GUARD: if the scheduled timer fires late, a request arriving + // after the interval has technically elapsed must NOT take the immediate + // path — it must still be captured as the pending call so the (still + // pending) scheduled flush sends the newest status, not a stale one. + it('sends the newest call, not a stale one, when a request arrives after the timer was due but before it fires', () => { + const send = jest.fn(); + + // A manual clock that lets time move forward WITHOUT auto-firing + // scheduled timers, so a "late-firing timer" can be simulated: time + // passes the deadline, a newer request arrives, and only THEN is the + // scheduled callback invoked manually. + let currentTime = 0; + let scheduledFn: (() => void) | undefined; + + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + now: () => currentTime, + schedule: (fn) => { + scheduledFn = fn; + }, + }); + + limiter.request({ status: 'online' }); + send.mockClear(); + + currentTime += 100; + limiter.request({ status: 'away' }); + + expect(scheduledFn).toBeDefined(); + + // Move time past the scheduled deadline without invoking the timer. + currentTime += 1000; + + // A newer request arrives after the deadline has technically elapsed, + // but the timer has not fired yet. + limiter.request({ status: 'busy' }); + + expect(send).not.toHaveBeenCalled(); + + // Now let the (late) scheduled timer fire. + scheduledFn?.(); + + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith({ status: 'busy' }); + }); + + it('calls onDeferred when a request is coalesced', () => { + const send = jest.fn(); + const onDeferred = jest.fn(); + const clock = createFakeClock(); + const limiter = createPresenceRateLimiter({ + minIntervalMs: 1000, + send, + onDeferred, + now: clock.now, + schedule: clock.schedule, + }); + + limiter.request({ status: 'online' }); + clock.advance(100); + limiter.request({ status: 'away' }); + + expect(onDeferred).toHaveBeenCalledTimes(1); + expect(onDeferred).toHaveBeenCalledWith({ status: 'away' }); + }); +}); diff --git a/src/servers/preload/__tests__/presenceSnapshot.spec.ts b/src/servers/preload/__tests__/presenceSnapshot.spec.ts new file mode 100644 index 0000000000..6dedb5f1b1 --- /dev/null +++ b/src/servers/preload/__tests__/presenceSnapshot.spec.ts @@ -0,0 +1,168 @@ +import { + buildPresenceSnapshot, + mapConnectionStatus, +} from '../presenceSnapshot'; + +describe('mapConnectionStatus', () => { + it.each([ + ['connected', 'connected'], + ['connecting', 'connecting'], + ['waiting', 'connecting'], + ['failed', 'disconnected'], + ['offline', 'disconnected'], + ])('maps Meteor.status() %s to %s', (meteorStatus, expected) => { + expect(mapConnectionStatus(meteorStatus)).toBe(expected); + }); + + it('falls back to disconnected for an unknown status', () => { + expect(mapConnectionStatus('something-else')).toBe('disconnected'); + }); + + it('falls back to disconnected when the status is missing', () => { + expect(mapConnectionStatus(undefined)).toBe('disconnected'); + }); + + // REGRESSION GUARD: a status value that collides with an inherited + // Object.prototype member (e.g. 'toString') must not resolve to that + // inherited function — it must fall back to 'disconnected'. + it('falls back to disconnected for a status that collides with Object.prototype', () => { + expect(mapConnectionStatus('toString')).toBe('disconnected'); + }); +}); + +describe('buildPresenceSnapshot', () => { + it('maps a store entry carrying status and statusText', () => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1', status: 'busy', statusText: 'In a meeting' }, + connectionStatus: 'connected', + supported: true, + }) + ).toEqual({ + presence: 'busy', + presenceStatusText: 'In a meeting', + presenceConnection: 'connected', + presenceSupported: true, + }); + }); + + it('leaves statusText undefined when the store entry has none', () => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1', status: 'online' }, + connectionStatus: 'connected', + supported: true, + }) + ).toEqual({ + presence: 'online', + presenceStatusText: undefined, + presenceConnection: 'connected', + presenceSupported: true, + }); + }); + + it('normalises an empty statusText to undefined', () => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1', status: 'away', statusText: '' }, + connectionStatus: 'connected', + supported: true, + }).presenceStatusText + ).toBeUndefined(); + }); + + // REGRESSION GUARD (CORE-2525): the original implementation derived + // `supported` from `status !== undefined`, so a store entry (or a user + // document) without a status reported the entire feature as unsupported and + // blanked both the tray icon and the tray menu. `supported` must reflect + // module availability only. + it('keeps supported true when the entry has no status at all', () => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1' }, + connectionStatus: 'connected', + supported: true, + }) + ).toEqual({ + presence: undefined, + presenceStatusText: undefined, + presenceConnection: 'connected', + presenceSupported: true, + }); + }); + + it('keeps supported true when there is no store entry yet', () => { + expect( + buildPresenceSnapshot({ + storeEntry: null, + connectionStatus: 'connecting', + supported: true, + }) + ).toEqual({ + presence: undefined, + presenceStatusText: undefined, + presenceConnection: 'connecting', + presenceSupported: true, + }); + }); + + it('reports unsupported and drops presence values when the module is unavailable', () => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1', status: 'online', statusText: 'Hi' }, + connectionStatus: 'connected', + supported: false, + }) + ).toEqual({ + presence: undefined, + presenceStatusText: undefined, + presenceConnection: 'connected', + presenceSupported: false, + }); + }); + + it('still reports the connection status when the module is unavailable', () => { + expect( + buildPresenceSnapshot({ + connectionStatus: 'failed', + supported: false, + }) + ).toEqual({ + presence: undefined, + presenceStatusText: undefined, + presenceConnection: 'disconnected', + presenceSupported: false, + }); + }); + + it.each(['online', 'away', 'busy', 'offline'])( + 'passes the %s presence value through', + (status) => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1', status }, + connectionStatus: 'connected', + supported: true, + }).presence + ).toBe(status); + } + ); + + // The presence store can hold statuses the tray has no icon for (the webapp + // writes `disabled` when presence is turned off server-side), so anything + // outside the four supported values must not reach the tray. + it('drops a presence value the tray cannot render', () => { + expect( + buildPresenceSnapshot({ + storeEntry: { _id: 'u1', status: 'disabled' }, + connectionStatus: 'connected', + supported: true, + }) + ).toEqual({ + presence: undefined, + presenceStatusText: undefined, + presenceConnection: 'connected', + presenceSupported: true, + }); + }); +}); diff --git a/src/servers/preload/__tests__/presenceSupport.spec.ts b/src/servers/preload/__tests__/presenceSupport.spec.ts new file mode 100644 index 0000000000..e8c61010a1 --- /dev/null +++ b/src/servers/preload/__tests__/presenceSupport.spec.ts @@ -0,0 +1,159 @@ +import { hasUsablePresenceApi } from '../presenceSupport'; + +describe('hasUsablePresenceApi', () => { + it('returns true for a module with a valid Presence (store + listen + get + stop functions)', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: new Map(), + listen: () => undefined, + get: () => undefined, + stop: () => undefined, + }, + }) + ).toBe(true); + }); + + it('returns false when store is present but listen is not a function', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: new Map(), + listen: 'not-a-function', + get: () => undefined, + stop: () => undefined, + }, + }) + ).toBe(false); + }); + + it('returns false when Presence is present but store is missing', () => { + expect( + hasUsablePresenceApi({ + Presence: { + listen: () => undefined, + get: () => undefined, + stop: () => undefined, + }, + }) + ).toBe(false); + }); + + it('returns false when store is present but undefined', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: undefined, + listen: () => undefined, + get: () => undefined, + stop: () => undefined, + }, + }) + ).toBe(false); + }); + + it('returns false when get is missing', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: new Map(), + listen: () => undefined, + stop: () => undefined, + }, + }) + ).toBe(false); + }); + + it('returns false when stop is missing', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: new Map(), + listen: () => undefined, + get: () => undefined, + }, + }) + ).toBe(false); + }); + + it('returns false when get is not a function', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: new Map(), + listen: () => undefined, + get: 'not-a-function', + stop: () => undefined, + }, + }) + ).toBe(false); + }); + + it('returns false when stop is not a function', () => { + expect( + hasUsablePresenceApi({ + Presence: { + store: new Map(), + listen: () => undefined, + get: () => undefined, + stop: 'not-a-function', + }, + }) + ).toBe(false); + }); + + it('returns false when get and stop are only inherited from the prototype chain', () => { + const proto = { get: () => undefined, stop: () => undefined }; + const resolved = Object.create(proto, { + store: { value: new Map(), enumerable: true }, + listen: { value: () => undefined, enumerable: true }, + }); + + expect(hasUsablePresenceApi({ Presence: resolved })).toBe(false); + }); + + it('returns false when Presence is missing entirely', () => { + expect(hasUsablePresenceApi({})).toBe(false); + }); + + it('returns false when the module itself is null', () => { + expect(hasUsablePresenceApi(null)).toBe(false); + }); + + it('returns false when the module itself is undefined', () => { + expect(hasUsablePresenceApi(undefined)).toBe(false); + }); + + it('returns false when listen is a non-function truthy value (string)', () => { + expect( + hasUsablePresenceApi({ + Presence: { store: new Map(), listen: 'listen' }, + }) + ).toBe(false); + }); + + it('returns false when listen is a non-function truthy value (object)', () => { + expect( + hasUsablePresenceApi({ + Presence: { store: new Map(), listen: {} }, + }) + ).toBe(false); + }); + + // REGRESSION GUARD (prototype-safety): store/listen values reaching the + // check only via the prototype chain — never as the object's own + // properties — must not be treated as a usable API. + it('returns false when store and listen are only inherited from the prototype chain', () => { + const proto = { store: new Map(), listen: () => undefined }; + const inherited = Object.create(proto); + + expect(hasUsablePresenceApi({ Presence: inherited })).toBe(false); + }); + + it('returns false when Presence itself is only inherited from the prototype chain', () => { + const proto = { Presence: { store: new Map(), listen: () => undefined } }; + const moduleWithInheritedPresence = Object.create(proto); + + expect(hasUsablePresenceApi(moduleWithInheritedPresence)).toBe(false); + }); +}); diff --git a/src/servers/preload/api.ts b/src/servers/preload/api.ts index 2f0a6e5929..aa1a9437e2 100644 --- a/src/servers/preload/api.ts +++ b/src/servers/preload/api.ts @@ -16,6 +16,7 @@ import { } from '../../outlookCalendar/preload'; import { onTelephonyCallRequested } from '../../telephony/preload'; import { setUserPresenceDetection } from '../../userPresence/preload'; +import type { Server } from '../common'; import { setBadge } from './badge'; import { writeTextToClipboard } from './clipboard'; import { @@ -31,6 +32,7 @@ import { } from './internalVideoChatWindow'; import { onNavigateToRoute } from './navigateToRoute'; import { openInBrowser } from './openInBrowser'; +import { onPresenceChangeRequested, setUserPresence } from './presence'; import { reloadServer } from './reloadServer'; import { setBackground, @@ -64,6 +66,18 @@ type ExtendedIRocketChatDesktop = IRocketChatDesktop & { supportedDocumentViewerFormats: () => string[]; onNavigateToRoute: (callback: (path: string) => void) => void; setUserRoles: (roles: string[]) => void; + setUserPresence: (payload: { + presence: Server['presence']; + presenceStatusText: Server['presenceStatusText']; + presenceConnection: Server['presenceConnection']; + presenceSupported: Server['presenceSupported']; + }) => void; + onPresenceChangeRequested: ( + callback: ( + status: NonNullable, + statusText?: string + ) => void + ) => void; }; declare global { @@ -94,6 +108,8 @@ export const RocketChatDesktop: Window['RocketChatDesktop'] = { setUserPresenceDetection, setUserLoggedIn, setUserRoles, + setUserPresence, + onPresenceChangeRequested, setUserThemeAppearance, createNotification, destroyNotification, diff --git a/src/servers/preload/presence.ts b/src/servers/preload/presence.ts new file mode 100644 index 0000000000..b545763421 --- /dev/null +++ b/src/servers/preload/presence.ts @@ -0,0 +1,54 @@ +import { ipcRenderer } from 'electron'; + +import { dispatch } from '../../store'; +import { WEBVIEW_USER_PRESENCE_CHANGED } from '../../ui/actions'; +import type { Server } from '../common'; +import { getServerUrl } from './urls'; + +type UserPresencePayload = { + presence: Server['presence']; + presenceStatusText: Server['presenceStatusText']; + presenceConnection: Server['presenceConnection']; + presenceSupported: Server['presenceSupported']; +}; + +export const setUserPresence = (payload: UserPresencePayload): void => { + dispatch({ + type: WEBVIEW_USER_PRESENCE_CHANGED, + payload: { + url: getServerUrl(), + ...payload, + }, + }); +}; + +type PresenceChangeCallback = ( + status: NonNullable, + statusText?: string +) => void; + +let presenceChangeCallback: PresenceChangeCallback | null = null; + +export const onPresenceChangeRequested = ( + callback: PresenceChangeCallback +): void => { + presenceChangeCallback = callback; +}; + +let listening = false; + +export const listenToPresenceChangeRequests = (): void => { + if (listening) { + return; + } + listening = true; + + ipcRenderer.on( + 'presence/change-requested', + (_event, status: NonNullable, statusText?: string) => { + if (presenceChangeCallback) { + presenceChangeCallback(status, statusText); + } + } + ); +}; diff --git a/src/servers/preload/presenceDebounce.ts b/src/servers/preload/presenceDebounce.ts new file mode 100644 index 0000000000..0aed094eba --- /dev/null +++ b/src/servers/preload/presenceDebounce.ts @@ -0,0 +1,79 @@ +// The server rate-limits Meteor.call('setUserStatus', ...) to 1 call per +// second per user. A request arriving within that window of the previous one +// is deferred (trailing-edge) rather than dropped, so the last requested +// status still eventually reaches the server once the window elapses. +export const shouldDebouncePresenceCall = ( + now: number, + lastCallAt: number, + minIntervalMs: number +): boolean => now - lastCallAt < minIntervalMs; + +export type PresenceCall = { + status: string; + statusText?: string; +}; + +export const createPresenceRateLimiter = (options: { + minIntervalMs: number; + send: (call: PresenceCall) => void; + now?: () => number; + schedule?: (fn: () => void, ms: number) => void; + onDeferred?: (call: PresenceCall) => void; +}): { request: (call: PresenceCall) => void } => { + const { + minIntervalMs, + send, + now = () => Date.now(), + schedule = (fn: () => void, ms: number) => setTimeout(fn, ms), + onDeferred, + } = options; + + let lastCallAt = 0; + let hasSentFirstCall = false; + let pendingCall: PresenceCall | null = null; + let timerScheduled = false; + + const flush = (): void => { + timerScheduled = false; + + if (!pendingCall) return; + + const call = pendingCall; + pendingCall = null; + lastCallAt = now(); + hasSentFirstCall = true; + send(call); + }; + + const request = (call: PresenceCall): void => { + const currentTime = now(); + + if (timerScheduled) { + pendingCall = call; + onDeferred?.(call); + return; + } + + if ( + hasSentFirstCall && + shouldDebouncePresenceCall(currentTime, lastCallAt, minIntervalMs) + ) { + pendingCall = call; + onDeferred?.(call); + + if (!timerScheduled) { + timerScheduled = true; + const remainingMs = minIntervalMs - (currentTime - lastCallAt); + schedule(flush, remainingMs); + } + + return; + } + + lastCallAt = currentTime; + hasSentFirstCall = true; + send(call); + }; + + return { request }; +}; diff --git a/src/servers/preload/presenceSnapshot.ts b/src/servers/preload/presenceSnapshot.ts new file mode 100644 index 0000000000..842c611b16 --- /dev/null +++ b/src/servers/preload/presenceSnapshot.ts @@ -0,0 +1,73 @@ +import type { Server, UserPresence } from '../common'; + +// Presence does NOT live in the `Meteor.users` minimongo collection: the +// webapp keeps it in a standalone `Map` fed by the `stream-user-presence` DDP +// stream (`apps/meteor/client/lib/presence.ts`). Reading `Meteor.user().status` +// therefore always yielded `undefined`. This module holds the pure mapping +// from a presence-store entry plus the Meteor connection status to the payload +// `setUserPresence` expects, so the computation is unit-testable without a +// live webapp. + +export type PresenceStoreEntry = { + _id?: string; + status?: string; + statusText?: string; +}; + +export type PresenceSnapshot = { + presence: Server['presence']; + presenceStatusText: Server['presenceStatusText']; + presenceConnection: Server['presenceConnection']; + presenceSupported: Server['presenceSupported']; +}; + +const KNOWN_PRESENCE_VALUES: UserPresence[] = [ + 'online', + 'away', + 'busy', + 'offline', +]; + +const CONNECTION_STATUS_MAP: Record< + string, + NonNullable +> = { + connected: 'connected', + connecting: 'connecting', + waiting: 'connecting', + failed: 'disconnected', + offline: 'disconnected', +}; + +export const mapConnectionStatus = ( + status: string | undefined +): NonNullable => + status && Object.prototype.hasOwnProperty.call(CONNECTION_STATUS_MAP, status) + ? CONNECTION_STATUS_MAP[status] + : 'disconnected'; + +const normalizePresence = ( + status: string | undefined +): Server['presence'] | undefined => + KNOWN_PRESENCE_VALUES.find((value) => value === status); + +// `supported` reflects whether the workspace exposes the presence module at +// all — NOT whether a status value happens to be present right now. Deriving +// it from the value is what hid the missing-presence bug: an absent status +// silently reported the whole feature as unsupported. +export const buildPresenceSnapshot = ({ + storeEntry, + connectionStatus, + supported, +}: { + storeEntry?: PresenceStoreEntry | null; + connectionStatus?: string; + supported: boolean; +}): PresenceSnapshot => ({ + presence: supported ? normalizePresence(storeEntry?.status) : undefined, + presenceStatusText: supported + ? storeEntry?.statusText || undefined + : undefined, + presenceConnection: mapConnectionStatus(connectionStatus), + presenceSupported: supported, +}); diff --git a/src/servers/preload/presenceSupport.ts b/src/servers/preload/presenceSupport.ts new file mode 100644 index 0000000000..de369622cd --- /dev/null +++ b/src/servers/preload/presenceSupport.ts @@ -0,0 +1,65 @@ +// Pure, importable shape-check for the `Presence` module resolved by +// `tryRequireFirstOf(presenceModulePaths)` in `injected.ts`. Extracted so the +// CAPABILITY decision that ultimately drives `presenceSupported` (and +// therefore whether the tray hides presence for old/unsupported servers) has +// unit-test coverage — `injected.ts` itself cannot be imported directly by +// Jest because it has heavy top-level side effects (reassigns +// `window.Notification`, patches `navigator.clipboard`, kicks off module +// loads on import). + +export type PresenceModuleShape = { + store?: unknown; + listen?: unknown; + get?: unknown; + stop?: unknown; +}; + +// Own-property check only: a `store`/`listen`/`get`/`stop` value that +// arrives merely inherited from the prototype chain (e.g. +// `Object.create({ listen: fn })`) must not be treated as a usable API — +// mirrors the own-property guard in `presenceSnapshot.ts` +// (`mapConnectionStatus`). +const hasOwn = (value: object, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key); + +export const hasUsablePresenceApi = (module: unknown): boolean => { + if (!module || typeof module !== 'object') { + return false; + } + + if (!hasOwn(module, 'Presence')) { + return false; + } + + const resolved = (module as { Presence?: unknown }).Presence; + + if (!resolved || typeof resolved !== 'object') { + return false; + } + + if (!hasOwn(resolved, 'store') || !(resolved as PresenceModuleShape).store) { + return false; + } + + if (!hasOwn(resolved, 'listen')) { + return false; + } + + if (typeof (resolved as PresenceModuleShape).listen !== 'function') { + return false; + } + + if (!hasOwn(resolved, 'get')) { + return false; + } + + if (typeof (resolved as PresenceModuleShape).get !== 'function') { + return false; + } + + if (!hasOwn(resolved, 'stop')) { + return false; + } + + return typeof (resolved as PresenceModuleShape).stop === 'function'; +}; diff --git a/src/servers/reducers.ts b/src/servers/reducers.ts index 2a22ce119d..f203fa53cf 100644 --- a/src/servers/reducers.ts +++ b/src/servers/reducers.ts @@ -17,6 +17,7 @@ import { WEBVIEW_UNREAD_CHANGED, WEBVIEW_USER_LOGGED_IN, WEBVIEW_USER_ROLES_CHANGED, + WEBVIEW_USER_PRESENCE_CHANGED, WEBVIEW_FAVICON_CHANGED, WEBVIEW_DID_START_LOADING, WEBVIEW_DID_FAIL_LOAD, @@ -58,6 +59,7 @@ type ServersActionTypes = | ActionOf | ActionOf | ActionOf + | ActionOf | ActionOf | ActionOf | ActionOf @@ -214,6 +216,23 @@ export const servers: Reducer = ( return upsert(state, { url, userRoles }); } + case WEBVIEW_USER_PRESENCE_CHANGED: { + const { + url, + presence, + presenceStatusText, + presenceConnection, + presenceSupported, + } = action.payload; + return upsert(state, { + url, + presence, + presenceStatusText, + presenceConnection, + presenceSupported, + }); + } + case WEBVIEW_ALLOWED_REDIRECTS_CHANGED: { const { url, allowedRedirects } = action.payload; return upsert(state, { url, allowedRedirects }); diff --git a/src/servers/reducers/__tests__/presence.spec.ts b/src/servers/reducers/__tests__/presence.spec.ts new file mode 100644 index 0000000000..3e54b48ce1 --- /dev/null +++ b/src/servers/reducers/__tests__/presence.spec.ts @@ -0,0 +1,75 @@ +import { WEBVIEW_USER_PRESENCE_CHANGED } from '../../../ui/actions'; +import type { Server } from '../../common'; +import { servers } from '../../reducers'; + +const url = 'https://open.rocket.chat/'; + +describe('servers reducer — WEBVIEW_USER_PRESENCE_CHANGED', () => { + it('upserts presence fields into a new server by url', () => { + const newState = servers([], { + type: WEBVIEW_USER_PRESENCE_CHANGED, + payload: { + url, + presence: 'online', + presenceStatusText: 'Working', + presenceConnection: 'connected', + presenceSupported: true, + }, + } as any); + + expect(newState).toEqual([ + { + url, + presence: 'online', + presenceStatusText: 'Working', + presenceConnection: 'connected', + presenceSupported: true, + }, + ]); + }); + + it('upserts presence fields into an existing server, preserving other fields', () => { + const existing: Server = { url, title: 'Open' }; + + const newState = servers([existing], { + type: WEBVIEW_USER_PRESENCE_CHANGED, + payload: { + url, + presence: 'busy', + presenceStatusText: undefined, + presenceConnection: 'connecting', + presenceSupported: true, + }, + } as any); + + expect(newState).toEqual([ + { + url, + title: 'Open', + presence: 'busy', + presenceStatusText: undefined, + presenceConnection: 'connecting', + presenceSupported: true, + }, + ]); + }); + + it('matches by url and does not affect other servers', () => { + const other: Server = { url: 'https://other.rocket.chat/' }; + const existing: Server = { url, title: 'Open' }; + + const newState = servers([existing, other], { + type: WEBVIEW_USER_PRESENCE_CHANGED, + payload: { + url, + presence: 'away', + presenceStatusText: undefined, + presenceConnection: 'connected', + presenceSupported: true, + }, + } as any); + + expect(newState.find((s) => s.url === other.url)).toEqual(other); + expect(newState.find((s) => s.url === url)?.presence).toBe('away'); + }); +}); diff --git a/src/settingsWindow/SettingsSidebar.tsx b/src/settingsWindow/SettingsSidebar.tsx new file mode 100644 index 0000000000..c4a6b884af --- /dev/null +++ b/src/settingsWindow/SettingsSidebar.tsx @@ -0,0 +1,185 @@ +import { Box, Icon, Scrollable, SearchInput } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ChangeEvent, KeyboardEvent } from 'react'; +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import packageJson from '../../package.json'; +import type { RootState } from '../store/rootReducer'; +import { NavRow } from '../ui/windowChrome/NavRow'; +import type { Surfaces } from '../ui/windowChrome/appearance'; +import { SIDEBAR_WIDTH } from '../ui/windowChrome/appearance'; +import { SEARCH_FIELD_CLASS } from '../ui/windowChrome/styles'; +import { useFindShortcut } from '../ui/windowChrome/useFindShortcut'; + +export const SETTINGS_TABPANEL_ID = 'settings-section-panel'; + +export const settingsSectionTabId = (sectionId: string): string => + `settings-section-tab-${sectionId}`; + +export type SettingsSectionItem = { + id: string; + label: string; + icon: IconName; + /** Settings inside this section that the query matched, if any. */ + matches: string[]; +}; + +export type SettingsSidebarProps = { + surfaces: Surfaces; + sections: SettingsSectionItem[]; + currentSection: string; + onSelectSection: (id: string) => void; + searchFilter: string; + onSearchFilterChange: (event: ChangeEvent) => void; +}; + +export const SettingsSidebar = ({ + surfaces, + sections, + currentSection, + onSelectSection, + searchFilter, + onSearchFilterChange, +}: SettingsSidebarProps) => { + const { t } = useTranslation(); + const searchFieldRef = useRef(null); + useFindShortcut(searchFieldRef); + + const appVersion = useSelector(({ appVersion }: RootState) => appVersion); + + const handleTabListKeyDown = useCallback( + (event: KeyboardEvent) => { + if (sections.length === 0) { + return; + } + + const currentIndex = Math.max( + 0, + sections.findIndex((section) => section.id === currentSection) + ); + + let nextIndex: number | null = null; + + switch (event.key) { + case 'ArrowUp': + nextIndex = + currentIndex <= 0 ? sections.length - 1 : currentIndex - 1; + break; + case 'ArrowDown': + nextIndex = + currentIndex === sections.length - 1 ? 0 : currentIndex + 1; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = sections.length - 1; + break; + default: + return; + } + + event.preventDefault(); + const nextSection = sections[nextIndex]; + if (!nextSection) { + return; + } + + if (nextSection.id !== currentSection) { + onSelectSection(nextSection.id); + } + + const tabs = + event.currentTarget.querySelectorAll('[role="tab"]'); + tabs[nextIndex]?.focus(); + }, + [currentSection, onSelectSection, sections] + ); + + // Straight from package.json rather than through `packageJsonInformation`: + // that module is main-process only, and importing it here would pull + // child_process and the root window into the settings bundle. + const copyright = `© 2016-${new Date().getFullYear()}, ${ + packageJson.productName + }`; + + return ( + + + } + aria-label={t('settings.search')} + placeholder={t('settings.search')} + value={searchFilter} + onChange={onSearchFilterChange} + /> + + + + + + {sections.length === 0 ? ( + + {t('settings.noResults')} + + ) : ( + sections.map((section) => ( + onSelectSection(section.id)} + /> + )) + )} + + + + + {/* + Signature at the foot of the window rather than a setting of its own: + worth being able to find and copy, not worth a row. The labels are + dropped — a version number and a copyright line read as themselves. + */} + + {appVersion} + {copyright} + + + ); +}; diff --git a/src/settingsWindow/SettingsWindow.tsx b/src/settingsWindow/SettingsWindow.tsx new file mode 100644 index 0000000000..5121fa644c --- /dev/null +++ b/src/settingsWindow/SettingsWindow.tsx @@ -0,0 +1,192 @@ +import { Box, Icon, Scrollable } from '@rocket.chat/fuselage'; +import { useLocalStorage } from '@rocket.chat/fuselage-hooks'; +import type { ChangeEvent } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import TooltipProvider from '../ui/components/utils/TooltipProvider'; +import { WindowToolbar } from '../ui/windowChrome/WindowToolbar'; +import type { PaletteTheme } from '../ui/windowChrome/appearance'; +import { getCardStyle, resolveSurfaces } from '../ui/windowChrome/appearance'; +import { WindowChromeGlobalStyles } from '../ui/windowChrome/styles'; +import { useTransparency } from '../ui/windowChrome/useTransparency'; +import { + SETTINGS_TABPANEL_ID, + SettingsSidebar, + settingsSectionTabId, +} from './SettingsSidebar'; +import type { SettingsSectionItem } from './SettingsSidebar'; +import { TRANSPARENCY_CHANNEL } from './constants'; +import { fuzzyMatch, matchesSearchText } from './fuzzy'; +import { collectSettingSearchTexts } from './searchIndex'; +import { SETTINGS_SECTIONS } from './sections'; + +const DEFAULT_SECTION = 'general'; + +type SettingsWindowProps = { + paletteTheme: PaletteTheme; +}; + +export const SettingsWindow = ({ paletteTheme }: SettingsWindowProps) => { + const { t } = useTranslation(); + const isTransparent = useTransparency(TRANSPARENCY_CHANNEL); + const surfaces = useMemo( + () => resolveSurfaces(paletteTheme, isTransparent), + [paletteTheme, isTransparent] + ); + const cardStyle = useMemo( + () => getCardStyle(paletteTheme, surfaces), + [paletteTheme, surfaces] + ); + + const [persistedSection, setPersistedSection] = useLocalStorage( + 'settings-window/section', + DEFAULT_SECTION + ); + const [searchFilter, setSearchFilter] = useState(''); + // While a search hides the persisted section, this holds the section shown + // instead — display-only, so clearing the search restores the persisted one + // rather than the last search result overwriting it permanently. + const [searchOverrideSection, setSearchOverrideSection] = useState< + string | null + >(null); + + const currentSection = searchOverrideSection ?? persistedSection; + + const setCurrentSection = useCallback( + (sectionId: string) => { + setSearchOverrideSection(null); + setPersistedSection(sectionId); + }, + [setPersistedSection] + ); + + const handleSearchFilterChange = useCallback( + (event: ChangeEvent) => { + setSearchFilter(event.target.value); + }, + [] + ); + + // Every section is listed: the parts that are developer-only live inside + // Advanced, which hides them itself. + const availableSections = SETTINGS_SECTIONS; + + /** + * A section survives the search if its own name matches, or if any setting it + * holds does — the matching settings are then named in the row, so it is clear + * why the section is still listed. + */ + const visibleSections = useMemo(() => { + const query = searchFilter.trim(); + + return availableSections + .map((section) => { + const label = t(section.labelKey); + const matches = query + ? section.settingKeys + .map((key) => collectSettingSearchTexts(t, key)) + .filter((setting) => + setting.texts.some((text) => matchesSearchText(query, text)) + ) + .map((setting) => setting.title) + : []; + + return { + id: section.id, + label, + icon: section.icon, + matches, + isVisible: !query || fuzzyMatch(query, label) || matches.length > 0, + }; + }) + .filter(({ isVisible }) => isVisible) + .map(({ isVisible: _isVisible, ...section }) => section); + }, [availableSections, searchFilter, t]); + + // The section is remembered across opens and can be filtered away by a search, + // so keep the selection on something that is actually listed — but only for + // the duration of the search, never persisting the substitution. + useEffect(() => { + if (visibleSections.length === 0) return; + + if (visibleSections.some((section) => section.id === persistedSection)) { + if (searchOverrideSection !== null) setSearchOverrideSection(null); + return; + } + + if ( + searchOverrideSection === null || + !visibleSections.some((section) => section.id === searchOverrideSection) + ) { + setSearchOverrideSection(visibleSections[0].id); + } + }, [persistedSection, searchOverrideSection, visibleSections]); + + const ActiveSection = useMemo( + () => + availableSections.find((section) => section.id === currentSection) + ?.Component, + [availableSections, currentSection] + ); + + return ( + + + + + + + {t('settings.title')} + + + + + + + + + + {ActiveSection && } + + + + + + + ); +}; diff --git a/src/settingsWindow/__tests__/SettingsSidebar.spec.tsx b/src/settingsWindow/__tests__/SettingsSidebar.spec.tsx new file mode 100644 index 0000000000..db9f574405 --- /dev/null +++ b/src/settingsWindow/__tests__/SettingsSidebar.spec.tsx @@ -0,0 +1,236 @@ +import { fireEvent } from '@testing-library/react'; + +import { invoke } from '../../ipc/renderer'; +import { renderWithStore, screen, within } from '../../ui/test-utils'; +import { NavRow } from '../../ui/windowChrome/NavRow'; +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { + SETTINGS_TABPANEL_ID, + SettingsSidebar, + settingsSectionTabId, +} from '../SettingsSidebar'; +import { SettingsWindow } from '../SettingsWindow'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, +})); + +jest.mock('../../ipc/renderer', () => ({ + invoke: jest.fn(), +})); + +const invokeMock = invoke as jest.MockedFunction; + +jest.mock('../sections', () => ({ + SETTINGS_SECTIONS: [ + { + id: 'general', + icon: 'cog', + labelKey: 'settings.general', + settingKeys: [], + Component: () =>
general
, + }, + { + id: 'appearance', + icon: 'palette', + labelKey: 'settings.appearance', + settingKeys: [], + Component: () =>
appearance
, + }, + { + id: 'advanced', + icon: 'code', + labelKey: 'settings.advanced', + settingKeys: [], + Component: () =>
advanced
, + }, + ], +})); + +const surfaces: Surfaces = { + panel: '#fff', + card: '#fff', + sticky: '#fff', + field: '#eee', + hover: '#ddd', + selected: '#ccc', + divider: '#000', +}; + +const sections = [ + { id: 'general', label: 'General', icon: 'cog' as const, matches: [] }, + { + id: 'appearance', + label: 'Appearance', + icon: 'palette' as const, + matches: [], + }, + { id: 'advanced', label: 'Advanced', icon: 'code' as const, matches: [] }, +]; + +const renderSidebar = (currentSection: string, onSelectSection = jest.fn()) => { + renderWithStore( + + ); + return { onSelectSection }; +}; + +describe('SettingsSidebar tabs contract', () => { + it('exposes a vertical tablist with roving tabindex and panel controls', () => { + renderSidebar('appearance'); + + expect(screen.getByRole('tablist')).toHaveAttribute( + 'aria-orientation', + 'vertical' + ); + + const general = screen.getByRole('tab', { name: 'General' }); + const appearance = screen.getByRole('tab', { name: 'Appearance' }); + const advanced = screen.getByRole('tab', { name: 'Advanced' }); + + expect(general).toHaveAttribute('tabIndex', '-1'); + expect(appearance).toHaveAttribute('tabIndex', '0'); + expect(advanced).toHaveAttribute('tabIndex', '-1'); + + expect(general).toHaveAttribute('aria-selected', 'false'); + expect(appearance).toHaveAttribute('aria-selected', 'true'); + + expect(general).toHaveAttribute('id', settingsSectionTabId('general')); + expect(appearance).toHaveAttribute('aria-controls', SETTINGS_TABPANEL_ID); + expect(advanced).toHaveAttribute('aria-controls', SETTINGS_TABPANEL_ID); + }); + + it('moves selection with ArrowUp/ArrowDown', () => { + const { onSelectSection } = renderSidebar('appearance'); + const tablist = screen.getByRole('tablist'); + + fireEvent.keyDown(tablist, { key: 'ArrowDown' }); + expect(onSelectSection).toHaveBeenCalledWith('advanced'); + + onSelectSection.mockClear(); + fireEvent.keyDown(tablist, { key: 'ArrowUp' }); + expect(onSelectSection).toHaveBeenCalledWith('general'); + }); + + it('wraps ArrowUp from the first row to the last', () => { + const { onSelectSection } = renderSidebar('general'); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowUp' }); + + expect(onSelectSection).toHaveBeenCalledWith('advanced'); + }); + + it('wraps ArrowDown from the last row to the first', () => { + const { onSelectSection } = renderSidebar('advanced'); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowDown' }); + + expect(onSelectSection).toHaveBeenCalledWith('general'); + }); + + it('jumps to the first and last rows with Home and End', () => { + const { onSelectSection } = renderSidebar('appearance'); + const tablist = screen.getByRole('tablist'); + + fireEvent.keyDown(tablist, { key: 'Home' }); + expect(onSelectSection).toHaveBeenCalledWith('general'); + + onSelectSection.mockClear(); + fireEvent.keyDown(tablist, { key: 'End' }); + expect(onSelectSection).toHaveBeenCalledWith('advanced'); + }); + + it('prevents default on vertical tab keys so the list does not scroll', () => { + renderSidebar('general'); + const tablist = screen.getByRole('tablist'); + const event = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }); + + expect(tablist.dispatchEvent(event)).toBe(false); + }); + + it('focuses the destination row after a keyboard move', () => { + renderSidebar('general'); + screen.getByRole('tab', { name: 'General' }).focus(); + + fireEvent.keyDown(screen.getByRole('tablist'), { key: 'ArrowDown' }); + + expect(screen.getByRole('tab', { name: 'Appearance' })).toHaveFocus(); + }); +}); + +describe('NavRow selected weight', () => { + it('pairs the selected fill with fontScale p2b and leaves unselected at p2', () => { + renderWithStore( + <> + + + + ); + + const selectedLabel = within( + screen.getByRole('tab', { name: 'Selected' }) + ).getByText('Selected'); + const idleLabel = within( + screen.getByRole('tab', { name: 'Idle' }) + ).getByText('Idle'); + + expect(getComputedStyle(selectedLabel).fontWeight).toBe('700'); + expect(getComputedStyle(idleLabel).fontWeight).toBe('400'); + expect(getComputedStyle(selectedLabel).color).toBe( + getComputedStyle(idleLabel).color + ); + }); +}); + +describe('SettingsWindow tabpanel', () => { + beforeEach(() => { + window.localStorage.clear(); + invokeMock.mockResolvedValue(false as never); + }); + + it('links the selected tab to the content panel', () => { + renderWithStore(); + + const selected = screen.getByRole('tab', { selected: true }); + const panel = screen.getByRole('tabpanel'); + + expect(panel).toHaveAttribute('id', SETTINGS_TABPANEL_ID); + expect(selected).toHaveAttribute('aria-controls', SETTINGS_TABPANEL_ID); + expect(panel).toHaveAttribute('aria-labelledby', selected.id); + expect(selected).toHaveAttribute('id', settingsSectionTabId('general')); + }); +}); diff --git a/src/settingsWindow/__tests__/SettingsWindow.spec.tsx b/src/settingsWindow/__tests__/SettingsWindow.spec.tsx new file mode 100644 index 0000000000..d97a54037b --- /dev/null +++ b/src/settingsWindow/__tests__/SettingsWindow.spec.tsx @@ -0,0 +1,114 @@ +import { fireEvent } from '@testing-library/react'; + +import { invoke } from '../../ipc/renderer'; +import { + renderWithStore, + screen, + userEvent, + waitFor, +} from '../../ui/test-utils'; +import { SettingsWindow } from '../SettingsWindow'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }, +})); + +jest.mock('../../ipc/renderer', () => ({ + invoke: jest.fn(), +})); + +const invokeMock = invoke as jest.MockedFunction; + +// Stubs sections cheaply so this spec exercises only the section-selection +// logic, not each section's own dependencies. +jest.mock('../sections', () => ({ + SETTINGS_SECTIONS: [ + { + id: 'general', + icon: 'cog', + labelKey: 'settings.general', + settingKeys: [], + Component: () =>
general
, + }, + { + id: 'appearance', + icon: 'palette', + labelKey: 'settings.appearance', + settingKeys: [], + Component: () =>
appearance
, + }, + ], +})); + +const LOCAL_STORAGE_KEY = 'fuselage-localStorage-settings-window/section'; + +const seedPersistedSection = (id: string): void => { + window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(id)); +}; + +describe('SettingsWindow section selection', () => { + beforeEach(() => { + window.localStorage.clear(); + invokeMock.mockResolvedValue(false as never); + }); + + it('renders the persisted section on open', () => { + seedPersistedSection('appearance'); + + renderWithStore(); + + expect(screen.getByTestId('section-appearance')).toBeInTheDocument(); + }); + + it('temporarily shows the first search match without persisting it, and restores the persisted section once the search clears', async () => { + seedPersistedSection('appearance'); + + renderWithStore(); + expect(screen.getByTestId('section-appearance')).toBeInTheDocument(); + + const searchInput = screen.getByRole('searchbox'); + fireEvent.change(searchInput, { target: { value: 'general' } }); + + await waitFor(() => + expect(screen.getByTestId('section-general')).toBeInTheDocument() + ); + // The override must never overwrite the persisted value. + expect( + JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY) ?? '""') + ).toBe('appearance'); + + fireEvent.change(searchInput, { target: { value: '' } }); + + await waitFor(() => + expect(screen.getByTestId('section-appearance')).toBeInTheDocument() + ); + }); + + it('persists the section when the user explicitly selects one', async () => { + seedPersistedSection('general'); + const user = userEvent.setup(); + + renderWithStore(); + expect(screen.getByTestId('section-general')).toBeInTheDocument(); + + await user.click(screen.getByRole('tab', { name: 'settings.appearance' })); + + expect(screen.getByTestId('section-appearance')).toBeInTheDocument(); + expect( + JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY) ?? '""') + ).toBe('appearance'); + }); +}); diff --git a/src/settingsWindow/__tests__/fuzzy.spec.ts b/src/settingsWindow/__tests__/fuzzy.spec.ts new file mode 100644 index 0000000000..10e9593a98 --- /dev/null +++ b/src/settingsWindow/__tests__/fuzzy.spec.ts @@ -0,0 +1,93 @@ +import { fuzzyFilter, fuzzyMatch, matchesSearchText } from '../fuzzy'; + +describe('fuzzyMatch', () => { + it('matches a plain substring', () => { + expect(fuzzyMatch('theme', 'Theme')).toBe(true); + }); + + it('matches characters spread through the text, in order', () => { + expect(fuzzyMatch('trwin', 'Transparent window effect')).toBe(true); + expect(fuzzyMatch('hwacc', 'Hardware acceleration')).toBe(true); + }); + + it('rejects characters that appear out of order', () => { + expect(fuzzyMatch('emeth', 'Theme')).toBe(false); + }); + + it('rejects characters that are not there at all', () => { + expect(fuzzyMatch('zzz', 'Theme')).toBe(false); + }); + + it('ignores case and spaces in the query', () => { + expect(fuzzyMatch('TRAY icon', 'Tray icon')).toBe(true); + expect(fuzzyMatch(' workspace ', 'Workspace switcher')).toBe(true); + }); + + it('folds accents both ways', () => { + expect(fuzzyMatch('video', 'Vídeo chamadas')).toBe(true); + expect(fuzzyMatch('vídeo', 'Video calls')).toBe(true); + }); + + it('treats an empty query as matching everything', () => { + expect(fuzzyMatch('', 'anything')).toBe(true); + expect(fuzzyMatch(' ', 'anything')).toBe(true); + }); + + it('never matches empty text with a real query', () => { + expect(fuzzyMatch('a', '')).toBe(false); + }); + + it('does not reuse a character to satisfy the query twice', () => { + // One "l" in the text cannot satisfy two in the query. + expect(fuzzyMatch('ll', 'value')).toBe(false); + expect(fuzzyMatch('ll', 'call')).toBe(true); + }); +}); + +describe('fuzzyFilter', () => { + const texts = ['Theme', 'Tray icon', 'Workspace switcher']; + + it('keeps matching entries in their original order', () => { + // "ic" is in "Tray icon" and in "switcher", but not in "Theme". + expect(fuzzyFilter('ic', texts)).toEqual([ + 'Tray icon', + 'Workspace switcher', + ]); + }); + + it('narrows to a single entry when the query is specific', () => { + expect(fuzzyFilter('them', texts)).toEqual(['Theme']); + }); + + it('returns nothing for a blank query, since nothing was searched for', () => { + expect(fuzzyFilter('', texts)).toEqual([]); + expect(fuzzyFilter(' ', texts)).toEqual([]); + }); +}); + +describe('matchesSearchText', () => { + const LABEL = 'Transparent window effect'; + const PROSE = + 'Enable native vibrancy on app window, so the desktop shows through.'; + + it('fuzzy matches short labels', () => { + expect(matchesSearchText('trwin', LABEL)).toBe(true); + }); + + it('requires a real substring in prose', () => { + expect(matchesSearchText('vibrancy', PROSE)).toBe(true); + }); + + it('does not let a subsequence match prose', () => { + // Every one of these characters appears in order in the sentence, which is + // exactly why prose cannot be fuzzy matched. + expect(fuzzyMatch('vibr', PROSE)).toBe(true); + expect(matchesSearchText('vibr', PROSE)).toBe(true); + expect(matchesSearchText('enw', PROSE)).toBe(false); + }); + + it('is case and accent insensitive on both paths', () => { + expect(matchesSearchText('VIBRANCY', PROSE)).toBe(true); + expect(matchesSearchText('TRWIN', LABEL)).toBe(true); + }); +}); diff --git a/src/settingsWindow/__tests__/searchIndex.spec.ts b/src/settingsWindow/__tests__/searchIndex.spec.ts new file mode 100644 index 0000000000..143e87be8f --- /dev/null +++ b/src/settingsWindow/__tests__/searchIndex.spec.ts @@ -0,0 +1,75 @@ +import { collectSettingSearchTexts } from '../searchIndex'; +import type { Translate } from '../searchIndex'; + +const DICTIONARY: Record = { + 'settings.options.themeAppearance': { + title: 'Theme', + description: 'App color theme.', + auto: 'Match system', + light: 'Light', + dark: 'Dark', + }, + 'settings.certificates': 'Certificates', + 'settings.options.empty': {}, + 'settings.options.shortcut': { + title: 'Global call shortcut', + reservedByApp: '{{accelerator}} is already used.', + }, +}; + +const t: Translate = (key) => (key in DICTIONARY ? DICTIONARY[key] : key); + +describe('collectSettingSearchTexts', () => { + it('indexes a setting title and its option labels', () => { + const entry = collectSettingSearchTexts( + t, + 'settings.options.themeAppearance' + ); + + expect(entry.title).toBe('Theme'); + expect(entry.texts).toEqual( + expect.arrayContaining(['Theme', 'Match system', 'Light', 'Dark']) + ); + }); + + it('indexes the description too, for substring searches', () => { + const entry = collectSettingSearchTexts( + t, + 'settings.options.themeAppearance' + ); + + // Prose is indexed but matched by containment, not subsequence — see + // `matchesSearchText`. + expect(entry.texts).toContain('App color theme.'); + }); + + it('skips interpolated runtime messages', () => { + const entry = collectSettingSearchTexts(t, 'settings.options.shortcut'); + + expect(entry.texts).toContain('Global call shortcut'); + expect(entry.texts).not.toContain('{{accelerator}} is already used.'); + }); + + it('handles a plain string key', () => { + const entry = collectSettingSearchTexts(t, 'settings.certificates'); + + expect(entry).toEqual({ + title: 'Certificates', + texts: ['Certificates'], + }); + }); + + it('falls back to the key when a namespace has no title', () => { + const entry = collectSettingSearchTexts(t, 'settings.options.empty'); + + expect(entry.title).toBe('settings.options.empty'); + expect(entry.texts).toEqual([]); + }); + + it('survives a missing key rather than throwing', () => { + const entry = collectSettingSearchTexts(t, 'settings.options.nope'); + + expect(entry.title).toBe('settings.options.nope'); + expect(entry.texts).toEqual(['settings.options.nope']); + }); +}); diff --git a/src/settingsWindow/__tests__/sections.spec.ts b/src/settingsWindow/__tests__/sections.spec.ts new file mode 100644 index 0000000000..40e7e20c78 --- /dev/null +++ b/src/settingsWindow/__tests__/sections.spec.ts @@ -0,0 +1,43 @@ +import { SETTINGS_SECTIONS } from '../sections'; + +describe('SETTINGS_SECTIONS', () => { + it('has unique ids', () => { + const ids = SETTINGS_SECTIONS.map((section) => section.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('gives every section a component and a label key', () => { + SETTINGS_SECTIONS.forEach((section) => { + expect(typeof section.Component).toBe('function'); + expect(section.labelKey).toMatch(/^settings\./); + }); + }); + + it('indexes every section for search', () => { + SETTINGS_SECTIONS.forEach((section) => { + expect(section.settingKeys.length).toBeGreaterThan(0); + // Keys are i18n namespaces; they do not all live under `settings.` — + // certificates index the `certificatesManager` tree. + section.settingKeys.forEach((key) => + expect(key).toMatch(/^[a-z][\w.]*[^.]$/i) + ); + }); + }); + + it('separates telephony from video calls', () => { + const ids = SETTINGS_SECTIONS.map((section) => section.id); + expect(ids).toContain('telephony'); + expect(ids).toContain('videoCalls'); + }); + + it('lists every section, gating developer content inside Advanced instead', () => { + const ids = SETTINGS_SECTIONS.map((section) => section.id); + expect(ids).toContain('advanced'); + expect(ids).not.toContain('developer'); + }); + + it('does not list the same setting in two sections', () => { + const keys = SETTINGS_SECTIONS.flatMap((section) => section.settingKeys); + expect(new Set(keys).size).toBe(keys.length); + }); +}); diff --git a/src/settingsWindow/constants.ts b/src/settingsWindow/constants.ts new file mode 100644 index 0000000000..d786f2b49c --- /dev/null +++ b/src/settingsWindow/constants.ts @@ -0,0 +1,38 @@ +import { thumbnailRowWidth } from '../ui/components/SettingsView/features/thumbnailMetrics'; +import { CARD_INSET, SIDEBAR_WIDTH } from '../ui/windowChrome/appearance'; + +/** Channel the main process pushes transparency changes on. */ +export const TRANSPARENCY_CHANNEL = 'settings-window/transparency-changed'; + +/** Window size as a multiplier of the screen it opens on. */ +// Matches the downloads window, so the secondary windows open at one size. +export const WINDOW_SIZE_MULTIPLIER = 0.52; + +/** Padding inside the content card, and the scrollbar that eats into it. */ +const CARD_PADDING = 24; +const SCROLLBAR_WIDTH = 10; + +/** + * Wide enough for the widest section, Appearance: a full row of theme + * thumbnails, inside the content card, next to the section list. Narrower and + * the options wrap, which reads as a layout accident rather than a choice. + */ +export const WINDOW_MIN_WIDTH = + SIDEBAR_WIDTH + + CARD_INSET * 2 + + CARD_PADDING * 2 + + SCROLLBAR_WIDTH + + thumbnailRowWidth(); +/** + * Tall enough for the whole Appearance section — both thumbnail groups plus the + * transparency toggle — without the last control being clipped. + */ +export const WINDOW_MIN_HEIGHT = 660; + +/** + * Size the window opens at when the screen allows it. The size multiplier alone + * yields a window that wraps or clips Appearance on smaller displays, so the + * larger of the two wins. + */ +export const WINDOW_PREFERRED_WIDTH = WINDOW_MIN_WIDTH; +export const WINDOW_PREFERRED_HEIGHT = 720; diff --git a/src/settingsWindow/fuzzy.ts b/src/settingsWindow/fuzzy.ts new file mode 100644 index 0000000000..ca137069a6 --- /dev/null +++ b/src/settingsWindow/fuzzy.ts @@ -0,0 +1,62 @@ +/** + * Subsequence fuzzy matching, the kind a command palette uses: every character + * of the query must appear in the text in order, but not adjacently — so "trwin" + * finds "Transparent window effect". + * + * Deliberately not a ranked search. The sidebar only needs to decide whether a + * section is worth showing, and a settings window has tens of entries, not + * thousands, so scoring would add a knob without changing what the reader sees. + */ +const normalize = (value: string): string => + value + .toLowerCase() + // Fold accents so "vídeo" matches "video" and vice versa. + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + +export const fuzzyMatch = (query: string, text: string): boolean => { + const needle = normalize(query).replace(/\s+/g, ''); + if (needle === '') return true; + + const haystack = normalize(text); + if (haystack === '') return false; + + let cursor = 0; + for (const character of needle) { + const found = haystack.indexOf(character, cursor); + if (found === -1) return false; + cursor = found + 1; + } + + return true; +}; + +/** + * Longest text still treated as a label. Above this it is prose, and prose is + * where subsequence matching falls apart: any short query's characters appear + * in order somewhere in a sentence, so "vibr" would "match" every description + * in the app. + */ +const LABEL_MAX_LENGTH = 32; + +/** + * How search decides whether a query hits one piece of settings text. + * + * Labels get fuzzy matching, so "trwin" finds "Transparent window effect". + * Descriptions get plain containment, so "vibrancy" still finds the setting + * whose description mentions it, without the sentence matching everything else. + */ +export const matchesSearchText = (query: string, text: string): boolean => { + if (text.length <= LABEL_MAX_LENGTH) { + return fuzzyMatch(query, text); + } + + const needle = normalize(query).trim(); + return needle === '' || normalize(text).includes(needle); +}; + +/** The entries of `texts` that the query matches, in their original order. */ +export const fuzzyFilter = (query: string, texts: string[]): string[] => { + if (query.trim() === '') return []; + return texts.filter((text) => fuzzyMatch(query, text)); +}; diff --git a/src/settingsWindow/ipc.ts b/src/settingsWindow/ipc.ts new file mode 100644 index 0000000000..61e80471ad --- /dev/null +++ b/src/settingsWindow/ipc.ts @@ -0,0 +1,244 @@ +import path from 'path'; + +import type { Event } from 'electron'; +import { app, BrowserWindow, dialog, screen } from 'electron'; +import i18next from 'i18next'; + +import { packageJsonInformation } from '../app/main/app'; +import { handle } from '../ipc/main'; +import { dispatch, listen, select, watch } from '../store'; +import type { RootState } from '../store/rootReducer'; +import { + SETTINGS_WINDOW_OPEN_STATE_CHANGED, + SIDE_BAR_SETTINGS_BUTTON_CLICKED, +} from '../ui/actions'; +import { getRootWindow } from '../ui/main/rootWindow'; +import { watchWindowControls } from '../ui/main/secondaryWindowControls'; +import { focusSecondaryWindow } from '../ui/main/secondaryWindowFocus'; +import { + getSavedWindowBounds, + watchWindowBounds, +} from '../ui/main/secondaryWindowState'; +import { + NOT_FULL_SCREENABLE, + getTitleBarOptions, +} from '../ui/windowChrome/appearance'; +import { + TRANSPARENCY_CHANNEL, + WINDOW_MIN_HEIGHT, + WINDOW_MIN_WIDTH, + WINDOW_PREFERRED_HEIGHT, + WINDOW_PREFERRED_WIDTH, + WINDOW_SIZE_MULTIPLIER, +} from './constants'; + +const t = i18next.t.bind(i18next); + +const isMac = process.platform === 'darwin'; + +let settingsWindow: BrowserWindow | null = null; + +/** + * Set once quitting starts. The window's `closed` handler fires both when the + * reader closes it and when the app tears its windows down on quit; without this + * the quit path would record "closed" and defeat restoring it next launch. + */ +let isAppQuitting = false; + +const selectIsTransparencyEnabled = ({ + isTransparentWindowEnabled, +}: RootState): boolean => isTransparentWindowEnabled; + +/** Set while a window is being built; see `createSettingsWindow`. */ +let pendingCreation: Promise | null = null; + +const buildSettingsWindow = async (focusOnShow: boolean): Promise => { + const mainWindow = await getRootWindow(); + const winBounds = mainWindow.getNormalBounds(); + + const actualScreen = screen.getDisplayNearestPoint({ + x: winBounds.x + winBounds.width / 2, + y: winBounds.y + winBounds.height / 2, + }); + + // Open wide and tall enough for the Appearance section, but never larger than + // the screen it opens on. + const width = Math.min( + actualScreen.workAreaSize.width, + Math.max( + WINDOW_PREFERRED_WIDTH, + Math.round(actualScreen.workAreaSize.width * WINDOW_SIZE_MULTIPLIER) + ) + ); + const height = Math.min( + actualScreen.workAreaSize.height, + Math.max( + WINDOW_PREFERRED_HEIGHT, + Math.round(actualScreen.workAreaSize.height * WINDOW_SIZE_MULTIPLIER) + ) + ); + const x = Math.round( + (actualScreen.workArea.width - width) / 2 + actualScreen.workArea.x + ); + const y = Math.round( + (actualScreen.workArea.height - height) / 2 + actualScreen.workArea.y + ); + + // Where the reader last left this window, falling back to centred on the + // display nearest the main window. + const savedBounds = getSavedWindowBounds('settings'); + + // Seeds the first paint only; the renderer then follows the setting live. + const isTransparencyEnabled = isMac && select(selectIsTransparencyEnabled); + + settingsWindow = new BrowserWindow({ + ...(savedBounds ?? { width, height, x, y }), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, + title: 'Settings - Rocket.Chat', + // The toolbar doubles as the title bar wherever the platform allows it, so + // the window shows one header instead of a native title bar stacked on an + // in-app one. + ...getTitleBarOptions(), + ...NOT_FULL_SCREENABLE, + // `transparent` cannot be toggled after creation, so — like the root window — + // the window is always transparent with a vibrancy material on macOS and the + // setting only decides whether the renderer paints an opaque surface over it. + ...(isMac + ? { + transparent: true, + vibrancy: 'sidebar' as const, + visualEffectState: 'active' as const, + } + : {}), + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + show: false, + }); + + settingsWindow.loadFile( + path.join(app.getAppPath(), 'app/settings-window.html'), + { query: { transparent: String(isTransparencyEnabled) } } + ); + + settingsWindow.once('ready-to-show', () => { + settingsWindow?.setTitle( + `Settings - ${packageJsonInformation.productName}` + ); + if (focusOnShow) { + settingsWindow?.show(); + return; + } + // Restored at launch: show it without stealing focus from the main window. + settingsWindow?.showInactive(); + }); + + dispatch({ type: SETTINGS_WINDOW_OPEN_STATE_CHANGED, payload: true }); + + settingsWindow.on('closed', () => { + settingsWindow = null; + if (!isAppQuitting) { + dispatch({ type: SETTINGS_WINDOW_OPEN_STATE_CHANGED, payload: false }); + } + }); + + settingsWindow.webContents.on( + 'will-navigate', + (event: Event, url: string) => { + if (!url.startsWith('file://')) { + event.preventDefault(); + } + } + ); + + watchWindowBounds('settings', settingsWindow); + watchWindowControls(settingsWindow); + + settingsWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); +}; + +/** + * Opens the window, or focuses the one already open. + * + * Building one awaits the main window before the module variable is assigned, + * so two opens arriving in that gap would each build a window and the second + * would orphan the first — visible, untracked, and impossible to close from its + * own channel. Every caller comes through here, so only one is ever in flight. + */ +const createSettingsWindow = async (focusOnShow: boolean): Promise => { + if (pendingCreation) await pendingCreation; + + if (settingsWindow && !settingsWindow.isDestroyed()) { + focusSecondaryWindow(settingsWindow); + return; + } + + pendingCreation = buildSettingsWindow(focusOnShow).finally(() => { + pendingCreation = null; + }); + await pendingCreation; +}; + +export const openSettingsWindow = (): Promise => + createSettingsWindow(true); + +/** + * Reopens the window at launch when it was open at shutdown, without taking + * focus from the main window. + */ +export const restoreSettingsWindow = async (): Promise => { + if (!select(({ isSettingsWindowOpen }: RootState) => isSettingsWindowOpen)) { + return; + } + await createSettingsWindow(false); +}; + +export const startSettingsWindowHandler = (): void => { + app.on('before-quit', () => { + isAppQuitting = true; + }); + + handle('settings-window/open-window', openSettingsWindow); + + // Every entry point — the app menu, the meatball menu and the telephony + // prompt — already dispatches this, so hooking it here redirects all of them. + listen(SIDE_BAR_SETTINGS_BUTTON_CLICKED, () => { + openSettingsWindow(); + }); + + handle('settings-window/close-requested', async () => { + settingsWindow?.close(); + }); + + handle( + 'settings-window/confirm-remove-certificate', + async (_webContents, domain) => { + if (!settingsWindow || settingsWindow.isDestroyed()) { + return false; + } + + const { response } = await dialog.showMessageBox(settingsWindow, { + type: 'warning', + buttons: [ + t('dialog.removeCertificate.yes'), + t('dialog.removeCertificate.cancel'), + ], + defaultId: 1, + cancelId: 1, + title: t('dialog.removeCertificate.title'), + message: t('dialog.removeCertificate.message', { domain }), + }); + + return response === 0; + } + ); + + // Transparency is a renderer concern here, so a change only needs pushing to + // the open window — no reopen, no restart. + watch(selectIsTransparencyEnabled, (isEnabled) => { + if (!settingsWindow || settingsWindow.isDestroyed()) return; + settingsWindow.webContents.send(TRANSPARENCY_CHANNEL, isEnabled); + }); +}; diff --git a/src/settingsWindow/main/ipc.main.spec.ts b/src/settingsWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..f3012d329e --- /dev/null +++ b/src/settingsWindow/main/ipc.main.spec.ts @@ -0,0 +1,189 @@ +/** + * Confirm path for removing a stored certificate decision. Lives in main/ so + * jest's main-process project discovers it. + */ +export {}; + +const handleRegistry = new Map any>(); + +jest.mock('../../ipc/main', () => ({ + handle: jest.fn((channel: string, cb: (...args: any[]) => any) => { + handleRegistry.set(channel, cb); + return () => handleRegistry.delete(channel); + }), +})); + +jest.mock('../../store', () => ({ + dispatch: jest.fn(), + listen: jest.fn(), + select: jest.fn(() => false), + watch: jest.fn(), +})); + +jest.mock('../../app/main/app', () => ({ + packageJsonInformation: { productName: 'Rocket.Chat' }, +})); + +jest.mock('../../ui/main/rootWindow', () => ({ + getRootWindow: jest.fn(() => + Promise.resolve({ + getNormalBounds: () => ({ x: 0, y: 0, width: 1200, height: 800 }), + }) + ), +})); + +jest.mock('../../ui/main/secondaryWindowControls', () => ({ + watchWindowControls: jest.fn(), +})); + +jest.mock('../../ui/main/secondaryWindowFocus', () => ({ + focusSecondaryWindow: jest.fn(), +})); + +jest.mock('../../ui/main/secondaryWindowState', () => ({ + getSavedWindowBounds: jest.fn(() => undefined), + watchWindowBounds: jest.fn(), +})); + +jest.mock('../../ui/windowChrome/appearance', () => ({ + NOT_FULL_SCREENABLE: {}, + getTitleBarOptions: jest.fn(() => ({})), +})); + +jest.mock('i18next', () => ({ + t: (key: string, options?: { domain?: string }) => + options?.domain ? `${key}:${options.domain}` : key, +})); + +type FakeBW = { + isDestroyed: jest.Mock; + webContents: { + on: jest.Mock; + setWindowOpenHandler: jest.Mock; + send: jest.Mock; + }; + loadFile: jest.Mock; + once: jest.Mock; + on: jest.Mock; + setTitle: jest.Mock; + show: jest.Mock; + showInactive: jest.Mock; +}; + +const createdWindows: FakeBW[] = []; + +class FakeBrowserWindow { + webContents = { + on: jest.fn(), + setWindowOpenHandler: jest.fn(), + send: jest.fn(), + }; + + loadFile = jest.fn(() => Promise.resolve()); + + once = jest.fn(); + + on = jest.fn(); + + setTitle = jest.fn(); + + show = jest.fn(); + + showInactive = jest.fn(); + + isDestroyed = jest.fn(() => false); + + constructor() { + createdWindows.push(this as unknown as FakeBW); + } +} + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/app'), + on: jest.fn(), + }, + BrowserWindow: jest.fn().mockImplementation(() => new FakeBrowserWindow()), + dialog: { + showMessageBox: jest.fn(), + }, + screen: { + getDisplayNearestPoint: jest.fn(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), + }, +})); + +const loadIpc = async () => { + const ipc = await import('../ipc'); + const { dialog } = jest.requireMock('electron') as { + dialog: { showMessageBox: jest.Mock }; + }; + return { ...ipc, showMessageBox: dialog.showMessageBox }; +}; + +const confirmHandler = () => { + const handler = handleRegistry.get( + 'settings-window/confirm-remove-certificate' + ); + if (!handler) { + throw new Error('confirm-remove-certificate was not registered'); + } + return handler; +}; + +describe('settings-window/confirm-remove-certificate', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + handleRegistry.clear(); + createdWindows.length = 0; + }); + + it('returns false and does not show a dialog when the window is gone', async () => { + const { startSettingsWindowHandler, showMessageBox } = await loadIpc(); + startSettingsWindowHandler(); + + await expect(confirmHandler()({} as never, 'example.test')).resolves.toBe( + false + ); + expect(showMessageBox).not.toHaveBeenCalled(); + }); + + it('returns false on cancel so the certificate is not removed', async () => { + const { openSettingsWindow, startSettingsWindowHandler, showMessageBox } = + await loadIpc(); + startSettingsWindowHandler(); + await openSettingsWindow(); + + showMessageBox.mockResolvedValue({ response: 1 }); + + await expect(confirmHandler()({} as never, 'example.test')).resolves.toBe( + false + ); + expect(showMessageBox).toHaveBeenCalledWith( + createdWindows[0], + expect.objectContaining({ + type: 'warning', + defaultId: 1, + cancelId: 1, + title: 'dialog.removeCertificate.title', + message: 'dialog.removeCertificate.message:example.test', + }) + ); + }); + + it('returns true on accept so the certificate can be removed', async () => { + const { openSettingsWindow, startSettingsWindowHandler, showMessageBox } = + await loadIpc(); + startSettingsWindowHandler(); + await openSettingsWindow(); + + showMessageBox.mockResolvedValue({ response: 0 }); + + await expect(confirmHandler()({} as never, 'example.test')).resolves.toBe( + true + ); + }); +}); diff --git a/src/settingsWindow/searchIndex.ts b/src/settingsWindow/searchIndex.ts new file mode 100644 index 0000000000..eee0a30ab1 --- /dev/null +++ b/src/settingsWindow/searchIndex.ts @@ -0,0 +1,56 @@ +/** Minimal shape of i18next's `t`, so this stays testable without i18next. */ +export type Translate = ( + key: string, + options?: { returnObjects?: boolean } +) => unknown; + +export type SettingSearchEntry = { + /** What the row reports as the reason it matched. */ + title: string; + /** Every string worth matching against. */ + texts: string[]; +}; + +/** + * Strings carrying interpolation are runtime messages ("{{accelerator}} is + * already used…"), not something anyone searches for. + */ +const isSearchable = (value: string): boolean => !value.includes('{{'); + +/** + * Expands a settings key into the text worth searching: the setting's own name, + * its option labels and its description — so "light" finds Theme and "vibrancy" + * finds the transparent window effect. `matchesSearchText` decides how strictly + * each of those is matched. + * + * Taking the whole namespace rather than a hand-listed set of keys means a new + * option becomes searchable the moment it is translated. + */ +export const collectSettingSearchTexts = ( + t: Translate, + key: string +): SettingSearchEntry => { + const resolved = t(key, { returnObjects: true }); + + if (typeof resolved === 'string') { + return { title: resolved, texts: [resolved] }; + } + + if (!resolved || typeof resolved !== 'object') { + return { title: key, texts: [] }; + } + + const entries = Object.entries(resolved as Record); + const title = entries.find(([name]) => name === 'title')?.[1]; + + const texts = entries + .filter( + ([, value]) => typeof value === 'string' && isSearchable(value as string) + ) + .map(([, value]) => value as string); + + return { + title: typeof title === 'string' ? title : key, + texts, + }; +}; diff --git a/src/settingsWindow/sections.ts b/src/settingsWindow/sections.ts new file mode 100644 index 0000000000..040b5a7d21 --- /dev/null +++ b/src/settingsWindow/sections.ts @@ -0,0 +1,119 @@ +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ComponentType } from 'react'; + +import type { Surfaces } from '../ui/windowChrome/appearance'; +import { AdvancedSection } from './sections/AdvancedSection'; +import { AppearanceSection } from './sections/AppearanceSection'; +import { CertificatesSection } from './sections/CertificatesSection'; +import { GeneralSection } from './sections/GeneralSection'; +import { TelephonySection } from './sections/TelephonySection'; +import { VideoCallsSection } from './sections/VideoCallsSection'; + +export type SettingsSectionId = + | 'general' + | 'appearance' + | 'certificates' + | 'telephony' + | 'videoCalls' + | 'advanced'; + +/** Every section is handed the window's surfaces; most ignore them. */ +export type SettingsSectionProps = { + surfaces: Surfaces; +}; + +export type SettingsSectionDefinition = { + id: SettingsSectionId; + icon: IconName; + /** i18n key for the section's own name. */ + labelKey: string; + /** + * i18n keys of the settings the section holds — an options namespace, whose + * title *and* option labels are all searchable, so "light" finds Theme. + * + * This is a hand-kept index: the feature components own their own labels, and + * reaching into them for text would mean rendering every section just to + * search. Adding a setting without listing it here only costs it a search + * hit, never a broken section; adding an *option* to a listed setting needs + * no change at all. + */ + settingKeys: string[]; + Component: ComponentType; +}; + +export const SETTINGS_SECTIONS: SettingsSectionDefinition[] = [ + { + id: 'general', + icon: 'cog', + labelKey: 'settings.general', + settingKeys: [ + 'settings.options.trayIcon', + 'settings.options.minimizeOnClose', + 'settings.options.menubar', + 'settings.options.flashFrame', + 'settings.options.downloadsPercentage', + 'settings.options.availableBrowsers', + 'settings.options.outlookCalendarSyncInterval', + 'settings.options.e2ePdfPreviewSizeLimit', + 'settings.options.ntlmCredentials', + 'dialog.about.checkUpdates', + 'dialog.about.checkUpdatesOnStart', + ], + Component: GeneralSection, + }, + { + id: 'appearance', + icon: 'palette', + labelKey: 'settings.appearance', + settingKeys: [ + 'settings.options.themeAppearance', + 'settings.options.navigation', + 'settings.options.transparentWindow', + ], + Component: AppearanceSection, + }, + { + id: 'certificates', + icon: 'shield', + labelKey: 'settings.certificates', + settingKeys: ['settings.certificates', 'certificatesManager'], + Component: CertificatesSection, + }, + { + id: 'telephony', + icon: 'phone', + labelKey: 'settings.sections.telephony', + settingKeys: [ + 'settings.options.telephony', + 'settings.options.telephonyShortcut', + 'settings.options.telephonyServer', + ], + Component: TelephonySection, + }, + { + id: 'videoCalls', + icon: 'video', + labelKey: 'settings.sections.videoCalls', + settingKeys: [ + 'settings.options.internalVideoChatWindow', + 'settings.options.videoCallWindowPersistence', + 'settings.options.videoCallScreenCaptureFallback', + 'settings.options.clearPermittedScreenCaptureServers', + ], + Component: VideoCallsSection, + }, + { + id: 'advanced', + icon: 'cog', + labelKey: 'settings.sections.advanced', + settingKeys: [ + 'dialog.about.updateChannel.label', + 'settings.options.hardwareAcceleration', + 'settings.options.report', + 'settings.options.verboseOutlookLogging', + 'settings.options.detailedEventsLogging', + 'settings.options.debugLogging', + ], + Component: AdvancedSection, + }, +]; diff --git a/src/settingsWindow/sections/AdvancedSection.tsx b/src/settingsWindow/sections/AdvancedSection.tsx new file mode 100644 index 0000000000..042b2ccb00 --- /dev/null +++ b/src/settingsWindow/sections/AdvancedSection.tsx @@ -0,0 +1,62 @@ +import { Box, FieldGroup } from '@rocket.chat/fuselage'; +import { useSelector } from 'react-redux'; + +import type { RootState } from '../../store/rootReducer'; +import { DebugLogging } from '../../ui/components/SettingsView/features/DebugLogging'; +import { DetailedEventsLogging } from '../../ui/components/SettingsView/features/DetailedEventsLogging'; +import { HardwareAcceleration } from '../../ui/components/SettingsView/features/HardwareAcceleration'; +import { ReportErrors } from '../../ui/components/SettingsView/features/ReportErrors'; +import { SettingGroupDivider } from '../../ui/components/SettingsView/features/SettingGroupDivider'; +import { UpdateChannel } from '../../ui/components/SettingsView/features/UpdateChannel'; +import { VerboseOutlookLogging } from '../../ui/components/SettingsView/features/VerboseOutlookLogging'; + +/** + * The settings a reader reaches for when something is wrong: which version they + * are on, how it updates, and the switches worth flipping while diagnosing — + * hardware acceleration, error reports, and the logging that used to sit behind + * its own Developer section. + */ +export const AdvancedSection = () => { + const isDeveloperModeEnabled = useSelector( + ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled + ); + const isUpdatingAllowed = useSelector( + ({ isUpdatingAllowed }: RootState) => isUpdatingAllowed + ); + const isUpdatingEnabled = useSelector( + ({ isUpdatingEnabled }: RootState) => isUpdatingEnabled + ); + + const canUpdate = isUpdatingAllowed && isUpdatingEnabled; + + return ( + + {canUpdate && isDeveloperModeEnabled && ( + <> + + + + + + + )} + + + + + + + {isDeveloperModeEnabled && ( + <> + + + + + + + + + )} + + ); +}; diff --git a/src/settingsWindow/sections/AppearanceSection.tsx b/src/settingsWindow/sections/AppearanceSection.tsx new file mode 100644 index 0000000000..c81a6179cd --- /dev/null +++ b/src/settingsWindow/sections/AppearanceSection.tsx @@ -0,0 +1,17 @@ +import { Box, FieldGroup } from '@rocket.chat/fuselage'; + +import { NavigationLayout } from '../../ui/components/SettingsView/features/NavigationLayout'; +import { ThemeAppearance } from '../../ui/components/SettingsView/features/ThemeAppearance'; +import { TransparentWindow } from '../../ui/components/SettingsView/features/TransparentWindow'; +import { isDarwin } from '../../ui/windowChrome/appearance'; + +/** How the app looks: theme, workspace switcher, window material. */ +export const AppearanceSection = () => ( + + + + + {isDarwin && } + + +); diff --git a/src/settingsWindow/sections/CertificateRow.spec.tsx b/src/settingsWindow/sections/CertificateRow.spec.tsx new file mode 100644 index 0000000000..dd8305470b --- /dev/null +++ b/src/settingsWindow/sections/CertificateRow.spec.tsx @@ -0,0 +1,78 @@ +import { invoke } from '../../ipc/renderer'; +import { render, screen, userEvent, waitFor } from '../../ui/test-utils'; +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { CertificateRow } from './CertificateRow'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: jest.fn() }, + }), + Trans: ({ children }: { children: React.ReactNode }) => children, + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('../../ipc/renderer', () => ({ + invoke: jest.fn(), +})); + +const invokeMock = invoke as jest.MockedFunction; + +const surfaces: Surfaces = { + panel: '#fff', + card: '#fff', + sticky: '#fff', + field: '#fff', + hover: '#eee', + selected: '#ddd', + divider: '#ccc', +}; + +describe('CertificateRow remove confirm', () => { + beforeEach(() => { + jest.clearAllMocks(); + invokeMock.mockResolvedValue(false as never); + }); + + it('does not remove the certificate when the confirm dialog is cancelled', async () => { + const user = userEvent.setup(); + render( + + ); + + invokeMock.mockResolvedValueOnce(false as never); + await user.click( + screen.getByRole('button', { name: 'certificatesManager.item.remove' }) + ); + + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith( + 'settings-window/confirm-remove-certificate', + 'example.test' + ) + ); + expect(invokeMock).not.toHaveBeenCalledWith( + 'certificatesManager/remove', + 'example.test' + ); + }); + + it('removes the certificate when the confirm dialog is accepted', async () => { + const user = userEvent.setup(); + render( + + ); + + invokeMock.mockResolvedValueOnce(true as never); + await user.click( + screen.getByRole('button', { name: 'certificatesManager.item.remove' }) + ); + + await waitFor(() => + expect(invokeMock).toHaveBeenCalledWith( + 'certificatesManager/remove', + 'example.test' + ) + ); + }); +}); diff --git a/src/settingsWindow/sections/CertificateRow.tsx b/src/settingsWindow/sections/CertificateRow.tsx new file mode 100644 index 0000000000..9cffa85974 --- /dev/null +++ b/src/settingsWindow/sections/CertificateRow.tsx @@ -0,0 +1,75 @@ +import { Box, Icon, IconButton, Tag } from '@rocket.chat/fuselage'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { invoke } from '../../ipc/renderer'; +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { LIST_ROW_CLASS } from '../../ui/windowChrome/styles'; + +export type CertificateRowProps = { + domain: string; + isTrusted: boolean; + surfaces: Surfaces; +}; + +/** + * One certificate as a list row, in the same shape as a download: an icon, the + * domain, and the actions that apply to it. The trust state is a tag on the row + * rather than which of two tables it happened to be in. + */ +export const CertificateRow = ({ + domain, + isTrusted, + surfaces, +}: CertificateRowProps) => { + const { t } = useTranslation(); + + const handleRemove = useCallback(async () => { + const confirmed = await invoke( + 'settings-window/confirm-remove-certificate', + domain + ); + if (!confirmed) return; + invoke('certificatesManager/remove', domain); + }, [domain]); + + return ( + + + + + + {domain} + + + + + + {isTrusted + ? t('certificatesManager.state.trusted') + : t('certificatesManager.state.notTrusted')} + + + + + + ); +}; diff --git a/src/settingsWindow/sections/CertificatesSection.tsx b/src/settingsWindow/sections/CertificatesSection.tsx new file mode 100644 index 0000000000..04ed324d45 --- /dev/null +++ b/src/settingsWindow/sections/CertificatesSection.tsx @@ -0,0 +1,138 @@ +import { + Box, + Icon, + SearchInput, + States, + StatesIcon, + StatesSubtitle, + StatesTitle, +} from '@rocket.chat/fuselage'; +import type { ChangeEvent } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import type { RootState } from '../../store/rootReducer'; +import { SectionLabel } from '../../ui/windowChrome/SectionLabel'; +import type { Surfaces } from '../../ui/windowChrome/appearance'; +import { SEARCH_FIELD_CLASS } from '../../ui/windowChrome/styles'; +import { fuzzyMatch } from '../fuzzy'; +import { CertificateRow } from './CertificateRow'; + +export type CertificatesSectionProps = { + surfaces: Surfaces; +}; + +type CertificateEntry = { + domain: string; + isTrusted: boolean; +}; + +/** + * One list of every certificate the app has an opinion about, trusted and not, + * each row saying which it is — rather than two tables where the state is + * implied by which one you happen to be reading. + */ +export const CertificatesSection = ({ surfaces }: CertificatesSectionProps) => { + const { t } = useTranslation(); + + const trustedCertificates = useSelector( + ({ trustedCertificates }: RootState) => trustedCertificates + ); + const notTrustedCertificates = useSelector( + ({ notTrustedCertificates }: RootState) => notTrustedCertificates + ); + + const [filter, setFilter] = useState(''); + + const handleFilterChange = useCallback( + (event: ChangeEvent) => { + setFilter(event.target.value); + }, + [] + ); + + // Untrusted first: they are the ones worth acting on. + const certificates = useMemo( + () => [ + ...Object.keys(notTrustedCertificates ?? {}).map((domain) => ({ + domain, + isTrusted: false, + })), + ...Object.keys(trustedCertificates ?? {}).map((domain) => ({ + domain, + isTrusted: true, + })), + ], + [notTrustedCertificates, trustedCertificates] + ); + + const visible = useMemo( + () => + filter.trim() === '' + ? certificates + : certificates.filter(({ domain }) => fuzzyMatch(filter, domain)), + [certificates, filter] + ); + + const trustedCount = certificates.filter(({ isTrusted }) => isTrusted).length; + + return ( + + + } + aria-label={t('certificatesManager.filter')} + placeholder={t('certificatesManager.filter')} + value={filter} + onChange={handleFilterChange} + /> + + + {certificates.length > 0 && ( + + + {t('certificatesManager.summary', { + count: certificates.length, + trusted: trustedCount, + })} + + + )} + + {visible.length === 0 ? ( + + + + {certificates.length === 0 + ? t('certificatesManager.empty.title') + : t('certificatesManager.noResults.title')} + + + {certificates.length === 0 + ? t('certificatesManager.empty.subtitle') + : t('certificatesManager.noResults.subtitle')} + + + ) : ( + + {visible.map(({ domain, isTrusted }) => ( + + ))} + + )} + + ); +}; diff --git a/src/settingsWindow/sections/GeneralSection.tsx b/src/settingsWindow/sections/GeneralSection.tsx new file mode 100644 index 0000000000..7211b40799 --- /dev/null +++ b/src/settingsWindow/sections/GeneralSection.tsx @@ -0,0 +1,44 @@ +import { Box, FieldGroup } from '@rocket.chat/fuselage'; + +import { AvailableBrowsers } from '../../ui/components/SettingsView/features/AvailableBrowsers'; +import { CheckForUpdates } from '../../ui/components/SettingsView/features/CheckForUpdates'; +import { DownloadsPercentage } from '../../ui/components/SettingsView/features/DownloadsPercentage'; +import { E2ePdfPreviewSizeLimit } from '../../ui/components/SettingsView/features/E2ePdfPreviewSizeLimit'; +import { FlashFrame } from '../../ui/components/SettingsView/features/FlashFrame'; +import { MenuBar } from '../../ui/components/SettingsView/features/MenuBar'; +import { MinimizeOnClose } from '../../ui/components/SettingsView/features/MinimizeOnClose'; +import { NTLMCredentials } from '../../ui/components/SettingsView/features/NTLMCredentials'; +import { OutlookCalendarSyncInterval } from '../../ui/components/SettingsView/features/OutlookCalendarSyncInterval'; +import { SettingGroupDivider } from '../../ui/components/SettingsView/features/SettingGroupDivider'; +import { TrayIcon } from '../../ui/components/SettingsView/features/TrayIcon'; +import { isDarwin } from '../../ui/windowChrome/appearance'; + +const isWin32 = process.platform === 'win32'; + +/** Everything that is not appearance, calling or advanced. */ +export const GeneralSection = () => ( + + + + + + + + {/* + One group, so every row is spaced the same. The extra margin these used to + be split across read as an accident — nothing distinguished the settings on + either side of it. + */} + + + {isWin32 && } + {!isDarwin && !isWin32 && } + + + + + + {isWin32 && } + + +); diff --git a/src/settingsWindow/sections/TelephonySection.tsx b/src/settingsWindow/sections/TelephonySection.tsx new file mode 100644 index 0000000000..7e3d461cf5 --- /dev/null +++ b/src/settingsWindow/sections/TelephonySection.tsx @@ -0,0 +1,15 @@ +import { Box, FieldGroup } from '@rocket.chat/fuselage'; + +import { Telephony } from '../../ui/components/SettingsView/features/Telephony'; +import { TelephonyGlobalShortcut } from '../../ui/components/SettingsView/features/TelephonyGlobalShortcut'; +import { TelephonyServer } from '../../ui/components/SettingsView/features/TelephonyServer'; + +export const TelephonySection = () => ( + + + + + + + +); diff --git a/src/settingsWindow/sections/VideoCallsSection.tsx b/src/settingsWindow/sections/VideoCallsSection.tsx new file mode 100644 index 0000000000..853d53bea4 --- /dev/null +++ b/src/settingsWindow/sections/VideoCallsSection.tsx @@ -0,0 +1,17 @@ +import { Box, FieldGroup } from '@rocket.chat/fuselage'; + +import { ClearPermittedScreenCaptureServers } from '../../ui/components/SettingsView/features/ClearPermittedScreenCaptureServers'; +import { InternalVideoChatWindow } from '../../ui/components/SettingsView/features/InternalVideoChatWindow'; +import { ScreenCaptureFallback } from '../../ui/components/SettingsView/features/ScreenCaptureFallback'; +import { VideoCallWindowPersistence } from '../../ui/components/SettingsView/features/VideoCallWindowPersistence'; + +export const VideoCallsSection = () => ( + + + + + {process.platform === 'win32' && } + {!process.mas && } + + +); diff --git a/src/settingsWindow/settings-window.tsx b/src/settingsWindow/settings-window.tsx new file mode 100644 index 0000000000..d3de92da1c --- /dev/null +++ b/src/settingsWindow/settings-window.tsx @@ -0,0 +1,69 @@ +import { PaletteStyleTag } from '@rocket.chat/fuselage'; +import type { Themes } from '@rocket.chat/fuselage/dist/components/PaletteStyleTag/types/themes'; +import { useState, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; + +import { setupI18n } from '../i18n/renderer'; +import '../logging/preload'; +import { createRendererReduxStore } from '../store'; +import { whenReady } from '../whenReady'; +import { SettingsWindow } from './SettingsWindow'; + +const useSystemTheme = (): Themes => { + const [theme, setTheme] = useState(() => + window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + ); + + useEffect(() => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = (event: MediaQueryListEvent) => { + setTheme(event.matches ? 'dark' : 'light'); + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + return theme; +}; + +const ThemedSettingsWindow = () => { + const theme = useSystemTheme(); + + return ( + <> + + + + ); +}; + +const start = async (): Promise => { + // Settings are redux state synced from the main process, so this window + // joins the same store rather than reimplementing them over IPC. + const reduxStore = await createRendererReduxStore(); + + await whenReady(); + await setupI18n(); + + const container = document.getElementById('root'); + + if (!container) { + throw new Error('cannot find the container node for React'); + } + + const root = createRoot(container); + + root.render( + + + + ); + + window.addEventListener('beforeunload', () => { + root.unmount(); + }); +}; + +start().catch(console.error); diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index e6ccde171a..fbfc35d624 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -14,6 +14,7 @@ import { trustedCertificates, notTrustedCertificates, } from '../navigation/reducers'; +import { isNotificationQuickReplyEnabled } from '../notifications/reducers/isNotificationQuickReplyEnabled'; import { allowInsecureOutlookConnections } from '../outlookCalendar/reducers/allowInsecureOutlookConnections'; import { outlookCalendarSyncInterval } from '../outlookCalendar/reducers/outlookCalendarSyncInterval'; import { outlookCalendarSyncIntervalOverride } from '../outlookCalendar/reducers/outlookCalendarSyncIntervalOverride'; @@ -33,14 +34,18 @@ import { isDebugLoggingEnabled } from '../ui/reducers/isDebugLoggingEnabled'; import { isDetailedEventsLoggingEnabled } from '../ui/reducers/isDetailedEventsLoggingEnabled'; import { isDeveloperModeEnabled } from '../ui/reducers/isDeveloperModeEnabled'; import { isDownloadsPercentageEnabled } from '../ui/reducers/isDownloadsPercentageEnabled'; +import { isDownloadsWindowOpen } from '../ui/reducers/isDownloadsWindowOpen'; import { isFlashFrameEnabled } from '../ui/reducers/isFlashFrameEnabled'; import { isHardwareAccelerationEnabled } from '../ui/reducers/isHardwareAccelerationEnabled'; import { isInternalVideoChatWindowEnabled } from '../ui/reducers/isInternalVideoChatWindowEnabled'; +import { isLogViewerWindowOpen } from '../ui/reducers/isLogViewerWindowOpen'; import { isMenuBarEnabled } from '../ui/reducers/isMenuBarEnabled'; import { isMessageBoxFocused } from '../ui/reducers/isMessageBoxFocused'; import { isMinimizeOnCloseEnabled } from '../ui/reducers/isMinimizeOnCloseEnabled'; import { isNTLMCredentialsEnabled } from '../ui/reducers/isNTLMCredentialsEnabled'; +import { isPresenceDisconnectionSimulated } from '../ui/reducers/isPresenceDisconnectionSimulated'; import { isReportEnabled } from '../ui/reducers/isReportEnabled'; +import { isSettingsWindowOpen } from '../ui/reducers/isSettingsWindowOpen'; import { isShowWindowOnUnreadChangedEnabled } from '../ui/reducers/isShowWindowOnUnreadChangedEnabled'; import { isSideBarEnabled } from '../ui/reducers/isSideBarEnabled'; import { isTelephonyEnabled } from '../ui/reducers/isTelephonyEnabled'; @@ -55,6 +60,7 @@ import { navigationLayout } from '../ui/reducers/navigationLayout'; import { openDialog } from '../ui/reducers/openDialog'; import { rootWindowIcon } from '../ui/reducers/rootWindowIcon'; import { rootWindowState } from '../ui/reducers/rootWindowState'; +import { secondaryWindowStates } from '../ui/reducers/secondaryWindowStates'; import { selectedBrowser } from '../ui/reducers/selectedBrowser'; import { userThemePreference } from '../ui/reducers/userThemePreference'; import { videoCallWindowState } from '../ui/reducers/videoCallWindowState'; @@ -72,6 +78,7 @@ import { updateDownloadStatus, updateError, updateChannel, + updateStore, } from '../updates/reducers'; export const rootReducer = combineReducers({ @@ -93,6 +100,7 @@ export const rootReducer = combineReducers({ isMenuBarEnabled, isMessageBoxFocused, isShowWindowOnUnreadChangedEnabled, + isSettingsWindowOpen, isSideBarEnabled, navigationLayout, isTrayIconEnabled, @@ -106,6 +114,7 @@ export const rootReducer = combineReducers({ openDialog, rootWindowIcon, rootWindowState, + secondaryWindowStates, selectedBrowser, servers, userThemePreference, @@ -134,14 +143,19 @@ export const rootReducer = combineReducers({ isDetailedEventsLoggingEnabled, isVerboseOutlookLoggingEnabled, updateChannel, + updateStore, screenCaptureFallbackForced, isVideoCallDevtoolsAutoOpenEnabled, + isPresenceDisconnectionSimulated, + isDownloadsWindowOpen, + isLogViewerWindowOpen, isTransparentWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, telephonyPreferredServer, telephonyGlobalShortcutConfig, telephonyGlobalShortcutRegistrationStatus, isTelephonyEnabled, + isNotificationQuickReplyEnabled, }); export type RootState = ReturnType; diff --git a/src/ui/__tests__/selectors.spec.ts b/src/ui/__tests__/selectors.spec.ts index 47162c8214..dcc375b8a5 100644 --- a/src/ui/__tests__/selectors.spec.ts +++ b/src/ui/__tests__/selectors.spec.ts @@ -3,6 +3,7 @@ import { selectGlobalBadge, selectGlobalBadgeCount, selectGlobalBadgeText, + selectActiveServerPresence, } from '../selectors'; const state = (servers: any[]): RootState => @@ -36,4 +37,82 @@ describe('ui/selectors', () => { 7 ); }); + + it('does not throw when a server has a malformed url', () => { + const rootState = { + servers: [{ url: 'not a url', title: 'Bad Server' }], + currentView: { url: 'https://server.test' }, + } as unknown as RootState; + + expect(() => selectActiveServerPresence(rootState)).not.toThrow(); + expect(selectActiveServerPresence(rootState).url).toBeUndefined(); + }); + + it('overrides connection to disconnected when presence disconnection is simulated', () => { + const rootState = { + servers: [ + { + url: 'https://server.test', + title: 'Server', + presenceConnection: 'connected', + }, + ], + currentView: { url: 'https://server.test' }, + isPresenceDisconnectionSimulated: true, + } as unknown as RootState; + + expect(selectActiveServerPresence(rootState).connection).toBe( + 'disconnected' + ); + }); + + it('keeps the real connection when presence disconnection is not simulated', () => { + const rootState = { + servers: [ + { + url: 'https://server.test', + title: 'Server', + presenceConnection: 'connected', + }, + ], + currentView: { url: 'https://server.test' }, + isPresenceDisconnectionSimulated: false, + } as unknown as RootState; + + expect(selectActiveServerPresence(rootState).connection).toBe('connected'); + }); + + it('passes through the failed flag from the active server', () => { + const rootState = { + servers: [ + { + url: 'https://server.test', + title: 'Server', + failed: true, + }, + ], + currentView: { url: 'https://server.test' }, + } as unknown as RootState; + + expect(selectActiveServerPresence(rootState).failed).toBe(true); + }); + + it('flags isAddingServer when the current view is the add-workspace screen, even with existing servers', () => { + const rootState = { + servers: [{ url: 'https://server.test', title: 'Server' }], + currentView: 'add-new-server', + } as unknown as RootState; + + expect(selectActiveServerPresence(rootState).isAddingServer).toBe(true); + expect(selectActiveServerPresence(rootState).hasServers).toBe(true); + }); + + it('does not flag isAddingServer when a server is selected', () => { + const rootState = { + servers: [{ url: 'https://server.test', title: 'Server' }], + currentView: { url: 'https://server.test' }, + } as unknown as RootState; + + expect(selectActiveServerPresence(rootState).isAddingServer).toBe(false); + }); }); diff --git a/src/ui/actions.ts b/src/ui/actions.ts index 963fd5de3d..25cb781509 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -17,7 +17,6 @@ export const CLEAR_CACHE_DIALOG_KEEP_LOGIN_DATA_CLICKED = 'clear-cache-dialog/keep-login-data-clicked'; export const LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED = 'loading-error-view/reload-server-clicked'; -export const MENU_BAR_ABOUT_CLICKED = 'menu-bar/about-clicked'; export const MENU_BAR_ADD_NEW_SERVER_CLICKED = 'menu-bar/add-new-server-clicked'; export const MENU_BAR_SELECT_SERVER_CLICKED = 'menu-bar/select-server-clicked'; @@ -33,6 +32,8 @@ export const MENU_BAR_TOGGLE_IS_DEVELOPER_MODE_ENABLED_CLICKED = 'menu-bar/toggle-is-developer-mode-enabled-clicked'; export const MENU_BAR_TOGGLE_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CLICKED = 'menu-bar/toggle-is-video-call-devtools-auto-open-enabled-clicked'; +export const SET_PRESENCE_DISCONNECTION_SIMULATED = + 'menu-bar/set-presence-disconnection-simulated'; export const MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED = 'menu-bar/set-navigation-layout-clicked'; export const ROOT_WINDOW_ICON_CHANGED = 'root-window/icon-changed'; @@ -76,6 +77,7 @@ export const WEBVIEW_PAGE_TITLE_CHANGED = 'webview/page-title-changed'; export const WEBVIEW_UNREAD_CHANGED = 'webview/unread-changed'; export const WEBVIEW_USER_LOGGED_IN = 'webview/user-loggedin'; export const WEBVIEW_USER_ROLES_CHANGED = 'webview/user-roles-changed'; +export const WEBVIEW_USER_PRESENCE_CHANGED = 'webview/user-presence-changed'; export const WEBVIEW_ALLOWED_REDIRECTS_CHANGED = 'webview/allowed-redirects-changed'; export const SETTINGS_SET_REPORT_OPT_IN_CHANGED = @@ -102,6 +104,13 @@ export const SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED = 'settings/set-is-transparent-window-enabled-changed'; export const SETTINGS_SET_IS_DEVELOPER_MODE_ENABLED_CHANGED = 'settings/set-is-developer-mode-enabled-changed'; +export const LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED = + 'log-viewer-window/open-state-changed'; +export const DOWNLOADS_WINDOW_OPEN_STATE_CHANGED = + 'downloads-window/open-state-changed'; +export const SETTINGS_WINDOW_OPEN_STATE_CHANGED = + 'settings-window/open-state-changed'; +export const SECONDARY_WINDOW_STATE_CHANGED = 'secondary-window/state-changed'; export const SETTINGS_SET_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CHANGED = 'settings/set-is-video-call-devtools-auto-open-enabled-changed'; export const SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED = @@ -184,7 +193,6 @@ export type UiActionTypeToPayloadMap = { [CLEAR_CACHE_DIALOG_DELETE_LOGIN_DATA_CLICKED]: WebContents['id']; [CLEAR_CACHE_DIALOG_KEEP_LOGIN_DATA_CLICKED]: WebContents['id']; [LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED]: { url: Server['url'] }; - [MENU_BAR_ABOUT_CLICKED]: void; [MENU_BAR_ADD_NEW_SERVER_CLICKED]: void; [MENU_BAR_SELECT_SERVER_CLICKED]: Server['url']; [MENU_BAR_TOGGLE_IS_MENU_BAR_ENABLED_CLICKED]: boolean; @@ -193,6 +201,7 @@ export type UiActionTypeToPayloadMap = { [MENU_BAR_TOGGLE_IS_TRAY_ICON_ENABLED_CLICKED]: boolean; [MENU_BAR_TOGGLE_IS_DEVELOPER_MODE_ENABLED_CLICKED]: boolean; [MENU_BAR_TOGGLE_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CLICKED]: boolean; + [SET_PRESENCE_DISCONNECTION_SIMULATED]: boolean; [MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED]: NavigationLayout; [ROOT_WINDOW_ICON_CHANGED]: RootWindowIcon | null; [ROOT_WINDOW_STATE_CHANGED]: WindowState; @@ -249,6 +258,13 @@ export type UiActionTypeToPayloadMap = { url: Server['url']; userRoles: Server['userRoles']; }; + [WEBVIEW_USER_PRESENCE_CHANGED]: { + url: Server['url']; + presence: Server['presence']; + presenceStatusText: Server['presenceStatusText']; + presenceConnection: Server['presenceConnection']; + presenceSupported: Server['presenceSupported']; + }; [WEBVIEW_GIT_COMMIT_HASH_CHECK]: { url: Server['url']; gitCommitHash: Server['gitCommitHash']; @@ -272,6 +288,13 @@ export type UiActionTypeToPayloadMap = { [SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED]: boolean; + [LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED]: boolean; + [DOWNLOADS_WINDOW_OPEN_STATE_CHANGED]: boolean; + [SETTINGS_WINDOW_OPEN_STATE_CHANGED]: boolean; + [SECONDARY_WINDOW_STATE_CHANGED]: { + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }; [SETTINGS_SET_DOWNLOADS_PERCENTAGE_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_DEVELOPER_MODE_ENABLED_CHANGED]: boolean; [SETTINGS_SET_IS_VIDEO_CALL_DEVTOOLS_AUTO_OPEN_ENABLED_CHANGED]: boolean; diff --git a/src/ui/components/AboutDialog/index.tsx b/src/ui/components/AboutDialog/index.tsx deleted file mode 100644 index a2256d446a..0000000000 --- a/src/ui/components/AboutDialog/index.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { css } from '@rocket.chat/css-in-js'; -import { - Box, - Button, - Field, - FieldLabel, - FieldRow, - Icon, - Margins, - Throbber, - ToggleSwitch, -} from '@rocket.chat/fuselage'; -import { useAutoFocus } from '@rocket.chat/fuselage-hooks'; -import type { ChangeEvent } from 'react'; -import React, { useState, useEffect, useId } from 'react'; -import { useTranslation, Trans } from 'react-i18next'; -import { useDispatch, useSelector } from 'react-redux'; -import type { Dispatch } from 'redux'; - -import { packageJsonInformation } from '../../../app/main/app'; -import type { RootAction } from '../../../store/actions'; -import type { RootState } from '../../../store/rootReducer'; -import { - UPDATES_CHECK_FOR_UPDATES_REQUESTED, - UPDATES_PANEL_TOGGLED, -} from '../../../updates/actions'; -import { UPDATE_CHANNELS } from '../../../updates/common'; -import { - ABOUT_DIALOG_TOGGLE_UPDATE_ON_START, - ABOUT_DIALOG_DISMISSED, - ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED, -} from '../../actions'; -import { Dialog } from '../Dialog'; -import { RocketChatLogo } from '../RocketChatLogo'; - -const copyright = `© 2016-${new Date().getFullYear()}, ${ - packageJsonInformation.productName -}`; - -/** - * The About dialog renders inside a native `` opened with - * `showModal()` (top layer). Fuselage's modern `Select` portals its dropdown - * to `document.body`, which sits below the top-layer dialog, so it cannot be - * used here (see docs/KNOWN_ISSUES.md). A native ` + + ); +}; diff --git a/src/ui/components/SettingsView/features/VerboseOutlookLogging.tsx b/src/ui/components/SettingsView/features/VerboseOutlookLogging.tsx index c5e6677dff..a6cd4f23d6 100644 --- a/src/ui/components/SettingsView/features/VerboseOutlookLogging.tsx +++ b/src/ui/components/SettingsView/features/VerboseOutlookLogging.tsx @@ -1,10 +1,3 @@ -import { - ToggleSwitch, - Field, - FieldRow, - FieldLabel, - FieldHint, -} from '@rocket.chat/fuselage'; import type { ChangeEvent } from 'react'; import { useCallback, useId } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,6 +7,7 @@ import type { Dispatch } from 'redux'; import type { RootAction } from '../../../../store/actions'; import type { RootState } from '../../../../store/rootReducer'; import { SETTINGS_SET_VERBOSE_OUTLOOK_LOGGING_CHANGED } from '../../../actions'; +import { ToggleField } from './ToggleField'; type VerboseOutlookLoggingProps = { className?: string; @@ -40,22 +34,13 @@ export const VerboseOutlookLogging = (props: VerboseOutlookLoggingProps) => { const isVerboseOutlookLoggingEnabledId = useId(); return ( - - - - {t('settings.options.verboseOutlookLogging.title')} - - - - - - {t('settings.options.verboseOutlookLogging.description')} - - - + ); }; diff --git a/src/ui/components/SettingsView/features/WindowChromeThumbnail.spec.tsx b/src/ui/components/SettingsView/features/WindowChromeThumbnail.spec.tsx new file mode 100644 index 0000000000..119cd99471 --- /dev/null +++ b/src/ui/components/SettingsView/features/WindowChromeThumbnail.spec.tsx @@ -0,0 +1,136 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; + +import { WindowChromeThumbnail } from './WindowChromeThumbnail'; + +const MAC_CLOSE_BUTTON = '#ff5f57'; +const DARK_CHROME = '#3c3f46'; +const LIGHT_CHROME = '#dfe3e8'; + +const draw = ( + props: Partial> = {} +) => + render( + + ).container; + +describe('WindowChromeThumbnail', () => { + it('draws a decorative svg, hidden from assistive tech', () => { + const svg = draw().querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + expect(svg).toHaveAttribute('focusable', 'false'); + }); + + describe('window controls', () => { + it('puts traffic lights on macOS', () => { + const container = draw({ platform: 'darwin' }); + expect( + container.querySelector(`[fill="${MAC_CLOSE_BUTTON}"]`) + ).toBeInTheDocument(); + }); + + it('puts minimise, maximise and close on Windows instead', () => { + const container = draw({ platform: 'win32' }); + expect( + container.querySelector(`[fill="${MAC_CLOSE_BUTTON}"]`) + ).not.toBeInTheDocument(); + // The minimise stroke starts at the trailing edge of the strip. + expect(container.querySelector('path[d^="M130"]')).toBeInTheDocument(); + }); + + it('draws none on Linux, which keeps its native title bar', () => { + const container = draw({ platform: 'linux' }); + expect( + container.querySelector(`[fill="${MAC_CLOSE_BUTTON}"]`) + ).not.toBeInTheDocument(); + expect( + container.querySelector('path[d^="M130"]') + ).not.toBeInTheDocument(); + }); + }); + + describe('theme', () => { + it('uses the dark palette for dark', () => { + const container = draw({ theme: 'dark' }); + expect( + container.querySelector(`[fill="${DARK_CHROME}"]`) + ).toBeInTheDocument(); + expect( + container.querySelector(`[fill="${LIGHT_CHROME}"]`) + ).not.toBeInTheDocument(); + }); + + it('uses the light palette for light', () => { + const container = draw({ theme: 'light' }); + expect( + container.querySelector(`[fill="${LIGHT_CHROME}"]`) + ).toBeInTheDocument(); + expect( + container.querySelector(`[fill="${DARK_CHROME}"]`) + ).not.toBeInTheDocument(); + }); + + it('draws both palettes behind a diagonal clip for auto', () => { + const container = draw({ theme: 'auto' }); + expect( + container.querySelector(`[fill="${DARK_CHROME}"]`) + ).toBeInTheDocument(); + expect( + container.querySelector(`[fill="${LIGHT_CHROME}"]`) + ).toBeInTheDocument(); + expect(container.querySelector('clipPath')).toBeInTheDocument(); + }); + + it('scopes the clip id so two thumbnails cannot collide', () => { + const first = draw({ theme: 'auto', idPrefix: 'one' }); + const second = draw({ theme: 'auto', idPrefix: 'two' }); + expect(first.querySelector('clipPath')).toHaveAttribute( + 'id', + 'one-auto-clip' + ); + expect(second.querySelector('clipPath')).toHaveAttribute( + 'id', + 'two-auto-clip' + ); + }); + }); + + describe('layout', () => { + it('draws a workspace column for sidebar', () => { + const container = draw({ layout: 'sidebar' }); + expect(container.querySelectorAll('rect[rx="3.6"]')).toHaveLength(3); + }); + + it('draws no workspace column for tabs or hidden', () => { + expect( + draw({ layout: 'tabs' }).querySelectorAll('rect[rx="3.6"]') + ).toHaveLength(0); + expect( + draw({ layout: 'hidden' }).querySelectorAll('rect[rx="3.6"]') + ).toHaveLength(0); + }); + + it('draws the switcher chevron only for hidden', () => { + expect( + draw({ layout: 'hidden' }).querySelectorAll('rect[rx="2.2"]') + ).toHaveLength(1); + expect( + draw({ layout: 'tabs' }).querySelectorAll('rect[rx="2.2"]') + ).toHaveLength(0); + }); + + it('rounds the window less on Windows than on macOS', () => { + const mac = draw({ platform: 'darwin' }).querySelector('rect'); + const win = draw({ platform: 'win32' }).querySelector('rect'); + expect(mac).toHaveAttribute('rx', '11'); + expect(win).toHaveAttribute('rx', '6'); + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/WindowChromeThumbnail.tsx b/src/ui/components/SettingsView/features/WindowChromeThumbnail.tsx new file mode 100644 index 0000000000..e7190161d2 --- /dev/null +++ b/src/ui/components/SettingsView/features/WindowChromeThumbnail.tsx @@ -0,0 +1,338 @@ +import type { ReactNode } from 'react'; + +import type { NavigationLayout } from '../../../common'; +import { THUMBNAIL_HEIGHT, THUMBNAIL_WIDTH } from './thumbnailMetrics'; + +export type ThumbnailTheme = 'light' | 'dark' | 'auto'; +export type ThumbnailPlatform = 'darwin' | 'win32' | 'linux'; + +const WIDTH = THUMBNAIL_WIDTH; +const HEIGHT = THUMBNAIL_HEIGHT; + +/** + * Literal colours rather than palette tokens — deliberately. + * + * These thumbnails are the one place in the app that must NOT follow the current + * theme: the Light option has to look light while the user is sitting in dark + * mode. Tokens would render all three identically. + */ +type ThumbnailPalette = { + chrome: string; + content: string; + edge: string; + active: string; + muted: string; + item: string; + faint: string; +}; + +const PALETTES: Record<'light' | 'dark', ThumbnailPalette> = { + light: { + chrome: '#dfe3e8', + content: '#ffffff', + edge: 'rgba(0, 0, 0, 0.13)', + active: '#ffffff', + muted: '#8a9099', + item: '#b3b9c0', + faint: 'rgba(0, 0, 0, 0.16)', + }, + dark: { + chrome: '#3c3f46', + content: '#23262d', + edge: 'rgba(255, 255, 255, 0.09)', + active: '#4d5158', + muted: '#9ea3aa', + item: '#71777f', + faint: 'rgba(255, 255, 255, 0.18)', + }, +}; + +/** macOS rounds its windows far more than Windows does. */ +const getRadii = (platform: ThumbnailPlatform) => + platform === 'darwin' ? { window: 11, card: 8 } : { window: 6, card: 5 }; + +const STRIP_HEIGHT: Record = { + tabs: 27, + sidebar: 23, + hidden: 23, +}; + +const bar = ( + key: string, + x: number, + y: number, + width: number, + fill: string +): ReactNode => ( + +); + +const plus = (cx: number, cy: number, stroke: string): ReactNode => ( + +); + +/** Three dots, horizontal for the overflow menu and vertical for the kebab. */ +const dotTriplet = ( + key: string, + cx: number, + cy: number, + fill: string, + vertical: boolean +): ReactNode => ( + + {[-1, 0, 1].map((offset) => ( + + ))} + +); + +/** + * macOS draws traffic lights at the leading edge; Windows draws minimise, + * maximise and close at the trailing edge. Linux keeps its native title bar, so + * the app draws no controls at all. + */ +const windowControls = ( + platform: ThumbnailPlatform, + cy: number, + palette: ThumbnailPalette +): ReactNode => { + if (platform === 'darwin') { + return ( + + + + + + ); + } + + if (platform === 'linux') { + return null; + } + + return ( + + + + + + ); +}; + +const contentCard = ( + platform: ThumbnailPlatform, + palette: ThumbnailPalette, + x: number, + top: number +): ReactNode => ( + +); + +const drawLayout = ( + layout: NavigationLayout, + platform: ThumbnailPlatform, + palette: ThumbnailPalette +): ReactNode => { + const strip = STRIP_HEIGHT[layout]; + const cy = strip / 2; + const isMac = platform === 'darwin'; + const parts: ReactNode[] = [ + , + windowControls(platform, cy, palette), + ]; + + if (layout === 'tabs') { + // Windows and Linux put the overflow menu at the leading edge, where macOS + // has its traffic lights. + const tabsStart = isMac ? 34 : 24; + if (!isMac) { + parts.push(dotTriplet('menu', 11, cy, palette.muted, false)); + } + parts.push( + , + bar('active-title', tabsStart + 6, 12.5, 33, palette.muted), + , + bar('next-title', tabsStart + 57, 12.5, isMac ? 32 : 24, palette.item), + plus(tabsStart + (isMac ? 100 : 90), 13.5, palette.muted) + ); + parts.push(contentCard(platform, palette, 4, strip + 1)); + return parts; + } + + // macOS centres the window title; Windows and Linux align it to the leading + // edge, after the overflow menu. + if (layout === 'sidebar') { + parts.push( + isMac + ? bar('title', 58, cy - 1.2, 52, palette.muted) + : bar('title', 10, cy - 1.2, 56, palette.muted) + ); + [29, 46, 63].forEach((y, index) => { + const isCurrent = index === 0; + parts.push( + + ); + if (isCurrent) { + parts.push( + + ); + } + }); + parts.push(plus(13, 82, palette.muted)); + // For this layout the overflow menu lives in the workspace column. + parts.push(dotTriplet('menu', 13, 99, palette.muted, true)); + parts.push(contentCard(platform, palette, 26, strip + 1)); + return parts; + } + + // 'hidden': the workspace switcher sits in the title bar next to the title. + if (!isMac) { + parts.push(dotTriplet('menu', 11, cy, palette.muted, false)); + } + const titleX = isMac ? 51 : 62; + const chevronX = titleX + (isMac ? 57 : 47); + parts.push( + bar('title', titleX, cy - 1.2, isMac ? 54 : 44, palette.muted), + , + , + contentCard(platform, palette, 4, strip + 1) + ); + return parts; +}; + +export type WindowChromeThumbnailProps = { + layout: NavigationLayout; + theme: ThumbnailTheme; + platform?: ThumbnailPlatform; + /** Unique across the page: SVG clip paths are referenced by id. */ + idPrefix: string; +}; + +/** + * Schematic preview of the app window, used by both the navigation layout and + * the appearance pickers — the same drawing, one palette each. + * + * Drawn rather than screenshotted so it themes itself, stays sharp at any DPI, + * needs no asset pipeline and cannot drift out of date as the chrome changes. + */ +export const WindowChromeThumbnail = ({ + layout, + theme, + platform, + idPrefix, +}: WindowChromeThumbnailProps) => { + const resolvedPlatform: ThumbnailPlatform = + platform ?? + (process.platform === 'darwin' || process.platform === 'win32' + ? process.platform + : 'linux'); + const clipId = `${idPrefix}-auto-clip`; + + return ( + + {theme === 'auto' ? ( + <> + {drawLayout(layout, resolvedPlatform, PALETTES.dark)} + + + + + {drawLayout(layout, resolvedPlatform, PALETTES.light)} + + + ) : ( + drawLayout(layout, resolvedPlatform, PALETTES[theme]) + )} + + ); +}; diff --git a/src/ui/components/SettingsView/features/moreSettings.spec.tsx b/src/ui/components/SettingsView/features/moreSettings.spec.tsx new file mode 100644 index 0000000000..36c7538366 --- /dev/null +++ b/src/ui/components/SettingsView/features/moreSettings.spec.tsx @@ -0,0 +1,244 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { Key } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET } from '../../../../app/actions'; +import { + SETTINGS_SET_E2E_PDF_PREVIEW_SIZE_LIMIT_CHANGED, + SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED, + SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED, + SETTINGS_NTLM_CREDENTIALS_CHANGED, + SETTINGS_SELECTED_BROWSER_CHANGED, +} from '../../../actions'; +import { AvailableBrowsers } from './AvailableBrowsers'; +import { E2ePdfPreviewSizeLimit } from './E2ePdfPreviewSizeLimit'; +import { NTLMCredentials } from './NTLMCredentials'; +import { OutlookCalendarSyncInterval } from './OutlookCalendarSyncInterval'; +import { ScreenCaptureFallback } from './ScreenCaptureFallback'; +import { ThemeAppearance } from './ThemeAppearance'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +// Fuselage Select is a custom ARIA dropdown — mock it to a native onChange(e.target.value)} + > + {options.map(([val, label]) => ( + + ))} + + ), + }; +}); + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +const renderWith = (ui: React.ReactElement, state: Record) => { + const store = makeStore(state); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + render({ui}); + return { dispatchSpy }; +}; + +describe('more settings features', () => { + describe('ScreenCaptureFallback', () => { + it('dispatches toggle and disables when forced', () => { + const { dispatchSpy } = renderWith(, { + isVideoCallScreenCaptureFallbackEnabled: false, + screenCaptureFallbackForced: false, + }); + fireEvent.click(screen.getByRole('checkbox')); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_IS_VIDEO_CALL_SCREEN_CAPTURE_FALLBACK_ENABLED_CHANGED, + payload: true, + }); + }); + + it('is disabled and shows forced description when forced', () => { + renderWith(, { + isVideoCallScreenCaptureFallbackEnabled: false, + screenCaptureFallbackForced: true, + }); + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect( + screen.getByText( + 'settings.options.videoCallScreenCaptureFallback.forcedDescription' + ) + ).toBeInTheDocument(); + }); + }); + + describe('E2ePdfPreviewSizeLimit', () => { + it('clamps and dispatches on change', () => { + const { dispatchSpy } = renderWith(, { + e2ePdfPreviewSizeLimit: 10, + }); + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '999' } }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_E2E_PDF_PREVIEW_SIZE_LIMIT_CHANGED, + payload: 500, + }); + }); + + it('ignores non-numeric input', () => { + const { dispatchSpy } = renderWith(, { + e2ePdfPreviewSizeLimit: 10, + }); + fireEvent.change(screen.getByRole('spinbutton'), { + target: { value: 'abc' }, + }); + expect(dispatchSpy).not.toHaveBeenCalled(); + }); + }); + + describe('OutlookCalendarSyncInterval', () => { + it('returns null when overridden', () => { + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('dispatches clamped interval', () => { + const { dispatchSpy } = renderWith(, { + outlookCalendarSyncIntervalOverride: null, + outlookCalendarSyncInterval: 15, + }); + fireEvent.change(screen.getByRole('spinbutton'), { + target: { value: '0' }, + }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_OUTLOOK_CALENDAR_SYNC_INTERVAL_CHANGED, + payload: 1, + }); + }); + }); + + describe('NTLMCredentials', () => { + it('toggles and updates domains on blur', () => { + const { dispatchSpy } = renderWith(, { + isNTLMCredentialsEnabled: true, + allowedNTLMCredentialsDomains: 'old.com', + }); + fireEvent.click(screen.getByRole('checkbox')); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_NTLM_CREDENTIALS_CHANGED, + payload: false, + }); + + const input = screen.getByPlaceholderText( + '*example.com, *foobar.com, *baz' + ); + fireEvent.blur(input, { target: { value: 'new.com' } }); + expect(dispatchSpy).toHaveBeenCalledWith({ + type: APP_ALLOWED_NTLM_CREDENTIALS_DOMAINS_SET, + payload: 'new.com', + }); + }); + }); + + describe('AvailableBrowsers', () => { + it('renders loading placeholder when no browsers', () => { + renderWith(, { + availableBrowsers: [], + selectedBrowser: null, + }); + expect( + screen.getByText('settings.options.availableBrowsers.title') + ).toBeInTheDocument(); + expect( + screen.getByText('settings.options.availableBrowsers.description') + ).toBeInTheDocument(); + expect(screen.getByTestId('available-browsers-select')).toBeDisabled(); + }); + + it('enables select when browsers are available', () => { + renderWith(, { + availableBrowsers: ['Chrome', 'Firefox'], + selectedBrowser: null, + }); + expect( + screen.getByTestId('available-browsers-select') + ).not.toBeDisabled(); + expect( + screen.getByText('settings.options.availableBrowsers.title') + ).toBeInTheDocument(); + }); + + it('dispatches SETTINGS_SELECTED_BROWSER_CHANGED when a browser is selected', () => { + const { dispatchSpy } = renderWith(, { + availableBrowsers: ['Chrome', 'Firefox'], + selectedBrowser: null, + }); + const select = screen.getByTestId('available-browsers-select'); + fireEvent.change(select, { target: { value: 'Firefox' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SELECTED_BROWSER_CHANGED, + payload: 'Firefox', + }); + }); + + it('dispatches null payload when system default is selected', () => { + const { dispatchSpy } = renderWith(, { + availableBrowsers: ['Chrome', 'Firefox'], + selectedBrowser: 'Chrome', + }); + const select = screen.getByTestId('available-browsers-select'); + fireEvent.change(select, { target: { value: 'system' } }); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SELECTED_BROWSER_CHANGED, + payload: null, + }); + }); + }); + + describe('ThemeAppearance', () => { + it('renders theme preference field', () => { + renderWith(, { userThemePreference: 'auto' }); + expect( + screen.getByText('settings.options.themeAppearance.title') + ).toBeInTheDocument(); + expect( + screen.getByText('settings.options.themeAppearance.description') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/settingsToggles.spec.tsx b/src/ui/components/SettingsView/features/settingsToggles.spec.tsx new file mode 100644 index 0000000000..8c76257606 --- /dev/null +++ b/src/ui/components/SettingsView/features/settingsToggles.spec.tsx @@ -0,0 +1,152 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ComponentType } from 'react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { + SETTINGS_SET_DEBUG_LOGGING_CHANGED, + SETTINGS_SET_DETAILED_EVENTS_LOGGING_CHANGED, + SETTINGS_SET_FLASHFRAME_OPT_IN_CHANGED, + SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED, + SETTINGS_SET_INTERNALVIDEOCHATWINDOW_OPT_IN_CHANGED, + SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED, + SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED, + SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED, + SETTINGS_SET_REPORT_OPT_IN_CHANGED, + SETTINGS_SET_VERBOSE_OUTLOOK_LOGGING_CHANGED, +} from '../../../actions'; +import { DebugLogging } from './DebugLogging'; +import { DetailedEventsLogging } from './DetailedEventsLogging'; +import { FlashFrame } from './FlashFrame'; +import { HardwareAcceleration } from './HardwareAcceleration'; +import { InternalVideoChatWindow } from './InternalVideoChatWindow'; +import { ReportErrors } from './ReportErrors'; +import { TransparentWindow } from './TransparentWindow'; +import { TrayIcon } from './TrayIcon'; +import { VerboseOutlookLogging } from './VerboseOutlookLogging'; +import { VideoCallWindowPersistence } from './VideoCallWindowPersistence'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type ToggleCase = { + name: string; + component: ComponentType; + stateKey: string; + actionType: string; + extraState?: Record; +}; + +const makeStore = (partial: Record) => { + const reducer = (state: Record = partial) => state; + return createStore(reducer as any); +}; + +const renderWithState = ( + component: ComponentType, + state: Record +) => { + const Component = component; + const store = makeStore(state); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const view = render( + + + + ); + return { store, dispatchSpy, ...view }; +}; + +const simpleToggles: ToggleCase[] = [ + { + name: 'DebugLogging', + component: DebugLogging, + stateKey: 'isDebugLoggingEnabled', + actionType: SETTINGS_SET_DEBUG_LOGGING_CHANGED, + }, + { + name: 'DetailedEventsLogging', + component: DetailedEventsLogging, + stateKey: 'isDetailedEventsLoggingEnabled', + actionType: SETTINGS_SET_DETAILED_EVENTS_LOGGING_CHANGED, + }, + { + name: 'HardwareAcceleration', + component: HardwareAcceleration, + stateKey: 'isHardwareAccelerationEnabled', + actionType: SETTINGS_SET_HARDWARE_ACCELERATION_OPT_IN_CHANGED, + }, + { + name: 'ReportErrors', + component: ReportErrors, + stateKey: 'isReportEnabled', + actionType: SETTINGS_SET_REPORT_OPT_IN_CHANGED, + }, + { + name: 'TrayIcon', + component: TrayIcon, + stateKey: 'isTrayIconEnabled', + actionType: SETTINGS_SET_IS_TRAY_ICON_ENABLED_CHANGED, + }, + { + name: 'TransparentWindow', + component: TransparentWindow, + stateKey: 'isTransparentWindowEnabled', + actionType: SETTINGS_SET_IS_TRANSPARENT_WINDOW_ENABLED_CHANGED, + }, + { + name: 'VerboseOutlookLogging', + component: VerboseOutlookLogging, + stateKey: 'isVerboseOutlookLoggingEnabled', + actionType: SETTINGS_SET_VERBOSE_OUTLOOK_LOGGING_CHANGED, + }, + { + name: 'VideoCallWindowPersistence', + component: VideoCallWindowPersistence, + stateKey: 'isVideoCallWindowPersistenceEnabled', + actionType: SETTINGS_SET_IS_VIDEO_CALL_WINDOW_PERSISTENCE_ENABLED_CHANGED, + }, + { + name: 'InternalVideoChatWindow', + component: InternalVideoChatWindow, + stateKey: 'isInternalVideoChatWindowEnabled', + actionType: SETTINGS_SET_INTERNALVIDEOCHATWINDOW_OPT_IN_CHANGED, + }, + { + name: 'FlashFrame', + component: FlashFrame, + stateKey: 'isFlashFrameEnabled', + actionType: SETTINGS_SET_FLASHFRAME_OPT_IN_CHANGED, + }, +]; + +describe.each(simpleToggles)( + '$name', + ({ component, stateKey, actionType, extraState = {} }) => { + it('renders unchecked when setting is false', () => { + renderWithState(component, { [stateKey]: false, ...extraState }); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders checked when setting is true', () => { + renderWithState(component, { [stateKey]: true, ...extraState }); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('dispatches change action on toggle', () => { + const { dispatchSpy } = renderWithState(component, { + [stateKey]: false, + ...extraState, + }); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: actionType, + payload: true, + }); + }); + } +); diff --git a/src/ui/components/SettingsView/features/thumbnailMetrics.ts b/src/ui/components/SettingsView/features/thumbnailMetrics.ts new file mode 100644 index 0000000000..7343172240 --- /dev/null +++ b/src/ui/components/SettingsView/features/thumbnailMetrics.ts @@ -0,0 +1,28 @@ +/** + * Size of the window thumbnails used to pick a theme or a workspace switcher + * layout, and the gap between them. + * + * Kept apart from the components that draw them so the settings window can size + * itself around a full row of options without the main process pulling a React + * component into its bundle. + */ +export const THUMBNAIL_WIDTH = 168; +export const THUMBNAIL_HEIGHT = 110; +export const THUMBNAIL_GAP = 20; + +/** The selection ring drawn around each thumbnail, on every side. */ +export const THUMBNAIL_FRAME_PADDING = 3; +export const THUMBNAIL_FRAME_BORDER = 2; + +/** + * An option is wider than the thumbnail it shows: the ring is drawn whether or + * not the option is selected, so the space is always taken. + */ +export const THUMBNAIL_OPTION_WIDTH = + THUMBNAIL_WIDTH + (THUMBNAIL_FRAME_PADDING + THUMBNAIL_FRAME_BORDER) * 2; + +/** Widest option group offered: the three theme choices. */ +export const THUMBNAIL_ROW_LENGTH = 3; + +export const thumbnailRowWidth = (count = THUMBNAIL_ROW_LENGTH): number => + THUMBNAIL_OPTION_WIDTH * count + THUMBNAIL_GAP * (count - 1); diff --git a/src/ui/components/SettingsView/tabs.spec.tsx b/src/ui/components/SettingsView/tabs.spec.tsx new file mode 100644 index 0000000000..e421081ea3 --- /dev/null +++ b/src/ui/components/SettingsView/tabs.spec.tsx @@ -0,0 +1,125 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import { CertificatesTab } from './CertificatesTab'; +import { DeveloperTab } from './DeveloperTab'; +import { GeneralTab } from './GeneralTab'; +import { VoiceVideoTab } from './VoiceVideoTab'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../CertificatesManager', () => ({ + CertificatesManager: () =>
, +})); + +jest.mock('./features/DebugLogging', () => ({ + DebugLogging: () =>
, +})); +jest.mock('./features/DetailedEventsLogging', () => ({ + DetailedEventsLogging: () =>
, +})); +jest.mock('./features/VerboseOutlookLogging', () => ({ + VerboseOutlookLogging: () =>
, +})); + +jest.mock('./features/ThemeAppearance', () => ({ + ThemeAppearance: () =>
, +})); +jest.mock('./features/NavigationLayout', () => ({ + NavigationLayout: () =>
, +})); +jest.mock('./features/TrayIcon', () => ({ + TrayIcon: () =>
, +})); +jest.mock('./features/FlashFrame', () => ({ + FlashFrame: () =>
, +})); +jest.mock('./features/DownloadsPercentage', () => ({ + DownloadsPercentage: () =>
, +})); +jest.mock('./features/AvailableBrowsers', () => ({ + AvailableBrowsers: () =>
, +})); +jest.mock('./features/OutlookCalendarSyncInterval', () => ({ + OutlookCalendarSyncInterval: () =>
, +})); +jest.mock('./features/HardwareAcceleration', () => ({ + HardwareAcceleration: () =>
, +})); +jest.mock('./features/E2ePdfPreviewSizeLimit', () => ({ + E2ePdfPreviewSizeLimit: () =>
, +})); +jest.mock('./features/ReportErrors', () => ({ + ReportErrors: () =>
, +})); +jest.mock('./features/TransparentWindow', () => ({ + TransparentWindow: () =>
, +})); +jest.mock('./features/MinimizeOnClose', () => ({ + MinimizeOnClose: () =>
, +})); +jest.mock('./features/MenuBar', () => ({ + MenuBar: () =>
, +})); +jest.mock('./features/NTLMCredentials', () => ({ + NTLMCredentials: () =>
, +})); + +jest.mock('./features/Telephony', () => ({ + Telephony: () =>
, +})); +jest.mock('./features/TelephonyGlobalShortcut', () => ({ + TelephonyGlobalShortcut: () =>
, +})); +jest.mock('./features/TelephonyServer', () => ({ + TelephonyServer: () =>
, +})); +jest.mock('./features/InternalVideoChatWindow', () => ({ + InternalVideoChatWindow: () =>
, +})); +jest.mock('./features/VideoCallWindowPersistence', () => ({ + VideoCallWindowPersistence: () =>
, +})); +jest.mock('./features/ScreenCaptureFallback', () => ({ + ScreenCaptureFallback: () =>
, +})); +jest.mock('./features/ClearPermittedScreenCaptureServers', () => ({ + ClearPermittedScreenCaptureServers: () =>
, +})); + +describe('Settings tabs', () => { + it('CertificatesTab renders certificates manager', () => { + render(); + expect(screen.getByTestId('certificates-manager')).toBeInTheDocument(); + }); + + it('DeveloperTab renders logging section features', () => { + render(); + expect(screen.getByText('settings.sections.logging')).toBeInTheDocument(); + expect(screen.getByTestId('debug-logging')).toBeInTheDocument(); + expect(screen.getByTestId('verbose-outlook')).toBeInTheDocument(); + expect(screen.getByTestId('detailed-events')).toBeInTheDocument(); + }); + + it('GeneralTab mounts core settings groups', () => { + render(); + expect(screen.getByTestId('nav-layout')).toBeInTheDocument(); + expect(screen.getByTestId('tray')).toBeInTheDocument(); + expect(screen.getByTestId('flash')).toBeInTheDocument(); + expect(screen.getByTestId('browsers')).toBeInTheDocument(); + expect(screen.getByTestId('hw')).toBeInTheDocument(); + expect(screen.getByTestId('report')).toBeInTheDocument(); + }); + + it('VoiceVideoTab mounts telephony and video sections', () => { + render(); + expect(screen.getByText('settings.sections.telephony')).toBeInTheDocument(); + expect( + screen.getByText('settings.sections.videoCalls') + ).toBeInTheDocument(); + expect(screen.getByTestId('telephony')).toBeInTheDocument(); + expect(screen.getByTestId('internal-video')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/Shell/index.spec.tsx b/src/ui/components/Shell/index.spec.tsx index 4a1df130da..c3129a8f73 100644 --- a/src/ui/components/Shell/index.spec.tsx +++ b/src/ui/components/Shell/index.spec.tsx @@ -96,11 +96,6 @@ jest.mock('../TopBar/DownloadsIndicator', () => ({ ), })); -jest.mock('../AboutDialog', () => ({ - __esModule: true, - AboutDialog: () =>
, -})); - jest.mock('../ServerInfoModal', () => ({ __esModule: true, ServerInfoModal: () =>
, @@ -215,7 +210,6 @@ describe('Shell', () => { it('mounts the top-level dialogs', () => { renderWithStore(, { preloadedState: buildState() }); - expect(screen.getByTestId('about-dialog')).toBeInTheDocument(); expect(screen.getByTestId('server-info-modal')).toBeInTheDocument(); expect(screen.getByTestId('supported-version-dialog')).toBeInTheDocument(); expect(screen.getByTestId('clear-cache-dialog')).toBeInTheDocument(); diff --git a/src/ui/components/Shell/index.tsx b/src/ui/components/Shell/index.tsx index 7a8b04e2e7..d16279438d 100644 --- a/src/ui/components/Shell/index.tsx +++ b/src/ui/components/Shell/index.tsx @@ -3,7 +3,6 @@ import { useLayoutEffect } from 'react'; import { useSelector } from 'react-redux'; import type { RootState } from '../../../store/rootReducer'; -import { AboutDialog } from '../AboutDialog'; import { AddServerView } from '../AddServerView'; import { ClearCacheDialog } from '../ClearCacheDialog'; import DownloadsManagerView from '../DownloadsManagerView'; @@ -189,7 +188,6 @@ export const Shell = () => { - diff --git a/src/ui/components/TabBar/styles.tsx b/src/ui/components/TabBar/styles.tsx index 51b00c2e9b..c4210c1d6d 100644 --- a/src/ui/components/TabBar/styles.tsx +++ b/src/ui/components/TabBar/styles.tsx @@ -2,6 +2,8 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { Badge } from '@rocket.chat/fuselage'; +import { TOOLBAR_HEIGHT } from '../../windowChrome/appearance'; + export type TabOrientation = 'horizontal' | 'vertical'; /** @@ -57,7 +59,7 @@ export const Strip = styled.div` padding-top: ${process.platform === 'darwin' ? '2px' : '1px'}; flex: 0 0 auto; width: 100%; - height: ${({ height = '40px' }) => height}; + height: ${({ height = `${TOOLBAR_HEIGHT}px` }) => height}; gap: 3px; -webkit-app-region: drag; user-select: none; diff --git a/src/ui/components/TopBar/DownloadsIndicator.spec.tsx b/src/ui/components/TopBar/DownloadsIndicator.spec.tsx index ec81993dc9..04cf266fda 100644 --- a/src/ui/components/TopBar/DownloadsIndicator.spec.tsx +++ b/src/ui/components/TopBar/DownloadsIndicator.spec.tsx @@ -204,8 +204,10 @@ describe('DownloadsIndicator', () => { expect(parseFloat(style.width)).toBeCloseTo(oneCh * 3, 1); }); - it('renders progress size text for a 0-byte-received download without the ??? sentinel', async () => { - const user = userEvent.setup(); + // fireEvent rather than userEvent, and a text search over the panel rather + // than the whole tree: with the richer row in the panel this test crossed + // Jest's 5s limit on the Windows runner. + it('leaves the size out of a transfer that has not moved any bytes yet', () => { renderWithStore(, { preloadedState: buildState({ 1: { @@ -217,19 +219,11 @@ describe('DownloadsIndicator', () => { }), }); - await user.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole('button')); - const sizeText = screen.getByText(/downloads\.item\.progressSize/, { - exact: false, - }); - expect(sizeText).toBeInTheDocument(); - expect(sizeText.textContent).not.toMatch(/\?\?\?/); - expect(sizeText.textContent).toEqual( - expect.stringContaining('"receivedBytes":0') - ); - expect(sizeText.textContent).toEqual( - expect.stringContaining('"totalBytes":901800') - ); + const panel = screen.getByRole('dialog'); + expect(panel).toHaveTextContent('report.pdf'); + expect(panel).not.toHaveTextContent('downloads.item.progressSize'); }); it('opens the popup listing recent downloads on click', async () => { @@ -255,7 +249,7 @@ describe('DownloadsIndicator', () => { await user.click(screen.getByRole('button')); await user.click( - screen.getByRole('button', { name: 'tabBar.downloads.pause' }) + screen.getByRole('button', { name: 'downloads.item.pause' }) ); expect(invokeMock).toHaveBeenCalledWith('downloads/pause', 1); @@ -327,16 +321,16 @@ describe('DownloadsIndicator', () => { ); const row = screen.getByRole('button', { - name: 'tabBar.downloads.showInFolder', + name: 'downloads.item.showInFolder', }); - expect(row).toHaveAttribute('title', 'tabBar.downloads.showInFolder'); + expect(row).toHaveAttribute('title', 'downloads.item.showInFolder'); await user.click(row); expect(invokeMock).toHaveBeenCalledWith('downloads/show-in-folder', 1); }); - it('shows the pill and canceled status for a cancelled download from this session, without a show-in-folder action', async () => { + it('tags a cancelled download as errored, without a show-in-folder action', async () => { const user = userEvent.setup(); renderWithStore(, { preloadedState: buildState({ @@ -354,10 +348,10 @@ describe('DownloadsIndicator', () => { await user.click(pill); expect(screen.getByText('report.pdf')).toBeInTheDocument(); - expect(screen.getByText('tabBar.downloads.canceled')).toBeInTheDocument(); + expect(screen.getByText('downloads.item.errored')).toBeInTheDocument(); expect( - screen.queryByRole('button', { name: 'tabBar.downloads.showInFolder' }) + screen.queryByRole('button', { name: 'downloads.item.showInFolder' }) ).not.toBeInTheDocument(); await user.click(screen.getByText('report.pdf')); @@ -365,7 +359,7 @@ describe('DownloadsIndicator', () => { expect(invokeMock).not.toHaveBeenCalledWith('downloads/show-in-folder', 1); }); - it('shows the failed status for an interrupted download', async () => { + it('tags an interrupted download as errored', async () => { const user = userEvent.setup(); renderWithStore(, { preloadedState: buildState({ @@ -381,7 +375,7 @@ describe('DownloadsIndicator', () => { screen.getByRole('button', { name: 'tabBar.downloads.title' }) ); - expect(screen.getByText('tabBar.downloads.failed')).toBeInTheDocument(); + expect(screen.getByText('downloads.item.errored')).toBeInTheDocument(); }); it('shows a dismiss button in the open popup', async () => { diff --git a/src/ui/components/TopBar/DownloadsIndicator.tsx b/src/ui/components/TopBar/DownloadsIndicator.tsx index 469220bbfa..dedbfd4633 100644 --- a/src/ui/components/TopBar/DownloadsIndicator.tsx +++ b/src/ui/components/TopBar/DownloadsIndicator.tsx @@ -1,15 +1,15 @@ import styled from '@emotion/styled'; -import { Box, Dropdown, IconButton } from '@rocket.chat/fuselage'; +import { Box, Button, Dropdown, IconButton } from '@rocket.chat/fuselage'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import type { Download } from '../../../downloads/common'; +import { DownloadRow } from '../../../downloadsWindow/DownloadRow'; import { dispatch } from '../../../store'; import type { RootState } from '../../../store/rootReducer'; import { SIDE_BAR_DOWNLOADS_BUTTON_CLICKED } from '../../actions'; import { TabBarButtonWrapper } from '../TabBar/styles'; -import { DownloadsIndicatorItem } from './DownloadsIndicatorItem'; // Captured once at module load, this marks the boundary between downloads // from earlier sessions (ignored, like Chrome's downloads button) and @@ -255,7 +255,7 @@ type DownloadsIndicatorProps = { export const DownloadsIndicator = ({ compact = false, }: DownloadsIndicatorProps = {}) => { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); const downloads = useSelector(({ downloads }: RootState) => downloads); const isDownloadsPercentageEnabled = useSelector( @@ -480,16 +480,15 @@ export const DownloadsIndicator = ({ aria-label={t('tabBar.downloads.title')} display='flex' flexDirection='column' - paddingInline='x12' - paddingBlock='x12' - width='x320' + width='x440' style={{ WebkitAppRegion: 'no-drag' } as never} > {t('tabBar.downloads.title')} {recentDownloads.map((download) => ( - ))} - {t('tabBar.downloads.showAll')} + diff --git a/src/ui/components/TopBar/DownloadsIndicatorItem.tsx b/src/ui/components/TopBar/DownloadsIndicatorItem.tsx deleted file mode 100644 index 9166ee1813..0000000000 --- a/src/ui/components/TopBar/DownloadsIndicatorItem.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import { Box, Icon, ProgressBar } from '@rocket.chat/fuselage'; -import { useMemo } from 'react'; -import type { useTranslation } from 'react-i18next'; - -import type { Download } from '../../../downloads/common'; -import { invoke } from '../../../ipc/renderer'; - -type DownloadsIndicatorItemProps = { - download: Download; - t: ReturnType['t']; - i18n: ReturnType['i18n']; -}; - -export const DownloadsIndicatorItem = ({ - download, - t, - i18n, -}: DownloadsIndicatorItemProps) => { - const { itemId, state, fileName, receivedBytes, totalBytes } = download; - - const isTerminalError = - state === 'cancelled' || state === 'interrupted' || state === 'expired'; - - const percentage = useMemo( - () => - totalBytes > 0 - ? Math.min( - 100, - Math.max(0, Math.floor((receivedBytes / totalBytes) * 100)) - ) - : 0, - [receivedBytes, totalBytes] - ); - - const statusLabel = useMemo(() => { - if (state === 'cancelled') { - return t('tabBar.downloads.canceled'); - } - - if (state === 'interrupted' || state === 'expired') { - return t('tabBar.downloads.failed'); - } - - return undefined; - }, [state, t]); - - const sizeLabel = useMemo(() => { - if (isTerminalError) { - return undefined; - } - - if (state === 'completed') { - return i18n.format(totalBytes, 'byteSize'); - } - - if (!totalBytes) { - return undefined; - } - - return t('downloads.item.progressSize', { - receivedBytes, - totalBytes, - ratio: totalBytes > 0 ? receivedBytes / totalBytes : 0, - }); - }, [i18n, isTerminalError, receivedBytes, state, t, totalBytes]); - - const handlePause = (): void => { - invoke('downloads/pause', itemId); - }; - - const handleResume = (): void => { - invoke('downloads/resume', itemId); - }; - - const handleCancel = (): void => { - invoke('downloads/cancel', itemId); - }; - - const handleShowInFolder = (): void => { - if (state === 'completed') { - invoke('downloads/show-in-folder', itemId); - } - }; - - return ( - - - - {fileName} - - {state === 'progressing' && ( - { - event.stopPropagation(); - handlePause(); - }} - > - {t('tabBar.downloads.pause')} - - )} - {state === 'paused' && ( - { - event.stopPropagation(); - handleResume(); - }} - > - {t('tabBar.downloads.resume')} - - )} - {(state === 'progressing' || state === 'paused') && ( - { - event.stopPropagation(); - handleCancel(); - }} - > - {t('tabBar.downloads.cancel')} - - )} - - {(state === 'progressing' || state === 'paused') && ( - - - - )} - {statusLabel && ( - - - {statusLabel} - - )} - {sizeLabel && ( - - {sizeLabel} - - )} - - ); -}; - -export default DownloadsIndicatorItem; diff --git a/src/ui/components/TopBar/UpdateLabel.spec.tsx b/src/ui/components/TopBar/UpdateLabel.spec.tsx index d272db73a0..54929d81eb 100644 --- a/src/ui/components/TopBar/UpdateLabel.spec.tsx +++ b/src/ui/components/TopBar/UpdateLabel.spec.tsx @@ -2,6 +2,7 @@ import { UPDATES_CHECK_FEEDBACK_DISMISSED, UPDATES_DOWNLOAD_REQUESTED, UPDATES_INSTALL_REQUESTED, + UPDATES_OPEN_STORE_PAGE_REQUESTED, UPDATES_PANEL_TOGGLED, UPDATES_SKIP_REQUESTED, } from '../../../updates/actions'; @@ -123,6 +124,30 @@ describe('UpdateLabel', () => { }); }); + it.each([ + ['mas', 'dialog.update.openStore.mas'], + ['windows', 'dialog.update.openStore.windows'], + ['snap', 'dialog.update.openStore.snap'], + ['flatpak', 'dialog.update.openStore.flatpak'], + ] as const)( + 'opens the store page instead of downloading on a %s build', + async (store, labelKey) => { + const user = userEvent.setup(); + renderWithStore(, { + preloadedState: openState({ updateStore: store }), + }); + + await user.click(screen.getByText(labelKey)); + + expect(mockDispatch).toHaveBeenCalledWith({ + type: UPDATES_OPEN_STORE_PAGE_REQUESTED, + }); + expect(mockDispatch).not.toHaveBeenCalledWith({ + type: UPDATES_DOWNLOAD_REQUESTED, + }); + } + ); + it('skips the version from the panel skip action', async () => { const user = userEvent.setup(); renderWithStore(, { preloadedState: openState() }); @@ -353,6 +378,22 @@ describe('UpdateLabel', () => { expect(screen.queryByRole('button')).not.toBeInTheDocument(); }); + it.each(['mas', 'windows', 'snap', 'flatpak'] as const)( + 'still shows checking feedback for a %s build despite isUpdatingAllowed being false', + (store) => { + renderWithStore(, { + preloadedState: checkState('checking', { + isUpdatingAllowed: false, + updateStore: store, + }), + }); + + expect(screen.getByRole('button')).toHaveTextContent( + 'tabBar.update.checking' + ); + } + ); + it('prefers the available-update pill over check feedback', () => { renderWithStore(, { preloadedState: buildState({ updateCheckStatus: 'checking' }), diff --git a/src/ui/components/TopBar/UpdateLabel.tsx b/src/ui/components/TopBar/UpdateLabel.tsx index eec54cf549..bad21df9b8 100644 --- a/src/ui/components/TopBar/UpdateLabel.tsx +++ b/src/ui/components/TopBar/UpdateLabel.tsx @@ -17,9 +17,19 @@ import { UPDATES_CHECK_FEEDBACK_DISMISSED, UPDATES_DOWNLOAD_REQUESTED, UPDATES_INSTALL_REQUESTED, + UPDATES_OPEN_STORE_PAGE_REQUESTED, UPDATES_PANEL_TOGGLED, UPDATES_SKIP_REQUESTED, } from '../../../updates/actions'; +import type { UpdateStore } from '../../../updates/common'; + +/** i18n key for the primary panel action's label, per store. */ +const OPEN_STORE_PAGE_LABEL_KEYS: Record, string> = { + mas: 'dialog.update.openStore.mas', + windows: 'dialog.update.openStore.windows', + snap: 'dialog.update.openStore.snap', + flatpak: 'dialog.update.openStore.flatpak', +}; type LabelVariant = 'primary' | 'success' | 'danger'; @@ -195,6 +205,8 @@ export const UpdateLabel = () => { const isUpdatingEnabled = useSelector( ({ isUpdatingEnabled }: RootState) => isUpdatingEnabled ); + const updateStore = useSelector(({ updateStore }: RootState) => updateStore); + const isStoreUpdate = updateStore !== null; const reference = useRef(null); const target = useRef(null); @@ -252,10 +264,10 @@ export const UpdateLabel = () => { if (!newUpdateVersion) { // In builds that cannot self-update the check listener is never // registered, so a requested check would sit at "checking" forever — - // show nothing instead. + // show nothing instead. Store builds have their own check listener + // despite isUpdatingAllowed being false, so they stay exempt. if ( - !isUpdatingAllowed || - !isUpdatingEnabled || + (!isStoreUpdate && (!isUpdatingAllowed || !isUpdatingEnabled)) || updateCheckStatus === 'idle' ) { return null; @@ -337,6 +349,15 @@ export const UpdateLabel = () => { const handleInstallClick = (): void => { toggle(false); + + if (isStoreUpdate) { + // Nothing to download on this build — hand off to the store's page + // via a distinct action, so the download-status reducers (which see + // every FSA) never flip to "downloading". + dispatch({ type: UPDATES_OPEN_STORE_PAGE_REQUESTED }); + return; + } + dispatch({ type: UPDATES_DOWNLOAD_REQUESTED }); }; @@ -433,7 +454,9 @@ export const UpdateLabel = () => { primary onClick={handleInstallClick} > - {t('dialog.update.install')} + {updateStore + ? t(OPEN_STORE_PAGE_LABEL_KEYS[updateStore]) + : t('dialog.update.install')} diff --git a/src/ui/components/TopBar/index.spec.tsx b/src/ui/components/TopBar/index.spec.tsx new file mode 100644 index 0000000000..74e7b51678 --- /dev/null +++ b/src/ui/components/TopBar/index.spec.tsx @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { TopBar } from './index'; + +const renderTopBar = (state: any) => { + const store = createStore(() => state); + return render( + + + + ); +}; + +describe('TopBar', () => { + it('renders main window title', () => { + renderTopBar({ + mainWindowTitle: 'Rocket.Chat Desktop', + isTransparentWindowEnabled: false, + }); + expect(screen.getByText('Rocket.Chat Desktop')).toBeInTheDocument(); + }); + + it('renders with transparent window enabled', () => { + renderTopBar({ + mainWindowTitle: 'Title', + isTransparentWindowEnabled: true, + }); + expect(screen.getByText('Title')).toBeInTheDocument(); + }); + + it('omits the tint background on darwin when transparent window is enabled', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + + try { + renderTopBar({ + mainWindowTitle: 'Title', + isTransparentWindowEnabled: true, + }); + const title = screen.getByText('Title'); + const sidebar = title.closest('.rcx-sidebar--main') as HTMLElement; + expect(getComputedStyle(sidebar).backgroundColor).toBe( + 'rgba(0, 0, 0, 0)' + ); + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + } + }); +}); diff --git a/src/ui/components/utils/ErrorCatcher.spec.tsx b/src/ui/components/utils/ErrorCatcher.spec.tsx new file mode 100644 index 0000000000..43170dc749 --- /dev/null +++ b/src/ui/components/utils/ErrorCatcher.spec.tsx @@ -0,0 +1,26 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import { ErrorCatcher } from './ErrorCatcher'; + +const dispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (...args: any[]) => dispatch(...args), +})); + +describe('ErrorCatcher', () => { + it('renders children when no error', () => { + render( + +
ok
+
+ ); + expect(screen.getByText('ok')).toBeInTheDocument(); + }); + + it('renders null children safely', () => { + const { container } = render(); + expect(container).toBeTruthy(); + }); +}); diff --git a/src/ui/components/utils/TooltipProvider.spec.tsx b/src/ui/components/utils/TooltipProvider.spec.tsx new file mode 100644 index 0000000000..b419106454 --- /dev/null +++ b/src/ui/components/utils/TooltipProvider.spec.tsx @@ -0,0 +1,90 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { useContext, useState } from 'react'; + +import { TooltipContext } from './TooltipContext'; +import TooltipProvider from './TooltipProvider'; + +jest.mock('@rocket.chat/fuselage-hooks', () => { + return { + useDebouncedState: (initial: unknown) => { + const [state, setState] = useState(initial); + const set = Object.assign( + (value: unknown) => { + setState(value); + }, + { flush: () => undefined } + ); + return [state, set]; + }, + useMediaQuery: () => true, + }; +}); + +jest.mock('./TooltipPortal', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock('./TooltipComponent', () => ({ + TooltipComponent: ({ title }: { title: React.ReactNode }) => ( +
{title}
+ ), +})); + +const Probe = () => { + const ctx = useContext(TooltipContext); + return ( + + ); +}; + +describe('TooltipProvider', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('opens tooltip content via context', () => { + render( + + + + ); + fireEvent.click(screen.getByText('hover-me')); + expect(screen.getByTestId('tooltip-portal')).toBeInTheDocument(); + expect(screen.getByText('Hello tip')).toBeInTheDocument(); + }); + + it('closes tooltip via context close', () => { + render( + + + + ); + fireEvent.click(screen.getByText('hover-me')); + expect(screen.getByText('Hello tip')).toBeInTheDocument(); + + fireEvent.doubleClick(screen.getByText('hover-me')); + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(screen.queryByTestId('tooltip-portal')).not.toBeInTheDocument(); + expect(screen.queryByText('Hello tip')).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/utils/createAnchor.renderer.spec.ts b/src/ui/components/utils/createAnchor.renderer.spec.ts new file mode 100644 index 0000000000..cddfa4b5ae --- /dev/null +++ b/src/ui/components/utils/createAnchor.renderer.spec.ts @@ -0,0 +1,29 @@ +import { createAnchor } from './createAnchor'; +import { deleteAnchor } from './deleteAnchor'; + +describe('createAnchor / deleteAnchor', () => { + afterEach(() => { + const el = document.getElementById('test-anchor'); + if (el) { + try { + deleteAnchor(el); + } catch { + el.remove(); + } + } + }); + + it('creates an element with id and reuses existing matching tag', () => { + const a = createAnchor('test-anchor', 'div'); + expect(a.id).toBe('test-anchor'); + expect(a.tagName.toLowerCase()).toBe('div'); + const again = createAnchor('test-anchor', 'div'); + expect(again).toBe(a); + }); + + it('deleteAnchor removes registered element', () => { + const a = createAnchor('test-anchor', 'div'); + deleteAnchor(a); + expect(document.getElementById('test-anchor')).toBeNull(); + }); +}); diff --git a/src/ui/components/utils/getServerDomId.spec.ts b/src/ui/components/utils/getServerDomId.spec.ts new file mode 100644 index 0000000000..a20485f53f --- /dev/null +++ b/src/ui/components/utils/getServerDomId.spec.ts @@ -0,0 +1,22 @@ +import { getServerPanelId, getServerTabId } from './getServerDomId'; + +describe('getServerDomId', () => { + it('builds stable tab and panel ids for the same url', () => { + const url = 'https://open.rocket.chat'; + const tabId = getServerTabId(url); + const panelId = getServerPanelId(url); + + expect(tabId).toMatch(/^workspace-tab-/); + expect(panelId).toMatch(/^workspace-panel-/); + expect(tabId).toBe(getServerTabId(url)); + expect(panelId).toBe(getServerPanelId(url)); + }); + + it('sanitizes non-alphanumeric characters and differentiates urls', () => { + const a = getServerTabId('https://a.example/path'); + const b = getServerTabId('https://b.example/path'); + + expect(a).not.toBe(b); + expect(a).toMatch(/^workspace-tab-[A-Za-z0-9-]+$/); + }); +}); diff --git a/src/ui/components/utils/getServerInitials.spec.ts b/src/ui/components/utils/getServerInitials.spec.ts new file mode 100644 index 0000000000..fb3a371cf8 --- /dev/null +++ b/src/ui/components/utils/getServerInitials.spec.ts @@ -0,0 +1,24 @@ +import { getServerInitials } from './getServerInitials'; + +describe('getServerInitials', () => { + it('returns undefined when title is undefined', () => { + expect( + getServerInitials(undefined, 'https://chat.example') + ).toBeUndefined(); + }); + + it('uses hostname when title contains the full URL', () => { + expect( + getServerInitials('https://open.rocket.chat', 'https://open.rocket.chat') + ).toBe('OR'); + }); + + it('takes up to two alphanumeric initials from the title', () => { + expect(getServerInitials('Acme Corp', 'https://chat.example')).toBe('AC'); + expect(getServerInitials('OnlyOne', 'https://chat.example')).toBe('O'); + }); + + it('ignores non-alphanumeric separators', () => { + expect(getServerInitials('Foo-Bar_Baz', 'https://chat.example')).toBe('FB'); + }); +}); diff --git a/src/ui/icons/AppIcon.tsx b/src/ui/icons/AppIcon.tsx index 10fc309e19..550921899a 100644 --- a/src/ui/icons/AppIcon.tsx +++ b/src/ui/icons/AppIcon.tsx @@ -2,53 +2,54 @@ import type { ReactNode } from 'react'; type AppIconProps = { children?: ReactNode; + cutout?: ReactNode; color?: string; }; -const AppIcon = ({ children, color = '#F5455C' }: AppIconProps) => ( +const AppIcon = ({ children, cutout, color = '#F5455C' }: AppIconProps) => ( - + {!!children && ( - <> - - - - {children} - + + + + {children} - - - - - - - - {children} - + + )} + {(!!children || !!cutout) && ( + + + + + + + + {cutout ?? children} - - - + - - - + in='SourceGraphic' + /> + + )} ); diff --git a/src/ui/icons/DisconnectedBadge.tsx b/src/ui/icons/DisconnectedBadge.tsx new file mode 100644 index 0000000000..b75910b3d3 --- /dev/null +++ b/src/ui/icons/DisconnectedBadge.tsx @@ -0,0 +1,41 @@ +type DisconnectedBadgeProps = { + backgroundColor?: string; +}; + +// Occupies the exact same footprint as `Badge`/`PresenceBullet` (viewBox +// `36 33 23 23`, circle centered at 47.5,44.5 with radius 11.5) so the +// disconnected mark sits where the presence dot/notification badge +// normally sits. +// +// A filled amber disc with an exclamation mark cut out of it — not a +// hollow ring, so it stays legible at menu-bar/tray size. The mark is +// drawn with explicit geometry (not the thin Fuselage glyph, which reads +// too faint at tray size): a vertical rounded bar centred at x=47.5, +// 4.4 wide (~19% of the 23-unit diameter) spanning y 38.2-47.2 with fully +// rounded ends (rx=2.2), plus a dot of radius 2.3 (~20% of the diameter) +// centred at (47.5, 51.0). +// +// The cut-out is built via `` (white = visible, black = hidden) so +// it stays transparent — the tray background shows through, matching the +// away/busy presence bullet cut-outs — rather than being painted white, +// which would leave a solid mark instead of a hole. +const DisconnectedBadge = ({ + backgroundColor = '#F38C39', +}: DisconnectedBadgeProps) => ( + + + + + + + + +); + +export default DisconnectedBadge; diff --git a/src/ui/icons/LinuxTrayIcon.tsx b/src/ui/icons/LinuxTrayIcon.tsx index f6daa6a855..521f83b1fd 100644 --- a/src/ui/icons/LinuxTrayIcon.tsx +++ b/src/ui/icons/LinuxTrayIcon.tsx @@ -1,15 +1,32 @@ -import type { Server } from '../../servers/common'; +import type { UserPresence } from '../../servers/common'; import AppIcon from './AppIcon'; -import Badge from './Badge'; +import DisconnectedBadge from './DisconnectedBadge'; +import PresenceBullet from './PresenceBullet'; +import PresenceBulletCutout from './PresenceBulletCutout'; type LinuxTrayIconProps = { - badge?: Server['badge']; + presence?: UserPresence; + disconnected?: boolean; }; -const LinuxTrayIcon = ({ badge }: LinuxTrayIconProps) => { - const color = '#9EA2A8'; +// Linux is status-only, same as macOS/Windows — the unread count lives in +// the tray tooltip instead of the icon. +const LinuxTrayIcon = ({ presence, disconnected }: LinuxTrayIconProps) => { + let overlay; + let cutout; + if (disconnected) { + overlay = ; + cutout = ; + } else if (presence) { + overlay = ; + cutout = ; + } - return {!!badge && }; + return ( + + {overlay} + + ); }; export default LinuxTrayIcon; diff --git a/src/ui/icons/MacOSTrayIcon.tsx b/src/ui/icons/MacOSTrayIcon.tsx index c0c2ec48eb..b5a224e9db 100644 --- a/src/ui/icons/MacOSTrayIcon.tsx +++ b/src/ui/icons/MacOSTrayIcon.tsx @@ -1,23 +1,48 @@ +import type { UserPresence } from '../../servers/common'; import AppIcon from './AppIcon'; -import Badge from './Badge'; +import DisconnectedBadge from './DisconnectedBadge'; +import PresenceBullet from './PresenceBullet'; +import PresenceBulletCutout from './PresenceBulletCutout'; type MacOSTrayIconProps = { - notification?: boolean; + presence?: UserPresence; + disconnected?: boolean; }; -const MacOSTrayIcon = ({ notification }: MacOSTrayIconProps) => ( - - - - {notification && } - - - -); +// The tray shows STATUS only — no unread count is ever baked into the +// artwork, because the macOS menu-bar title already carries the count. There +// is no "unread, presence unknown" fallback asset: when presence is unknown +// the icon is always the default template icon. +// +// `DisconnectedBadge`'s amber fill is outside `invertDarkAchromaticPixels`'s +// inversion window (see macOSTrayGlyph.ts): its saturation is well above +// the colour threshold, so it never gets flipped to white along with the +// black rocket glyph. +const MacOSTrayIcon = ({ presence, disconnected }: MacOSTrayIconProps) => { + let overlay; + let cutout; + if (disconnected) { + overlay = ; + cutout = ; + } else if (presence) { + overlay = ; + cutout = ; + } + + return ( + + + + {overlay} + + + + ); +}; export default MacOSTrayIcon; diff --git a/src/ui/icons/PresenceBullet.tsx b/src/ui/icons/PresenceBullet.tsx new file mode 100644 index 0000000000..5d666f5096 --- /dev/null +++ b/src/ui/icons/PresenceBullet.tsx @@ -0,0 +1,56 @@ +import type { UserPresence } from '../../servers/common'; +import { PRESENCE_COLORS } from './presenceColors'; + +type PresenceBulletProps = { + presence: UserPresence; +}; + +// Occupies the exact same footprint as `Badge` (viewBox `36 33 23 23`, +// circle centered at 47.5,44.5 with radius 11.5) so the presence bullet +// sits where the presence dot/notification badge normally sits. +// +// The away/busy paths are Fuselage's `StatusBullet` glyphs verbatim (10x10 +// viewBox, evenodd fill rule cutting a clock hand / busy bar out of the +// filled circle), scaled 2.3x (23/10) and translated so the source glyph's +// centre (5.13337, 5.13337) lands on (47.5, 44.5). The cut-outs use +// `fillRule=evenodd` so they stay transparent — the tray background shows +// through, matching Fuselage's status bullets. +const AWAY_PATH = + 'M5.13337 9.93325C7.78434 9.93325 9.93338 7.78422 9.93338 5.13325C9.93338 2.48229 7.78434 0.333252 5.13337 0.333252C2.48241 0.333252 0.333374 2.48229 0.333374 5.13325C0.333374 7.78422 2.48241 9.93325 5.13337 9.93325ZM5.80004 2.33325C5.80004 1.96506 5.50156 1.66659 5.13337 1.66659C4.76518 1.66659 4.46671 1.96506 4.46671 2.33325V5.13325V5.45367L4.71691 5.65383L6.71691 7.25383C7.00442 7.48384 7.42395 7.43722 7.65395 7.14972C7.88396 6.86221 7.83735 6.44268 7.54984 6.21267L5.80004 4.81284V2.33325Z'; + +const BUSY_PATH = + 'M5.13337 9.93325C7.78434 9.93325 9.93338 7.78422 9.93338 5.13325C9.93338 2.48229 7.78434 0.333252 5.13337 0.333252C2.48241 0.333252 0.333374 2.48229 0.333374 5.13325C0.333374 7.78422 2.48241 9.93325 5.13337 9.93325ZM3.53338 4.46655C3.16519 4.46655 2.86671 4.76503 2.86671 5.13322C2.86671 5.50141 3.16519 5.79989 3.53338 5.79989H6.73338C7.10157 5.79989 7.40004 5.50141 7.40004 5.13322C7.40004 4.76503 7.10157 4.46655 6.73338 4.46655H3.53338Z'; + +const PresenceBullet = ({ presence }: PresenceBulletProps) => { + const color = PRESENCE_COLORS[presence]; + + return ( + + {presence === 'online' && ( + + )} + {presence === 'offline' && ( + + )} + {(presence === 'away' || presence === 'busy') && ( + + + + )} + + ); +}; + +export default PresenceBullet; diff --git a/src/ui/icons/PresenceBulletCutout.tsx b/src/ui/icons/PresenceBulletCutout.tsx new file mode 100644 index 0000000000..731596b42c --- /dev/null +++ b/src/ui/icons/PresenceBulletCutout.tsx @@ -0,0 +1,13 @@ +// The mask "cut" for a `PresenceBullet` overlay in `AppIcon`. A solid disc +// covering the full badge footprint — not the bullet's own shape — so that +// whatever the bullet does NOT paint (the away/busy cut-outs, the offline +// ring's hollow centre) masks out to the transparent tray background +// instead of letting the rocket glyph show through the hole. Matches +// Fuselage's status bullets, where cut-outs read as background, not glyph. +const PresenceBulletCutout = () => ( + + + +); + +export default PresenceBulletCutout; diff --git a/src/ui/icons/PresenceMenuIcon.tsx b/src/ui/icons/PresenceMenuIcon.tsx new file mode 100644 index 0000000000..71e2bf0d3f --- /dev/null +++ b/src/ui/icons/PresenceMenuIcon.tsx @@ -0,0 +1,52 @@ +import type { UserPresence } from '../../servers/common'; +import { PRESENCE_COLORS } from './presenceColors'; + +type PresenceMenuIconProps = { + presence: UserPresence; +}; + +// Same Fuselage glyphs/colours as `PresenceBullet`, with a viewBox that +// frames the bullet's own footprint (the circle centered at 47.5,44.5 with +// radius 11.5) plus a 1-unit margin on every side (`35 32 25 25` instead of +// the bullet's exact `36 33 23 23` box) so menu-icon renders keep a hairline +// of transparent padding instead of letting the ring/circle's antialiasing +// touch the edge of the rendered square. Tray/menu bar icon slots render +// whatever the viewBox contains at full size, so a viewBox padded to match +// a different UI element would shrink the bullet down to a speck here. +const PresenceMenuIcon = ({ presence }: PresenceMenuIconProps) => { + const color = PRESENCE_COLORS[presence]; + + return ( + + {presence === 'online' && ( + + )} + {presence === 'offline' && ( + + )} + {(presence === 'away' || presence === 'busy') && ( + + + + )} + + ); +}; + +export default PresenceMenuIcon; diff --git a/src/ui/icons/WindowsTrayIcon.tsx b/src/ui/icons/WindowsTrayIcon.tsx index 8a2cd60bc7..81e3380c11 100644 --- a/src/ui/icons/WindowsTrayIcon.tsx +++ b/src/ui/icons/WindowsTrayIcon.tsx @@ -1,15 +1,34 @@ -import type { Server } from '../../servers/common'; +import type { UserPresence } from '../../servers/common'; import AppIcon from './AppIcon'; -import Badge from './Badge'; +import DisconnectedBadge from './DisconnectedBadge'; +import PresenceBullet from './PresenceBullet'; +import PresenceBulletCutout from './PresenceBulletCutout'; type WindowsTrayIconProps = { - badge?: Server['badge']; + presence?: UserPresence; + disconnected?: boolean; }; -const WindowsTrayIcon = ({ badge }: WindowsTrayIconProps) => { - const color = '#9EA2A8'; +// The tray shows STATUS only — no unread count is ever baked into the +// artwork, because the Windows taskbar overlay already carries the count. +// There is no "unread, presence unknown" fallback asset: when presence is +// unknown the icon is always the default icon. +const WindowsTrayIcon = ({ presence, disconnected }: WindowsTrayIconProps) => { + let overlay; + let cutout; + if (disconnected) { + overlay = ; + cutout = ; + } else if (presence) { + overlay = ; + cutout = ; + } - return {!!badge && }; + return ( + + {overlay} + + ); }; export default WindowsTrayIcon; diff --git a/src/ui/icons/presenceColors.ts b/src/ui/icons/presenceColors.ts new file mode 100644 index 0000000000..5ab115d085 --- /dev/null +++ b/src/ui/icons/presenceColors.ts @@ -0,0 +1,8 @@ +import type { UserPresence } from '../../servers/common'; + +export const PRESENCE_COLORS: Record = { + online: '#2DE0A5', + away: '#FFD21F', + busy: '#F5455C', + offline: '#9EA2A8', +}; diff --git a/src/ui/main/__tests__/rootWindowGeometry.main.spec.ts b/src/ui/main/__tests__/rootWindowGeometry.main.spec.ts new file mode 100644 index 0000000000..98e8d87db6 --- /dev/null +++ b/src/ui/main/__tests__/rootWindowGeometry.main.spec.ts @@ -0,0 +1,156 @@ +import { screen } from 'electron'; + +import { + applyRootWindowState, + isInsideSomeScreen, + normalizeNumber, +} from '../rootWindow'; + +jest.mock('electron', () => ({ + app: { + quit: jest.fn(), + addListener: jest.fn(), + name: 'Test', + }, + BrowserWindow: jest.fn(), + nativeImage: { + createEmpty: jest.fn(), + createFromPath: jest.fn(), + }, + nativeTheme: { + shouldUseDarkColors: false, + on: jest.fn(), + }, + screen: { + getPrimaryDisplay: jest.fn(() => ({ + bounds: { x: 0, y: 0, width: 1920, height: 1080 }, + workAreaSize: { width: 1920, height: 1080 }, + })), + getAllDisplays: jest.fn(() => [ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + { bounds: { x: 1920, y: 0, width: 1920, height: 1080 } }, + ]), + }, +})); + +const select = jest.fn(); +jest.mock('../../../store', () => ({ + select: (...args: unknown[]) => select(...args), + watch: jest.fn(() => jest.fn()), + listen: jest.fn(() => jest.fn()), + dispatchLocal: jest.fn(), + dispatch: jest.fn(), +})); + +jest.mock('../../../app/main/dev', () => ({ + setupRootWindowReload: jest.fn(), +})); + +jest.mock('../icons', () => ({ + getTrayIconPath: jest.fn(), + getAppIconPath: jest.fn(), +})); + +describe('rootWindow geometry helpers', () => { + describe('normalizeNumber', () => { + it('returns finite values unchanged', () => { + expect(normalizeNumber(12)).toBe(12); + expect(normalizeNumber(-3.5)).toBe(-3.5); + }); + + it('maps undefined, 0 and NaN to 0', () => { + expect(normalizeNumber(undefined)).toBe(0); + expect(normalizeNumber(0)).toBe(0); + expect(normalizeNumber(Number.NaN)).toBe(0); + }); + }); + + describe('isInsideSomeScreen', () => { + it('returns true when the rectangle overlaps primary display', () => { + expect( + isInsideSomeScreen({ x: 100, y: 100, width: 800, height: 600 }) + ).toBe(true); + }); + + it('returns true when the rectangle sits on a secondary display', () => { + expect( + isInsideSomeScreen({ x: 2000, y: 50, width: 400, height: 300 }) + ).toBe(true); + }); + + it('returns false when the rectangle is completely off all displays', () => { + expect( + isInsideSomeScreen({ x: -5000, y: -5000, width: 100, height: 100 }) + ).toBe(false); + }); + }); + + describe('applyRootWindowState', () => { + const browserWindow = { + setBounds: jest.fn(), + setMinimumSize: jest.fn(), + maximize: jest.fn(), + unmaximize: jest.fn(), + minimize: jest.fn(), + restore: jest.fn(), + setFullScreen: jest.fn(), + show: jest.fn(), + showInactive: jest.fn(), + // applyRootWindowState early-returns when already visible + isVisible: jest.fn(() => false), + isMinimized: jest.fn(() => false), + isMaximized: jest.fn(() => false), + isFullScreen: jest.fn(() => false), + focus: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + browserWindow.isVisible.mockReturnValue(false); + }); + + it('centers on primary display when saved bounds are off-screen', () => { + select.mockImplementation((selector: any) => + selector({ + rootWindowState: { + focused: true, + visible: false, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: -9999, y: -9999, width: 800, height: 600 }, + }, + isTrayIconEnabled: true, + }) + ); + + applyRootWindowState(browserWindow as any); + expect(screen.getPrimaryDisplay).toHaveBeenCalled(); + expect(browserWindow.setBounds).toHaveBeenCalled(); + const bounds = browserWindow.setBounds.mock.calls[0][0]; + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(0); + }); + + it('skips layout when window is already visible', () => { + browserWindow.isVisible.mockReturnValue(true); + select.mockImplementation((selector: any) => + selector({ + rootWindowState: { + focused: true, + visible: true, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: 10, y: 10, width: 800, height: 600 }, + }, + isTrayIconEnabled: true, + }) + ); + applyRootWindowState(browserWindow as any); + expect(browserWindow.setBounds).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/ui/main/dock.main.spec.ts b/src/ui/main/dock.main.spec.ts new file mode 100644 index 0000000000..06c7c45855 --- /dev/null +++ b/src/ui/main/dock.main.spec.ts @@ -0,0 +1,112 @@ +export {}; + +const select = jest.fn(); +const watchCallbacks = new Map(); + +jest.mock('electron', () => ({ + app: { + dock: { + setBadge: jest.fn(), + bounce: jest.fn(), + }, + }, +})); + +jest.mock('../../store', () => { + class Service { + protected watch(selector: unknown, cb: Function) { + watchCallbacks.set(selector, cb); + } + + protected initialize(): void {} + + setUp() { + this.initialize(); + } + } + return { + Service, + select: (...args: unknown[]) => select(...args), + }; +}); + +describe('ui/main/dock', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + jest.resetModules(); + }); + + it('exports a service instance', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + expect(dock).toBeDefined(); + expect(typeof dock.setUp).toBe('function'); + }); + + it('no-ops initialize on non-darwin platforms', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + watchCallbacks.clear(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + expect(() => dock.setUp()).not.toThrow(); + expect(watchCallbacks.size).toBe(0); + }); + + it('registers badge and bounce watches on darwin and drives dock APIs', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + watchCallbacks.clear(); + select.mockImplementation((selector: any) => + selector({ isFlashFrameEnabled: true }) + ); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { app } = require('electron'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + dock.setUp(); + + expect(watchCallbacks.size).toBe(2); + + const [badgeTextCallback, badgeCountCallback] = [ + ...watchCallbacks.values(), + ]; + + badgeTextCallback('3'); + expect(app.dock.setBadge).toHaveBeenCalledWith('3'); + + badgeCountCallback(3, 0); + expect(app.dock.bounce).toHaveBeenCalled(); + }); + + it('does not bounce the dock when flash frame is disabled', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + watchCallbacks.clear(); + select.mockImplementation((selector: any) => + selector({ isFlashFrameEnabled: false }) + ); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { app } = require('electron'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const dock = require('./dock').default; + dock.setUp(); + + const [, badgeCountCallback] = [...watchCallbacks.values()]; + badgeCountCallback(3, 0); + expect(app.dock.bounce).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/main/icons.spec.ts b/src/ui/main/icons.spec.ts index 3417a29e77..a6f29e753d 100644 --- a/src/ui/main/icons.spec.ts +++ b/src/ui/main/icons.spec.ts @@ -33,7 +33,7 @@ describe('getTrayIconPath', () => { 'images', 'tray', 'darwin', - 'notificationTemplate.png' + 'defaultTemplate.png' ) ); expect(getTrayIconPath({ platform: 'darwin', badge: 1 })).toBe( @@ -43,37 +43,7 @@ describe('getTrayIconPath', () => { 'images', 'tray', 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 2 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 3 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 4 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' + 'defaultTemplate.png' ) ); expect(getTrayIconPath({ platform: 'darwin', badge: 5 })).toBe( @@ -83,47 +53,7 @@ describe('getTrayIconPath', () => { 'images', 'tray', 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 6 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 7 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 8 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' - ) - ); - expect(getTrayIconPath({ platform: 'darwin', badge: 9 })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'darwin', - 'notificationTemplate.png' + 'defaultTemplate.png' ) ); expect(getTrayIconPath({ platform: 'darwin', badge: 10 })).toBe( @@ -133,7 +63,7 @@ describe('getTrayIconPath', () => { 'images', 'tray', 'darwin', - 'notificationTemplate.png' + 'defaultTemplate.png' ) ); }); @@ -143,104 +73,301 @@ describe('getTrayIconPath', () => { path.join('/app', 'app', 'images', 'tray', 'win32', 'default.ico') ); expect(getTrayIconPath({ platform: 'win32', badge: '•' })).toBe( - path.join( - '/app', - 'app', - 'images', - 'tray', - 'win32', - 'notification-dot.ico' - ) + path.join('/app', 'app', 'images', 'tray', 'win32', 'default.ico') ); expect(getTrayIconPath({ platform: 'win32', badge: 1 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-1.ico') + path.join('/app', 'app', 'images', 'tray', 'win32', 'default.ico') + ); + expect(getTrayIconPath({ platform: 'win32', badge: 5 })).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'default.ico') ); - expect(getTrayIconPath({ platform: 'win32', badge: 2 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-2.ico') + expect(getTrayIconPath({ platform: 'win32', badge: 10 })).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'default.ico') ); - expect(getTrayIconPath({ platform: 'win32', badge: 3 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-3.ico') + }); + + it('matches path for linux platform (badge ignored — status only)', () => { + expect(getTrayIconPath({ platform: 'linux' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'default.png') + ); + expect(getTrayIconPath({ platform: 'linux', badge: '•' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'default.png') ); - expect(getTrayIconPath({ platform: 'win32', badge: 4 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-4.ico') + expect(getTrayIconPath({ platform: 'linux', badge: 1 })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'default.png') ); - expect(getTrayIconPath({ platform: 'win32', badge: 5 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-5.ico') + expect(getTrayIconPath({ platform: 'linux', badge: 5 })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'default.png') ); - expect(getTrayIconPath({ platform: 'win32', badge: 6 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-6.ico') + expect(getTrayIconPath({ platform: 'linux', badge: 10 })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'default.png') ); - expect(getTrayIconPath({ platform: 'win32', badge: 7 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-7.ico') + }); + + it('returns the legacy paths when presence is undefined (regression guard)', () => { + expect(getTrayIconPath({ platform: 'win32', presence: undefined })).toBe( + getTrayIconPath({ platform: 'win32' }) + ); + expect(getTrayIconPath({ platform: 'linux', presence: undefined })).toBe( + getTrayIconPath({ platform: 'linux' }) + ); + expect(getTrayIconPath({ platform: 'darwin', presence: undefined })).toBe( + getTrayIconPath({ platform: 'darwin' }) + ); + expect( + getTrayIconPath({ platform: 'win32', badge: 3, presence: undefined }) + ).toBe(getTrayIconPath({ platform: 'win32', badge: 3 })); + expect( + getTrayIconPath({ platform: 'linux', badge: 3, presence: undefined }) + ).toBe(getTrayIconPath({ platform: 'linux', badge: 3 })); + }); + + it('matches presence paths for win32 platform', () => { + expect(getTrayIconPath({ platform: 'win32', presence: 'online' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'presence-online.ico') ); - expect(getTrayIconPath({ platform: 'win32', badge: 8 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-8.ico') + expect(getTrayIconPath({ platform: 'win32', presence: 'away' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'presence-away.ico') ); - expect(getTrayIconPath({ platform: 'win32', badge: 9 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'win32', 'notification-9.ico') + expect(getTrayIconPath({ platform: 'win32', presence: 'busy' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'presence-busy.ico') ); - expect(getTrayIconPath({ platform: 'win32', badge: 10 })).toBe( + expect(getTrayIconPath({ platform: 'win32', presence: 'offline' })).toBe( path.join( '/app', 'app', 'images', 'tray', 'win32', - 'notification-plus-9.ico' + 'presence-offline.ico' ) ); }); - it('matches path for linux platform', () => { - expect(getTrayIconPath({ platform: 'linux' })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'default.png') + it('matches presence paths for linux platform', () => { + expect(getTrayIconPath({ platform: 'linux', presence: 'online' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'presence-online.png') ); - expect(getTrayIconPath({ platform: 'linux', badge: '•' })).toBe( + expect(getTrayIconPath({ platform: 'linux', presence: 'away' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'presence-away.png') + ); + expect(getTrayIconPath({ platform: 'linux', presence: 'busy' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'presence-busy.png') + ); + expect(getTrayIconPath({ platform: 'linux', presence: 'offline' })).toBe( path.join( '/app', 'app', 'images', 'tray', 'linux', - 'notification-dot.png' + 'presence-offline.png' ) ); - expect(getTrayIconPath({ platform: 'linux', badge: 1 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-1.png') - ); - expect(getTrayIconPath({ platform: 'linux', badge: 2 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-2.png') - ); - expect(getTrayIconPath({ platform: 'linux', badge: 3 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-3.png') - ); - expect(getTrayIconPath({ platform: 'linux', badge: 4 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-4.png') + }); + + it('ignores badge for win32 platform when presence is known (presence-only tray — taskbar overlay shows the count)', () => { + expect( + getTrayIconPath({ platform: 'win32', presence: 'online', badge: 5 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'presence-online.ico') + ); + expect( + getTrayIconPath({ platform: 'win32', presence: 'busy', badge: 5 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'presence-busy.ico') + ); + expect( + getTrayIconPath({ platform: 'win32', presence: 'away', badge: 10 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'presence-away.ico') ); - expect(getTrayIconPath({ platform: 'linux', badge: 5 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-5.png') + }); + + it('ignores badge for linux platform when presence is known (presence-only tray — tooltip shows the count)', () => { + expect( + getTrayIconPath({ platform: 'linux', presence: 'online', badge: '•' }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'presence-online.png') + ); + expect( + getTrayIconPath({ platform: 'linux', presence: 'busy', badge: 5 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'presence-busy.png') + ); + expect( + getTrayIconPath({ platform: 'linux', presence: 'away', badge: 10 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'presence-away.png') ); - expect(getTrayIconPath({ platform: 'linux', badge: 6 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-6.png') + }); + + it('matches presence paths for darwin platform', () => { + expect(getTrayIconPath({ platform: 'darwin', presence: 'online' })).toBe( + path.join( + '/app', + 'app', + 'images', + 'tray', + 'darwin', + 'presence-online.png' + ) ); - expect(getTrayIconPath({ platform: 'linux', badge: 7 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-7.png') + expect(getTrayIconPath({ platform: 'darwin', presence: 'away' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'presence-away.png') ); - expect(getTrayIconPath({ platform: 'linux', badge: 8 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-8.png') + expect(getTrayIconPath({ platform: 'darwin', presence: 'busy' })).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'presence-busy.png') ); - expect(getTrayIconPath({ platform: 'linux', badge: 9 })).toBe( - path.join('/app', 'app', 'images', 'tray', 'linux', 'notification-9.png') + expect(getTrayIconPath({ platform: 'darwin', presence: 'offline' })).toBe( + path.join( + '/app', + 'app', + 'images', + 'tray', + 'darwin', + 'presence-offline.png' + ) ); - expect(getTrayIconPath({ platform: 'linux', badge: 10 })).toBe( + }); + + it('ignores badge for darwin platform when presence is known (presence-only tray — menu-bar title shows the count)', () => { + expect( + getTrayIconPath({ platform: 'darwin', presence: 'online', badge: 5 }) + ).toBe( path.join( '/app', 'app', 'images', 'tray', - 'linux', - 'notification-plus-9.png' + 'darwin', + 'presence-online.png' ) ); + expect( + getTrayIconPath({ platform: 'darwin', presence: 'busy', badge: '•' }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'presence-busy.png') + ); + expect( + getTrayIconPath({ platform: 'darwin', presence: 'away', badge: 10 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'presence-away.png') + ); + }); + + it('never returns a Template path for darwin when presence is set (colour must survive)', () => { + const presences: Array<'online' | 'away' | 'busy' | 'offline'> = [ + 'online', + 'away', + 'busy', + 'offline', + ]; + + for (const presence of presences) { + expect(getTrayIconPath({ platform: 'darwin', presence })).not.toContain( + 'Template' + ); + expect( + getTrayIconPath({ platform: 'darwin', presence, badge: 3 }) + ).not.toContain('Template'); + } + }); + + describe('disconnected state', () => { + it('matches the disconnected path for win32 platform', () => { + expect(getTrayIconPath({ platform: 'win32', disconnected: true })).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'disconnected.ico') + ); + }); + + it('matches the disconnected path for linux platform', () => { + expect(getTrayIconPath({ platform: 'linux', disconnected: true })).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'disconnected.png') + ); + }); + + it('matches the disconnected path for darwin platform (never a Template asset)', () => { + const trayPath = getTrayIconPath({ + platform: 'darwin', + disconnected: true, + }); + expect(trayPath).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'disconnected.png') + ); + expect(trayPath).not.toContain('Template'); + }); + + it('ignores badge for win32 platform when disconnected (no numeral — taskbar overlay shows the count)', () => { + expect( + getTrayIconPath({ platform: 'win32', disconnected: true, badge: '•' }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'disconnected.ico') + ); + expect( + getTrayIconPath({ platform: 'win32', disconnected: true, badge: 5 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'disconnected.ico') + ); + expect( + getTrayIconPath({ platform: 'win32', disconnected: true, badge: 10 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'disconnected.ico') + ); + }); + + it('ignores badge for linux platform when disconnected (DisconnectedBadge ignores the count)', () => { + expect( + getTrayIconPath({ platform: 'linux', disconnected: true, badge: '•' }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'disconnected.png') + ); + expect( + getTrayIconPath({ platform: 'linux', disconnected: true, badge: 5 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'linux', 'disconnected.png') + ); + }); + + it('ignores badge for darwin platform when disconnected (no numeral — menu-bar title shows the count)', () => { + expect( + getTrayIconPath({ platform: 'darwin', disconnected: true, badge: '•' }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'disconnected.png') + ); + expect( + getTrayIconPath({ platform: 'darwin', disconnected: true, badge: 5 }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'darwin', 'disconnected.png') + ); + }); + + it('ignores a stale presence value when disconnected is true (fault state wins)', () => { + expect( + getTrayIconPath({ + platform: 'win32', + disconnected: true, + presence: 'online', + }) + ).toBe( + path.join('/app', 'app', 'images', 'tray', 'win32', 'disconnected.ico') + ); + }); + + it('falls back to the existing presence/default paths when disconnected is false or omitted (regression guard)', () => { + expect(getTrayIconPath({ platform: 'win32', disconnected: false })).toBe( + getTrayIconPath({ platform: 'win32' }) + ); + expect(getTrayIconPath({ platform: 'linux' })).toBe( + getTrayIconPath({ platform: 'linux', disconnected: undefined }) + ); + expect( + getTrayIconPath({ + platform: 'win32', + presence: 'online', + disconnected: false, + }) + ).toBe(getTrayIconPath({ platform: 'win32', presence: 'online' })); + }); }); }); diff --git a/src/ui/main/icons.ts b/src/ui/main/icons.ts index 19b809d78c..dac50561b2 100644 --- a/src/ui/main/icons.ts +++ b/src/ui/main/icons.ts @@ -2,7 +2,7 @@ import path from 'path'; import { app } from 'electron'; -import type { Server } from '../../servers/common'; +import type { Server, UserPresence } from '../../servers/common'; export const getAppIconPath = ({ platform, @@ -16,46 +16,92 @@ export const getAppIconPath = ({ return `${app.getAppPath()}/app/images/icon.ico`; }; -const getMacOSTrayIconPath = (badge: Server['badge']): string => - path.join( +// All three platforms show STATUS only in the tray icon — the Windows +// taskbar overlay, the macOS menu-bar title, and the Linux tray tooltip +// already show the unread count. There is no "unread + presence unknown" +// fallback asset on any platform: when presence is unknown the icon is +// always the default/disconnected icon, regardless of `badge`, so `badge` +// is ignored entirely. +const getMacOSTrayIconPath = ( + _badge: Server['badge'], + presence: UserPresence | undefined, + disconnected: boolean | undefined +): string => { + if (disconnected) { + return path.join( + app.getAppPath(), + 'app/images/tray/darwin/disconnected.png' + ); + } + + if (!presence) { + return path.join( + app.getAppPath(), + 'app/images/tray/darwin/defaultTemplate.png' + ); + } + + return path.join( app.getAppPath(), - `app/images/tray/darwin/${badge ? 'notification' : 'default'}Template.png` + `app/images/tray/darwin/presence-${presence}.png` ); +}; -const getWindowsTrayIconPath = (badge: Server['badge']): string => { - const name = - (!badge && 'default') || - (badge === '•' && 'notification-dot') || - (typeof badge === 'number' && badge > 9 && 'notification-plus-9') || - `notification-${badge}`; +const getWindowsTrayIconPath = ( + _badge: Server['badge'], + presence: UserPresence | undefined, + disconnected: boolean | undefined +): string => { + if (disconnected) { + return path.join( + app.getAppPath(), + 'app/images/tray/win32/disconnected.ico' + ); + } + + const name = presence ? `presence-${presence}` : 'default'; return path.join(app.getAppPath(), `app/images/tray/win32/${name}.ico`); }; -const getLinuxTrayIconPath = (badge: Server['badge']): string => { - const name = - (!badge && 'default') || - (badge === '•' && 'notification-dot') || - (typeof badge === 'number' && badge > 9 && 'notification-plus-9') || - `notification-${badge}`; +const getLinuxTrayIconPath = ( + _badge: Server['badge'], + presence: UserPresence | undefined, + disconnected: boolean | undefined +): string => { + if (disconnected) { + return path.join( + app.getAppPath(), + 'app/images/tray/linux/disconnected.png' + ); + } + + const name = presence ? `presence-${presence}` : 'default'; return path.join(app.getAppPath(), `app/images/tray/linux/${name}.png`); }; +export const getPresenceMenuIconPath = (presence: UserPresence): string => + path.join(app.getAppPath(), `app/images/presence/${presence}.png`); + export const getTrayIconPath = ({ badge, + presence, + disconnected, platform, }: { badge?: Server['badge']; + presence?: UserPresence; + disconnected?: boolean; platform: NodeJS.Platform; }): string => { switch (platform ?? process.platform) { case 'darwin': - return getMacOSTrayIconPath(badge); + return getMacOSTrayIconPath(badge, presence, disconnected); case 'win32': - return getWindowsTrayIconPath(badge); + return getWindowsTrayIconPath(badge, presence, disconnected); case 'linux': - return getLinuxTrayIconPath(badge); + return getLinuxTrayIconPath(badge, presence, disconnected); default: throw Error(`unsupported platform (${platform})`); diff --git a/src/ui/main/macOSTrayGlyph.main.spec.ts b/src/ui/main/macOSTrayGlyph.main.spec.ts new file mode 100644 index 0000000000..954b1978a8 --- /dev/null +++ b/src/ui/main/macOSTrayGlyph.main.spec.ts @@ -0,0 +1,101 @@ +import { + applyMacOSMenuBarGlyphAppearance, + invertDarkAchromaticPixels, +} from './macOSTrayGlyph'; + +const addRepresentation = jest.fn(); +const createEmpty = jest.fn(() => ({ addRepresentation })); + +jest.mock('electron', () => ({ + nativeImage: { + createEmpty: () => createEmpty(), + }, +})); + +const pixel = (b: number, g: number, r: number, a: number): Buffer => + Buffer.from([b, g, r, a]); + +describe('invertDarkAchromaticPixels', () => { + it('inverts opaque black (the rocket glyph) to white', () => { + expect([...invertDarkAchromaticPixels(pixel(0, 0, 0, 255))]).toEqual([ + 255, 255, 255, 255, + ]); + }); + + it('inverts anti-aliased black edges and keeps coverage alpha', () => { + expect([...invertDarkAchromaticPixels(pixel(0, 0, 0, 115))]).toEqual([ + 255, 255, 255, 115, + ]); + }); + + it('does not invert the online presence green', () => { + const online = pixel(165, 224, 45, 255); + expect([...invertDarkAchromaticPixels(online)]).toEqual([...online]); + }); + + it('does not invert busy red or away yellow', () => { + const busy = pixel(92, 69, 245, 255); + const away = pixel(31, 210, 255, 255); + expect([...invertDarkAchromaticPixels(busy)]).toEqual([...busy]); + expect([...invertDarkAchromaticPixels(away)]).toEqual([...away]); + }); + + it('does not invert the offline mid-grey dot', () => { + const offline = pixel(168, 162, 158, 255); + expect([...invertDarkAchromaticPixels(offline)]).toEqual([...offline]); + }); + + it('does not invert the disconnected-badge amber fill', () => { + const amber = pixel(57, 140, 243, 255); + expect([...invertDarkAchromaticPixels(amber)]).toEqual([...amber]); + }); + + it('leaves fully transparent pixels alone', () => { + expect([...invertDarkAchromaticPixels(pixel(0, 0, 0, 0))]).toEqual([ + 0, 0, 0, 0, + ]); + }); + + it('does not mutate the input buffer', () => { + const input = pixel(0, 0, 0, 255); + invertDarkAchromaticPixels(input); + expect([...input]).toEqual([0, 0, 0, 255]); + }); +}); + +describe('applyMacOSMenuBarGlyphAppearance', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + addRepresentation.mockClear(); + createEmpty.mockClear(); + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('inverts the glyph even when the OS appearance setting is light', () => { + const black = Buffer.from([0, 0, 0, 255]); + const image = { + isEmpty: () => false, + getSize: () => ({ width: 1, height: 1 }), + getScaleFactors: () => [1], + toBitmap: () => black, + }; + + applyMacOSMenuBarGlyphAppearance(image as never); + + expect(addRepresentation).toHaveBeenCalledTimes(1); + expect([...addRepresentation.mock.calls[0][0].buffer]).toEqual([ + 255, 255, 255, 255, + ]); + }); +}); diff --git a/src/ui/main/macOSTrayGlyph.ts b/src/ui/main/macOSTrayGlyph.ts new file mode 100644 index 0000000000..7515a08516 --- /dev/null +++ b/src/ui/main/macOSTrayGlyph.ts @@ -0,0 +1,77 @@ +import { nativeImage, type NativeImage } from 'electron'; + +const CHANNEL_SATURATION_MAX = 30; +const LUMA_MAX = 80; + +const luma = (r: number, g: number, b: number): number => + (r * 299 + g * 587 + b * 114) / 1000; + +/** + * Invert the black rocket glyph to white, without touching the coloured + * presence dot. + * + * Presence assets cannot be `*Template.png`: AppKit would bleach the dot to + * the menu-bar tint as well. They also cannot follow + * `nativeTheme.shouldUseDarkColors`. Liquid Glass keeps status items + * light-tinted even when Appearance is Light; Template icons (and the + * installed build) stay white. Following the Light/Dark *setting* is what + * turned only this glyph black. + * + * `toBitmap()` on darwin is BGRA, straight alpha. Glyph pixels are (0,0,0,a); + * online/away/busy are saturated; offline is mid-grey (~160 luma) so it stays. + */ +export const invertDarkAchromaticPixels = (bgra: Buffer): Buffer => { + const out = Buffer.from(bgra); + + for (let i = 0; i + 3 < out.length; i += 4) { + const b = out[i]; + const g = out[i + 1]; + const r = out[i + 2]; + const a = out[i + 3]; + + if (a === 0) { + continue; + } + + const saturation = Math.max(r, g, b) - Math.min(r, g, b); + if (saturation > CHANNEL_SATURATION_MAX) { + continue; + } + + if (luma(r, g, b) > LUMA_MAX) { + continue; + } + + out[i] = 255 - b; + out[i + 1] = 255 - g; + out[i + 2] = 255 - r; + } + + return out; +}; + +export const applyMacOSMenuBarGlyphAppearance = ( + image: NativeImage +): NativeImage => { + if (process.platform !== 'darwin' || image.isEmpty()) { + return image; + } + + const pointSize = image.getSize(); + const adapted = nativeImage.createEmpty(); + + for (const scaleFactor of image.getScaleFactors()) { + const bitmap = image.toBitmap({ scaleFactor }); + const width = Math.round(pointSize.width * scaleFactor); + const height = Math.round(pointSize.height * scaleFactor); + + adapted.addRepresentation({ + scaleFactor, + width, + height, + buffer: invertDarkAchromaticPixels(bitmap), + }); + } + + return adapted; +}; diff --git a/src/ui/main/menuBar.main.spec.ts b/src/ui/main/menuBar.main.spec.ts new file mode 100644 index 0000000000..8cacc908fd --- /dev/null +++ b/src/ui/main/menuBar.main.spec.ts @@ -0,0 +1,838 @@ +import type { MenuItemConstructorOptions } from 'electron'; + +import { DOWNLOADS_SIMULATION_REQUESTED } from '../../downloads/actions'; +import type { Server } from '../../servers/common'; +import { dispatch } from '../../store'; +import type { RootState } from '../../store/rootReducer'; +import { + UPDATES_CHECK_FOR_UPDATES_REQUESTED, + UPDATES_SIMULATION_REQUESTED, +} from '../../updates/actions'; +import { + MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + SET_PRESENCE_DISCONNECTION_SIMULATED, +} from '../actions'; +import { + getServerContextMenuTemplate, + selectAppMenuPopupTemplate, + selectMenuBarTemplate, + selectMenuBarTemplateAsJson, + selectServerSwitcherMenuTemplate, +} from './menuBar'; +import { getRootWindow } from './rootWindow'; + +jest.mock('electron', () => ({ + app: { + name: 'Rocket.Chat', + quit: jest.fn(), + commandLine: { hasSwitch: jest.fn(() => false) }, + getPath: jest.fn(() => ''), + showAboutPanel: jest.fn(), + }, + shell: { + showItemInFolder: jest.fn(), + openExternal: jest.fn(), + }, + BrowserWindow: { + getAllWindows: jest.fn(() => []), + getFocusedWindow: jest.fn(() => null), + }, + Menu: { + buildFromTemplate: jest.fn((template) => ({ + popup: jest.fn(), + template, + })), + setApplicationMenu: jest.fn(), + }, +})); + +jest.mock('i18next', () => ({ + t: (key: string, opts?: { appName?: string }) => + opts?.appName ? `${key}:${opts.appName}` : key, +})); + +jest.mock('../../app/main/app', () => ({ + relaunchApp: jest.fn(), +})); + +jest.mock('../../utils/browserLauncher', () => ({ + openExternal: jest.fn(), +})); + +jest.mock('../../videoCallWindow/ipc', () => ({ + openVideoCallWebviewDevTools: jest.fn(), +})); + +jest.mock('./dialogs', () => ({ + askForAppDataReset: jest.fn().mockResolvedValue(false), +})); + +const mockBrowserWindow = { + isVisible: jest.fn(() => true), + showInactive: jest.fn(), + show: jest.fn(), + focus: jest.fn(), + hide: jest.fn(), + minimize: jest.fn(), + maximize: jest.fn(), + unmaximize: jest.fn(), + close: jest.fn(), + isFullScreen: jest.fn(() => false), + setFullScreen: jest.fn(), + getNormalBounds: jest.fn(() => ({ x: 0, y: 0, width: 1000, height: 600 })), + getBounds: jest.fn(() => ({ x: 0, y: 0, width: 1000, height: 600 })), + webContents: { + openDevTools: jest.fn(), + toggleDevTools: jest.fn(), + reload: jest.fn(), + reloadIgnoringCache: jest.fn(), + goBack: jest.fn(), + goForward: jest.fn(), + zoomIn: jest.fn(), + zoomOut: jest.fn(), + setZoomLevel: jest.fn(), + getZoomLevel: jest.fn(() => 0), + }, + setMenu: jest.fn(), + setMenuBarVisibility: jest.fn(), + autoHideMenuBar: false, +}; + +jest.mock('./rootWindow', () => ({ + getRootWindow: jest.fn(async () => mockBrowserWindow), +})); + +jest.mock('./serverView', () => ({ + getWebContentsByServerUrl: jest.fn(() => ({ + reload: jest.fn(), + reloadIgnoringCache: jest.fn(), + openDevTools: jest.fn(), + })), +})); + +const createServer = ( + url: string, + title: string, + extras: Partial = {} +): Server => ({ + url, + title, + ...extras, +}); + +const createState = (overrides: Partial = {}): RootState => + ({ + servers: [], + currentView: 'downloads', + isTrayIconEnabled: true, + isMenuBarEnabled: true, + isAddNewServersEnabled: true, + isShowWindowOnUnreadChangedEnabled: false, + isDeveloperModeEnabled: true, + isVideoCallDevtoolsAutoOpenEnabled: false, + isPresenceDisconnectionSimulated: false, + navigationLayout: 'tabs', + rootWindowState: { + focused: true, + visible: true, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: undefined, y: undefined, width: 1000, height: 600 }, + }, + ...overrides, + }) as RootState; + +jest.mock('../../store', () => ({ + dispatch: jest.fn(), + select: jest.fn((selector: (state: any) => unknown) => + selector({ + servers: [], + currentView: 'downloads', + isTrayIconEnabled: true, + isMenuBarEnabled: true, + isAddNewServersEnabled: true, + isShowWindowOnUnreadChangedEnabled: false, + isDeveloperModeEnabled: true, + isVideoCallDevtoolsAutoOpenEnabled: false, + isPresenceDisconnectionSimulated: false, + navigationLayout: 'tabs', + rootWindowState: { + focused: true, + visible: true, + maximized: false, + minimized: false, + fullscreen: false, + normal: true, + bounds: { x: undefined, y: undefined, width: 1000, height: 600 }, + }, + }) + ), + Service: class Service { + protected initialize(): void {} + + setUp(): void { + this.initialize(); + } + }, + watch: jest.fn(), + listen: jest.fn(), +})); + +const findMenu = ( + template: MenuItemConstructorOptions[], + id: string +): MenuItemConstructorOptions => { + const menu = template.find((item) => item.id === id); + if (!menu) { + throw new Error(`Menu with id "${id}" not found`); + } + return menu; +}; + +const collectClickableItems = ( + items: MenuItemConstructorOptions[] | undefined, + acc: MenuItemConstructorOptions[] = [] +): MenuItemConstructorOptions[] => { + if (!items) return acc; + for (const item of items) { + if (typeof item.click === 'function') { + acc.push(item); + } + if (Array.isArray(item.submenu)) { + collectClickableItems(item.submenu, acc); + } + } + return acc; +}; + +describe('ui/main/menuBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getRootWindow as jest.Mock).mockResolvedValue(mockBrowserWindow); + mockBrowserWindow.isVisible.mockReturnValue(true); + mockBrowserWindow.isFullScreen.mockReturnValue(false); + }); + + describe('selectMenuBarTemplateAsJson', () => { + it('differs between a 1-server state and a 2-server state', () => { + const oneServerState = createState({ + servers: [createServer('https://one.example', 'One')], + }); + const twoServerState = createState({ + servers: [ + createServer('https://one.example', 'One'), + createServer('https://two.example', 'Two'), + ], + }); + + const oneServerJson = selectMenuBarTemplateAsJson(oneServerState); + const twoServerJson = selectMenuBarTemplateAsJson(twoServerState); + + expect(oneServerJson).toBeDefined(); + expect(twoServerJson).toBeDefined(); + expect(oneServerJson).not.toEqual(twoServerJson); + }); + }); + + describe('Window menu', () => { + it('lists per-server items with accelerators matching server order', () => { + const state = createState({ + servers: [ + createServer('https://one.example', 'One'), + createServer('https://two.example', 'Two'), + createServer('https://three.example', 'Three'), + ], + }); + + const template = selectMenuBarTemplate(state); + const windowMenu = findMenu( + template as MenuItemConstructorOptions[], + 'windowMenu' + ); + const submenu = windowMenu.submenu as MenuItemConstructorOptions[]; + + const serverItems = state.servers.map((server) => + submenu.find((item) => item.id === server.url) + ); + + serverItems.forEach((item, index) => { + expect(item).toBeDefined(); + expect(item?.accelerator).toBe(`CommandOrControl+${index + 1}`); + }); + }); + }); + + describe('View menu', () => { + it('contains workspaceTabs and workspaceBar items with checked state following navigationLayout', () => { + const tabsState = createState({ navigationLayout: 'tabs' }); + const sidebarState = createState({ navigationLayout: 'sidebar' }); + + const tabsTemplate = selectMenuBarTemplate(tabsState); + const sidebarTemplate = selectMenuBarTemplate(sidebarState); + + const tabsViewMenu = findMenu( + tabsTemplate as MenuItemConstructorOptions[], + 'viewMenu' + ); + const sidebarViewMenu = findMenu( + sidebarTemplate as MenuItemConstructorOptions[], + 'viewMenu' + ); + + const tabsSubmenu = tabsViewMenu.submenu as MenuItemConstructorOptions[]; + const sidebarSubmenu = + sidebarViewMenu.submenu as MenuItemConstructorOptions[]; + + const workspaceTabsInTabsState = tabsSubmenu.find( + (item) => item.id === 'workspaceTabs' + ); + const workspaceBarInTabsState = tabsSubmenu.find( + (item) => item.id === 'workspaceBar' + ); + expect(workspaceTabsInTabsState?.checked).toBe(true); + expect(workspaceBarInTabsState?.checked).toBe(false); + + const workspaceTabsInSidebarState = sidebarSubmenu.find( + (item) => item.id === 'workspaceTabs' + ); + const workspaceBarInSidebarState = sidebarSubmenu.find( + (item) => item.id === 'workspaceBar' + ); + expect(workspaceTabsInSidebarState?.checked).toBe(false); + expect(workspaceBarInSidebarState?.checked).toBe(true); + }); + + it('puts the layout shortcut on the next step of the cycle, and clicking it advances the cycle', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dispatch } = require('../../store'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getRootWindow } = require('./rootWindow'); + (getRootWindow as jest.Mock).mockResolvedValue({ + isVisible: () => true, + showInactive: jest.fn(), + focus: jest.fn(), + }); + + const layoutRadios: Record< + 'tabs' | 'sidebar' | 'hidden', + 'workspaceTabs' | 'workspaceBar' | 'workspaceHidden' + > = { + tabs: 'workspaceTabs', + sidebar: 'workspaceBar', + hidden: 'workspaceHidden', + }; + + const getRadios = ( + current: 'tabs' | 'sidebar' | 'hidden' + ): MenuItemConstructorOptions[] => { + const template = selectMenuBarTemplate( + createState({ navigationLayout: current }) + ); + const viewMenu = findMenu( + template as MenuItemConstructorOptions[], + 'viewMenu' + ); + return viewMenu.submenu as MenuItemConstructorOptions[]; + }; + + const expectNext = async ( + current: 'tabs' | 'sidebar' | 'hidden', + next: 'tabs' | 'sidebar' | 'hidden' + ): Promise => { + const submenu = getRadios(current); + const byId = (id: string) => submenu.find((item) => item.id === id); + + // Only the next-step radio carries the shortcut; the others are bare. + expect(byId(layoutRadios[next])?.accelerator).toBeDefined(); + (['tabs', 'sidebar', 'hidden'] as const) + .filter((layout) => layout !== next) + .forEach((layout) => { + expect(byId(layoutRadios[layout])?.accelerator).toBeUndefined(); + }); + + // Triggering the shortcut-bearing radio moves the cycle forward. + (dispatch as jest.Mock).mockClear(); + await (byId(layoutRadios[next])?.click as any)(); + expect(dispatch).toHaveBeenLastCalledWith({ + type: MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + payload: next, + }); + }; + + await expectNext('tabs', 'sidebar'); + await expectNext('sidebar', 'hidden'); + await expectNext('hidden', 'tabs'); + }); + + it('reflects developer mode and tray toggles', () => { + const state = createState({ + isDeveloperModeEnabled: true, + isTrayIconEnabled: false, + isShowWindowOnUnreadChangedEnabled: true, + isMenuBarEnabled: false, + }); + const template = selectMenuBarTemplate(state); + const viewMenu = findMenu( + template as MenuItemConstructorOptions[], + 'viewMenu' + ); + const submenu = viewMenu.submenu as MenuItemConstructorOptions[]; + const ids = submenu.map((item) => item.id).filter(Boolean); + + // Platform-dependent items may be omitted; assert those that exist. + const tray = submenu.find((item) => item.id === 'showTrayIcon'); + if (tray) expect(tray.checked).toBe(false); + const menuBar = submenu.find((item) => item.id === 'showMenuBar'); + if (menuBar) expect(menuBar.checked).toBe(false); + const unread = submenu.find((item) => item.id === 'showOnUnreadMessage'); + if (unread) expect(unread.checked).toBe(true); + expect(ids.length).toBeGreaterThan(3); + }); + }); + + describe('server switcher menu', () => { + it('lists servers with accelerators plus settings/downloads/add-server', () => { + const state = createState({ + servers: [ + createServer('https://a.rocket.chat/', 'Server A'), + createServer('https://b.rocket.chat/', 'Server B'), + ], + currentView: { url: 'https://a.rocket.chat/' }, + isAddNewServersEnabled: true, + }); + + const template = selectServerSwitcherMenuTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).toEqual( + expect.arrayContaining([ + 'https://a.rocket.chat/', + 'https://b.rocket.chat/', + 'settings', + 'downloads', + 'addNewServer', + ]) + ); + + const serverA = template.find( + (item) => item.id === 'https://a.rocket.chat/' + ); + expect(serverA?.accelerator).toBe('CommandOrControl+1'); + expect(serverA?.checked).toBe(true); + + const serverB = template.find( + (item) => item.id === 'https://b.rocket.chat/' + ); + expect(serverB?.accelerator).toBe('CommandOrControl+2'); + expect(serverB?.checked).toBe(false); + }); + + it('includes checkForUpdates and dispatches on click when updating is allowed and enabled', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dispatch } = require('../../store'); + const state = createState({ + isDeveloperModeEnabled: false, + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); + const template = selectServerSwitcherMenuTemplate(state); + + const checkForUpdates = findMenu(template, 'checkForUpdates'); + await (checkForUpdates.click as any)(); + + expect(dispatch).toHaveBeenCalledWith({ + type: UPDATES_CHECK_FOR_UPDATES_REQUESTED, + }); + }); + + it('includes checkForUpdates for a store build even when isUpdatingAllowed is false', () => { + const state = createState({ + isDeveloperModeEnabled: false, + isUpdatingAllowed: false, + isUpdatingEnabled: false, + updateStore: 'mas', + }); + const template = selectServerSwitcherMenuTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).toContain('checkForUpdates'); + }); + + it('omits checkForUpdates when updating is neither allowed nor store-distributed (e.g. Linux deb/rpm/tar.gz)', () => { + const state = createState({ + isDeveloperModeEnabled: false, + isUpdatingAllowed: false, + isUpdatingEnabled: true, + updateStore: null, + }); + const template = selectServerSwitcherMenuTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('checkForUpdates'); + }); + + it('omits checkForUpdates when updating is allowed but admin-disabled (isUpdatingEnabled: false) on a non-store build', () => { + const state = createState({ + isDeveloperModeEnabled: false, + isUpdatingAllowed: true, + isUpdatingEnabled: false, + updateStore: null, + }); + const template = selectServerSwitcherMenuTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('checkForUpdates'); + }); + + it('omits simulate items when developer mode is off', () => { + const state = createState({ isDeveloperModeEnabled: false }); + const template = selectServerSwitcherMenuTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('simulateUpdate'); + expect(ids).not.toContain('simulateDownload'); + expect(ids).not.toContain('simulateDisconnected'); + }); + + it('lists simulate items right after checkForUpdates when developer mode is on and dispatches on click', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { dispatch } = require('../../store'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { getRootWindow } = require('./rootWindow'); + (getRootWindow as jest.Mock).mockResolvedValue({ + isVisible: () => true, + showInactive: jest.fn(), + focus: jest.fn(), + }); + + const state = createState({ + isDeveloperModeEnabled: true, + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); + const template = selectServerSwitcherMenuTemplate(state); + const ids = template.map((item) => item.id); + + const checkForUpdatesIndex = ids.indexOf('checkForUpdates'); + expect(checkForUpdatesIndex).toBeGreaterThanOrEqual(0); + expect(template[checkForUpdatesIndex + 1]?.type).toBe('separator'); + expect(ids[checkForUpdatesIndex + 2]).toBe('simulateUpdate'); + expect(ids[checkForUpdatesIndex + 3]).toBe('simulateDownload'); + expect(ids[checkForUpdatesIndex + 4]).toBe('simulateDisconnected'); + + const simulateUpdate = findMenu(template, 'simulateUpdate'); + await (simulateUpdate.click as any)(); + expect(dispatch).toHaveBeenCalledWith({ + type: UPDATES_SIMULATION_REQUESTED, + }); + + const simulateDownload = findMenu(template, 'simulateDownload'); + await (simulateDownload.click as any)(); + expect(dispatch).toHaveBeenCalledWith({ + type: DOWNLOADS_SIMULATION_REQUESTED, + }); + + const simulateDisconnected = findMenu(template, 'simulateDisconnected'); + expect(simulateDisconnected.type).toBe('checkbox'); + expect(simulateDisconnected.checked).toBe(false); + await (simulateDisconnected.click as any)(); + expect(dispatch).toHaveBeenCalledWith({ + type: SET_PRESENCE_DISCONNECTION_SIMULATED, + payload: true, + }); + }); + + it('reflects the checked state of the simulate disconnected item from the store', () => { + const state = createState({ + isDeveloperModeEnabled: true, + isPresenceDisconnectionSimulated: true, + }); + const template = selectServerSwitcherMenuTemplate(state); + const simulateDisconnected = findMenu(template, 'simulateDisconnected'); + expect(simulateDisconnected.checked).toBe(true); + }); + }); + + describe('selectAppMenuPopupTemplate', () => { + const originalPlatform = process.platform; + + const setPlatform = (platform: NodeJS.Platform): void => { + Object.defineProperty(process, 'platform', { value: platform }); + }; + + afterEach(() => { + setPlatform(originalPlatform); + }); + + (['darwin', 'win32'] as const).forEach((platform) => { + describe(`on ${platform}`, () => { + beforeEach(() => { + setPlatform(platform); + }); + + it('omits simulate items and the extra separator when developer mode is off', () => { + const state = createState({ isDeveloperModeEnabled: false }); + const template = selectAppMenuPopupTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('simulateUpdate'); + expect(ids).not.toContain('simulateDownload'); + expect(ids).not.toContain('simulateDisconnected'); + }); + + it('lists simulate items right after checkForUpdates when developer mode is on', () => { + const state = createState({ + isDeveloperModeEnabled: true, + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); + const template = selectAppMenuPopupTemplate(state); + const ids = template.map((item) => item.id); + + const checkForUpdatesIndex = ids.indexOf('checkForUpdates'); + expect(checkForUpdatesIndex).toBeGreaterThanOrEqual(0); + expect(template[checkForUpdatesIndex + 1]?.type).toBe('separator'); + expect(ids[checkForUpdatesIndex + 2]).toBe('simulateUpdate'); + expect(ids[checkForUpdatesIndex + 3]).toBe('simulateDownload'); + expect(ids[checkForUpdatesIndex + 4]).toBe('simulateDisconnected'); + }); + + it('includes checkForUpdates when updating is allowed and enabled', () => { + const state = createState({ + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); + const template = selectAppMenuPopupTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).toContain('checkForUpdates'); + }); + + it('includes checkForUpdates for a store build even when isUpdatingAllowed is false', () => { + const state = createState({ + isUpdatingAllowed: false, + isUpdatingEnabled: false, + updateStore: 'mas', + }); + const template = selectAppMenuPopupTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).toContain('checkForUpdates'); + }); + + it('omits checkForUpdates when updating is neither allowed nor store-distributed (e.g. Linux deb/rpm/tar.gz)', () => { + const state = createState({ + isUpdatingAllowed: false, + isUpdatingEnabled: true, + updateStore: null, + }); + const template = selectAppMenuPopupTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('checkForUpdates'); + }); + + it('omits checkForUpdates when updating is allowed but admin-disabled (isUpdatingEnabled: false) on a non-store build', () => { + const state = createState({ + isUpdatingAllowed: true, + isUpdatingEnabled: false, + updateStore: null, + }); + const template = selectAppMenuPopupTemplate(state); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('checkForUpdates'); + }); + }); + }); + }); + + describe('server context menu', () => { + const servers = [createServer('https://a.rocket.chat/', 'Server A')]; + + it('offers the per-server actions with shortcuts on the ones that have them', () => { + const template = getServerContextMenuTemplate( + 'https://a.rocket.chat/', + servers, + true + ); + const ids = template.map((item) => item.id); + + expect(ids).toEqual( + expect.arrayContaining([ + 'reload', + 'reloadClearingCache', + 'copyCurrentUrl', + 'openDevTools', + 'serverInfo', + 'remove', + 'addWorkspace', + ]) + ); + + const byId = (id: string) => template.find((item) => item.id === id); + expect(byId('reload')?.accelerator).toBe('CommandOrControl+R'); + expect(byId('openDevTools')?.accelerator).toBeDefined(); + expect(byId('addWorkspace')?.accelerator).toBe('CommandOrControl+N'); + // Actions without an app-level shortcut stay bare. + expect(byId('copyCurrentUrl')?.accelerator).toBeUndefined(); + expect(byId('serverInfo')?.accelerator).toBeUndefined(); + }); + + it('isolates Remove with a separator and omits Add workspace when disabled', () => { + const template = getServerContextMenuTemplate( + 'https://a.rocket.chat/', + servers, + false + ); + const ids = template.map((item) => item.id); + + expect(ids).not.toContain('addWorkspace'); + + const removeIndex = template.findIndex((item) => item.id === 'remove'); + expect(template[removeIndex - 1]?.type).toBe('separator'); + }); + }); + + describe('full template coverage', () => { + it('builds app/edit/view/window/help menus for multi-server developer state', () => { + const state = createState({ + servers: [ + createServer('https://one.example', 'One', { badge: 3 }), + createServer('https://two.example', 'Two', { failed: true }), + ], + currentView: { url: 'https://one.example' }, + isAddNewServersEnabled: true, + isDeveloperModeEnabled: true, + isVideoCallDevtoolsAutoOpenEnabled: true, + rootWindowState: { + focused: true, + visible: true, + maximized: true, + minimized: false, + fullscreen: false, + normal: false, + bounds: { x: 0, y: 0, width: 1200, height: 800 }, + }, + }); + + const template = selectMenuBarTemplate( + state + ) as MenuItemConstructorOptions[]; + expect(template.map((item) => item.id)).toEqual( + expect.arrayContaining([ + 'appMenu', + 'editMenu', + 'viewMenu', + 'windowMenu', + 'helpMenu', + ]) + ); + + const clickables = collectClickableItems(template); + expect(clickables.length).toBeGreaterThan(15); + }); + + it('invokes click handlers without throwing for common menu actions', async () => { + const state = createState({ + servers: [ + createServer('https://one.example', 'One'), + createServer('https://two.example', 'Two'), + ], + currentView: { url: 'https://one.example' }, + isAddNewServersEnabled: true, + isDeveloperModeEnabled: true, + }); + + const template = selectMenuBarTemplate( + state + ) as MenuItemConstructorOptions[]; + const clickables = collectClickableItems(template); + + const errors: Array<{ id: unknown; error: unknown }> = []; + for (const item of clickables) { + try { + // Handlers must run sequentially to avoid overlapping shared mock + // state (e.g. mockBrowserWindow.isVisible toggling mid-iteration). + // eslint-disable-next-line no-await-in-loop + await Promise.resolve( + item.click?.({} as any, mockBrowserWindow as any, {} as any) + ); + } catch (error) { + // All handlers are backed by fully mocked dependencies + // (getRootWindow, dispatch, shell, app, BrowserWindow, + // openExternal, relaunchApp, askForAppDataReset, + // getWebContentsByServerUrl); none are expected to throw here. + errors.push({ id: item.id, error }); + } + } + + expect(errors).toEqual([]); + expect(dispatch).toHaveBeenCalled(); + expect(getRootWindow).toHaveBeenCalled(); + }); + + it('builds selectAppMenuPopupTemplate and runs its click handlers', async () => { + const state = createState({ + servers: [createServer('https://one.example', 'One')], + isAddNewServersEnabled: true, + }); + const template = selectAppMenuPopupTemplate(state); + expect(template.find((item) => item.id === 'settings')).toBeDefined(); + expect(template.find((item) => item.id === 'downloads')).toBeDefined(); + expect( + template.find((item) => item.id === 'checkForUpdates') + ).toBeDefined(); + + const clickables = collectClickableItems(template); + const errors: Array<{ id: unknown; error: unknown }> = []; + for (const item of clickables) { + try { + // Handlers must run sequentially to avoid overlapping shared mock + // state (see note in the previous test). + // eslint-disable-next-line no-await-in-loop + await Promise.resolve( + item.click?.({} as any, mockBrowserWindow as any, {} as any) + ); + } catch (error) { + // All handlers are backed by fully mocked dependencies; none are + // expected to throw here (see note in the previous test). + errors.push({ id: item.id, error }); + } + } + + expect(errors).toEqual([]); + expect(dispatch).toHaveBeenCalled(); + }); + + it('shows inactive root window before focusing when hidden', async () => { + // Use windowMenu's 'settings' item: it carries the same + // show-if-hidden-then-focus behavior as the darwin-only 'about'/ + // 'preferences' items, but unlike those, it is registered on every + // platform — selectMenuBarTemplate is memoized per createSelector, so a + // test-local process.platform override can't force recomputation of a + // platform-gated item already cached under the runner's real platform. + mockBrowserWindow.isVisible.mockReturnValue(false); + const state = createState({ + isAddNewServersEnabled: true, + }); + const template = selectMenuBarTemplate( + state + ) as MenuItemConstructorOptions[]; + const windowMenu = findMenu(template, 'windowMenu'); + const settings = ( + windowMenu.submenu as MenuItemConstructorOptions[] + ).find((item) => item.id === 'settings'); + expect(settings).toBeDefined(); + await settings?.click?.({} as any, mockBrowserWindow as any, {} as any); + expect(mockBrowserWindow.showInactive).toHaveBeenCalled(); + expect(mockBrowserWindow.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/ui/main/menuBar.spec.ts b/src/ui/main/menuBar.spec.ts deleted file mode 100644 index 87b96f5698..0000000000 --- a/src/ui/main/menuBar.spec.ts +++ /dev/null @@ -1,452 +0,0 @@ -import type { MenuItemConstructorOptions } from 'electron'; - -import { DOWNLOADS_SIMULATION_REQUESTED } from '../../downloads/actions'; -import type { Server } from '../../servers/common'; -import type { RootState } from '../../store/rootReducer'; -import { - UPDATES_CHECK_FOR_UPDATES_REQUESTED, - UPDATES_SIMULATION_REQUESTED, -} from '../../updates/actions'; -import { MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED } from '../actions'; -import { - getServerContextMenuTemplate, - selectAppMenuPopupTemplate, - selectMenuBarTemplate, - selectMenuBarTemplateAsJson, - selectServerSwitcherMenuTemplate, -} from './menuBar'; - -jest.mock('electron', () => ({ - app: { - name: 'Rocket.Chat', - commandLine: { hasSwitch: jest.fn(() => false) }, - getPath: jest.fn(() => ''), - }, - shell: { - showItemInFolder: jest.fn(), - }, - BrowserWindow: { - getAllWindows: jest.fn(() => []), - getFocusedWindow: jest.fn(() => null), - }, - Menu: { - buildFromTemplate: jest.fn(), - setApplicationMenu: jest.fn(), - }, -})); - -jest.mock('i18next', () => ({ - t: (key: string) => key, -})); - -jest.mock('../../app/main/app', () => ({ - relaunchApp: jest.fn(), -})); - -jest.mock('../../utils/browserLauncher', () => ({ - openExternal: jest.fn(), -})); - -jest.mock('../../videoCallWindow/ipc', () => ({ - openVideoCallWebviewDevTools: jest.fn(), -})); - -jest.mock('./dialogs', () => ({ - askForAppDataReset: jest.fn(), -})); - -jest.mock('./rootWindow', () => ({ - getRootWindow: jest.fn(), -})); - -jest.mock('./serverView', () => ({ - getWebContentsByServerUrl: jest.fn(), -})); - -jest.mock('../../store', () => ({ - dispatch: jest.fn(), - select: jest.fn(), - Service: class Service { - protected initialize(): void {} - - setUp(): void { - this.initialize(); - } - }, -})); - -const createServer = (url: string, title: string): Server => ({ - url, - title, -}); - -const createState = (overrides: Partial = {}): RootState => - ({ - servers: [], - currentView: 'downloads', - isTrayIconEnabled: true, - isMenuBarEnabled: true, - isAddNewServersEnabled: true, - isShowWindowOnUnreadChangedEnabled: false, - isDeveloperModeEnabled: false, - isVideoCallDevtoolsAutoOpenEnabled: false, - navigationLayout: 'tabs', - rootWindowState: { - focused: true, - visible: true, - maximized: false, - minimized: false, - fullscreen: false, - normal: true, - bounds: { x: undefined, y: undefined, width: 1000, height: 600 }, - }, - ...overrides, - }) as RootState; - -const findMenu = ( - template: MenuItemConstructorOptions[], - id: string -): MenuItemConstructorOptions => { - const menu = template.find((item) => item.id === id); - if (!menu) { - throw new Error(`Menu with id "${id}" not found`); - } - return menu; -}; - -describe('ui/main/menuBar', () => { - describe('selectMenuBarTemplateAsJson', () => { - it('differs between a 1-server state and a 2-server state', () => { - const oneServerState = createState({ - servers: [createServer('https://one.example', 'One')], - }); - const twoServerState = createState({ - servers: [ - createServer('https://one.example', 'One'), - createServer('https://two.example', 'Two'), - ], - }); - - const oneServerJson = selectMenuBarTemplateAsJson(oneServerState); - const twoServerJson = selectMenuBarTemplateAsJson(twoServerState); - - expect(oneServerJson).toBeDefined(); - expect(twoServerJson).toBeDefined(); - expect(oneServerJson).not.toEqual(twoServerJson); - }); - }); - - describe('Window menu', () => { - it('lists per-server items with accelerators matching server order', () => { - const state = createState({ - servers: [ - createServer('https://one.example', 'One'), - createServer('https://two.example', 'Two'), - createServer('https://three.example', 'Three'), - ], - }); - - const template = selectMenuBarTemplate(state); - const windowMenu = findMenu( - template as MenuItemConstructorOptions[], - 'windowMenu' - ); - const submenu = windowMenu.submenu as MenuItemConstructorOptions[]; - - const serverItems = state.servers.map((server) => - submenu.find((item) => item.id === server.url) - ); - - serverItems.forEach((item, index) => { - expect(item).toBeDefined(); - expect(item?.accelerator).toBe(`CommandOrControl+${index + 1}`); - }); - }); - }); - - describe('View menu', () => { - it('contains workspaceTabs and workspaceBar items with checked state following navigationLayout', () => { - const tabsState = createState({ navigationLayout: 'tabs' }); - const sidebarState = createState({ navigationLayout: 'sidebar' }); - - const tabsTemplate = selectMenuBarTemplate(tabsState); - const sidebarTemplate = selectMenuBarTemplate(sidebarState); - - const tabsViewMenu = findMenu( - tabsTemplate as MenuItemConstructorOptions[], - 'viewMenu' - ); - const sidebarViewMenu = findMenu( - sidebarTemplate as MenuItemConstructorOptions[], - 'viewMenu' - ); - - const tabsSubmenu = tabsViewMenu.submenu as MenuItemConstructorOptions[]; - const sidebarSubmenu = - sidebarViewMenu.submenu as MenuItemConstructorOptions[]; - - const workspaceTabsInTabsState = tabsSubmenu.find( - (item) => item.id === 'workspaceTabs' - ); - const workspaceBarInTabsState = tabsSubmenu.find( - (item) => item.id === 'workspaceBar' - ); - expect(workspaceTabsInTabsState?.checked).toBe(true); - expect(workspaceBarInTabsState?.checked).toBe(false); - - const workspaceTabsInSidebarState = sidebarSubmenu.find( - (item) => item.id === 'workspaceTabs' - ); - const workspaceBarInSidebarState = sidebarSubmenu.find( - (item) => item.id === 'workspaceBar' - ); - expect(workspaceTabsInSidebarState?.checked).toBe(false); - expect(workspaceBarInSidebarState?.checked).toBe(true); - }); - - it('puts the layout shortcut on the next step of the cycle, and clicking it advances the cycle', async () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { dispatch } = require('../../store'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { getRootWindow } = require('./rootWindow'); - (getRootWindow as jest.Mock).mockResolvedValue({ - isVisible: () => true, - showInactive: jest.fn(), - focus: jest.fn(), - }); - - const layoutRadios: Record< - 'tabs' | 'sidebar' | 'hidden', - 'workspaceTabs' | 'workspaceBar' | 'workspaceHidden' - > = { - tabs: 'workspaceTabs', - sidebar: 'workspaceBar', - hidden: 'workspaceHidden', - }; - - const getRadios = ( - current: 'tabs' | 'sidebar' | 'hidden' - ): MenuItemConstructorOptions[] => { - const template = selectMenuBarTemplate( - createState({ navigationLayout: current }) - ); - const viewMenu = findMenu( - template as MenuItemConstructorOptions[], - 'viewMenu' - ); - return viewMenu.submenu as MenuItemConstructorOptions[]; - }; - - const expectNext = async ( - current: 'tabs' | 'sidebar' | 'hidden', - next: 'tabs' | 'sidebar' | 'hidden' - ): Promise => { - const submenu = getRadios(current); - const byId = (id: string) => submenu.find((item) => item.id === id); - - // Only the next-step radio carries the shortcut; the others are bare. - expect(byId(layoutRadios[next])?.accelerator).toBeDefined(); - (['tabs', 'sidebar', 'hidden'] as const) - .filter((layout) => layout !== next) - .forEach((layout) => { - expect(byId(layoutRadios[layout])?.accelerator).toBeUndefined(); - }); - - // Triggering the shortcut-bearing radio moves the cycle forward. - (dispatch as jest.Mock).mockClear(); - await (byId(layoutRadios[next])?.click as any)(); - expect(dispatch).toHaveBeenLastCalledWith({ - type: MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, - payload: next, - }); - }; - - await expectNext('tabs', 'sidebar'); - await expectNext('sidebar', 'hidden'); - await expectNext('hidden', 'tabs'); - }); - }); - - describe('server switcher menu', () => { - it('lists servers with accelerators plus settings/downloads/add-server', () => { - const state = createState({ - servers: [ - createServer('https://a.rocket.chat/', 'Server A'), - createServer('https://b.rocket.chat/', 'Server B'), - ], - currentView: { url: 'https://a.rocket.chat/' }, - isAddNewServersEnabled: true, - }); - - const template = selectServerSwitcherMenuTemplate(state); - const ids = template.map((item) => item.id); - - expect(ids).toEqual( - expect.arrayContaining([ - 'https://a.rocket.chat/', - 'https://b.rocket.chat/', - 'settings', - 'downloads', - 'addNewServer', - ]) - ); - - const serverA = template.find( - (item) => item.id === 'https://a.rocket.chat/' - ); - expect(serverA?.accelerator).toBe('CommandOrControl+1'); - expect(serverA?.checked).toBe(true); - - const serverB = template.find( - (item) => item.id === 'https://b.rocket.chat/' - ); - expect(serverB?.accelerator).toBe('CommandOrControl+2'); - expect(serverB?.checked).toBe(false); - }); - - it('always includes checkForUpdates and dispatches on click', async () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { dispatch } = require('../../store'); - const state = createState({ isDeveloperModeEnabled: false }); - const template = selectServerSwitcherMenuTemplate(state); - - const checkForUpdates = findMenu(template, 'checkForUpdates'); - await (checkForUpdates.click as any)(); - - expect(dispatch).toHaveBeenCalledWith({ - type: UPDATES_CHECK_FOR_UPDATES_REQUESTED, - }); - }); - - it('omits simulate items when developer mode is off', () => { - const state = createState({ isDeveloperModeEnabled: false }); - const template = selectServerSwitcherMenuTemplate(state); - const ids = template.map((item) => item.id); - - expect(ids).not.toContain('simulateUpdate'); - expect(ids).not.toContain('simulateDownload'); - }); - - it('lists simulate items right after checkForUpdates when developer mode is on and dispatches on click', async () => { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { dispatch } = require('../../store'); - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { getRootWindow } = require('./rootWindow'); - (getRootWindow as jest.Mock).mockResolvedValue({ - isVisible: () => true, - showInactive: jest.fn(), - focus: jest.fn(), - }); - - const state = createState({ isDeveloperModeEnabled: true }); - const template = selectServerSwitcherMenuTemplate(state); - const ids = template.map((item) => item.id); - - const checkForUpdatesIndex = ids.indexOf('checkForUpdates'); - expect(checkForUpdatesIndex).toBeGreaterThanOrEqual(0); - expect(template[checkForUpdatesIndex + 1]?.type).toBe('separator'); - expect(ids[checkForUpdatesIndex + 2]).toBe('simulateUpdate'); - expect(ids[checkForUpdatesIndex + 3]).toBe('simulateDownload'); - - const simulateUpdate = findMenu(template, 'simulateUpdate'); - await (simulateUpdate.click as any)(); - expect(dispatch).toHaveBeenCalledWith({ - type: UPDATES_SIMULATION_REQUESTED, - }); - - const simulateDownload = findMenu(template, 'simulateDownload'); - await (simulateDownload.click as any)(); - expect(dispatch).toHaveBeenCalledWith({ - type: DOWNLOADS_SIMULATION_REQUESTED, - }); - }); - }); - - describe('selectAppMenuPopupTemplate', () => { - const originalPlatform = process.platform; - - const setPlatform = (platform: NodeJS.Platform): void => { - Object.defineProperty(process, 'platform', { value: platform }); - }; - - afterEach(() => { - setPlatform(originalPlatform); - }); - - (['darwin', 'win32'] as const).forEach((platform) => { - describe(`on ${platform}`, () => { - beforeEach(() => { - setPlatform(platform); - }); - - it('omits simulate items and the extra separator when developer mode is off', () => { - const state = createState({ isDeveloperModeEnabled: false }); - const template = selectAppMenuPopupTemplate(state); - const ids = template.map((item) => item.id); - - expect(ids).not.toContain('simulateUpdate'); - expect(ids).not.toContain('simulateDownload'); - }); - - it('lists simulate items right after checkForUpdates when developer mode is on', () => { - const state = createState({ isDeveloperModeEnabled: true }); - const template = selectAppMenuPopupTemplate(state); - const ids = template.map((item) => item.id); - - const checkForUpdatesIndex = ids.indexOf('checkForUpdates'); - expect(checkForUpdatesIndex).toBeGreaterThanOrEqual(0); - expect(template[checkForUpdatesIndex + 1]?.type).toBe('separator'); - expect(ids[checkForUpdatesIndex + 2]).toBe('simulateUpdate'); - expect(ids[checkForUpdatesIndex + 3]).toBe('simulateDownload'); - }); - }); - }); - }); - - describe('server context menu', () => { - const servers = [createServer('https://a.rocket.chat/', 'Server A')]; - - it('offers the per-server actions with shortcuts on the ones that have them', () => { - const template = getServerContextMenuTemplate( - 'https://a.rocket.chat/', - servers, - true - ); - const ids = template.map((item) => item.id); - - expect(ids).toEqual( - expect.arrayContaining([ - 'reload', - 'reloadClearingCache', - 'copyCurrentUrl', - 'openDevTools', - 'serverInfo', - 'remove', - 'addWorkspace', - ]) - ); - - const byId = (id: string) => template.find((item) => item.id === id); - expect(byId('reload')?.accelerator).toBe('CommandOrControl+R'); - expect(byId('openDevTools')?.accelerator).toBeDefined(); - expect(byId('addWorkspace')?.accelerator).toBe('CommandOrControl+N'); - // Actions without an app-level shortcut stay bare. - expect(byId('copyCurrentUrl')?.accelerator).toBeUndefined(); - expect(byId('serverInfo')?.accelerator).toBeUndefined(); - }); - - it('isolates Remove with a separator and omits Add workspace when disabled', () => { - const template = getServerContextMenuTemplate( - 'https://a.rocket.chat/', - servers, - false - ); - const ids = template.map((item) => item.id); - - expect(ids).not.toContain('addWorkspace'); - - const removeIndex = template.findIndex((item) => item.id === 'remove'); - expect(template[removeIndex - 1]?.type).toBe('separator'); - }); - }); -}); diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index a958ad589a..9e23c9f78a 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -20,7 +20,6 @@ import { openVideoCallWebviewDevTools } from '../../videoCallWindow/ipc'; import { APP_MENU_TRIGGERED, CLEAR_CACHE_TRIGGERED, - MENU_BAR_ABOUT_CLICKED, MENU_BAR_ADD_NEW_SERVER_CLICKED, MENU_BAR_SELECT_SERVER_CLICKED, MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, @@ -32,6 +31,7 @@ import { OPEN_SERVER_INFO_MODAL, SERVER_CONTEXT_MENU_TRIGGERED, SERVER_SWITCHER_MENU_TRIGGERED, + SET_PRESENCE_DISCONNECTION_SIMULATED, SIDE_BAR_ADD_NEW_SERVER_CLICKED, SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, SIDE_BAR_SERVER_COPY_URL, @@ -54,7 +54,9 @@ const on = ( getMenuItems: () => MenuItemConstructorOptions[] ): MenuItemConstructorOptions[] => (condition ? getMenuItems() : []); -const createSimulationMenuItems = (): MenuItemConstructorOptions[] => [ +const createSimulationMenuItems = ( + isPresenceDisconnectionSimulated: boolean +): MenuItemConstructorOptions[] => [ { id: 'simulateUpdate', label: t('menus.simulateUpdate'), @@ -81,6 +83,24 @@ const createSimulationMenuItems = (): MenuItemConstructorOptions[] => [ dispatch({ type: DOWNLOADS_SIMULATION_REQUESTED }); }, }, + { + id: 'simulateDisconnected', + label: t('menus.simulateDisconnected'), + type: 'checkbox', + checked: isPresenceDisconnectionSimulated, + click: async () => { + const browserWindow = await getRootWindow(); + + if (!browserWindow.isVisible()) { + browserWindow.showInactive(); + } + browserWindow.focus(); + dispatch({ + type: SET_PRESENCE_DISCONNECTION_SIMULATED, + payload: !isPresenceDisconnectionSimulated, + }); + }, + }, ]; // The layout shortcut advances through tabs → sidebar → hidden → tabs. It is @@ -108,17 +128,18 @@ const selectAddServersDeps = createStructuredSelector({ isAddNewServersEnabled, }); +/** + * macOS only, and deliberately the system panel rather than anything of ours: + * every Mac app has this item in the same place, and the version and copyright + * it shows come from the bundle. Windows and Linux have no such convention, so + * they get no About item at all — the version lives in the settings window's + * Advanced section instead. + */ const createAboutMenuItem = (): MenuItemConstructorOptions => ({ id: 'about', label: t('menus.about', { appName: app.name }), - click: async () => { - const browserWindow = await getRootWindow(); - - if (!browserWindow.isVisible()) { - browserWindow.showInactive(); - } - browserWindow.focus(); - dispatch({ type: MENU_BAR_ABOUT_CLICKED }); + click: () => { + app.showAboutPanel(); }, }); @@ -588,6 +609,12 @@ const selectWindowDeps = createStructuredSelector({ isAddNewServersEnabled, isDeveloperModeEnabled: ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled, + isUpdatingAllowed: ({ isUpdatingAllowed }: RootState) => isUpdatingAllowed, + isUpdatingEnabled: ({ isUpdatingEnabled }: RootState) => isUpdatingEnabled, + updateStore: ({ updateStore }: RootState) => updateStore, + isPresenceDisconnectionSimulated: ({ + isPresenceDisconnectionSimulated, + }: RootState) => isPresenceDisconnectionSimulated, }); export const createWindowMenu = createSelector( @@ -738,6 +765,9 @@ const selectHelpDeps = createStructuredSelector({ isVideoCallDevtoolsAutoOpenEnabled: ({ isVideoCallDevtoolsAutoOpenEnabled, }: RootState) => isVideoCallDevtoolsAutoOpenEnabled, + isPresenceDisconnectionSimulated: ({ + isPresenceDisconnectionSimulated, + }: RootState) => isPresenceDisconnectionSimulated, }); export const createHelpMenu = createSelector( @@ -745,6 +775,7 @@ export const createHelpMenu = createSelector( ({ isDeveloperModeEnabled, isVideoCallDevtoolsAutoOpenEnabled, + isPresenceDisconnectionSimulated, }): MenuItemConstructorOptions => ({ id: 'helpMenu', label: t('menus.helpMenu'), @@ -843,7 +874,9 @@ export const createHelpMenu = createSelector( }); }, }, - ...on(isDeveloperModeEnabled, createSimulationMenuItems), + ...on(isDeveloperModeEnabled, () => + createSimulationMenuItems(isPresenceDisconnectionSimulated) + ), { id: 'videoCallToolsSubmenu', label: t('menus.videoCallTools'), @@ -938,21 +971,6 @@ export const createHelpMenu = createSelector( openExternal(urls.rocketchat.site); }, }, - ...on(process.platform !== 'darwin', () => [ - { - id: 'about', - label: t('menus.about', { appName: app.name }), - click: async () => { - const browserWindow = await getRootWindow(); - - if (!browserWindow.isVisible()) { - browserWindow.showInactive(); - } - browserWindow.focus(); - dispatch({ type: MENU_BAR_ABOUT_CLICKED }); - }, - }, - ]), ], }) ); @@ -978,11 +996,7 @@ const createRocketChatMenu = createSelector( (): MenuItemConstructorOptions => ({ id: 'rocketChatMenu', label: app.name, - submenu: [ - createAboutMenuItem(), - { type: 'separator' }, - createDisableGpuMenuItem(), - ], + submenu: [createDisableGpuMenuItem()], }) ); @@ -1006,6 +1020,11 @@ export const selectAppMenuPopupTemplate = createSelector( createWindowMenu, createHelpMenu, ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled, + ({ isUpdatingAllowed }: RootState) => isUpdatingAllowed, + ({ isUpdatingEnabled }: RootState) => isUpdatingEnabled, + ({ updateStore }: RootState) => updateStore, + ({ isPresenceDisconnectionSimulated }: RootState) => + isPresenceDisconnectionSimulated, ], ( rocketChatMenu, @@ -1014,8 +1033,20 @@ export const selectAppMenuPopupTemplate = createSelector( viewMenu, windowMenu, helpMenu, - isDeveloperModeEnabled + isDeveloperModeEnabled, + isUpdatingAllowed, + isUpdatingEnabled, + updateStore, + isPresenceDisconnectionSimulated ): MenuItemConstructorOptions[] => { + // Store builds stay unconditional on isUpdatingEnabled (an admin can + // disable electron-updater, but that setting has no bearing on a store's + // own update mechanism); non-store builds need BOTH allowed and enabled, + // otherwise clicking the item flips updateCheckStatus to 'checking' + // (reducers.ts) with no listener ever registered to settle it back. + const canCheckForUpdates = + (isUpdatingAllowed && isUpdatingEnabled) || updateStore !== null; + const settingsItem: MenuItemConstructorOptions = { id: 'settings', label: t('menus.settings'), @@ -1047,10 +1078,10 @@ export const selectAppMenuPopupTemplate = createSelector( return [ settingsItem, downloadsItem, - checkForUpdatesItem, + ...on(canCheckForUpdates, () => [checkForUpdatesItem]), ...on(isDeveloperModeEnabled, () => [ { type: 'separator' }, - ...createSimulationMenuItems(), + ...createSimulationMenuItems(isPresenceDisconnectionSimulated), ]), ]; } @@ -1070,10 +1101,10 @@ export const selectAppMenuPopupTemplate = createSelector( { type: 'separator' }, settingsItem, downloadsItem, - checkForUpdatesItem, + ...on(canCheckForUpdates, () => [checkForUpdatesItem]), ...on(isDeveloperModeEnabled, () => [ { type: 'separator' }, - ...createSimulationMenuItems(), + ...createSimulationMenuItems(isPresenceDisconnectionSimulated), ]), { type: 'separator' }, createQuitMenuItem(), @@ -1091,7 +1122,19 @@ export const selectServerSwitcherMenuTemplate = createSelector( currentView, isAddNewServersEnabled, isDeveloperModeEnabled, + isUpdatingAllowed, + isUpdatingEnabled, + updateStore, + isPresenceDisconnectionSimulated, }): MenuItemConstructorOptions[] => { + // Store builds stay unconditional on isUpdatingEnabled (an admin can + // disable electron-updater, but that setting has no bearing on a store's + // own update mechanism); non-store builds need BOTH allowed and enabled, + // otherwise clicking the item flips updateCheckStatus to 'checking' + // (reducers.ts) with no listener ever registered to settle it back. + const canCheckForUpdates = + (isUpdatingAllowed && isUpdatingEnabled) || updateStore !== null; + const serverItems = servers.map((server, i): MenuItemConstructorOptions => { const isActive = typeof currentView === 'object' && currentView.url === server.url; @@ -1156,16 +1199,18 @@ export const selectServerSwitcherMenuTemplate = createSelector( }, } as MenuItemConstructorOptions, ]), - { - id: 'checkForUpdates', - label: t('menus.checkForUpdates'), - click: () => { - dispatch({ type: UPDATES_CHECK_FOR_UPDATES_REQUESTED }); - }, - }, + ...on(canCheckForUpdates, () => [ + { + id: 'checkForUpdates', + label: t('menus.checkForUpdates'), + click: () => { + dispatch({ type: UPDATES_CHECK_FOR_UPDATES_REQUESTED }); + }, + } as MenuItemConstructorOptions, + ]), ...on(isDeveloperModeEnabled, () => [ { type: 'separator' } as MenuItemConstructorOptions, - ...createSimulationMenuItems(), + ...createSimulationMenuItems(isPresenceDisconnectionSimulated), ]), ]; } diff --git a/src/ui/main/secondaryWindowControls.ts b/src/ui/main/secondaryWindowControls.ts new file mode 100644 index 0000000000..f1fbaae1d1 --- /dev/null +++ b/src/ui/main/secondaryWindowControls.ts @@ -0,0 +1,61 @@ +import type { WebContents } from 'electron'; +import { BrowserWindow } from 'electron'; + +import { handle } from '../../ipc/main'; +import { WINDOW_MAXIMIZED_CHANNEL } from '../windowChrome/channels'; + +/** + * Minimise, maximise and close for the secondary windows, whose toolbars draw + * their own caption buttons on the platforms where the native title bar is + * hidden. + * + * The handlers act on whichever window sent the request rather than taking a + * window id, so one registration serves the log viewer, downloads and settings + * windows alike. + */ +const senderWindow = (webContents: WebContents): BrowserWindow | null => + BrowserWindow.fromWebContents(webContents); + +export const startSecondaryWindowControlsHandler = (): void => { + handle('secondary-window/minimize', async (webContents) => { + senderWindow(webContents)?.minimize(); + }); + + handle('secondary-window/toggle-maximize', async (webContents) => { + const browserWindow = senderWindow(webContents); + if (!browserWindow) return; + + if (browserWindow.isMaximized()) { + browserWindow.unmaximize(); + return; + } + browserWindow.maximize(); + }); + + handle('secondary-window/close', async (webContents) => { + senderWindow(webContents)?.close(); + }); + + handle( + 'secondary-window/is-maximized', + async (webContents) => senderWindow(webContents)?.isMaximized() ?? false + ); +}; + +/** + * Keeps the window's caption buttons in step with its state, so the glyph shows + * restore once maximised — including when the change came from a double click + * on the toolbar or a keyboard shortcut rather than from the buttons. + */ +export const watchWindowControls = (browserWindow: BrowserWindow): void => { + const notify = (): void => { + if (browserWindow.isDestroyed()) return; + browserWindow.webContents.send( + WINDOW_MAXIMIZED_CHANNEL, + browserWindow.isMaximized() + ); + }; + + browserWindow.addListener('maximize', notify); + browserWindow.addListener('unmaximize', notify); +}; diff --git a/src/ui/main/secondaryWindowFocus.ts b/src/ui/main/secondaryWindowFocus.ts new file mode 100644 index 0000000000..73c4780313 --- /dev/null +++ b/src/ui/main/secondaryWindowFocus.ts @@ -0,0 +1,15 @@ +import type { BrowserWindow } from 'electron'; + +/** + * Brings an already-open secondary window back to the front. + * + * A bare `focus()` does not deminiaturize on macOS or restore on Windows, so + * reopening a minimized window from the menu/sidebar appeared to do nothing. + */ +export const focusSecondaryWindow = (browserWindow: BrowserWindow): void => { + if (browserWindow.isMinimized()) { + browserWindow.restore(); + } + browserWindow.show(); + browserWindow.focus(); +}; diff --git a/src/ui/main/secondaryWindowState.main.spec.ts b/src/ui/main/secondaryWindowState.main.spec.ts new file mode 100644 index 0000000000..8779760123 --- /dev/null +++ b/src/ui/main/secondaryWindowState.main.spec.ts @@ -0,0 +1,77 @@ +import { screen } from 'electron'; + +import { select } from '../../store'; +import { getSavedWindowBounds } from './secondaryWindowState'; + +jest.mock('electron', () => ({ + screen: { getAllDisplays: jest.fn() }, +})); + +jest.mock('../../store', () => ({ + select: jest.fn(), + dispatch: jest.fn(), +})); + +const mockSelect = select as jest.MockedFunction; +const mockGetAllDisplays = screen.getAllDisplays as jest.Mock; + +const withSaved = (bounds: unknown) => { + mockSelect.mockImplementation((selector: any) => + selector({ secondaryWindowStates: { downloads: bounds } }) + ); +}; + +describe('getSavedWindowBounds', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAllDisplays.mockReturnValue([ + { bounds: { x: 0, y: 0, width: 1920, height: 1080 } }, + ]); + }); + + it('returns bounds that land on a display', () => { + withSaved({ x: 100, y: 80, width: 900, height: 600 }); + + expect(getSavedWindowBounds('downloads')).toEqual({ + x: 100, + y: 80, + width: 900, + height: 600, + }); + }); + + it('keeps a window that merely overlaps an edge', () => { + withSaved({ x: -40, y: 20, width: 900, height: 600 }); + + expect(getSavedWindowBounds('downloads')).not.toBeUndefined(); + }); + + it('drops bounds on a display that is no longer attached', () => { + // Saved on a second monitor that has since been unplugged. + withSaved({ x: 3000, y: 200, width: 900, height: 600 }); + + expect(getSavedWindowBounds('downloads')).toBeUndefined(); + }); + + it('ignores nothing-saved and malformed entries', () => { + withSaved(undefined); + expect(getSavedWindowBounds('downloads')).toBeUndefined(); + + withSaved({ x: 10, y: 10 }); + expect(getSavedWindowBounds('downloads')).toBeUndefined(); + + withSaved({ x: 10, y: 10, width: 0, height: 600 }); + expect(getSavedWindowBounds('downloads')).toBeUndefined(); + + withSaved({ x: NaN, y: 10, width: 900, height: 600 }); + expect(getSavedWindowBounds('downloads')).toBeUndefined(); + }); + + it('returns nothing when the window has no saved entry', () => { + mockSelect.mockImplementation((selector: any) => + selector({ secondaryWindowStates: {} }) + ); + + expect(getSavedWindowBounds('settings')).toBeUndefined(); + }); +}); diff --git a/src/ui/main/secondaryWindowState.ts b/src/ui/main/secondaryWindowState.ts new file mode 100644 index 0000000000..644a80105d --- /dev/null +++ b/src/ui/main/secondaryWindowState.ts @@ -0,0 +1,91 @@ +import type { BrowserWindow, Rectangle } from 'electron'; +import { screen } from 'electron'; + +import { dispatch, select } from '../../store'; +import type { RootState } from '../../store/rootReducer'; +import { SECONDARY_WINDOW_STATE_CHANGED } from '../actions'; +import { debounce } from './debounce'; + +/** Windows that remember where they were, keyed in persisted state. */ +export type SecondaryWindowId = + | 'logViewer' + | 'downloads' + | 'settings' + | 'documentViewer'; + +export type SecondaryWindowBounds = Rectangle; + +/** + * A window counts as on-screen if it overlaps any display, rather than being + * fully contained by one — the same rule the root window uses, so bounds parked + * at a screen edge or spanning two monitors survive instead of being recentred. + */ +const isInsideSomeScreen = ({ x, y, width, height }: Rectangle): boolean => + screen + .getAllDisplays() + .some( + ({ bounds }) => + x < bounds.x + bounds.width && + x + width > bounds.x && + y < bounds.y + bounds.height && + y + height > bounds.y + ); + +const isUsableBounds = (bounds: unknown): bounds is SecondaryWindowBounds => { + if (!bounds || typeof bounds !== 'object') return false; + const { x, y, width, height } = bounds as Record; + return ( + [x, y, width, height].every( + (value) => typeof value === 'number' && Number.isFinite(value) + ) && + (width as number) > 0 && + (height as number) > 0 + ); +}; + +/** + * Where this window should open, or undefined to let the caller fall back to its + * default placement. + * + * Saved bounds are dropped when they no longer land on a display: monitors get + * unplugged, and a window restored onto one that is gone is a window the reader + * cannot reach. + */ +export const getSavedWindowBounds = ( + id: SecondaryWindowId +): SecondaryWindowBounds | undefined => { + const saved = select( + ({ secondaryWindowStates }: RootState) => secondaryWindowStates?.[id] + ); + + if (!isUsableBounds(saved) || !isInsideSomeScreen(saved)) { + return undefined; + } + + return saved; +}; + +/** + * Records the window's position and size as it is moved or resized. + * + * Debounced because `move` and `resize` fire continuously while dragging, and + * every dispatch reaches the store and then disk. Normal bounds are used rather + * than current ones, so maximising does not overwrite the size to restore to. + */ +export const watchWindowBounds = ( + id: SecondaryWindowId, + browserWindow: BrowserWindow +): void => { + const save = debounce(async () => { + if (browserWindow.isDestroyed()) return; + if (browserWindow.isMinimized() || browserWindow.isFullScreen()) return; + + dispatch({ + type: SECONDARY_WINDOW_STATE_CHANGED, + payload: { id, bounds: browserWindow.getNormalBounds() }, + }); + }, 500); + + browserWindow.addListener('move', save); + browserWindow.addListener('resize', save); +}; diff --git a/src/ui/main/touchBar.main.spec.ts b/src/ui/main/touchBar.main.spec.ts new file mode 100644 index 0000000000..62cf9e5368 --- /dev/null +++ b/src/ui/main/touchBar.main.spec.ts @@ -0,0 +1,116 @@ +export {}; + +const select = jest.fn(); +const dispatch = jest.fn(); +const watchCallbacks: Function[] = []; +const getRootWindow = jest.fn(); +const touchBarCtor = jest.fn(() => ({})); +const scrubberCtor = jest.fn((opts) => ({ ...opts, items: [] })); +const popoverCtor = jest.fn((opts) => ({ ...opts })); +const segmentedCtor = jest.fn((opts) => ({ + ...opts, + segments: (opts.segments || []).map((s: any) => ({ ...s })), +})); +const spacerCtor = jest.fn(() => ({})); + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/app'), + }, + nativeImage: { + createFromPath: jest.fn(() => ({})), + createFromDataURL: jest.fn(() => ({})), + createEmpty: jest.fn(() => ({})), + }, + TouchBar: Object.assign(touchBarCtor, { + TouchBarScrubber: scrubberCtor, + TouchBarPopover: popoverCtor, + TouchBarSegmentedControl: segmentedCtor, + TouchBarSpacer: spacerCtor, + }), +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('../../store', () => { + class Service { + protected watch(_sel: unknown, cb: Function) { + watchCallbacks.push(cb); + } + + protected initialize(): void {} + + setUp() { + this.initialize(); + } + } + return { + Service, + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + }; +}); + +jest.mock('./rootWindow', () => ({ + getRootWindow: (...args: unknown[]) => getRootWindow(...args), +})); + +describe('ui/main/touchBar', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + jest.clearAllMocks(); + watchCallbacks.length = 0; + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + getRootWindow.mockResolvedValue({ + isVisible: () => true, + showInactive: jest.fn(), + focus: jest.fn(), + setTouchBar: jest.fn(), + }); + select.mockImplementation((selector: any) => + selector({ + servers: [{ url: 'https://a.example', title: 'A', favicon: null }], + currentView: { url: 'https://a.example' }, + isMessageBoxFocused: true, + }) + ); + jest.resetModules(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('no-ops on non-darwin platforms', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./touchBar').default; + service.setUp(); + expect(watchCallbacks).toHaveLength(0); + }); + + it('initializes touch bar service and registers state watches on darwin', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./touchBar').default; + // Override setUp to call initialize if Service mock setUp is empty + // The real Service subclass implements initialize; our mock setUp calls it. + service.setUp(); + expect(scrubberCtor).toHaveBeenCalled(); + expect(popoverCtor).toHaveBeenCalled(); + expect(segmentedCtor).toHaveBeenCalled(); + expect(touchBarCtor).toHaveBeenCalled(); + expect(watchCallbacks.length).toBe(3); + }); +}); diff --git a/src/ui/main/trayIcon.main.spec.ts b/src/ui/main/trayIcon.main.spec.ts new file mode 100644 index 0000000000..e602758749 --- /dev/null +++ b/src/ui/main/trayIcon.main.spec.ts @@ -0,0 +1,295 @@ +export {}; + +const select = jest.fn(); +const dispatch = jest.fn(); +const watchCallbacks = new Map(); +const getRootWindow = jest.fn(); +const getTrayIconPath = jest.fn((..._args: any[]) => '/icon.png'); +const getAppIconPath = jest.fn((..._args: any[]) => '/app.png'); + +const trayMethods = { + addListener: jest.fn(), + setImage: jest.fn(), + setTitle: jest.fn(), + setToolTip: jest.fn(), + setContextMenu: jest.fn(), + displayBalloon: jest.fn(), + destroy: jest.fn(), + popUpContextMenu: jest.fn(), +}; + +jest.mock('electron', () => ({ + app: { + name: 'Rocket.Chat', + quit: jest.fn(), + }, + Menu: { + buildFromTemplate: jest.fn((template) => template), + }, + nativeImage: { + createEmpty: jest.fn(() => ({})), + createFromPath: jest.fn(() => ({})), + }, + Tray: jest.fn(() => trayMethods), + webContents: { + fromId: jest.fn(() => undefined), + }, +})); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('../../store', () => { + class Service { + protected watch(selector: unknown, cb: Function) { + watchCallbacks.set(selector, cb); + // immediately invoke with a default to mirror store watch first-fire + try { + cb(true); + } catch { + // ignore + } + } + + protected initialize(): void {} + + setUp() { + this.initialize(); + } + + protected destroy(): void {} + } + return { + Service, + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + watch: jest.fn((selector: unknown, cb: Function) => { + watchCallbacks.set(selector, cb); + return jest.fn(); + }), + }; +}); + +jest.mock('../selectors', () => ({ + selectGlobalBadge: (state: any) => state.globalBadge, + selectActiveServerPresence: (state: any) => + state.activeServerPresence ?? { hasServers: false }, +})); + +jest.mock('./icons', () => ({ + getTrayIconPath: (...args: any[]) => getTrayIconPath(...args), + getAppIconPath: (...args: any[]) => getAppIconPath(...args), + getPresenceMenuIconPath: (presence: string) => `/presence/${presence}.png`, +})); + +jest.mock('./rootWindow', () => ({ + getRootWindow: () => getRootWindow(), +})); + +jest.mock('./macOSTrayGlyph', () => ({ + applyMacOSMenuBarGlyphAppearance: jest.fn((image: unknown) => image), +})); + +describe('ui/main/trayIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + watchCallbacks.clear(); + getRootWindow.mockResolvedValue({ + isVisible: () => true, + show: jest.fn(), + hide: jest.fn(), + }); + select.mockImplementation((selector: any) => { + if (typeof selector === 'function') { + return selector({ + rootWindowState: { visible: true }, + hasHideOnTrayNotificationShown: false, + globalBadge: 3, + activeServerPresence: { hasServers: false }, + }); + } + return undefined; + }); + jest.resetModules(); + }); + + it('exports a tray icon service', () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./trayIcon').default; + expect(service).toBeDefined(); + expect(typeof service.setUp).toBe('function'); + }); + + it('creates tray when enabled via initialize watch', async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { Tray } = require('electron'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./trayIcon').default; + service.setUp(); + // Allow manageTrayIcon promise to resolve + await new Promise((r) => setTimeout(r, 20)); + expect(Tray).toHaveBeenCalled(); + expect(trayMethods.setImage).toHaveBeenCalled(); + }); + + describe('disconnected icon wiring', () => { + const triggerActiveServerPresence = (activeServerPresence: any) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { selectActiveServerPresence } = require('../selectors'); + const cb = watchCallbacks.get(selectActiveServerPresence); + cb?.(activeServerPresence); + }; + + beforeEach(() => { + select.mockImplementation((selector: any) => { + if (typeof selector === 'function') { + return selector({ + rootWindowState: { visible: true }, + hasHideOnTrayNotificationShown: false, + globalBadge: undefined, + activeServerPresence: { hasServers: false }, + }); + } + return undefined; + }); + }); + + const setUpService = async () => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const service = require('./trayIcon').default; + service.setUp(); + await new Promise((r) => setTimeout(r, 20)); + getTrayIconPath.mockClear(); + }; + + it('requests the disconnected icon when connection is disconnected', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'disconnected', + loggedIn: true, + supported: true, + }); + + expect(getTrayIconPath).toHaveBeenCalledWith( + expect.objectContaining({ disconnected: true }) + ); + }); + + it('requests the disconnected icon when connection is connecting', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'connecting', + loggedIn: true, + supported: true, + }); + + expect(getTrayIconPath).toHaveBeenCalledWith( + expect.objectContaining({ disconnected: true }) + ); + }); + + it('does not request the disconnected icon when connection is unknown (fresh boot)', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: undefined, + loggedIn: true, + supported: true, + }); + + expect(getTrayIconPath).toHaveBeenCalledWith( + expect.objectContaining({ disconnected: false }) + ); + }); + + it('does not request the disconnected icon when unsupported, even if disconnected', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'disconnected', + loggedIn: true, + supported: false, + }); + + expect(getTrayIconPath).toHaveBeenCalledWith( + expect.objectContaining({ disconnected: false, presence: undefined }) + ); + }); + + it('does not request the disconnected icon when there is no active server url', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: false, + connection: 'disconnected', + }); + + expect(getTrayIconPath).toHaveBeenCalledWith( + expect.objectContaining({ disconnected: false }) + ); + }); + + it('does not request the disconnected icon when logged out, even if disconnected', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + connection: 'disconnected', + loggedIn: false, + supported: true, + }); + + expect(getTrayIconPath).toHaveBeenCalledWith( + expect.objectContaining({ disconnected: false }) + ); + }); + + it('reverts to the last known presence icon once the connection is restored', async () => { + await setUpService(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'disconnected', + loggedIn: true, + supported: true, + }); + + expect(getTrayIconPath).toHaveBeenLastCalledWith( + expect.objectContaining({ disconnected: true }) + ); + + getTrayIconPath.mockClear(); + + triggerActiveServerPresence({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'connected', + loggedIn: true, + supported: true, + }); + + expect(getTrayIconPath).toHaveBeenLastCalledWith( + expect.objectContaining({ disconnected: false, presence: 'online' }) + ); + }); + }); +}); diff --git a/src/ui/main/trayIcon.ts b/src/ui/main/trayIcon.ts index 2baca3d90e..0c7d1bca5a 100644 --- a/src/ui/main/trayIcon.ts +++ b/src/ui/main/trayIcon.ts @@ -1,16 +1,26 @@ -import { app, Menu, nativeImage, Tray } from 'electron'; +import type { MenuItemConstructorOptions } from 'electron'; +import { app, Menu, nativeImage, Tray, webContents } from 'electron'; import i18next from 'i18next'; -import type { Server } from '../../servers/common'; +import { loggers } from '../../logging/scopes'; +import type { Server, UserPresence } from '../../servers/common'; import { watch, select, Service, dispatch } from '../../store'; import type { RootState } from '../../store/rootReducer'; import { SET_HAS_TRAY_MINIMIZE_NOTIFICATION_SHOWN } from '../actions'; -import { selectGlobalBadge } from '../selectors'; -import { getTrayIconPath, getAppIconPath } from './icons'; +import type { ActiveServerPresence } from '../selectors'; +import { selectGlobalBadge, selectActiveServerPresence } from '../selectors'; +import { + getTrayIconPath, + getAppIconPath, + getPresenceMenuIconPath, +} from './icons'; +import { applyMacOSMenuBarGlyphAppearance } from './macOSTrayGlyph'; import { getRootWindow } from './rootWindow'; const t = i18next.t.bind(i18next); +const PRESENCE_CHANGE_REQUESTED_CHANNEL = 'presence/change-requested'; + const selectIsRootWindowVisible = ({ rootWindowState: { visible }, }: RootState): boolean => visible; @@ -19,6 +29,182 @@ const selectHasHideOnTrayNotificationShown = ({ hasHideOnTrayNotificationShown, }: RootState): boolean => hasHideOnTrayNotificationShown; +const requestPresenceChange = ( + activeServerPresence: ActiveServerPresence, + presence: UserPresence +): void => { + const server = select(({ servers }: RootState): Server | undefined => + servers.find((s) => s.url === activeServerPresence.url) + ); + + if (!server?.webContentsId) { + return; + } + + const targetWebContents = webContents.fromId(server.webContentsId); + targetWebContents?.send(PRESENCE_CHANGE_REQUESTED_CHANNEL, presence); +}; + +const showRootWindow = async (): Promise => { + try { + const browserWindow = await getRootWindow(); + browserWindow.show(); + } catch (error) { + loggers.ui.error( + 'Failed to show the root window from the tray menu', + error + ); + } +}; + +const PRESENCE_OPTIONS: { presence: UserPresence; labelKey: string }[] = [ + { presence: 'online', labelKey: 'tray.presence.online' }, + { presence: 'away', labelKey: 'tray.presence.away' }, + { presence: 'busy', labelKey: 'tray.presence.busy' }, + { presence: 'offline', labelKey: 'tray.presence.offline' }, +]; + +const buildPresenceMenuItems = ( + activeServerPresence: ActiveServerPresence +): MenuItemConstructorOptions[] => { + const { + url, + title, + presence, + statusText, + connection, + supported, + loggedIn, + failed, + hasServers, + isAddingServer, + } = activeServerPresence; + + if (!hasServers || isAddingServer) { + return [ + { + label: t('tray.presence.addWorkspace'), + enabled: true, + click: () => { + showRootWindow(); + }, + }, + ]; + } + + if (!url) { + return []; + } + + if (!loggedIn) { + return [ + { + label: t('tray.presence.signIn', { workspace: title ?? url }), + enabled: true, + click: () => { + showRootWindow(); + }, + }, + ]; + } + + if (supported === false || failed) { + return []; + } + + const isDisconnected = + connection === 'disconnected' || connection === 'connecting'; + const isConnected = !isDisconnected; + + const submenu: MenuItemConstructorOptions[] = PRESENCE_OPTIONS.map( + ({ presence: optionPresence, labelKey }) => ({ + label: t(labelKey), + icon: nativeImage.createFromPath(getPresenceMenuIconPath(optionPresence)), + type: 'radio', + checked: presence === optionPresence, + enabled: isConnected, + click: () => { + requestPresenceChange(activeServerPresence, optionPresence); + }, + }) + ); + + const currentOption = PRESENCE_OPTIONS.find( + (option) => option.presence === presence + ); + + const items: MenuItemConstructorOptions[] = [ + { + label: currentOption + ? t(currentOption.labelKey) + : t('tray.presence.status'), + ...(currentOption && { + icon: nativeImage.createFromPath( + getPresenceMenuIconPath(currentOption.presence) + ), + }), + submenu, + }, + ]; + + if (!isConnected) { + items.push({ + label: t('tray.presence.disconnected'), + enabled: false, + }); + } + + if (statusText) { + items.push({ + label: statusText, + enabled: false, + }); + } + + return items; +}; + +export const buildMenuTemplate = (state: { + isRootWindowVisible: boolean; + activeServerPresence: ActiveServerPresence; +}): MenuItemConstructorOptions[] => { + const { isRootWindowVisible, activeServerPresence } = state; + + const presenceItems = buildPresenceMenuItems(activeServerPresence); + + return [ + ...presenceItems, + ...(presenceItems.length > 0 ? [{ type: 'separator' as const }] : []), + { + label: isRootWindowVisible ? t('tray.menu.hide') : t('tray.menu.show'), + click: async () => { + try { + const isRootWindowVisible = select(selectIsRootWindowVisible); + const browserWindow = await getRootWindow(); + + if (isRootWindowVisible) { + browserWindow.hide(); + return; + } + + browserWindow.show(); + } catch (error) { + loggers.ui.error( + 'Failed to toggle the root window from the tray menu', + error + ); + } + }, + }, + { + label: t('tray.menu.quit'), + click: () => { + app.quit(); + }, + }, + ]; +}; + const createTrayIcon = (): Tray => { const image = getTrayIconPath({ platform: process.platform, @@ -29,6 +215,27 @@ const createTrayIcon = (): Tray => { if (process.platform !== 'darwin') { trayIcon.addListener('click', async () => { + try { + const isRootWindowVisible = select(selectIsRootWindowVisible); + const browserWindow = await getRootWindow(); + + if (isRootWindowVisible) { + browserWindow.hide(); + return; + } + + browserWindow.show(); + } catch (error) { + loggers.ui.error( + 'Failed to toggle the root window from the tray icon click', + error + ); + } + }); + } + + trayIcon.addListener('balloon-click', async () => { + try { const isRootWindowVisible = select(selectIsRootWindowVisible); const browserWindow = await getRootWindow(); @@ -38,19 +245,12 @@ const createTrayIcon = (): Tray => { } browserWindow.show(); - }); - } - - trayIcon.addListener('balloon-click', async () => { - const isRootWindowVisible = select(selectIsRootWindowVisible); - const browserWindow = await getRootWindow(); - - if (isRootWindowVisible) { - browserWindow.hide(); - return; + } catch (error) { + loggers.ui.error( + 'Failed to toggle the root window from the tray balloon click', + error + ); } - - browserWindow.show(); }); trayIcon.addListener('right-click', (_event, bounds) => { @@ -62,12 +262,33 @@ const createTrayIcon = (): Tray => { return trayIcon; }; -const updateTrayIconImage = (trayIcon: Tray, badge: Server['badge']): void => { - const image = getTrayIconPath({ +const loadTrayImage = ( + badge: Server['badge'], + presence: UserPresence | undefined, + disconnected: boolean +): ReturnType => { + const imagePath = getTrayIconPath({ platform: process.platform, badge, + presence, + disconnected, }); - trayIcon.setImage(nativeImage.createFromPath(image)); + const image = nativeImage.createFromPath(imagePath); + + if (process.platform === 'darwin' && (presence || disconnected)) { + return applyMacOSMenuBarGlyphAppearance(image); + } + + return image; +}; + +const updateTrayIconImage = ( + trayIcon: Tray, + badge: Server['badge'], + presence: UserPresence | undefined, + disconnected: boolean +): void => { + trayIcon.setImage(loadTrayImage(badge, presence, disconnected)); }; const updateTrayIconTitle = ( @@ -119,63 +340,107 @@ const warnStillRunning = (trayIcon: Tray): void => { } }; +const getActivePresenceForIcon = ( + activeServerPresence: ActiveServerPresence +): UserPresence | undefined => { + if (!activeServerPresence.url || activeServerPresence.supported === false) { + return undefined; + } + + return activeServerPresence.presence; +}; + +// Mirrors `buildPresenceMenuItems`'s `isDisconnected` predicate so the tray +// icon and the tray menu always agree on whether the workspace is reachable. +// No-url / unsupported / logged-out states win over this — those already +// return `undefined` from `getActivePresenceForIcon` and keep showing the +// base icon, matching the menu, which hides presence options entirely for +// those same states. +const isDisconnectedForIcon = ( + activeServerPresence: ActiveServerPresence +): boolean => { + if ( + !activeServerPresence.url || + activeServerPresence.supported === false || + !activeServerPresence.loggedIn + ) { + return false; + } + + return ( + activeServerPresence.connection === 'disconnected' || + activeServerPresence.connection === 'connecting' + ); +}; + const manageTrayIcon = async (): Promise<() => void> => { const trayIcon = createTrayIcon(); + let firstTrayIconBalloonShown = false; + + const refreshMenu = ( + isRootWindowVisible: boolean, + prevIsRootWindowVisible: boolean | undefined + ): void => { + const activeServerPresence = select(selectActiveServerPresence); + const menuTemplate = buildMenuTemplate({ + isRootWindowVisible, + activeServerPresence, + }); + + const menu = Menu.buildFromTemplate(menuTemplate); + trayIcon.setContextMenu(menu); + + if ( + prevIsRootWindowVisible && + !isRootWindowVisible && + process.platform === 'win32' && + !firstTrayIconBalloonShown + ) { + warnStillRunning(trayIcon); + firstTrayIconBalloonShown = true; + } + }; + const unwatchGlobalBadge = watch(selectGlobalBadge, (globalBadge) => { - updateTrayIconImage(trayIcon, globalBadge); + const activeServerPresence = select(selectActiveServerPresence); + updateTrayIconImage( + trayIcon, + globalBadge, + getActivePresenceForIcon(activeServerPresence), + isDisconnectedForIcon(activeServerPresence) + ); updateTrayIconTitle(trayIcon, globalBadge); updateTrayIconToolTip(trayIcon, globalBadge); }); - let firstTrayIconBalloonShown = false; - const unwatchIsRootWindowVisible = watch( selectIsRootWindowVisible, (isRootWindowVisible, prevIsRootWindowVisible) => { - const menuTemplate = [ - { - label: isRootWindowVisible - ? t('tray.menu.hide') - : t('tray.menu.show'), - click: async () => { - const isRootWindowVisible = select(selectIsRootWindowVisible); - const browserWindow = await getRootWindow(); - - if (isRootWindowVisible) { - browserWindow.hide(); - return; - } - - browserWindow.show(); - }, - }, - { - label: t('tray.menu.quit'), - click: () => { - app.quit(); - }, - }, - ]; - - const menu = Menu.buildFromTemplate(menuTemplate); - trayIcon.setContextMenu(menu); - - if ( - prevIsRootWindowVisible && - !isRootWindowVisible && - process.platform === 'win32' && - !firstTrayIconBalloonShown - ) { - warnStillRunning(trayIcon); - firstTrayIconBalloonShown = true; - } + refreshMenu(isRootWindowVisible, prevIsRootWindowVisible); + } + ); + + const unwatchActiveServerPresence = watch( + selectActiveServerPresence, + (activeServerPresence) => { + const isRootWindowVisible = select(selectIsRootWindowVisible); + refreshMenu(isRootWindowVisible, isRootWindowVisible); + + const globalBadge = select(selectGlobalBadge); + updateTrayIconImage( + trayIcon, + globalBadge, + getActivePresenceForIcon(activeServerPresence), + isDisconnectedForIcon(activeServerPresence) + ); } ); return () => { unwatchGlobalBadge(); unwatchIsRootWindowVisible(); + unwatchActiveServerPresence(); trayIcon.destroy(); }; }; diff --git a/src/ui/main/trayIconMenu.main.spec.ts b/src/ui/main/trayIconMenu.main.spec.ts new file mode 100644 index 0000000000..921e4b3d80 --- /dev/null +++ b/src/ui/main/trayIconMenu.main.spec.ts @@ -0,0 +1,383 @@ +export {}; + +jest.mock('i18next', () => ({ + t: (key: string, opts?: { workspace?: string }) => + opts?.workspace ? `${key}:${opts.workspace}` : key, +})); + +jest.mock('electron', () => ({ + app: { + name: 'Rocket.Chat', + quit: jest.fn(), + }, + Menu: { + buildFromTemplate: jest.fn((template) => template), + }, + nativeImage: { + createEmpty: jest.fn(() => ({})), + createFromPath: jest.fn(() => ({})), + }, + Tray: jest.fn(), + webContents: { + fromId: jest.fn(() => undefined), + }, +})); + +jest.mock('../../store', () => ({ + Service: class Service {}, + select: jest.fn(), + dispatch: jest.fn(), + watch: jest.fn(() => jest.fn()), +})); + +jest.mock('../selectors', () => ({ + selectGlobalBadge: jest.fn(), + selectActiveServerPresence: jest.fn(), +})); + +jest.mock('./icons', () => ({ + getTrayIconPath: jest.fn(() => '/icon.png'), + getAppIconPath: jest.fn(() => '/app.png'), + getPresenceMenuIconPath: jest.fn( + (presence: string) => `/presence/${presence}.png` + ), +})); + +jest.mock('./rootWindow', () => ({ + getRootWindow: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const trayIconModule = require('./trayIcon'); + +type ActiveServerPresence = { + url?: string; + title?: string; + presence?: 'online' | 'away' | 'busy' | 'offline'; + statusText?: string; + connection?: 'connected' | 'connecting' | 'disconnected'; + supported?: boolean; + loggedIn?: boolean; + failed?: boolean; + hasServers: boolean; + isAddingServer?: boolean; +}; + +const baseState = ( + activeServerPresence: ActiveServerPresence, + isRootWindowVisible = true +) => ({ isRootWindowVisible, activeServerPresence }); + +const buildMenuTemplate = (state: { + isRootWindowVisible: boolean; + activeServerPresence: ActiveServerPresence; +}): any[] => trayIconModule.buildMenuTemplate(state); + +const findItem = (template: any[], labelIncludes: string) => + template.find( + (item) => + typeof item.label === 'string' && item.label.includes(labelIncludes) + ); + +describe('ui/main/trayIcon buildMenuTemplate', () => { + it('always includes Show/Hide and Quit', () => { + const template = buildMenuTemplate(baseState({ hasServers: false })); + + expect(findItem(template, 'tray.menu.hide')).toBeDefined(); + expect(findItem(template, 'tray.menu.quit')).toBeDefined(); + }); + + it('shows label as Show when root window is not visible', () => { + const template = buildMenuTemplate(baseState({ hasServers: false }, false)); + + expect(findItem(template, 'tray.menu.show')).toBeDefined(); + }); + + const findPresenceRootItem = (template: any[]) => + template.find((item) => Array.isArray(item.submenu)); + + it('shows the current presence as the top-level label/icon and four radios in the submenu when connected', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + title: 'My Server', + presence: 'away', + connection: 'connected', + loggedIn: true, + supported: true, + }) + ); + + const rootItem = findPresenceRootItem(template); + expect(rootItem).toBeDefined(); + expect(rootItem.label).toBe('tray.presence.away'); + expect(rootItem.icon).toBeDefined(); + + const radios = rootItem.submenu.filter( + (item: any) => item.type === 'radio' + ); + expect(radios).toHaveLength(4); + expect(radios.map((item: any) => item.checked)).toEqual([ + false, + true, + false, + false, + ]); + radios.forEach((item: any) => { + expect(item.enabled).toBe(true); + expect(item.icon).toBeDefined(); + }); + }); + + it('disables submenu radios and shows a disconnected line when connecting', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'connecting', + loggedIn: true, + supported: true, + }) + ); + + const rootItem = findPresenceRootItem(template); + const radios = rootItem.submenu.filter( + (item: any) => item.type === 'radio' + ); + expect(radios).toHaveLength(4); + radios.forEach((item: any) => expect(item.enabled).toBe(false)); + + expect(findItem(template, 'tray.presence.disconnected')).toBeDefined(); + }); + + it('disables submenu radios and shows a disconnected line when disconnected', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + connection: 'disconnected', + loggedIn: true, + supported: true, + }) + ); + + const rootItem = findPresenceRootItem(template); + const radios = rootItem.submenu.filter( + (item: any) => item.type === 'radio' + ); + expect(radios).toHaveLength(4); + radios.forEach((item: any) => expect(item.enabled).toBe(false)); + + expect(findItem(template, 'tray.presence.disconnected')).toBeDefined(); + }); + + it('disables submenu radios and shows a disconnected line when presence disconnection is simulated (connection forced to disconnected while otherwise connected)', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + // Mirrors what selectActiveServerPresence returns when + // isPresenceDisconnectionSimulated overrides a real 'connected' value. + connection: 'disconnected', + loggedIn: true, + supported: true, + }) + ); + + const rootItem = findPresenceRootItem(template); + const radios = rootItem.submenu.filter( + (item: any) => item.type === 'radio' + ); + expect(radios).toHaveLength(4); + radios.forEach((item: any) => expect(item.enabled).toBe(false)); + + expect(findItem(template, 'tray.presence.disconnected')).toBeDefined(); + }); + + it('enables submenu radios and shows no disconnected line when connection is not yet known (fresh boot)', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'away', + connection: undefined, + loggedIn: true, + supported: true, + }) + ); + + const rootItem = findPresenceRootItem(template); + const radios = rootItem.submenu.filter( + (item: any) => item.type === 'radio' + ); + expect(radios).toHaveLength(4); + radios.forEach((item: any) => expect(item.enabled).toBe(true)); + + expect(findItem(template, 'tray.presence.disconnected')).toBeUndefined(); + }); + + it('labels the top-level item with the neutral status label and no icon, with none checked in the submenu, when presence and connection are both not yet known', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: undefined, + connection: undefined, + loggedIn: true, + supported: true, + }) + ); + + const rootItem = findPresenceRootItem(template); + expect(rootItem.label).toBe('tray.presence.status'); + expect(rootItem.icon).toBeUndefined(); + + const radios = rootItem.submenu.filter( + (item: any) => item.type === 'radio' + ); + expect(radios).toHaveLength(4); + radios.forEach((item: any) => expect(item.checked).toBe(false)); + }); + + it('shows a disabled custom status line when statusText is set', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + statusText: 'Out for lunch', + connection: 'connected', + loggedIn: true, + supported: true, + }) + ); + + const statusItem = findItem(template, 'Out for lunch'); + expect(statusItem).toBeDefined(); + expect(statusItem.enabled).toBe(false); + }); + + it('omits the custom status line when statusText is empty', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + statusText: '', + connection: 'connected', + loggedIn: true, + supported: true, + }) + ); + + expect(template.some((item) => item.label === '')).toBe(false); + }); + + it('shows a Sign in item and no radios when logged out', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + title: 'My Server', + loggedIn: false, + supported: true, + }) + ); + + const radios = template.filter((item) => item.type === 'radio'); + expect(radios).toHaveLength(0); + expect(findItem(template, 'tray.presence.signIn:My Server')).toBeDefined(); + }); + + it('shows an Add workspace item and no radios when there are no servers', () => { + const template = buildMenuTemplate(baseState({ hasServers: false })); + + const radios = template.filter((item) => item.type === 'radio'); + expect(radios).toHaveLength(0); + expect(findItem(template, 'tray.presence.addWorkspace')).toBeDefined(); + }); + + it('shows an Add workspace item and no radios when on the add-workspace screen with existing servers', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + isAddingServer: true, + url: 'https://server.test', + presence: 'online', + connection: 'connected', + loggedIn: true, + supported: true, + }) + ); + + const radios = template.filter((item) => item.type === 'radio'); + expect(radios).toHaveLength(0); + expect(findItem(template, 'tray.presence.addWorkspace')).toBeDefined(); + }); + + it('hides presence items entirely when unsupported', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + statusText: 'Some status', + connection: 'connected', + loggedIn: true, + supported: false, + }) + ); + + const radios = template.filter((item) => item.type === 'radio'); + expect(radios).toHaveLength(0); + expect(template.some((item) => item.label === 'Some status')).toBe(false); + expect(findItem(template, 'tray.presence.signIn')).toBeUndefined(); + expect(findItem(template, 'tray.presence.addWorkspace')).toBeUndefined(); + expect(findItem(template, 'tray.menu.hide')).toBeDefined(); + expect(findItem(template, 'tray.menu.quit')).toBeDefined(); + }); + + it('hides presence items entirely when the active server failed to load', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + presence: 'online', + statusText: 'Some status', + connection: 'connected', + loggedIn: true, + supported: true, + failed: true, + }) + ); + + expect(findPresenceRootItem(template)).toBeUndefined(); + const radios = template.filter((item) => item.type === 'radio'); + expect(radios).toHaveLength(0); + expect(template.some((item) => item.label === 'Some status')).toBe(false); + expect(findItem(template, 'tray.presence.signIn')).toBeUndefined(); + expect(findItem(template, 'tray.presence.addWorkspace')).toBeUndefined(); + expect(findItem(template, 'tray.menu.hide')).toBeDefined(); + expect(findItem(template, 'tray.menu.quit')).toBeDefined(); + }); + + it('shows a Sign in item when logged out and unsupported', () => { + const template = buildMenuTemplate( + baseState({ + hasServers: true, + url: 'https://server.test', + title: 'My Server', + loggedIn: false, + supported: false, + }) + ); + + const radios = template.filter((item) => item.type === 'radio'); + expect(radios).toHaveLength(0); + expect(findItem(template, 'tray.presence.signIn:My Server')).toBeDefined(); + }); +}); diff --git a/src/ui/reducers/__tests__/stateGroups.spec.ts b/src/ui/reducers/__tests__/stateGroups.spec.ts index 57fb942866..2d89aaf29e 100644 --- a/src/ui/reducers/__tests__/stateGroups.spec.ts +++ b/src/ui/reducers/__tests__/stateGroups.spec.ts @@ -54,9 +54,9 @@ describe('currentView reducer', () => { expect( currentView('add-new-server', { type: APP_SETTINGS_LOADED, - payload: { currentView: 'downloads' }, + payload: { currentView: { url: 'https://saved.example' } }, } as any) - ).toEqual('downloads'); + ).toEqual({ url: 'https://saved.example' }); expect( currentView('add-new-server', { type: DEEP_LINKS_SERVER_ADDED, @@ -71,13 +71,29 @@ describe('currentView reducer', () => { ).toEqual({ url: 'https://focused.example' }); }); - it('handles focus/download/action transitions', () => { + it.each(['downloads', 'settings'])( + 'ignores a persisted %s view, which the root window no longer has', + (retired) => { + // Restoring it would strand the reader there: the sidebar buttons that + // used to leave those views now open windows instead. + expect( + currentView({ url: 'https://abc' }, { + type: APP_SETTINGS_LOADED, + payload: { currentView: retired }, + } as any) + ).toEqual({ url: 'https://abc' }); + } + ); + + it('focuses the requested server whatever view the request names', () => { + // Downloads open in a window of their own now, so a focus request only + // ever selects a server. expect( currentView({ url: 'https://abc' }, { type: uiActions.WEBVIEW_FOCUS_REQUESTED, payload: { url: 'https://focused.example', view: 'downloads' }, } as any) - ).toBe('downloads'); + ).toEqual({ url: 'https://focused.example' }); expect( currentView({ url: 'https://abc' }, { @@ -122,17 +138,19 @@ describe('currentView reducer', () => { } as any) ).toEqual('add-new-server'); + // Downloads open in their own window, so the root window keeps its view. expect( currentView('settings', { type: uiActions.SIDE_BAR_DOWNLOADS_BUTTON_CLICKED, } as any) - ).toEqual('downloads'); + ).toEqual('settings'); + // Settings open in their own window now, so the root window stays put. expect( currentView('downloads', { type: uiActions.SIDE_BAR_SETTINGS_BUTTON_CLICKED, } as any) - ).toEqual('settings'); + ).toEqual('downloads'); expect( currentView('downloads', { @@ -311,10 +329,7 @@ describe('dialogs reducer', () => { describe('openDialog reducer', () => { it('sets and clears open dialog state', () => { expect( - openDialog(null, { type: uiActions.MENU_BAR_ABOUT_CLICKED } as any) - ).toBe('about'); - expect( - openDialog('about', { + openDialog('screen-sharing', { type: navigationActions.CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED, } as any) ).toBe('select-client-certificate'); @@ -335,23 +350,15 @@ describe('openDialog reducer', () => { }); it('ignores unknown actions', () => { - expect(openDialog('about', { type: 'UNKNOWN_DIALOG_ACTION' } as any)).toBe( - 'about' - ); + expect( + openDialog('screen-sharing', { type: 'UNKNOWN_DIALOG_ACTION' } as any) + ).toBe('screen-sharing'); expect( openDialog(undefined, { type: 'UNKNOWN_DIALOG_ACTION' } as any) ).toBe(null); }); it('handles dismiss actions and alternate branches', () => { - expect( - openDialog('about', { type: uiActions.ABOUT_DIALOG_DISMISSED } as any) - ).toBe(null); - - expect( - openDialog('settings', { type: uiActions.ABOUT_DIALOG_DISMISSED } as any) - ).toBe('settings'); - expect( openDialog(null, { type: outlookActions.OUTLOOK_CALENDAR_DIALOG_DISMISSED, diff --git a/src/ui/reducers/currentView.ts b/src/ui/reducers/currentView.ts index 3b20f95773..dddc99158c 100644 --- a/src/ui/reducers/currentView.ts +++ b/src/ui/reducers/currentView.ts @@ -42,6 +42,17 @@ type CurrentViewState = | 'settings' | { url: string }; +/** + * Views the root window no longer has: downloads and settings each open in a + * window of their own now. + * + * A profile saved before that change still names one, and restoring it would + * strand the reader on a view whose only way out was the sidebar button that + * now opens a window instead. + */ +const isRetiredView = (view: CurrentViewState): boolean => + view === 'downloads' || view === 'settings'; + export const currentView = ( state: CurrentViewState = 'add-new-server', action: CurrentViewAction @@ -58,8 +69,7 @@ export const currentView = ( } case WEBVIEW_FOCUS_REQUESTED: { - const { url, view } = action.payload; - if (view === 'downloads') return 'downloads'; + const { url } = action.payload; return { url }; } @@ -70,7 +80,7 @@ export const currentView = ( case APP_SETTINGS_LOADED: { const { currentView = state } = action.payload; - return currentView; + return isRetiredView(currentView) ? state : currentView; } case MENU_BAR_ADD_NEW_SERVER_CLICKED: @@ -85,11 +95,15 @@ export const currentView = ( return state; } + // Downloads open in their own window now, so the root window keeps + // whatever view it was on. case SIDE_BAR_DOWNLOADS_BUTTON_CLICKED: - return 'downloads'; + return state; + // Settings open in their own window now, so the root window keeps + // whatever view it was on. case SIDE_BAR_SETTINGS_BUTTON_CLICKED: - return 'settings'; + return state; default: return state; diff --git a/src/ui/reducers/isDownloadsWindowOpen.ts b/src/ui/reducers/isDownloadsWindowOpen.ts new file mode 100644 index 0000000000..c410f93705 --- /dev/null +++ b/src/ui/reducers/isDownloadsWindowOpen.ts @@ -0,0 +1,42 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { DOWNLOADS_WINDOW_OPEN_STATE_CHANGED } from '../actions'; + +type IsDownloadsWindowOpenAction = + | ActionOf + | ActionOf; + +/** + * Whether the downloads window was open when the app last shut down, so it can + * be restored on the next launch. + * + * Only a deliberate close writes `false`; the window closing as part of quitting + * leaves the flag alone, which is what makes the restore work at all. + */ +export const isDownloadsWindowOpen: Reducer< + boolean, + IsDownloadsWindowOpenAction +> = (state = false, action) => { + switch (action.type) { + case DOWNLOADS_WINDOW_OPEN_STATE_CHANGED: { + const { payload } = action; + if (typeof payload === 'boolean') { + return payload; + } + console.warn( + `Invalid payload type for ${DOWNLOADS_WINDOW_OPEN_STATE_CHANGED}: expected boolean, got ${typeof payload}` + ); + return state; + } + + case APP_SETTINGS_LOADED: { + const { isDownloadsWindowOpen = state } = action.payload; + return isDownloadsWindowOpen; + } + + default: + return state; + } +}; diff --git a/src/ui/reducers/isLogViewerWindowOpen.spec.ts b/src/ui/reducers/isLogViewerWindowOpen.spec.ts new file mode 100644 index 0000000000..4294a02115 --- /dev/null +++ b/src/ui/reducers/isLogViewerWindowOpen.spec.ts @@ -0,0 +1,61 @@ +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import { LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED } from '../actions'; +import { isLogViewerWindowOpen } from './isLogViewerWindowOpen'; + +describe('isLogViewerWindowOpen', () => { + it('defaults to closed', () => { + expect(isLogViewerWindowOpen(undefined, { type: 'unknown' } as any)).toBe( + false + ); + }); + + it('records the window opening and closing', () => { + expect( + isLogViewerWindowOpen(false, { + type: LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED, + payload: true, + }) + ).toBe(true); + + expect( + isLogViewerWindowOpen(true, { + type: LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED, + payload: false, + }) + ).toBe(false); + }); + + it('ignores a non-boolean payload rather than persisting junk', () => { + const warn = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + + expect( + isLogViewerWindowOpen(true, { + type: LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED, + payload: 'yes' as any, + }) + ).toBe(true); + expect(warn).toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it('restores the persisted value on load', () => { + expect( + isLogViewerWindowOpen(false, { + type: APP_SETTINGS_LOADED, + payload: { isLogViewerWindowOpen: true }, + } as any) + ).toBe(true); + }); + + it('keeps the current value when settings omit the key', () => { + expect( + isLogViewerWindowOpen(true, { + type: APP_SETTINGS_LOADED, + payload: {}, + } as any) + ).toBe(true); + }); +}); diff --git a/src/ui/reducers/isLogViewerWindowOpen.ts b/src/ui/reducers/isLogViewerWindowOpen.ts new file mode 100644 index 0000000000..bd65c77947 --- /dev/null +++ b/src/ui/reducers/isLogViewerWindowOpen.ts @@ -0,0 +1,42 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED } from '../actions'; + +type IsLogViewerWindowOpenAction = + | ActionOf + | ActionOf; + +/** + * Whether the log viewer window was open when the app last shut down, so it can + * be restored on the next launch. + * + * Only a deliberate close writes `false`; the window closing as part of quitting + * leaves the flag alone, which is what makes the restore work at all. + */ +export const isLogViewerWindowOpen: Reducer< + boolean, + IsLogViewerWindowOpenAction +> = (state = false, action) => { + switch (action.type) { + case LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED: { + const { payload } = action; + if (typeof payload === 'boolean') { + return payload; + } + console.warn( + `Invalid payload type for ${LOG_VIEWER_WINDOW_OPEN_STATE_CHANGED}: expected boolean, got ${typeof payload}` + ); + return state; + } + + case APP_SETTINGS_LOADED: { + const { isLogViewerWindowOpen = state } = action.payload; + return isLogViewerWindowOpen; + } + + default: + return state; + } +}; diff --git a/src/ui/reducers/isPresenceDisconnectionSimulated.ts b/src/ui/reducers/isPresenceDisconnectionSimulated.ts new file mode 100644 index 0000000000..d4b4553513 --- /dev/null +++ b/src/ui/reducers/isPresenceDisconnectionSimulated.ts @@ -0,0 +1,21 @@ +import type { Reducer } from 'redux'; + +import type { ActionOf } from '../../store/actions'; +import { SET_PRESENCE_DISCONNECTION_SIMULATED } from '../actions'; + +type IsPresenceDisconnectionSimulatedAction = ActionOf< + typeof SET_PRESENCE_DISCONNECTION_SIMULATED +>; + +export const isPresenceDisconnectionSimulated: Reducer< + boolean, + IsPresenceDisconnectionSimulatedAction +> = (state = false, action) => { + switch (action.type) { + case SET_PRESENCE_DISCONNECTION_SIMULATED: + return action.payload; + + default: + return state; + } +}; diff --git a/src/ui/reducers/isSettingsWindowOpen.ts b/src/ui/reducers/isSettingsWindowOpen.ts new file mode 100644 index 0000000000..5172230482 --- /dev/null +++ b/src/ui/reducers/isSettingsWindowOpen.ts @@ -0,0 +1,42 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { SETTINGS_WINDOW_OPEN_STATE_CHANGED } from '../actions'; + +type IsSettingsWindowOpenAction = + | ActionOf + | ActionOf; + +/** + * Whether the settings window was open when the app last shut down, so it can + * be restored on the next launch. + * + * Only a deliberate close writes `false`; the window closing as part of quitting + * leaves the flag alone, which is what makes the restore work at all. + */ +export const isSettingsWindowOpen: Reducer< + boolean, + IsSettingsWindowOpenAction +> = (state = false, action) => { + switch (action.type) { + case SETTINGS_WINDOW_OPEN_STATE_CHANGED: { + const { payload } = action; + if (typeof payload === 'boolean') { + return payload; + } + console.warn( + `Invalid payload type for ${SETTINGS_WINDOW_OPEN_STATE_CHANGED}: expected boolean, got ${typeof payload}` + ); + return state; + } + + case APP_SETTINGS_LOADED: { + const { isSettingsWindowOpen = state } = action.payload; + return isSettingsWindowOpen; + } + + default: + return state; + } +}; diff --git a/src/ui/reducers/openDialog.ts b/src/ui/reducers/openDialog.ts index 76d2f4cc70..e52241b207 100644 --- a/src/ui/reducers/openDialog.ts +++ b/src/ui/reducers/openDialog.ts @@ -14,16 +14,12 @@ import { SCREEN_SHARING_DIALOG_DISMISSED } from '../../screenSharing/actions'; import type { ActionOf } from '../../store/actions'; import type { SUPPORTED_VERSION_DIALOG_DISMISS } from '../actions'; import { - ABOUT_DIALOG_DISMISSED, - MENU_BAR_ABOUT_CLICKED, WEBVIEW_SCREEN_SHARING_SOURCE_REQUESTED, WEBVIEW_SCREEN_SHARING_SOURCE_RESPONDED, } from '../actions'; type OpenDialogAction = - | ActionOf | ActionOf - | ActionOf | ActionOf | ActionOf | ActionOf @@ -39,21 +35,12 @@ export const openDialog: Reducer = ( action ) => { switch (action.type) { - case MENU_BAR_ABOUT_CLICKED: - return 'about'; - case WEBVIEW_SCREEN_SHARING_SOURCE_REQUESTED: return 'screen-sharing'; case CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED: return 'select-client-certificate'; - case ABOUT_DIALOG_DISMISSED: - if (state === 'about') { - return null; - } - return state; - case OUTLOOK_CALENDAR_ASK_CREDENTIALS: return 'outlook-credentials'; diff --git a/src/ui/reducers/secondaryWindowStates.ts b/src/ui/reducers/secondaryWindowStates.ts new file mode 100644 index 0000000000..f0c9a65a10 --- /dev/null +++ b/src/ui/reducers/secondaryWindowStates.ts @@ -0,0 +1,41 @@ +import type { Reducer } from 'redux'; + +import { APP_SETTINGS_LOADED } from '../../app/actions'; +import type { ActionOf } from '../../store/actions'; +import { SECONDARY_WINDOW_STATE_CHANGED } from '../actions'; + +export type SecondaryWindowStates = Record< + string, + { x: number; y: number; width: number; height: number } +>; + +type SecondaryWindowStatesAction = + | ActionOf + | ActionOf; + +/** + * Where each secondary window was last placed, keyed by window. + * + * One map rather than a value per window: they all remember the same thing, and + * a new window should not need its own reducer, action and persisted key. + */ +export const secondaryWindowStates: Reducer< + SecondaryWindowStates, + SecondaryWindowStatesAction +> = (state = {}, action) => { + switch (action.type) { + case SECONDARY_WINDOW_STATE_CHANGED: { + const { id, bounds } = action.payload; + if (!id || !bounds) return state; + return { ...state, [id]: bounds }; + } + + case APP_SETTINGS_LOADED: { + const { secondaryWindowStates = state } = action.payload; + return secondaryWindowStates ?? state; + } + + default: + return state; + } +}; diff --git a/src/ui/selectors.ts b/src/ui/selectors.ts index 7c134db7d4..1258cd88f1 100644 --- a/src/ui/selectors.ts +++ b/src/ui/selectors.ts @@ -1,6 +1,6 @@ import { createSelector } from 'reselect'; -import type { Server } from '../servers/common'; +import type { Server, UserPresence } from '../servers/common'; import type { RootState } from '../store/rootReducer'; export type Selector = (state: RootState) => T; @@ -43,3 +43,67 @@ export const selectGlobalBadgeCount = createSelector( selectGlobalBadge, (badge): number => (isBadgeCount(badge) ? badge : 0) ); + +export type ActiveServerPresence = { + url?: string; + title?: string; + presence?: UserPresence; + statusText?: string; + connection?: 'connected' | 'connecting' | 'disconnected'; + supported?: boolean; + loggedIn?: boolean; + failed?: boolean; + hasServers: boolean; + isAddingServer: boolean; +}; + +const toHref = (url: string): string => { + try { + return new URL(url).href; + } catch { + return url; + } +}; + +export const selectActiveServerPresence = createSelector( + ({ servers }: RootState) => servers, + ({ currentView }: RootState) => currentView, + ({ isPresenceDisconnectionSimulated }: RootState) => + isPresenceDisconnectionSimulated, + ( + servers, + currentView, + isPresenceDisconnectionSimulated + ): ActiveServerPresence => { + const hasServers = servers.length > 0; + const isAddingServer = currentView === 'add-new-server'; + + if (typeof currentView !== 'object' || !currentView.url) { + return { hasServers, isAddingServer }; + } + + const currentViewHref = toHref(currentView.url); + const activeServer = servers.find( + (server) => toHref(server.url) === currentViewHref + ); + + if (!activeServer) { + return { hasServers, isAddingServer }; + } + + return { + url: activeServer.url, + title: activeServer.title, + presence: activeServer.presence, + statusText: activeServer.presenceStatusText, + connection: isPresenceDisconnectionSimulated + ? 'disconnected' + : activeServer.presenceConnection, + supported: activeServer.presenceSupported, + loggedIn: activeServer.userLoggedIn, + failed: activeServer.failed, + hasServers, + isAddingServer, + }; + } +); diff --git a/src/ui/windowChrome/DayHeader.tsx b/src/ui/windowChrome/DayHeader.tsx new file mode 100644 index 0000000000..002a993c88 --- /dev/null +++ b/src/ui/windowChrome/DayHeader.tsx @@ -0,0 +1,52 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +import { SectionLabel } from './SectionLabel'; +import type { Surfaces } from './appearance'; + +export type DayHeaderProps = { + label: string; + surfaces: Surfaces; + /** Blur the sticky fill only over a vibrant window. */ + blurred?: boolean; + /** Trailing content, such as how many entries the day holds. */ + trailing?: ReactNode; + /** + * Pin to the top of the scroller. Virtual lists render these as group headers + * and stick them themselves, so those pass `false`. + */ + sticky?: boolean; +}; + +/** + * Day heading for a run of rows, so the date stays readable at any scroll + * position rather than only where the day changes. + */ +export const DayHeader = ({ + label, + surfaces, + trailing, + sticky = false, + blurred = false, +}: DayHeaderProps) => ( + + {label} + {trailing !== undefined && ( + + {trailing} + + )} + +); diff --git a/src/ui/windowChrome/FilterRow.tsx b/src/ui/windowChrome/FilterRow.tsx new file mode 100644 index 0000000000..154f7b8566 --- /dev/null +++ b/src/ui/windowChrome/FilterRow.tsx @@ -0,0 +1,112 @@ +import { Box, CheckBox } from '@rocket.chat/fuselage'; +import type { KeyboardEvent, MouseEvent } from 'react'; +import { useCallback } from 'react'; + +import { FILTER_ROW_CLASS } from './styles'; + +export type FilterRowProps = { + label: string; + title?: string; + count?: number; + checked: boolean; + accent?: string; + onToggle: () => void; +}; + +/** + * A checkbox filter whose entire row is the hit target. + * + * Clicks on the checkbox are kept from reaching the row. Fuselage draws it as a + * label wrapping a real input, so a click on the visible box reaches the row + * twice — once on its way up from the box, once from the click the label + * forwards to the input — and the filter toggled straight back to where it + * started. The checkbox reports its own change instead, and the row handles + * everywhere else. + */ +export const FilterRow = ({ + label, + title, + count, + checked, + accent, + onToggle, +}: FilterRowProps) => { + const handleCheckBoxClick = useCallback((event: MouseEvent) => { + event.stopPropagation(); + }, []); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + onToggle(); + } + }, + [onToggle] + ); + + return ( + + + + + {accent && ( + + )} + + {label} + + {count !== undefined && ( + + {count} + + )} + + ); +}; diff --git a/src/ui/windowChrome/FilterSection.tsx b/src/ui/windowChrome/FilterSection.tsx new file mode 100644 index 0000000000..bdac650292 --- /dev/null +++ b/src/ui/windowChrome/FilterSection.tsx @@ -0,0 +1,45 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +import { SectionLabel } from './SectionLabel'; +import { TextButton } from './TextButton'; + +export type FilterSectionProps = { + title: string; + children: ReactNode; + /** + * Resets this section to "everything selected". Rendered only when something + * is deselected, so the control appears exactly where the narrowing happened + * instead of one shared button at the bottom of the sidebar. + */ + selectAllLabel?: string; + onSelectAll?: () => void; + canSelectAll?: boolean; +}; + +/** Sidebar group heading, styled after a native inspector section label. */ +export const FilterSection = ({ + title, + children, + selectAllLabel, + onSelectAll, + canSelectAll = false, +}: FilterSectionProps) => ( + + + {title} + {canSelectAll && onSelectAll && ( + {selectAllLabel} + )} + + {children} + +); diff --git a/src/ui/windowChrome/NavRow.tsx b/src/ui/windowChrome/NavRow.tsx new file mode 100644 index 0000000000..6cbbf084d6 --- /dev/null +++ b/src/ui/windowChrome/NavRow.tsx @@ -0,0 +1,92 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { KeyboardEvent } from 'react'; +import { useCallback } from 'react'; + +import type { Surfaces } from './appearance'; +import { FILTER_ROW_CLASS } from './styles'; + +export type NavRowProps = { + label: string; + /** Secondary line, e.g. why a search matched this row. */ + description?: string; + icon?: IconName; + isSelected: boolean; + surfaces: Surfaces; + onSelect: () => void; + id?: string; + controlsId?: string; +}; + +/** + * A selectable sidebar row, for windows whose sidebar navigates sections rather + * than filtering a list. Shares the filter row's hover treatment so both kinds + * of sidebar feel the same. + * + * Every row carries the same text and icon colour: the fill behind the selected + * one already says which is selected, and dimming the rest made the list read as + * mostly disabled. + */ +export const NavRow = ({ + label, + description, + icon, + isSelected, + surfaces, + onSelect, + id, + controlsId, +}: NavRowProps) => { + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + onSelect(); + } + }, + [onSelect] + ); + + return ( + + {icon && ( + + )} + + + {label} + + {description && ( + + {description} + + )} + + + ); +}; diff --git a/src/ui/windowChrome/SectionLabel.tsx b/src/ui/windowChrome/SectionLabel.tsx new file mode 100644 index 0000000000..86bbac9b1a --- /dev/null +++ b/src/ui/windowChrome/SectionLabel.tsx @@ -0,0 +1,18 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +export type SectionLabelProps = { + children: ReactNode; +}; + +/** Uppercase inspector-style label used by sidebar sections and list headers. */ +export const SectionLabel = ({ children }: SectionLabelProps) => ( + + {children} + +); diff --git a/src/ui/windowChrome/SidebarPlaceholder.tsx b/src/ui/windowChrome/SidebarPlaceholder.tsx new file mode 100644 index 0000000000..a12c3c1607 --- /dev/null +++ b/src/ui/windowChrome/SidebarPlaceholder.tsx @@ -0,0 +1,38 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; + +export type SidebarPlaceholderProps = { + icon: IconName; + children: string; +}; + +/** + * Stands in for the filter sections while there is nothing to filter. + * + * Without it the sidebar reads as a rendering failure — a search field over an + * empty column — rather than as a list that has not been populated yet. + */ +export const SidebarPlaceholder = ({ + icon, + children, +}: SidebarPlaceholderProps) => ( + + + + {children} + + +); diff --git a/src/ui/windowChrome/StatusBar.tsx b/src/ui/windowChrome/StatusBar.tsx new file mode 100644 index 0000000000..a2c00c4724 --- /dev/null +++ b/src/ui/windowChrome/StatusBar.tsx @@ -0,0 +1,36 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +import { CARD_INSET } from './appearance'; + +export type StatusBarProps = { + children: ReactNode; + /** Pushed to the trailing edge — a destructive link, usually. */ + action?: ReactNode; +}; + +/** + * The strip under the content card. It lives inside the content column rather + * than spanning the window, so it starts where the list does instead of cutting + * across the sidebar, and its padding lines its content up with the card's. + */ +export const StatusBar = ({ children, action }: StatusBarProps) => ( + + {children} + + {action} + +); diff --git a/src/ui/windowChrome/StatusItem.tsx b/src/ui/windowChrome/StatusItem.tsx new file mode 100644 index 0000000000..46a975bf3e --- /dev/null +++ b/src/ui/windowChrome/StatusItem.tsx @@ -0,0 +1,25 @@ +import { Box, Icon } from '@rocket.chat/fuselage'; +import type { Keys as IconName } from '@rocket.chat/icons'; +import type { ReactNode } from 'react'; + +export type StatusItemProps = { + icon: IconName; + children: ReactNode; + title?: string; +}; + +/** One icon-and-value cell of a window's status bar. */ +export const StatusItem = ({ icon, children, title }: StatusItemProps) => ( + + + + {children} + + +); diff --git a/src/ui/windowChrome/TextButton.tsx b/src/ui/windowChrome/TextButton.tsx new file mode 100644 index 0000000000..6b73f9fba4 --- /dev/null +++ b/src/ui/windowChrome/TextButton.tsx @@ -0,0 +1,46 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +import { TEXT_BUTTON_CLASS } from './styles'; + +export type TextButtonProps = { + children: ReactNode; + danger?: boolean; + title?: string; + onClick: () => void; +}; + +/** + * A link-weight control for secondary actions that sit inside dense chrome — + * section headers and the status bar — where a real button's fill and padding + * would outweigh what it does. + */ +export const TextButton = ({ + children, + danger, + title, + onClick, +}: TextButtonProps) => ( + + {children} + +); diff --git a/src/ui/windowChrome/WindowControls.tsx b/src/ui/windowChrome/WindowControls.tsx new file mode 100644 index 0000000000..effc6a64ee --- /dev/null +++ b/src/ui/windowChrome/WindowControls.tsx @@ -0,0 +1,93 @@ +import { ipcRenderer } from 'electron'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { invoke } from '../../ipc/renderer'; +import { CloseGlyph } from '../components/TabBar/CloseGlyph'; +import { MaximizeGlyph } from '../components/TabBar/MaximizeGlyph'; +import { MinimizeGlyph } from '../components/TabBar/MinimizeGlyph'; +import { RestoreGlyph } from '../components/TabBar/RestoreGlyph'; +import { + WindowControlButton, + WindowControlsGroup, +} from '../components/TabBar/styles'; +import { WINDOW_MAXIMIZED_CHANNEL } from './channels'; + +/** + * Minimise, maximise and close, drawn into the toolbar on the platforms whose + * native title bar these windows hide. + * + * Glyphs and button styling come from the main window's own controls, so the + * secondary windows are not a second set of caption buttons that drift from it. + * What differs is the wiring: the main window's buttons dispatch redux actions + * bound to that one window, while these ask the main process to act on whichever + * window sent the request. + */ +export const WindowControls = () => { + const { t } = useTranslation(); + const [isMaximized, setIsMaximized] = useState(false); + + useEffect(() => { + let isCurrent = true; + + invoke('secondary-window/is-maximized').then((maximized) => { + if (isCurrent) setIsMaximized(maximized); + }); + + const handleChange = (_event: unknown, maximized: boolean): void => { + setIsMaximized(Boolean(maximized)); + }; + + ipcRenderer.on(WINDOW_MAXIMIZED_CHANNEL, handleChange); + return () => { + isCurrent = false; + ipcRenderer.off(WINDOW_MAXIMIZED_CHANNEL, handleChange); + }; + }, []); + + const handleMinimize = useCallback(() => { + invoke('secondary-window/minimize'); + }, []); + + const handleMaximize = useCallback(() => { + invoke('secondary-window/toggle-maximize'); + }, []); + + const handleClose = useCallback(() => { + invoke('secondary-window/close'); + }, []); + + const maximizeLabel = isMaximized + ? t('tabBar.windowControls.restore') + : t('tabBar.windowControls.maximize'); + + return ( + + + + + + {isMaximized ? : } + + + + + + ); +}; diff --git a/src/ui/windowChrome/WindowToolbar.tsx b/src/ui/windowChrome/WindowToolbar.tsx new file mode 100644 index 0000000000..db2fd7df08 --- /dev/null +++ b/src/ui/windowChrome/WindowToolbar.tsx @@ -0,0 +1,71 @@ +import { Box } from '@rocket.chat/fuselage'; +import type { ReactNode } from 'react'; + +import { WindowControls } from './WindowControls'; +import { + TOOLBAR_HEIGHT, + TRAFFIC_LIGHTS_INSET, + WINDOW_CONTROLS_WIDTH, + hasInAppTitleBar, + isDarwin, + isWindows, +} from './appearance'; +import { DRAG_REGION_CLASS, NO_DRAG_REGION_CLASS } from './styles'; + +const leadingInset = (): number => { + if (isDarwin) return TRAFFIC_LIGHTS_INSET; + if (isWindows) return WINDOW_CONTROLS_WIDTH; + return 12; +}; + +export type WindowToolbarProps = { + /** Centred title area. */ + children: ReactNode; + /** Trailing controls; wrapped so they stay clickable inside the drag region. */ + actions?: ReactNode; +}; + +/** + * The window's header, and on macOS and Windows the title bar itself — hence the + * drag region, the room reserved for the traffic lights that float over it, and + * the caption buttons drawn into its trailing edge on Windows. Either way the + * window shows one header instead of a native title bar stacked on an in-app + * one. It paints no background: the window's panel colour shows through. + */ +export const WindowToolbar = ({ children, actions }: WindowToolbarProps) => ( + + + {children} + + {actions && ( + + {actions} + + )} + {isWindows && } + +); diff --git a/src/ui/windowChrome/__tests__/appearance.spec.ts b/src/ui/windowChrome/__tests__/appearance.spec.ts new file mode 100644 index 0000000000..37705bbdfd --- /dev/null +++ b/src/ui/windowChrome/__tests__/appearance.spec.ts @@ -0,0 +1,50 @@ +import type * as appearanceModule from '../appearance'; + +type Appearance = typeof appearanceModule; + +const withPlatform = async (platform: NodeJS.Platform): Promise => { + const original = process.platform; + Object.defineProperty(process, 'platform', { value: platform }); + + jest.resetModules(); + const appearance = await import('../appearance'); + + Object.defineProperty(process, 'platform', { value: original }); + return appearance; +}; + +describe('getTitleBarOptions', () => { + it('insets the title bar and places the traffic lights on macOS', async () => { + const { getTitleBarOptions, TRAFFIC_LIGHTS_X, TRAFFIC_LIGHTS_Y } = + await withPlatform('darwin'); + + expect(getTitleBarOptions()).toEqual({ + titleBarStyle: 'hiddenInset', + trafficLightPosition: { x: TRAFFIC_LIGHTS_X, y: TRAFFIC_LIGHTS_Y }, + }); + }); + + it('hides the title bar on Windows, where the toolbar draws its own controls', async () => { + const { getTitleBarOptions } = await withPlatform('win32'); + + expect(getTitleBarOptions()).toEqual({ titleBarStyle: 'hidden' }); + }); + + it('leaves the native frame alone elsewhere', async () => { + const { getTitleBarOptions } = await withPlatform('linux'); + + expect(getTitleBarOptions()).toEqual({}); + }); +}); + +describe('hasInAppTitleBar', () => { + it.each<[NodeJS.Platform, boolean]>([ + ['darwin', true], + ['win32', true], + ['linux', false], + ])('on %s, is %s', async (platform, expected) => { + const { hasInAppTitleBar } = await withPlatform(platform); + + expect(hasInAppTitleBar).toBe(expected); + }); +}); diff --git a/src/ui/windowChrome/__tests__/copiedFeedback.spec.tsx b/src/ui/windowChrome/__tests__/copiedFeedback.spec.tsx new file mode 100644 index 0000000000..b785a7cef2 --- /dev/null +++ b/src/ui/windowChrome/__tests__/copiedFeedback.spec.tsx @@ -0,0 +1,57 @@ +import { act, render, renderHook, screen } from '@testing-library/react'; + +import { TextButton } from '../TextButton'; +import { CopiedStatus, useCopiedFeedback } from '../useCopiedFeedback'; + +describe('useCopiedFeedback', () => { + it('keeps the [hasCopied, acknowledge] tuple', () => { + const { result } = renderHook(() => useCopiedFeedback()); + + expect(result.current).toHaveLength(2); + expect(result.current[0]).toBe(false); + expect(typeof result.current[1]).toBe('function'); + }); + + it('announces a copied status in a polite live region', () => { + const { result, unmount } = renderHook(() => useCopiedFeedback()); + + const region = document.body.querySelector('[role="status"]'); + expect(region).not.toBeNull(); + expect(region?.getAttribute('aria-live')).toBe('polite'); + expect(region?.textContent).toBe(''); + + act(() => { + result.current[1](); + }); + + expect(result.current[0]).toBe(true); + expect(region?.textContent).toBe('Copied'); + + unmount(); + expect(document.body.querySelector('[role="status"]')).toBeNull(); + }); +}); + +describe('CopiedStatus', () => { + it('renders a visually hidden polite status only while copied', () => { + const { rerender } = render(); + + const region = screen.getByRole('status'); + expect(region).toHaveAttribute('aria-live', 'polite'); + expect(region).toHaveTextContent(''); + + rerender(); + expect(screen.getByRole('status')).toHaveTextContent('Copied'); + }); +}); + +describe('TextButton', () => { + it('is a button with a tokenized minimum hit area', () => { + render( undefined}>Select all); + + const button = screen.getByRole('button', { name: 'Select all' }); + const { minWidth, minHeight } = window.getComputedStyle(button); + expect(minWidth).toBe('24px'); + expect(minHeight).toBe('24px'); + }); +}); diff --git a/src/ui/windowChrome/__tests__/filters.spec.ts b/src/ui/windowChrome/__tests__/filters.spec.ts new file mode 100644 index 0000000000..1ad1a175a1 --- /dev/null +++ b/src/ui/windowChrome/__tests__/filters.spec.ts @@ -0,0 +1,104 @@ +import { + isFacetNarrowed, + isFacetSelected, + readFacetSelection, + toggleFacet, +} from '../filters'; + +const OPTIONS = ['error', 'warn', 'info']; + +describe('isFacetSelected', () => { + it('treats an untouched facet as everything selected', () => { + expect(isFacetSelected(null, 'error')).toBe(true); + expect(isFacetSelected(null, 'warn')).toBe(true); + }); + + it('honours an explicit selection', () => { + expect(isFacetSelected(['error'], 'error')).toBe(true); + expect(isFacetSelected(['error'], 'warn')).toBe(false); + }); + + it('treats an empty selection as nothing selected', () => { + expect(isFacetSelected([], 'error')).toBe(false); + }); + + it('always matches a value outside the facet universe, even when narrowed', () => { + expect(isFacetSelected(['error'], 'all', OPTIONS)).toBe(true); + expect(isFacetSelected([], 'all', OPTIONS)).toBe(true); + }); + + it('honours an explicit selection for a value inside the universe', () => { + expect(isFacetSelected(['error'], 'warn', OPTIONS)).toBe(false); + }); +}); + +describe('toggleFacet', () => { + it('expands "all" into an explicit selection when one option is removed', () => { + expect(toggleFacet(null, 'warn', OPTIONS)).toEqual(['error', 'info']); + }); + + it('collapses back to "all" once every option is selected again', () => { + expect(toggleFacet(['error', 'info'], 'warn', OPTIONS)).toBeNull(); + }); + + it('lets the reader clear the last option instead of ticking them all back', () => { + expect(toggleFacet(['warn'], 'warn', OPTIONS)).toEqual([]); + }); + + it('adds back to an emptied facet', () => { + expect(toggleFacet([], 'warn', OPTIONS)).toEqual(['warn']); + }); + + it('removes an option from a partial selection', () => { + expect(toggleFacet(['error', 'warn'], 'error', OPTIONS)).toEqual(['warn']); + }); + + it('adds an option to a partial selection', () => { + expect(toggleFacet(['error'], 'info', OPTIONS)).toEqual(['error', 'info']); + }); + + it('collapses stale selections that outgrew the option list once every current option is picked', () => { + expect(toggleFacet(['error', 'warn', 'gone'], 'info', OPTIONS)).toBeNull(); + }); + + it('does not let a stale persisted value inflate the count and force-collapse to "all"', () => { + expect(toggleFacet(['error', 'gone'], 'warn', OPTIONS)).toEqual([ + 'error', + 'warn', + ]); + }); + + it('drops stale values instead of counting them toward the universe', () => { + expect(toggleFacet(['gone'], 'error', OPTIONS)).toEqual(['error']); + }); + + it('never mutates the incoming selection', () => { + const selected = ['error']; + toggleFacet(selected, 'info', OPTIONS); + expect(selected).toEqual(['error']); + }); +}); + +describe('isFacetNarrowed', () => { + it('is false while the facet is untouched', () => { + expect(isFacetNarrowed(null)).toBe(false); + }); + + it('is true for any explicit selection, including none', () => { + expect(isFacetNarrowed(['error'])).toBe(true); + expect(isFacetNarrowed([])).toBe(true); + }); +}); + +describe('readFacetSelection', () => { + it('keeps an array as an explicit selection', () => { + expect(readFacetSelection(['error'])).toEqual(['error']); + expect(readFacetSelection([])).toEqual([]); + }); + + it('treats anything else as untouched', () => { + expect(readFacetSelection(undefined)).toBeNull(); + expect(readFacetSelection('error')).toBeNull(); + expect(readFacetSelection({ error: true })).toBeNull(); + }); +}); diff --git a/src/ui/windowChrome/appearance.ts b/src/ui/windowChrome/appearance.ts new file mode 100644 index 0000000000..5b01e46b15 --- /dev/null +++ b/src/ui/windowChrome/appearance.ts @@ -0,0 +1,213 @@ +/** + * Shared chrome for the app's secondary windows (log viewer, downloads). + * + * They follow the main window's shell: an inset rounded card holding the + * content, floating over a single continuous panel that the toolbar, sidebar and + * status bar all sit on without painting anything themselves. + */ + +export type PaletteTheme = 'light' | 'dark'; + +export const isDarwin = process.platform === 'darwin'; +export const isWindows = process.platform === 'win32'; + +/** Platforms where the toolbar replaces the native title bar entirely. */ +export const hasInAppTitleBar = isDarwin || isWindows; + +/** + * Height of the toolbar, which doubles as the window drag region on macOS. + * + * The main window's tab strip reads from this too, so the app has one title bar + * height rather than two that drift. + */ +export const TOOLBAR_HEIGHT = 40; + +/** + * macOS traffic light geometry. The three buttons are 12px wide with 8px gaps + * between them, so the cluster is 52px wide and ends at `TRAFFIC_LIGHTS_X + 52`. + */ +const TRAFFIC_LIGHT_DIAMETER = 12; +const TRAFFIC_LIGHTS_WIDTH = TRAFFIC_LIGHT_DIAMETER * 3 + 8 * 2; + +export const TRAFFIC_LIGHTS_X = 16; +/** Vertically centred in the toolbar, since the toolbar is the title bar. */ +export const TRAFFIC_LIGHTS_Y = Math.round( + (TOOLBAR_HEIGHT - TRAFFIC_LIGHT_DIAMETER) / 2 +); +/** Where toolbar content may start without colliding with the buttons. */ +export const TRAFFIC_LIGHTS_INSET = + TRAFFIC_LIGHTS_X + TRAFFIC_LIGHTS_WIDTH + 16; + +/** + * Width of the Windows caption buttons drawn into the toolbar: three 46px + * buttons, the metric the OS itself uses. The toolbar reserves as much again at + * its leading edge so the title stays centred in the window rather than in what + * is left of it. + */ +export const WINDOW_CONTROLS_WIDTH = 46 * 3; + +/** + * Window options that hand the title bar over to the toolbar, matching what the + * main window does on each platform: macOS keeps its traffic lights floating + * over the toolbar, Windows hides the caption entirely and the toolbar draws its + * own buttons, and Linux keeps its native frame. + */ +export const getTitleBarOptions = () => { + if (isDarwin) { + return { + titleBarStyle: 'hiddenInset' as const, + trafficLightPosition: { x: TRAFFIC_LIGHTS_X, y: TRAFFIC_LIGHTS_Y }, + }; + } + + if (isWindows) { + return { titleBarStyle: 'hidden' as const }; + } + + return {}; +}; + +/** + * Secondary windows are never full-screen surfaces of their own. + * + * On macOS a window created while the app is in full screen is otherwise given + * full screen itself, which is how opening Downloads from a full-screen + * workspace ended up replacing it rather than appearing alongside it. + */ +export const NOT_FULL_SCREENABLE = { fullscreenable: false } as const; + +/** Width of the filters sidebar. */ +export const SIDEBAR_WIDTH = 248; + +/** + * The setting as of window creation, encoded in the page URL by the main process + * so the first paint already matches — an IPC round trip would flash an opaque + * surface over the vibrancy first. Live changes arrive over IPC afterwards; see + * `useTransparency`. + */ +export const readInitialTransparency = (): boolean => { + try { + return ( + new URLSearchParams(window.location.search).get('transparent') === 'true' + ); + } catch { + return false; + } +}; + +export type Surfaces = { + /** + * The window's own background, painted once on the root container. The + * toolbar, sidebar, status bar and the gutter around the card all paint + * nothing and let this show through, so there is a single continuous panel + * behind the card instead of bars that each carry their own fill. + */ + panel: string; + /** Content card: kept close to opaque so text stays legible over vibrancy. */ + card: string; + /** Sticky headers: opaque enough to hide content sliding under them. */ + sticky: string; + /** Input fill: a tint of the palette's opposite, so it reads on either. */ + field: string; + hover: string; + selected: string; + divider: string; +}; + +/** + * Over a vibrant window the card gets a translucent scrim rather than staying + * fully see-through: text over live desktop content is unreadable. + */ +const TRANSLUCENT_CARD_SCRIM: Record = { + dark: 'rgba(0, 0, 0, 0.28)', + light: 'rgba(255, 255, 255, 0.58)', +}; + +/** + * A sticky header sits over scrolling content, so over a vibrant window it needs + * a heavier fill than the card scrim — otherwise text slides visibly under it. + */ +const TRANSLUCENT_STICKY_FILL: Record = { + dark: 'rgba(30, 30, 32, 0.86)', + light: 'rgba(246, 246, 248, 0.9)', +}; + +const FIELD_FILL: Record = { + dark: 'rgba(255, 255, 255, 0.08)', + light: 'rgba(0, 0, 0, 0.05)', +}; + +const HOVER_FILL: Record = { + dark: 'rgba(255, 255, 255, 0.07)', + light: 'rgba(0, 0, 0, 0.045)', +}; + +const SELECTED_FILL: Record = { + dark: 'rgba(255, 255, 255, 0.12)', + light: 'rgba(0, 0, 0, 0.075)', +}; + +/** + * Opaque surfaces come straight from the main window's shell so every window + * reads as the same app: `surface-neutral` behind the content, `surface-light` + * for the content itself, in both palettes. + */ +const OPAQUE_PANEL = 'var(--rcx-color-surface-neutral)'; +const OPAQUE_CARD = 'var(--rcx-color-surface-light)'; +const OPAQUE_HOVER = 'var(--rcx-color-surface-hover)'; +const OPAQUE_SELECTED = 'var(--rcx-color-surface-selected)'; + +export const resolveSurfaces = ( + theme: PaletteTheme, + isTransparent: boolean +): Surfaces => ({ + panel: isTransparent ? 'transparent' : OPAQUE_PANEL, + card: isTransparent ? TRANSLUCENT_CARD_SCRIM[theme] : OPAQUE_CARD, + sticky: isTransparent + ? TRANSLUCENT_STICKY_FILL[theme] + : 'var(--rcx-color-surface-tint)', + // No field/input surface token in fuselage 0.80.0; keep the tinted fill. + field: FIELD_FILL[theme], + hover: isTransparent ? HOVER_FILL[theme] : OPAQUE_HOVER, + selected: isTransparent ? SELECTED_FILL[theme] : OPAQUE_SELECTED, + divider: 'var(--rcx-color-stroke-extra-light)', +}); + +export const CARD_INSET = 4; +/** + * Nearest token on the Fuselage border-radius scale (small 2px / medium 4px / + * large 8px / extra-large 20px) to the previous literal geometry (14px / 8px): + * `large` matches the non-macOS radius exactly and is the closest single step + * below the macOS radius (-6px, vs. +6px for `extra-large`). + */ +export const CARD_RADIUS = 'var(--rcx-border-radius-large, 0.5rem)'; + +export type CardStyle = { + boxShadow: string; +}; + +/** + * No hairline: the shadow alone lifts the card off the panel. Colour comes + * from Fuselage's elevation-1 shadow token, the same one `Tile` uses for its + * single-shadow elevation, so the card moves with theme/high-contrast changes + * instead of a fixed literal. + */ +export const resolveCardStyle = (): CardStyle => ({ + boxShadow: + '0 0 3px 0 var(--rcx-color-shadow-elevation-1, var(--rcx-color-neutral-800-10, rgba(47, 52, 61, 0.1)))', +}); + +/** Inline style for the inset content card, shared by both windows. */ +export const getCardStyle = ( + _theme: PaletteTheme, + surfaces: Surfaces +): Record => { + const { boxShadow } = resolveCardStyle(); + return { + backgroundColor: surfaces.card, + margin: `${CARD_INSET}px`, + borderRadius: CARD_RADIUS, + boxShadow, + overflow: 'hidden', + }; +}; diff --git a/src/ui/windowChrome/channels.ts b/src/ui/windowChrome/channels.ts new file mode 100644 index 0000000000..254e853b27 --- /dev/null +++ b/src/ui/windowChrome/channels.ts @@ -0,0 +1,6 @@ +/** + * The main process pushes the window's maximised state here, so the toolbar's + * caption buttons can show restore instead of maximise. Sent per window, so a + * renderer only ever hears about its own. + */ +export const WINDOW_MAXIMIZED_CHANNEL = 'secondary-window/maximized-changed'; diff --git a/src/ui/windowChrome/filters.ts b/src/ui/windowChrome/filters.ts new file mode 100644 index 0000000000..8debef90b3 --- /dev/null +++ b/src/ui/windowChrome/filters.ts @@ -0,0 +1,62 @@ +/** + * A facet's selection: `null` while the reader has not touched it, which means + * everything and keeps including options that show up in the data later, or an + * explicit list of what they picked — including none at all. + * + * Untouched and "none selected" have to be distinguishable. Folding them + * together, as an empty array did, meant unchecking the last option silently + * ticked every box again, so the list could never be emptied from the sidebar. + */ +export type FacetSelection = T[] | null; + +/** + * Toggles one option, expanding "everything" into an explicit list first and + * collapsing back to it once every option is picked again — so a facet the + * reader has fully re-selected keeps taking in new options. + */ +export const toggleFacet = ( + selected: FacetSelection, + option: T, + universe: T[] +): FacetSelection => { + const current = + selected === null + ? universe + : selected.filter((value) => universe.includes(value)); + const next = current.includes(option) + ? current.filter((value) => value !== option) + : [...current, option]; + + if (next.length >= universe.length) { + return null; + } + + return next; +}; + +/** + * A value outside the facet's universe (e.g. a status the facet doesn't + * offer as an option) can never be narrowed out by that facet, so it always + * matches regardless of the current selection. + */ +export const isFacetSelected = ( + selected: FacetSelection, + option: T, + universe?: readonly T[] +): boolean => + selected === null || + (universe !== undefined && !universe.includes(option)) || + selected.includes(option); + +/** Whether the reader has narrowed this facet at all. */ +export const isFacetNarrowed = ( + selected: FacetSelection +): boolean => selected !== null; + +/** + * Persisted selections are user-editable JSON, so never trust their shape: only + * an array is an explicit selection, anything else means untouched. + */ +export const readFacetSelection = ( + stored: unknown +): FacetSelection => (Array.isArray(stored) ? (stored as T[]) : null); diff --git a/src/ui/windowChrome/styles.tsx b/src/ui/windowChrome/styles.tsx new file mode 100644 index 0000000000..689ef3670f --- /dev/null +++ b/src/ui/windowChrome/styles.tsx @@ -0,0 +1,109 @@ +import { Global, css } from '@emotion/react'; + +import type { PaletteTheme, Surfaces } from './appearance'; + +export const DRAG_REGION_CLASS = 'rcx-window-drag'; +export const NO_DRAG_REGION_CLASS = 'rcx-window-no-drag'; +export const FILTER_ROW_CLASS = 'rcx-window-filter-row'; +export const TEXT_BUTTON_CLASS = 'rcx-window-text-button'; +export const SEARCH_FIELD_CLASS = 'rcx-window-search-field'; +export const LIST_ROW_CLASS = 'rcx-window-list-row'; + +type WindowChromeGlobalStylesProps = { + paletteTheme: PaletteTheme; + surfaces: Surfaces; +}; + +/** + * Chrome shared by the secondary windows: the window background, the macOS drag + * regions, sidebar filter hover and scrollbars. + * + * Hover lives here rather than in React state because these lists can run to + * thousands of rows, and per-row state would re-render on every pointer move. + */ +export const WindowChromeGlobalStyles = ({ + surfaces, +}: WindowChromeGlobalStylesProps) => ( + +); diff --git a/src/ui/windowChrome/useCopiedFeedback.ts b/src/ui/windowChrome/useCopiedFeedback.ts new file mode 100644 index 0000000000..7605515144 --- /dev/null +++ b/src/ui/windowChrome/useCopiedFeedback.ts @@ -0,0 +1,99 @@ +import type { CSSProperties, ReactElement } from 'react'; +import { createElement, useCallback, useEffect, useRef, useState } from 'react'; + +/** How long a copy button stays acknowledged before returning to its icon. */ +const ACKNOWLEDGEMENT = 1500; + +const DEFAULT_ANNOUNCEMENT = 'Copied'; + +const VISUALLY_HIDDEN: CSSProperties = { + position: 'absolute', + width: '1px', + height: '1px', + padding: 0, + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +}; + +export type CopiedStatusProps = { + hasCopied: boolean; + label?: string; +}; + +/** Visually hidden polite status for the tick that `useCopiedFeedback` shows. */ +export const CopiedStatus = ({ + hasCopied, + label = DEFAULT_ANNOUNCEMENT, +}: CopiedStatusProps): ReactElement => + createElement( + 'span', + { + 'role': 'status', + 'aria-live': 'polite', + 'aria-atomic': 'true', + 'style': VISUALLY_HIDDEN, + }, + hasCopied ? label : '' + ); + +const applyLiveRegionStyle = (node: HTMLElement): void => { + Object.assign(node.style, VISUALLY_HIDDEN); +}; + +/** + * Says that something was copied. + * + * Copying to the clipboard produces nothing a reader can see, so the button + * that did it answers for itself: it holds a tick for a moment and then goes + * back to what it was. Cheaper than a toast, and it appears where the reader is + * already looking — at the button they just pressed. + * + * Existing callers keep `[hasCopied, acknowledge]`. A live region is mounted + * by the hook so those sites announce without a render change. Callers that + * show the tick can also mount `CopiedStatus` if they want the node in-tree. + */ +export const useCopiedFeedback = ( + label = DEFAULT_ANNOUNCEMENT +): [boolean, () => void] => { + const [hasCopied, setHasCopied] = useState(false); + const timer = useRef | undefined>(undefined); + const liveRegion = useRef(null); + + useEffect( + () => () => { + if (timer.current) clearTimeout(timer.current); + }, + [] + ); + + useEffect(() => { + const region = document.createElement('span'); + region.setAttribute('role', 'status'); + region.setAttribute('aria-live', 'polite'); + region.setAttribute('aria-atomic', 'true'); + applyLiveRegionStyle(region); + document.body.appendChild(region); + liveRegion.current = region; + return () => { + region.remove(); + liveRegion.current = null; + }; + }, []); + + useEffect(() => { + if (liveRegion.current) { + liveRegion.current.textContent = hasCopied ? label : ''; + } + }, [hasCopied, label]); + + const acknowledge = useCallback(() => { + setHasCopied(true); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setHasCopied(false), ACKNOWLEDGEMENT); + }, []); + + return [hasCopied, acknowledge]; +}; diff --git a/src/ui/windowChrome/useFindShortcut.ts b/src/ui/windowChrome/useFindShortcut.ts new file mode 100644 index 0000000000..648e59fd2f --- /dev/null +++ b/src/ui/windowChrome/useFindShortcut.ts @@ -0,0 +1,17 @@ +import { useEffect, type RefObject } from 'react'; + +/** Focuses the first input inside `containerRef` on Cmd/Ctrl+F. */ +export const useFindShortcut = ( + containerRef: RefObject +): void => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (!(event.ctrlKey || event.metaKey) || event.key !== 'f') return; + event.preventDefault(); + containerRef.current?.querySelector('input')?.focus(); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [containerRef]); +}; diff --git a/src/ui/windowChrome/useTransparency.ts b/src/ui/windowChrome/useTransparency.ts new file mode 100644 index 0000000000..dd3176c431 --- /dev/null +++ b/src/ui/windowChrome/useTransparency.ts @@ -0,0 +1,35 @@ +import { ipcRenderer } from 'electron'; +import { useEffect, useState } from 'react'; + +import { isDarwin, readInitialTransparency } from './appearance'; + +/** + * Whether the window should let the desktop show through. + * + * These windows are always created transparent with a vibrancy material on + * macOS — `transparent` cannot be toggled after creation — so honouring the + * setting is the renderer's job: it either paints an opaque surface over the + * material or gets out of the way. That is what lets the setting apply without + * reopening the window. + * + * Seeded from the page query so the first paint already matches, then kept in + * step with the setting by the main process over `channel`. + */ +export const useTransparency = (channel: string): boolean => { + const [isEnabled, setIsEnabled] = useState(readInitialTransparency); + + useEffect(() => { + const handleChange = (_event: unknown, enabled: boolean): void => { + setIsEnabled(Boolean(enabled)); + }; + + ipcRenderer.on(channel, handleChange); + return () => { + ipcRenderer.off(channel, handleChange); + }; + }, [channel]); + + // Vibrancy only reads as a material on macOS; elsewhere the window is opaque + // regardless of the setting, matching how the main window treats it. + return isDarwin && isEnabled; +}; diff --git a/src/updates/actions.ts b/src/updates/actions.ts index 2af0f9e0b5..53b828c144 100644 --- a/src/updates/actions.ts +++ b/src/updates/actions.ts @@ -14,6 +14,16 @@ export const UPDATES_DOWNLOAD_PROGRESSED = 'updates/download-progressed'; export const UPDATES_UPDATE_DOWNLOADED = 'updates/update-downloaded'; /** The titlebar update label was clicked while an update was available. */ export const UPDATES_DOWNLOAD_REQUESTED = 'updates/download-requested'; +/** + * The titlebar update label's primary action was clicked on a store build + * (Mac App Store, Microsoft Store, Snap Store, Flathub), where there is + * nothing to download — this hands off to the store's page instead. Kept + * distinct from UPDATES_DOWNLOAD_REQUESTED so the download-status reducers + * (which every FSA reaches, regardless of which branch main.ts takes) never + * flip to "downloading" for a build that never downloads anything. + */ +export const UPDATES_OPEN_STORE_PAGE_REQUESTED = + 'updates/open-store-page-requested'; /** The titlebar update label was clicked once the update finished downloading. */ export const UPDATES_INSTALL_REQUESTED = 'updates/install-requested'; /** "Skip this version" was chosen in the titlebar update panel. */ @@ -45,6 +55,7 @@ export type UpdatesActionTypeToPayloadMap = { [UPDATES_DOWNLOAD_PROGRESSED]: number; [UPDATES_UPDATE_DOWNLOADED]: void; [UPDATES_DOWNLOAD_REQUESTED]: void; + [UPDATES_OPEN_STORE_PAGE_REQUESTED]: void; [UPDATES_INSTALL_REQUESTED]: void; [UPDATES_SKIP_REQUESTED]: string | null; [UPDATES_PANEL_TOGGLED]: boolean; diff --git a/src/updates/common.ts b/src/updates/common.ts index f009ca9650..f83f03bf69 100644 --- a/src/updates/common.ts +++ b/src/updates/common.ts @@ -10,8 +10,17 @@ export type UpdateConfiguration = { isInternalVideoChatWindowEnabled: boolean; isVideoCallScreenCaptureFallbackEnabled: boolean; updateChannel: string; + updateStore: UpdateStore; }; +/** + * Which store-flavored update flow the current build uses, if any — routes + * update checks through the store's own lookup/page instead of + * electron-updater. `'mas'` (Mac App Store), `'windows'` (Microsoft Store), + * `'snap'` (Snap Store), `'flatpak'` (Flathub). + */ +export type UpdateStore = 'mas' | 'windows' | 'snap' | 'flatpak' | null; + export type AppLevelUpdateConfiguration = { forced?: boolean; canUpdate?: boolean; diff --git a/src/updates/main.spec.ts b/src/updates/main.spec.ts index 3571b7b869..f4a12dc489 100644 --- a/src/updates/main.spec.ts +++ b/src/updates/main.spec.ts @@ -4,6 +4,7 @@ import type { UserLevelUpdateConfiguration, } from './common'; import { mergeConfigurations } from './main'; +import type * as MainModule from './main'; describe('mergeConfigurations', () => { it('keeps default configuration', () => { @@ -19,6 +20,7 @@ describe('mergeConfigurations', () => { isInternalVideoChatWindowEnabled: true, isVideoCallScreenCaptureFallbackEnabled: false, updateChannel: 'latest', + updateStore: null, }; const appConfiguration: AppLevelUpdateConfiguration = {}; const userConfiguration: UserLevelUpdateConfiguration = {}; @@ -45,6 +47,7 @@ describe('mergeConfigurations', () => { isInternalVideoChatWindowEnabled: true, isVideoCallScreenCaptureFallbackEnabled: false, updateChannel: 'latest', + updateStore: null, }; const appConfiguration: AppLevelUpdateConfiguration = { autoUpdate: false, @@ -78,6 +81,7 @@ describe('mergeConfigurations', () => { isInternalVideoChatWindowEnabled: true, isVideoCallScreenCaptureFallbackEnabled: false, updateChannel: 'latest', + updateStore: null, }; const appConfiguration: AppLevelUpdateConfiguration = { autoUpdate: false, @@ -115,6 +119,7 @@ describe('mergeConfigurations', () => { isInternalVideoChatWindowEnabled: true, isVideoCallScreenCaptureFallbackEnabled: false, updateChannel: 'latest', + updateStore: null, }; const appConfiguration: AppLevelUpdateConfiguration = { forced: true, @@ -141,3 +146,107 @@ describe('mergeConfigurations', () => { }); }); }); + +/** + * Regression coverage for the windows check-for-updates path: it has no + * version-lookup API (see storeUpdates.ts), so its + * UPDATES_CHECK_FOR_UPDATES_REQUESTED handling opens the Microsoft Store + * directly instead of running the checking/available sequence. Because + * UPDATES_CHECK_FOR_UPDATES_REQUESTED's own reducer case (reducers.ts) flips + * updateCheckStatus to 'checking' before this listener even runs, the + * listener must explicitly dismiss that feedback afterward — otherwise the + * titlebar is left stuck showing "Checking for updates…" forever (regression + * verified live; see reducers.ts's UPDATES_CHECK_FEEDBACK_DISMISSED case). + * + * Uses jest.isolateModules + jest.doMock so this suite's mocks of `../store` + * and `./storeUpdates` don't leak into the mergeConfigurations tests above, + * which exercise the real modules. + */ +describe('setupUpdateLabelFlow — windows check-for-updates path', () => { + type Listener = (action: { type: string; payload?: unknown }) => unknown; + + const loadWithMocks = ( + openStorePageImpl: jest.Mock + ): { listeners: Map; dispatch: jest.Mock } => { + const listeners = new Map(); + const dispatch = jest.fn(); + + let setupUpdateLabelFlow!: () => void; + + jest.isolateModules(() => { + jest.doMock('../store', () => ({ + listen: jest.fn((type: string, listener: Listener) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }), + dispatch, + select: jest.fn((selector: (state: unknown) => unknown) => + selector({}) + ), + })); + + jest.doMock('./storeUpdates', () => ({ + detectUpdateStore: jest.fn(() => 'windows'), + fetchLatestStoreVersion: jest.fn(), + isStoreVersionNewer: jest.fn(), + openStorePage: openStorePageImpl, + })); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mainModule = require('./main') as typeof MainModule; + setupUpdateLabelFlow = mainModule.setupUpdateLabelFlow; + }); + + setupUpdateLabelFlow(); + + return { listeners, dispatch }; + }; + + afterEach(() => { + jest.resetModules(); + }); + + it('opens the Microsoft Store and returns to idle, without claiming a check result', async () => { + const openStorePageImpl = jest.fn().mockResolvedValue(undefined); + const { listeners, dispatch } = loadWithMocks(openStorePageImpl); + + const checkListener = listeners.get('updates/check-for-updates-requested'); + expect(checkListener).toBeDefined(); + + await checkListener?.({ type: 'updates/check-for-updates-requested' }); + + expect(openStorePageImpl).toHaveBeenCalledTimes(1); + expect(openStorePageImpl).toHaveBeenCalledWith('windows'); + + // Never claims a result — no "up to date" and no "new version" dispatch. + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'updates/new-version-not-available' }) + ); + expect(dispatch).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'updates/new-version-available' }) + ); + + // Settles the transient "checking" feedback back to idle instead of + // leaving it stuck. + expect(dispatch).toHaveBeenCalledWith({ + type: 'updates/check-feedback-dismissed', + }); + }); + + it('reports a real error (and does not dismiss to idle) when openStorePage fails', async () => { + const openStorePageImpl = jest + .fn() + .mockRejectedValue(new Error('failed to open store')); + const { listeners, dispatch } = loadWithMocks(openStorePageImpl); + + const checkListener = listeners.get('updates/check-for-updates-requested'); + await checkListener?.({ type: 'updates/check-for-updates-requested' }); + + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ type: 'updates/error-thrown' }) + ); + expect(dispatch).not.toHaveBeenCalledWith({ + type: 'updates/check-feedback-dismissed', + }); + }); +}); diff --git a/src/updates/main.ts b/src/updates/main.ts index ba394f1e62..4769fe0027 100644 --- a/src/updates/main.ts +++ b/src/updates/main.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; -import { BrowserWindow, app, autoUpdater as nativeUpdater } from 'electron'; +import { app } from 'electron'; import { autoUpdater } from 'electron-updater'; import { gt as semverGt, inc as semverInc } from 'semver'; @@ -16,6 +16,7 @@ import { } from '../ui/main/dialogs'; import { UPDATE_SKIPPED, + UPDATES_CHECK_FEEDBACK_DISMISSED, UPDATES_CHECK_FOR_UPDATES_REQUESTED, UPDATES_CHECKING_FOR_UPDATE, UPDATES_DOWNLOAD_PROGRESSED, @@ -24,6 +25,7 @@ import { UPDATES_INSTALL_REQUESTED, UPDATES_NEW_VERSION_AVAILABLE, UPDATES_NEW_VERSION_NOT_AVAILABLE, + UPDATES_OPEN_STORE_PAGE_REQUESTED, UPDATES_READY, UPDATES_SIMULATION_REQUESTED, UPDATES_SKIP_REQUESTED, @@ -33,8 +35,15 @@ import { import type { AppLevelUpdateConfiguration, UpdateConfiguration, + UpdateStore, UserLevelUpdateConfiguration, } from './common'; +import { + detectUpdateStore, + fetchLatestStoreVersion, + isStoreVersionNewer, + openStorePage, +} from './storeUpdates'; const readJsonObject = async ( filePath: string @@ -141,6 +150,7 @@ const loadConfiguration = async (): Promise => { (process.platform === 'linux' && !!process.env.APPIMAGE) || (process.platform === 'win32' && !process.windowsStore) || (process.platform === 'darwin' && !process.mas), + updateStore: detectUpdateStore(), isEachUpdatesSettingConfigurable: true, isUpdatingEnabled, doCheckForUpdatesOnStartup, @@ -207,28 +217,39 @@ const endSimulatedUpdate = (): void => { dispatch({ type: UPDATES_NEW_VERSION_NOT_AVAILABLE }); }; -const nativeUpdateDownloadedCallback = (): void => { - nativeUpdater.removeListener( - 'update-downloaded', - nativeUpdateDownloadedCallback - ); - nativeUpdater.quitAndInstall(); -}; +// Thrown values are not guaranteed to be Errors, and the dispatched payload +// crosses the IPC boundary, so it has to be a plain serializable object. +const normalizeUpdateError = ( + error: unknown +): Pick => + error instanceof Error + ? { message: error.message, stack: error.stack, name: error.name } + : { message: String(error), stack: undefined, name: 'Error' }; /** Quits and relaunches into the freshly downloaded update. */ const installDownloadedUpdate = (): void => { + // The try/catch must live inside the setImmediate callback: a throw from a + // later tick escapes a catch wrapping only the scheduling call, which is how + // macOS install failures went unreported for years (see #955). setImmediate(() => { + // Detached so the pending quit isn't cancelled by the app's own + // window-all-closed handler. If the install fails we never quit, so the + // listeners have to go back or closing every window would no longer + // reach that handler for the rest of the session. + const windowAllClosedListeners = app.listeners('window-all-closed'); app.removeAllListeners('window-all-closed'); - if (process.platform === 'darwin') { - const allBrowserWindows = BrowserWindow.getAllWindows(); - allBrowserWindows.forEach((browserWindow) => { - browserWindow.removeAllListeners('close'); - browserWindow.destroy(); - }); - nativeUpdater.checkForUpdates(); - nativeUpdater.on('update-downloaded', nativeUpdateDownloadedCallback); - } else { + + try { autoUpdater.quitAndInstall(true, true); + } catch (error) { + for (const listener of windowAllClosedListeners) { + app.addListener('window-all-closed', listener as () => void); + } + + dispatch({ + type: UPDATES_ERROR_THROWN, + payload: normalizeUpdateError(error), + }); } }); }; @@ -303,6 +324,62 @@ const dispatchUpdateError = (error: unknown): void => { } }; +/** Store URL from the last successful store version lookup, used by the "Open store page" action. */ +let lastKnownStoreUrl: string | undefined; + +/** + * Runs a user-initiated update check against a store's own version lookup + * instead of electron-updater. Mirrors the non-store check's dispatch + * sequence (checking → available/not-available) and error-reporting + * convention (a failed lookup is not actionable for the user: warn-level log + * + UPDATES_ERROR_THROWN, never a modal). Only called for stores that expose + * a version API (mas, snap, flatpak) — windows has none, see + * setupUpdateLabelFlow's UPDATES_CHECK_FOR_UPDATES_REQUESTED listener. + */ +const checkForStoreUpdate = async ( + store: Exclude +): Promise => { + dispatch({ type: UPDATES_CHECKING_FOR_UPDATE }); + + try { + const result = await fetchLatestStoreVersion(store); + + if (!result) { + console.warn(`Store (${store}) update check failed: no result`); + // dispatchUpdateError shapes the payload as a plain + // { message, stack, name } object (not a real Error instance) — the + // action crosses to renderers via webContents.send, whose structured + // clone resets a real Error's custom `name` back to 'Error'. + dispatchUpdateError( + Object.assign(new Error(`Store (${store}) update check failed`), { + name: 'StoreLookupError', + }) + ); + return; + } + + const currentVersion = app.getVersion(); + + if (!isStoreVersionNewer(result.version, currentVersion)) { + lastKnownStoreUrl = undefined; + dispatch({ type: UPDATES_NEW_VERSION_NOT_AVAILABLE }); + return; + } + + lastKnownStoreUrl = result.storeUrl; + dispatch({ + type: UPDATES_NEW_VERSION_AVAILABLE, + payload: result.version, + }); + } catch (error) { + console.warn( + `Store (${store}) update check failed:`, + error instanceof Error ? error.message : error + ); + dispatchUpdateError(error); + } +}; + /** * Wires the titlebar update label to the updater. Registered before the * "updating not allowed/enabled" bail-out so the simulated flow stays available @@ -340,6 +417,26 @@ export const setupUpdateLabelFlow = (): void => { } }); + // Store builds cannot self-update: hand the user off to the store's own + // page instead of touching autoUpdater. Kept as a distinct listener + // (rather than a branch inside UPDATES_DOWNLOAD_REQUESTED) so the + // download-status reducers, which see every FSA regardless of this + // branch, never flip to "downloading" for a build that never downloads + // anything. + listen(UPDATES_OPEN_STORE_PAGE_REQUESTED, async () => { + const store = detectUpdateStore(); + + if (!store) { + return; + } + + try { + await openStorePage(store, lastKnownStoreUrl); + } catch (error) { + dispatchUpdateError(error); + } + }); + listen(UPDATES_INSTALL_REQUESTED, async () => { if (isSimulatingUpdate) { // Nothing to install — unwind the simulation instead of restarting. @@ -364,6 +461,39 @@ export const setupUpdateLabelFlow = (): void => { await warnAboutUpdateSkipped(); dispatch({ type: UPDATE_SKIPPED, payload: action.payload }); }); + + // Store builds cannot use electron-updater, so their user-initiated check + // is registered here (unconditionally) instead of in setupUpdates, which + // bails out before reaching the electron-updater-only listener below. + listen(UPDATES_CHECK_FOR_UPDATES_REQUESTED, async () => { + const store = detectUpdateStore(); + + if (!store) { + return; + } + + isUserInitiatedCheck = true; + + // windows has no version-lookup API (see storeUpdates.ts) — the Store + // app itself is the only source of truth for whether an update exists, + // so open it directly instead of faking a checking/available sequence. + // UPDATES_CHECK_FOR_UPDATES_REQUESTED already flipped updateCheckStatus + // to 'checking' (see reducers.ts) before this listener ran, and nothing + // else settles it for this path, so it must be dismissed explicitly on + // success — never NEW_VERSION_NOT_AVAILABLE, since we never actually + // checked anything and must not claim "up to date". + if (store === 'windows') { + try { + await openStorePage(store); + dispatch({ type: UPDATES_CHECK_FEEDBACK_DISMISSED }); + } catch (error) { + dispatchUpdateError(error); + } + return; + } + + await checkForStoreUpdate(store); + }); }; export const setupUpdates = async (): Promise => { @@ -406,6 +536,7 @@ export const setupUpdates = async (): Promise => { isInternalVideoChatWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, updateChannel, + updateStore, } = await loadConfiguration(); dispatch({ @@ -422,6 +553,7 @@ export const setupUpdates = async (): Promise => { isInternalVideoChatWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, updateChannel, + updateStore, }, }); @@ -429,6 +561,15 @@ export const setupUpdates = async (): Promise => { // reachable even when this build cannot self-update. setupUpdateLabelFlow(); + // The store update path (mas/windows/snap/flatpak) is wired inside + // setupUpdateLabelFlow's listeners (gated on detectUpdateStore()); it does + // not depend on isUpdatingAllowed (which is already false for mas/windows + // by construction, and irrelevant for snap/flatpak) nor on electron-updater + // setup below. electron-updater must never initialize for any store build. + if (updateStore !== null) { + return; + } + if (!isUpdatingAllowed || !isUpdatingEnabled) { return; } diff --git a/src/updates/main/detectUpdateStore.main.spec.ts b/src/updates/main/detectUpdateStore.main.spec.ts new file mode 100644 index 0000000000..a054552957 --- /dev/null +++ b/src/updates/main/detectUpdateStore.main.spec.ts @@ -0,0 +1,241 @@ +import type * as StoreUpdatesModule from '../storeUpdates'; + +/** + * `detectUpdateStore` (src/updates/storeUpdates.ts) captures `app.isPackaged` + * at module load — before setupUpdates's dev-mode patch + * (`Object.defineProperty(app, 'isPackaged', { get: () => true })`) can run — + * so each scenario here needs its own fresh module instance with + * `app.isPackaged`/env/process markers set up beforehand. This lives in its + * own file (no top-level `jest.mock('electron', ...)`, unlike + * storeUpdates.main.spec.ts) because a hoisted `jest.mock` for a module id + * wins over a later `jest.doMock` for the same id inside + * `jest.isolateModules`, which would otherwise make every isolated `require` + * see the same (wrong) `app` mock. + */ +describe('detectUpdateStore', () => { + const originalMas = process.mas; + const originalWindowsStore = process.windowsStore; + const originalSnap = process.env.SNAP; + const originalFlatpakId = process.env.FLATPAK_ID; + const originalNodeEnv = process.env.NODE_ENV; + const originalSimulateStore = process.env.ROCKETCHAT_SIMULATE_STORE; + + // process.env values are always strings — assigning `undefined` coerces to + // the literal string "undefined" (truthy!) instead of clearing the key, so + // every optional env var here must be `delete`d, not assigned `undefined`. + const setOrDeleteEnv = (key: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + + afterEach(() => { + (process as { mas?: boolean }).mas = originalMas; + (process as { windowsStore?: boolean }).windowsStore = originalWindowsStore; + setOrDeleteEnv('SNAP', originalSnap); + setOrDeleteEnv('FLATPAK_ID', originalFlatpakId); + setOrDeleteEnv('NODE_ENV', originalNodeEnv); + setOrDeleteEnv('ROCKETCHAT_SIMULATE_STORE', originalSimulateStore); + }); + + const loadDetectUpdateStore = (options: { + mas?: boolean; + windowsStore?: boolean; + snap?: string; + flatpakId?: string; + isPackaged: boolean; + nodeEnv?: string; + simulateStore?: string; + }): ReturnType => { + (process as { mas?: boolean }).mas = options.mas ?? false; + (process as { windowsStore?: boolean }).windowsStore = + options.windowsStore ?? false; + setOrDeleteEnv('SNAP', options.snap); + setOrDeleteEnv('FLATPAK_ID', options.flatpakId); + setOrDeleteEnv('NODE_ENV', options.nodeEnv); + setOrDeleteEnv('ROCKETCHAT_SIMULATE_STORE', options.simulateStore); + + let result!: ReturnType; + jest.isolateModules(() => { + jest.doMock('electron', () => ({ + app: { isPackaged: options.isPackaged }, + shell: { openExternal: jest.fn() }, + })); + const storeUpdatesModule = // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../storeUpdates') as typeof StoreUpdatesModule; + result = storeUpdatesModule.detectUpdateStore(); + }); + return result; + }; + + it('returns null with no markers and no simulation', () => { + expect( + loadDetectUpdateStore({ isPackaged: false, nodeEnv: 'development' }) + ).toBeNull(); + }); + + it('returns mas when process.mas is true, regardless of other markers', () => { + expect( + loadDetectUpdateStore({ + mas: true, + windowsStore: true, + snap: '/snap/rocketchat-desktop/x1', + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('mas'); + }); + + it('returns windows when process.windowsStore is true (and mas is not)', () => { + expect( + loadDetectUpdateStore({ + windowsStore: true, + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('windows'); + }); + + it('returns snap when SNAP is set (and mas/windowsStore are not)', () => { + expect( + loadDetectUpdateStore({ + snap: '/snap/rocketchat-desktop/x1', + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('snap'); + }); + + it('returns flatpak when FLATPAK_ID is set (and nothing else matches)', () => { + expect( + loadDetectUpdateStore({ + flatpakId: 'chat.rocket.RocketChat', + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('flatpak'); + }); + + it('detection precedence: mas wins over windows/snap/flatpak all present', () => { + expect( + loadDetectUpdateStore({ + mas: true, + windowsStore: true, + snap: '/snap/rocketchat-desktop/x1', + flatpakId: 'chat.rocket.RocketChat', + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('mas'); + }); + + it('detection precedence: windows wins over snap/flatpak when mas is absent', () => { + expect( + loadDetectUpdateStore({ + windowsStore: true, + snap: '/snap/rocketchat-desktop/x1', + flatpakId: 'chat.rocket.RocketChat', + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('windows'); + }); + + it('detection precedence: snap wins over flatpak when mas/windows are absent', () => { + expect( + loadDetectUpdateStore({ + snap: '/snap/rocketchat-desktop/x1', + flatpakId: 'chat.rocket.RocketChat', + isPackaged: true, + nodeEnv: 'production', + }) + ).toBe('snap'); + }); + + it.each(['mas', 'windows', 'snap', 'flatpak'] as const)( + 'returns %s in unpackaged dev runs when ROCKETCHAT_SIMULATE_STORE=%s', + (store) => { + expect( + loadDetectUpdateStore({ + isPackaged: false, + nodeEnv: 'development', + simulateStore: store, + }) + ).toBe(store); + } + ); + + it('real store markers take precedence over the simulation override', () => { + expect( + loadDetectUpdateStore({ + mas: true, + isPackaged: false, + nodeEnv: 'development', + simulateStore: 'flatpak', + }) + ).toBe('mas'); + }); + + it('ignores an unrecognized ROCKETCHAT_SIMULATE_STORE value', () => { + expect( + loadDetectUpdateStore({ + isPackaged: false, + nodeEnv: 'development', + simulateStore: 'nonsense', + }) + ).toBeNull(); + }); + + it('stays store-detected when isPackaged is force-patched to true after load (the setupUpdates dev patch), since the value is captured at load', () => { + process.env.NODE_ENV = 'development'; + process.env.ROCKETCHAT_SIMULATE_STORE = 'snap'; + (process as { mas?: boolean }).mas = false; + (process as { windowsStore?: boolean }).windowsStore = false; + delete process.env.SNAP; + delete process.env.FLATPAK_ID; + + let result!: ReturnType; + jest.isolateModules(() => { + // Starts unpackaged, exactly like setupUpdates sees it before its own + // Object.defineProperty patch runs later in the same dev process. + const mockApp: { isPackaged: boolean } = { isPackaged: false }; + jest.doMock('electron', () => ({ + app: mockApp, + shell: { openExternal: jest.fn() }, + })); + const storeUpdatesModule = // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../storeUpdates') as typeof StoreUpdatesModule; + + // Simulates setupUpdates's + // Object.defineProperty(app, 'isPackaged', { get: () => true }) + // running after storeUpdates.ts already captured the real value. + mockApp.isPackaged = true; + + result = storeUpdatesModule.detectUpdateStore(); + }); + + expect(result).toBe('snap'); + }); + + it('ignores the simulation override in packaged (non-store) builds', () => { + expect( + loadDetectUpdateStore({ + isPackaged: true, + nodeEnv: 'production', + simulateStore: 'mas', + }) + ).toBeNull(); + }); + + it('ignores the simulation override when NODE_ENV is production even if unpackaged', () => { + expect( + loadDetectUpdateStore({ + isPackaged: false, + nodeEnv: 'production', + simulateStore: 'mas', + }) + ).toBeNull(); + }); +}); diff --git a/src/updates/main/setupUpdates.main.spec.ts b/src/updates/main/setupUpdates.main.spec.ts new file mode 100644 index 0000000000..c864a88dea --- /dev/null +++ b/src/updates/main/setupUpdates.main.spec.ts @@ -0,0 +1,168 @@ +import fs from 'fs'; + +import { ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED } from '../../ui/actions'; +// eslint-disable-next-line import/order +import { + UPDATE_SKIPPED, + UPDATES_CHECK_FOR_UPDATES_REQUESTED, + UPDATES_INSTALL_REQUESTED, + UPDATES_SKIP_REQUESTED, +} from '../actions'; + +const listeners = new Map(); +const select = jest.fn(); +const dispatch = jest.fn(); +const autoUpdater = { + logger: null as unknown, + autoDownload: false, + allowPrerelease: false, + channel: 'latest', + checkForUpdates: jest.fn(async () => undefined), + checkForUpdatesAndNotify: jest.fn(async () => undefined), + quitAndInstall: jest.fn(), + downloadUpdate: jest.fn(async () => undefined), + on: jest.fn(), + once: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + removeAllListeners: jest.fn(), + updateConfigPath: '', +}; + +jest.mock('fs', () => ({ + promises: { + readFile: jest.fn(async () => '{}'), + }, +})); + +jest.mock('electron', () => ({ + app: { + getAppPath: jest.fn(() => '/app'), + getPath: jest.fn(() => '/userData'), + isPackaged: true, + }, + BrowserWindow: { + getAllWindows: jest.fn(() => []), + getFocusedWindow: jest.fn(() => null), + }, + autoUpdater: { + on: jest.fn(), + }, +})); + +jest.mock('electron-updater', () => ({ + autoUpdater, +})); + +jest.mock('../../store', () => ({ + select: (...args: unknown[]) => select(...args), + dispatch: (...args: unknown[]) => dispatch(...args), + listen: (type: string, fn: Function) => { + listeners.set(type, fn); + return () => listeners.delete(type); + }, +})); + +jest.mock('../../ui/main/dialogs', () => ({ + askUpdateInstall: jest.fn(async () => 0), + AskUpdateInstallResponse: { INSTALL_UPDATE_AND_RESTART: 0 }, + warnAboutInstallUpdateLater: jest.fn(), + warnAboutUpdateDownload: jest.fn(), + warnAboutUpdateSkipped: jest.fn(), +})); + +// Must stay below the `autoUpdater` const and jest.mock('electron-updater', ...) +// above: importing '../main' pulls in electron-updater, whose mock factory +// closes over `autoUpdater` — hoisting this import breaks that initialization order. +// eslint-disable-next-line import/first +import { setupUpdates } from '../main'; + +describe('updates/setupUpdates', () => { + // `isUpdatingAllowed` is computed by the real loadConfiguration() selector + // straight from process.platform/process.mas/process.windowsStore — never + // from the mocked store state — so it varies by CI runner OS unless pinned + // here. Force the win32-without-windowsStore branch deterministically. + const originalPlatform = process.platform; + const originalWindowsStore = process.windowsStore; + + beforeEach(() => { + jest.clearAllMocks(); + listeners.clear(); + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + Object.defineProperty(process, 'windowsStore', { + value: false, + configurable: true, + }); + select.mockImplementation((selector: any) => + selector({ + isUpdatingEnabled: true, + doCheckForUpdatesOnStartup: false, + skippedUpdateVersion: null, + isReportEnabled: true, + isFlashFrameEnabled: true, + isHardwareAccelerationEnabled: true, + isInternalVideoChatWindowEnabled: true, + isVideoCallScreenCaptureFallbackEnabled: false, + updateChannel: 'latest', + isEachUpdatesSettingConfigurable: true, + isUpdatingAllowed: true, + newUpdateVersion: null, + }) + ); + (fs.promises.readFile as jest.Mock).mockResolvedValue('{}'); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + Object.defineProperty(process, 'windowsStore', { + value: originalWindowsStore, + configurable: true, + }); + }); + + it('wires autoUpdater and action listeners', async () => { + await setupUpdates(); + // electron-updater may use on() and/or addListener() + expect( + (autoUpdater.on as jest.Mock).mock.calls.length + + (autoUpdater.addListener as jest.Mock).mock.calls.length + ).toBeGreaterThan(0); + expect(listeners.has(UPDATES_CHECK_FOR_UPDATES_REQUESTED)).toBe(true); + expect(listeners.has(UPDATES_SKIP_REQUESTED)).toBe(true); + expect(listeners.has(UPDATES_INSTALL_REQUESTED)).toBe(true); + expect(listeners.has(ABOUT_DIALOG_UPDATE_CHANNEL_CHANGED)).toBe(true); + }); + + it('checks for updates when requested', async () => { + await setupUpdates(); + await listeners.get(UPDATES_CHECK_FOR_UPDATES_REQUESTED)?.({ + type: UPDATES_CHECK_FOR_UPDATES_REQUESTED, + }); + expect(autoUpdater.checkForUpdates).toHaveBeenCalled(); + }); + + it('dispatches UPDATE_SKIPPED when skip dialog action fires', async () => { + await setupUpdates(); + await listeners.get(UPDATES_SKIP_REQUESTED)?.({ + type: UPDATES_SKIP_REQUESTED, + payload: '9.9.9', + }); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: UPDATE_SKIPPED, + payload: '9.9.9', + }) + ); + }); + + it('loads update.json configuration files', async () => { + await setupUpdates(); + expect(fs.promises.readFile).toHaveBeenCalled(); + }); +}); diff --git a/src/updates/main/storeUpdates.main.spec.ts b/src/updates/main/storeUpdates.main.spec.ts new file mode 100644 index 0000000000..1a69075810 --- /dev/null +++ b/src/updates/main/storeUpdates.main.spec.ts @@ -0,0 +1,455 @@ +import { shell } from 'electron'; + +import { + fetchLatestStoreVersion, + isStoreVersionNewer, + openStorePage, +} from '../storeUpdates'; + +jest.mock('electron', () => ({ + app: { + isPackaged: true, + }, + shell: { + openExternal: jest.fn(), + }, +})); + +describe('fetchLatestStoreVersion — mas', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('resolves the version and store url on a happy-path lookup', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + resultCount: 1, + results: [ + { + trackId: 1086818840, + bundleId: 'chat.rocket', + version: '4.16.0', + kind: 'mac-software', + trackViewUrl: + 'https://apps.apple.com/us/app/rocket-chat/id1086818840?mt=12&uo=4', + }, + ], + }), + }); + + const result = await fetchLatestStoreVersion('mas'); + + expect(result).toEqual({ + version: '4.16.0', + storeUrl: + 'https://apps.apple.com/us/app/rocket-chat/id1086818840?mt=12&uo=4', + }); + }); + + it('falls back to the constant store url when the response omits trackViewUrl', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + resultCount: 1, + results: [{ version: '4.16.0' }], + }), + }); + + const result = await fetchLatestStoreVersion('mas'); + + expect(result).toEqual({ + version: '4.16.0', + storeUrl: 'https://apps.apple.com/app/id1086818840', + }); + }); + + it('returns null when resultCount is 0', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ resultCount: 0, results: [] }), + }); + + expect(await fetchLatestStoreVersion('mas')).toBeNull(); + }); + + it('returns null on a malformed JSON shape', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ nonsense: true }), + }); + + expect(await fetchLatestStoreVersion('mas')).toBeNull(); + }); + + it('returns null when the result entry has no usable version', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + resultCount: 1, + results: [{ trackViewUrl: 'https://example.com' }], + }), + }); + + expect(await fetchLatestStoreVersion('mas')).toBeNull(); + }); + + it('returns null when the response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ ok: false }); + + expect(await fetchLatestStoreVersion('mas')).toBeNull(); + }); + + it('returns null on a network rejection', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('network down')); + + expect(await fetchLatestStoreVersion('mas')).toBeNull(); + }); + + it('returns null when the request is aborted (timeout), manually signaling abort', async () => { + global.fetch = jest.fn().mockImplementation((_url, options) => { + const { signal } = options as { signal: AbortSignal }; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }); + + const promise = fetchLatestStoreVersion('mas'); + + // Trigger the abort synchronously instead of waiting on the real timer. + const controllerAbort = (global.fetch as jest.Mock).mock.calls[0][1] + .signal as AbortSignal; + controllerAbort.dispatchEvent(new Event('abort')); + + expect(await promise).toBeNull(); + }); + + it("returns null when the module's own 10s timeout fires, driven by fake timers", async () => { + jest.useFakeTimers(); + + try { + global.fetch = jest.fn().mockImplementation((_url, options) => { + const { signal } = options as { signal: AbortSignal }; + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + reject( + new DOMException('The operation was aborted.', 'AbortError') + ); + }); + }); + }); + + const promise = fetchLatestStoreVersion('mas'); + + // Advances the real setTimeout(() => controller.abort(), 10_000) inside + // fetchJson, rather than firing the abort event by hand. + await jest.advanceTimersByTimeAsync(10_000); + + expect(await promise).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe('fetchLatestStoreVersion — windows', () => { + it('has no version API and always resolves null without calling fetch', async () => { + const fetchSpy = jest.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + + expect(await fetchLatestStoreVersion('windows')).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('fetchLatestStoreVersion — snap', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('parses the stable/latest channel-map entry', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + 'name': 'rocketchat-desktop', + 'channel-map': [ + { + channel: { risk: 'edge', track: 'latest', architecture: 'amd64' }, + version: '4.17.0-edge', + }, + { + channel: { risk: 'beta', track: 'latest', architecture: 'amd64' }, + version: '4.17.0-beta', + }, + { + channel: { risk: 'stable', track: 'latest', architecture: 'amd64' }, + version: '4.16.0', + }, + ], + }), + }); + + const result = await fetchLatestStoreVersion('snap'); + + expect(result).toEqual({ + version: '4.16.0', + storeUrl: 'https://snapcraft.io/rocketchat-desktop', + }); + }); + + it('sends the Snap-Device-Series: 16 header', async () => { + const fetchMock = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + 'channel-map': [ + { channel: { risk: 'stable', track: 'latest' }, version: '4.16.0' }, + ], + }), + }); + global.fetch = fetchMock; + + await fetchLatestStoreVersion('snap'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.snapcraft.io/v2/snaps/info/rocketchat-desktop', + expect.objectContaining({ + headers: { 'Snap-Device-Series': '16' }, + }) + ); + }); + + it('falls back to any stable entry when no latest-track stable entry exists', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + 'channel-map': [ + { + channel: { risk: 'stable', track: '4.x', architecture: 'amd64' }, + version: '4.16.0', + }, + ], + }), + }); + + const result = await fetchLatestStoreVersion('snap'); + + expect(result).toEqual({ + version: '4.16.0', + storeUrl: 'https://snapcraft.io/rocketchat-desktop', + }); + }); + + it('returns null when no stable entry exists', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + 'channel-map': [ + { channel: { risk: 'edge', track: 'latest' }, version: '4.17.0' }, + { channel: { risk: 'beta', track: 'latest' }, version: '4.17.0' }, + ], + }), + }); + + expect(await fetchLatestStoreVersion('snap')).toBeNull(); + }); + + it('returns null on a malformed response (missing channel-map)', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ nonsense: true }), + }); + + expect(await fetchLatestStoreVersion('snap')).toBeNull(); + }); + + it('returns null on a network rejection', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('network down')); + + expect(await fetchLatestStoreVersion('snap')).toBeNull(); + }); +}); + +describe('fetchLatestStoreVersion — flatpak', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('parses the newest (first) release entry', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + releases: [ + { type: 'stable', version: '4.16.0', timestamp: '1786320000' }, + { version: '4.15.6' }, + ], + }), + }); + + const result = await fetchLatestStoreVersion('flatpak'); + + expect(result).toEqual({ + version: '4.16.0', + storeUrl: 'https://flathub.org/apps/chat.rocket.RocketChat', + }); + }); + + it('returns null when releases is empty', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ releases: [] }), + }); + + expect(await fetchLatestStoreVersion('flatpak')).toBeNull(); + }); + + it('returns null on a malformed response (missing releases)', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ nonsense: true }), + }); + + expect(await fetchLatestStoreVersion('flatpak')).toBeNull(); + }); + + it('returns null on a network rejection', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('network down')); + + expect(await fetchLatestStoreVersion('flatpak')).toBeNull(); + }); +}); + +describe('isStoreVersionNewer', () => { + it('returns false when the store version matches the local numeric core despite a prerelease suffix', () => { + expect(isStoreVersionNewer('4.16.0', '4.17.0-alpha.1')).toBe(false); + }); + + it('returns true when the store version is newer', () => { + expect(isStoreVersionNewer('4.17.0', '4.16.0')).toBe(true); + }); + + it('returns false when versions are equal', () => { + expect(isStoreVersionNewer('4.16.0', '4.16.0')).toBe(false); + }); + + it('returns true for a newer patch version', () => { + expect(isStoreVersionNewer('4.16.1', '4.16.0')).toBe(true); + }); + + it('compares multi-digit segments numerically, not lexicographically', () => { + expect(isStoreVersionNewer('4.10.0', '4.9.0')).toBe(true); + expect(isStoreVersionNewer('4.9.0', '4.10.0')).toBe(false); + }); +}); + +describe('openStorePage — mas', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('opens the provided store url', async () => { + await openStorePage( + 'mas', + 'https://apps.apple.com/us/app/rocket-chat/id1086818840' + ); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/us/app/rocket-chat/id1086818840' + ); + }); + + it('falls back to the constant app store url when none is provided', async () => { + await openStorePage('mas'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); + + it('accepts an itunes.apple.com https url', async () => { + await openStorePage( + 'mas', + 'https://itunes.apple.com/us/app/rocket-chat/id1086818840' + ); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://itunes.apple.com/us/app/rocket-chat/id1086818840' + ); + }); + + it('falls back on an http (non-https) downgrade', async () => { + await openStorePage('mas', 'http://apps.apple.com/app/id1086818840'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); + + it('falls back on a file: URL', async () => { + await openStorePage('mas', 'file:///etc/passwd'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); + + it('falls back on a javascript: URL', async () => { + await openStorePage('mas', 'javascript:alert(1)'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); + + it('falls back on an https url with an unexpected host', async () => { + await openStorePage('mas', 'https://evil.example.com/apps.apple.com'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); + + it('falls back on a malformed URL string', async () => { + await openStorePage('mas', 'not a url'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); + + it('falls back when no url is provided', async () => { + await openStorePage('mas', undefined); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://apps.apple.com/app/id1086818840' + ); + }); +}); + +describe('openStorePage — other stores use their hardcoded constant, ignoring any override', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('windows always opens the ms-windows-store URI', async () => { + await openStorePage('windows', 'https://example.com/should-be-ignored'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'ms-windows-store://pdp/?ProductId=9nblggh52jv6' + ); + }); + + it('snap always opens the snapcraft.io listing', async () => { + await openStorePage('snap', 'https://example.com/should-be-ignored'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://snapcraft.io/rocketchat-desktop' + ); + }); + + it('flatpak always opens the flathub.org listing', async () => { + await openStorePage('flatpak', 'https://example.com/should-be-ignored'); + expect(shell.openExternal).toHaveBeenCalledWith( + 'https://flathub.org/apps/chat.rocket.RocketChat' + ); + }); +}); diff --git a/src/updates/reducers.ts b/src/updates/reducers.ts index 1c655049ee..55b5fe7f56 100644 --- a/src/updates/reducers.ts +++ b/src/updates/reducers.ts @@ -15,6 +15,7 @@ import { UPDATES_ERROR_THROWN, UPDATES_NEW_VERSION_AVAILABLE, UPDATES_NEW_VERSION_NOT_AVAILABLE, + UPDATES_OPEN_STORE_PAGE_REQUESTED, UPDATES_PANEL_TOGGLED, UPDATES_READY, UPDATES_SKIP_REQUESTED, @@ -22,7 +23,11 @@ import { UPDATE_SKIPPED, UPDATES_CHANNEL_CHANGED, } from './actions'; -import type { UpdateCheckStatus, UpdateDownloadStatus } from './common'; +import type { + UpdateCheckStatus, + UpdateDownloadStatus, + UpdateStore, +} from './common'; type DoCheckForUpdatesOnStartupAction = | ActionOf @@ -123,6 +128,23 @@ export const isUpdatingAllowed: Reducer = ( } }; +type UpdateStoreAction = ActionOf; + +export const updateStore: Reducer = ( + state = null, + action +) => { + switch (action.type) { + case UPDATES_READY: { + const { updateStore } = action.payload; + return updateStore; + } + + default: + return state; + } +}; + type IsUpdatingEnabledAction = | ActionOf | ActionOf; @@ -331,6 +353,7 @@ export const updateCheckStatus: Reducer< type IsUpdatePanelOpenAction = | ActionOf | ActionOf + | ActionOf | ActionOf | ActionOf | ActionOf @@ -346,6 +369,7 @@ export const isUpdatePanelOpen: Reducer = ( // Acting on the panel, or the update going away, dismisses it. case UPDATES_DOWNLOAD_REQUESTED: + case UPDATES_OPEN_STORE_PAGE_REQUESTED: case UPDATES_SKIP_REQUESTED: case UPDATES_NEW_VERSION_NOT_AVAILABLE: case UPDATES_ERROR_THROWN: diff --git a/src/updates/reducers/__tests__/updates.spec.ts b/src/updates/reducers/__tests__/updates.spec.ts index dd4a185d0a..f3b54a834a 100644 --- a/src/updates/reducers/__tests__/updates.spec.ts +++ b/src/updates/reducers/__tests__/updates.spec.ts @@ -12,6 +12,7 @@ import { UPDATES_ERROR_THROWN, UPDATES_NEW_VERSION_AVAILABLE, UPDATES_NEW_VERSION_NOT_AVAILABLE, + UPDATES_OPEN_STORE_PAGE_REQUESTED, UPDATES_PANEL_TOGGLED, UPDATES_READY, UPDATES_SKIP_REQUESTED, @@ -33,6 +34,7 @@ import { updateDownloadStatus, updateError, updateChannel, + updateStore, } from '../../reducers'; const unknown = { type: 'UNKNOWN_ACTION' } as any; @@ -170,6 +172,37 @@ describe('isUpdatingAllowed reducer', () => { }); }); +describe('updateStore reducer', () => { + it('should default to null', () => { + expect(updateStore(undefined, unknown)).toBeNull(); + }); + + it.each(['mas', 'windows', 'snap', 'flatpak'] as const)( + "should read '%s' from UPDATES_READY", + (store) => { + expect( + updateStore(null, { + type: UPDATES_READY, + payload: { updateStore: store }, + } as any) + ).toBe(store); + } + ); + + it('should read null from UPDATES_READY on non-store builds', () => { + expect( + updateStore('mas', { + type: UPDATES_READY, + payload: { updateStore: null }, + } as any) + ).toBeNull(); + }); + + it('should preserve state on unknown action', () => { + expect(updateStore('mas', unknown)).toBe('mas'); + }); +}); + describe('isUpdatingEnabled reducer', () => { it('should default to true', () => { expect(isUpdatingEnabled(undefined, unknown)).toBe(true); @@ -410,6 +443,14 @@ describe('updateDownloadStatus reducer', () => { ])('should reset to idle on %s', (type) => { expect(updateDownloadStatus('downloaded', { type } as any)).toBe('idle'); }); + + it('should NOT enter downloading on UPDATES_OPEN_STORE_PAGE_REQUESTED (store builds have nothing to download)', () => { + expect( + updateDownloadStatus('idle', { + type: UPDATES_OPEN_STORE_PAGE_REQUESTED, + } as any) + ).toBe('idle'); + }); }); describe('updateDownloadProgress reducer', () => { @@ -445,6 +486,14 @@ describe('updateDownloadProgress reducer', () => { } as any) ).toBe(0); }); + + it('should NOT reset progress on UPDATES_OPEN_STORE_PAGE_REQUESTED (store builds have nothing to download)', () => { + expect( + updateDownloadProgress(42, { + type: UPDATES_OPEN_STORE_PAGE_REQUESTED, + } as any) + ).toBe(42); + }); }); describe('isUpdatePanelOpen reducer', () => { @@ -469,6 +518,7 @@ describe('isUpdatePanelOpen reducer', () => { it.each([ UPDATES_DOWNLOAD_REQUESTED, + UPDATES_OPEN_STORE_PAGE_REQUESTED, UPDATES_SKIP_REQUESTED, UPDATES_NEW_VERSION_NOT_AVAILABLE, UPDATES_ERROR_THROWN, diff --git a/src/updates/storeUpdates.ts b/src/updates/storeUpdates.ts new file mode 100644 index 0000000000..c7f0121b4d --- /dev/null +++ b/src/updates/storeUpdates.ts @@ -0,0 +1,370 @@ +import { app, shell } from 'electron'; + +import type { UpdateStore } from './common'; + +/** + * Snapshot of `app.isPackaged` taken at module load, i.e. before + * `setupUpdates` (src/updates/main.ts) can monkey-patch the getter to force + * it `true` in development (needed to make electron-updater work locally). + * The dev-simulation gate reads this instead of `app.isPackaged` directly so + * it isn't defeated by that patch — this module is imported by main.ts above + * the `setupUpdates` call, so the capture always runs first. + */ +const isActuallyPackaged = app.isPackaged; + +/** Network timeout for store version-lookup requests. */ +const LOOKUP_TIMEOUT_MS = 10_000; + +export type StoreVersionInfo = { + version: string; + storeUrl: string; +}; + +/** + * Whether the dev-simulation override (`ROCKETCHAT_SIMULATE_STORE`) should + * take effect. Checked two independent ways so it can't be reactivated by + * accident: `NODE_ENV === 'development'` (rollup.config.mjs bakes this in at + * build time; release CI always sets `NODE_ENV=production`, see + * .github/workflows/build-release.yml) and `isActuallyPackaged` (captured at + * module load, before setupUpdates's dev-mode monkey-patch of + * `app.isPackaged` can run — see the comment above `isActuallyPackaged`). + * Requiring both means a local build that forgets to set + * `NODE_ENV=production` still can't accidentally activate this in a real + * packaged app. + */ +const isDevSimulationAllowed = (): boolean => + process.env.NODE_ENV === 'development' && !isActuallyPackaged; + +const simulatedStore = (): UpdateStore => { + if (!isDevSimulationAllowed()) { + return null; + } + + switch (process.env.ROCKETCHAT_SIMULATE_STORE) { + case 'mas': + case 'windows': + case 'snap': + case 'flatpak': + return process.env.ROCKETCHAT_SIMULATE_STORE; + default: + return null; + } +}; + +const fetchJson = async ( + url: string, + headers?: Record +): Promise => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LOOKUP_TIMEOUT_MS); + + try { + const response = await fetch(url, { signal: controller.signal, headers }); + + if (!response.ok) { + return null; + } + + return await response.json(); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +}; + +// --------------------------------------------------------------------------- +// mas — Mac App Store +// --------------------------------------------------------------------------- + +/** Rocket.Chat's Mac App Store listing id, used when the lookup response omits its own link. */ +const MAS_FALLBACK_URL = 'https://apps.apple.com/app/id1086818840'; + +const MAS_LOOKUP_URL = 'https://itunes.apple.com/lookup?bundleId=chat.rocket'; + +type ITunesLookupResult = { + version?: unknown; + trackViewUrl?: unknown; +}; + +type ITunesLookupResponse = { + resultCount?: unknown; + results?: unknown; +}; + +/** Hostnames the App Store deep link is allowed to point at. */ +const MAS_ALLOWED_HOSTNAMES = new Set(['apps.apple.com', 'itunes.apple.com']); + +/** + * Validates a store URL before it's handed to `shell.openExternal`: only + * `https:` URLs pointed at an Apple App Store hostname pass through. + * Anything else (a protocol downgrade, an unexpected host, a malformed + * string, or no URL at all) falls back to the known-safe constant instead of + * being opened as-is. Only MAS fetches a remote URL (`trackViewUrl`); the + * other stores use hardcoded constants below and never need this check. + */ +const toSafeMasUrl = (storeUrl?: string): string => { + if (!storeUrl) { + return MAS_FALLBACK_URL; + } + + try { + const url = new URL(storeUrl); + + if (url.protocol === 'https:' && MAS_ALLOWED_HOSTNAMES.has(url.hostname)) { + return storeUrl; + } + } catch { + // Malformed URL — fall through to the fallback below. + } + + return MAS_FALLBACK_URL; +}; + +const fetchLatestMasVersion = async (): Promise => { + const data = (await fetchJson(MAS_LOOKUP_URL)) as ITunesLookupResponse | null; + + if ( + !data || + typeof data.resultCount !== 'number' || + data.resultCount < 1 || + !Array.isArray(data.results) + ) { + return null; + } + + const result = data.results[0] as ITunesLookupResult | undefined; + + if (!result || typeof result.version !== 'string' || !result.version) { + return null; + } + + const storeUrl = + typeof result.trackViewUrl === 'string' && result.trackViewUrl + ? result.trackViewUrl + : MAS_FALLBACK_URL; + + return { version: result.version, storeUrl }; +}; + +// --------------------------------------------------------------------------- +// windows — Microsoft Store +// --------------------------------------------------------------------------- + +/** + * Rocket.Chat's Microsoft Store listing. `ms-windows-store:` is the Store + * app's own deep-link scheme — see + * learn.microsoft.com/en-us/windows/apps/develop/launch/launch-store-app. + * There is no unauthenticated version-lookup API (the DisplayCatalog API + * requires auth — verified: an unauthenticated request returns HTTP 400), so + * this store has no `fetchLatestVersion`; the Store app itself is the source + * of truth once opened. + */ +const WINDOWS_STORE_URL = 'ms-windows-store://pdp/?ProductId=9nblggh52jv6'; + +// --------------------------------------------------------------------------- +// snap — Snap Store +// --------------------------------------------------------------------------- + +/** Web listing (not `snap://`, which is not reliable across distros). */ +const SNAP_STORE_URL = 'https://snapcraft.io/rocketchat-desktop'; + +const SNAP_INFO_URL = + 'https://api.snapcraft.io/v2/snaps/info/rocketchat-desktop'; + +type SnapChannelMapEntry = { + channel?: { risk?: unknown; track?: unknown }; + version?: unknown; +}; + +type SnapInfoResponse = { + 'channel-map'?: unknown; +}; + +const fetchLatestSnapVersion = async (): Promise => { + const data = (await fetchJson(SNAP_INFO_URL, { + 'Snap-Device-Series': '16', + })) as SnapInfoResponse | null; + + const channelMap = data?.['channel-map']; + + if (!Array.isArray(channelMap)) { + return null; + } + + const entries = channelMap as SnapChannelMapEntry[]; + + const stableEntry = + entries.find( + (entry) => + entry.channel?.risk === 'stable' && entry.channel?.track === 'latest' + ) ?? entries.find((entry) => entry.channel?.risk === 'stable'); + + if (!stableEntry || typeof stableEntry.version !== 'string') { + return null; + } + + return { version: stableEntry.version, storeUrl: SNAP_STORE_URL }; +}; + +// --------------------------------------------------------------------------- +// flatpak — Flathub +// --------------------------------------------------------------------------- + +const FLATHUB_STORE_URL = 'https://flathub.org/apps/chat.rocket.RocketChat'; + +const FLATHUB_INFO_URL = + 'https://flathub.org/api/v2/appstream/chat.rocket.RocketChat'; + +type FlathubRelease = { + version?: unknown; +}; + +type FlathubAppstreamResponse = { + releases?: unknown; +}; + +const fetchLatestFlatpakVersion = + async (): Promise => { + const data = (await fetchJson( + FLATHUB_INFO_URL + )) as FlathubAppstreamResponse | null; + + if (!data || !Array.isArray(data.releases) || data.releases.length === 0) { + return null; + } + + const [latest] = data.releases as FlathubRelease[]; + + if (!latest || typeof latest.version !== 'string' || !latest.version) { + return null; + } + + return { version: latest.version, storeUrl: FLATHUB_STORE_URL }; + }; + +// --------------------------------------------------------------------------- +// Per-store adapter map +// --------------------------------------------------------------------------- + +type StoreAdapter = { + detect(): boolean; + fetchLatestVersion?(): Promise; + storeUrl: string; +}; + +const STORE_ADAPTERS: Record, StoreAdapter> = { + mas: { + detect: () => process.mas === true, + fetchLatestVersion: fetchLatestMasVersion, + storeUrl: MAS_FALLBACK_URL, + }, + windows: { + detect: () => process.windowsStore === true, + // No fetchLatestVersion — see the comment above WINDOWS_STORE_URL. + storeUrl: WINDOWS_STORE_URL, + }, + snap: { + detect: () => !!process.env.SNAP, + fetchLatestVersion: fetchLatestSnapVersion, + storeUrl: SNAP_STORE_URL, + }, + flatpak: { + detect: () => !!process.env.FLATPAK_ID, + fetchLatestVersion: fetchLatestFlatpakVersion, + storeUrl: FLATHUB_STORE_URL, + }, +}; + +/** Detection precedence when multiple markers are somehow present. */ +const STORE_DETECTION_ORDER: Exclude[] = [ + 'mas', + 'windows', + 'snap', + 'flatpak', +]; + +/** + * Detects which store-distributed build this is, if any. Real store markers + * are checked first (mas → windows → snap → flatpak); only if none match does + * the dev-only `ROCKETCHAT_SIMULATE_STORE` override apply. + */ +export const detectUpdateStore = (): UpdateStore => { + for (const store of STORE_DETECTION_ORDER) { + if (STORE_ADAPTERS[store].detect()) { + return store; + } + } + + return simulatedStore(); +}; + +/** + * Fetches the latest published version for the given store. Returns `null` + * when the store has no version API (e.g. `windows`) or on any failure + * (network error, timeout, no result, or a malformed response) — never + * throws, so callers can fold it into their own error reporting. + */ +export const fetchLatestStoreVersion = async ( + store: Exclude +): Promise => { + const { fetchLatestVersion } = STORE_ADAPTERS[store]; + return fetchLatestVersion ? fetchLatestVersion() : null; +}; + +/** + * Opens the given store's page/listing, falling back to its known constant + * URL. `urlOverride` is only meaningful for `mas` (the one store with a + * remote, per-lookup URL) and is validated through `toSafeMasUrl` before use; + * the other stores always use their hardcoded constant. + */ +export const openStorePage = ( + store: Exclude, + urlOverride?: string +): Promise => { + const url = + store === 'mas' + ? toSafeMasUrl(urlOverride) + : STORE_ADAPTERS[store].storeUrl; + return shell.openExternal(url); +}; + +/** Parses the leading dotted-numeric core of a version string (ignores any prerelease suffix). */ +const parseNumericCore = (version: string): number[] => + version + .split('-')[0] + .split('.') + .map((segment) => { + const value = Number.parseInt(segment, 10); + return Number.isNaN(value) ? 0 : value; + }); + +/** + * Whether the store's published version is strictly newer than the local + * version, comparing only the dotted-numeric core segment by segment (any + * prerelease suffix on the local version, e.g. `-alpha.1`, is ignored so a + * matching numeric core never prompts an update). + */ +export const isStoreVersionNewer = ( + storeVersion: string, + currentVersion: string +): boolean => { + const storeSegments = parseNumericCore(storeVersion); + const currentSegments = parseNumericCore(currentVersion); + const length = Math.max(storeSegments.length, currentSegments.length); + + for (let i = 0; i < length; i += 1) { + const storeSegment = storeSegments[i] ?? 0; + const currentSegment = currentSegments[i] ?? 0; + + if (storeSegment > currentSegment) { + return true; + } + + if (storeSegment < currentSegment) { + return false; + } + } + + return false; +}; diff --git a/src/userPresence/main/setup.main.spec.ts b/src/userPresence/main/setup.main.spec.ts new file mode 100644 index 0000000000..b004b510be --- /dev/null +++ b/src/userPresence/main/setup.main.spec.ts @@ -0,0 +1,51 @@ +export {}; + +const handlers = new Map(); +const dispatch = jest.fn(); +const powerListeners = new Map(); + +jest.mock('../../ipc/main', () => ({ + handle: (channel: string, fn: Function) => { + handlers.set(channel, fn); + }, +})); + +jest.mock('../../store', () => ({ + dispatch: (...args: any[]) => dispatch(...args), +})); + +jest.mock('electron', () => ({ + powerMonitor: { + addListener: (event: string, fn: Function) => { + powerListeners.set(event, fn); + }, + getSystemIdleState: jest.fn(() => 'active'), + }, +})); + +describe('userPresence/main setupPowerMonitor', () => { + beforeEach(() => { + jest.clearAllMocks(); + handlers.clear(); + powerListeners.clear(); + jest.resetModules(); + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('../main').setupPowerMonitor(); + }); + + it('registers power monitor listeners and idle-state handler', async () => { + expect(powerListeners.has('suspend')).toBe(true); + expect(powerListeners.has('lock-screen')).toBe(true); + expect(handlers.has('power-monitor/get-system-idle-state')).toBe(true); + + powerListeners.get('suspend')?.(); + powerListeners.get('lock-screen')?.(); + expect(dispatch).toHaveBeenCalledTimes(2); + + const state = await handlers.get('power-monitor/get-system-idle-state')?.( + {}, + 60 + ); + expect(state).toBe('active'); + }); +}); diff --git a/src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts b/src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts new file mode 100644 index 0000000000..692a000390 --- /dev/null +++ b/src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts @@ -0,0 +1,63 @@ +const render = jest.fn(); +const createRoot = jest.fn((_el?: any) => ({ + render, + unmount: jest.fn(), +})) as jest.Mock; + +jest.mock('react-dom/client', () => ({ + createRoot: (el: any) => (createRoot as any)(el), +})); + +jest.mock('react-i18next', () => ({ + I18nextProvider: ({ children }: any) => children, +})); + +jest.mock('i18next', () => ({ + __esModule: true, + default: { t: (k: string) => k }, +})); + +jest.mock('../../screenSharing/screenSharePicker', () => ({ + ScreenSharePicker: () => null, +})); + +describe('screenSharePickerMount', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + document.body.innerHTML = ''; + }); + + it('mounts when container exists and is idempotent', () => { + const root = document.createElement('div'); + root.id = 'screen-picker-root'; + document.body.appendChild(root); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../screenSharePickerMount'); + mod.mount(); + mod.mount(); + expect(createRoot).toHaveBeenCalledTimes(1); + expect(render).toHaveBeenCalled(); + }); + + it('logs error when container missing', () => { + const err = jest.spyOn(console, 'error').mockImplementation(() => {}); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../screenSharePickerMount'); + mod.mount(); + expect(err).toHaveBeenCalled(); + err.mockRestore(); + }); + + it('show mounts when not yet mounted', () => { + const root = document.createElement('div'); + root.id = 'screen-picker-root'; + document.body.appendChild(root); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const mod = require('../screenSharePickerMount'); + mod.show(); + expect(createRoot).toHaveBeenCalled(); + }); +}); diff --git a/src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts b/src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts new file mode 100644 index 0000000000..bef4b41345 --- /dev/null +++ b/src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts @@ -0,0 +1,25 @@ +import { validateVideoCallUrl } from '../validateVideoCallUrl'; + +describe('validateVideoCallUrl', () => { + it('accepts http and https urls', () => { + expect(validateVideoCallUrl('https://meet.example/room')).toBe( + 'https://meet.example/room' + ); + expect(validateVideoCallUrl('http://localhost:8080/call')).toContain( + 'http://localhost:8080/call' + ); + }); + + it('rejects non-http(s) protocols', () => { + expect(() => validateVideoCallUrl('file:///etc/passwd')).toThrow( + /Invalid URL protocol/ + ); + expect(() => validateVideoCallUrl('javascript:alert(1)')).toThrow( + /Invalid URL protocol/ + ); + }); + + it('rejects malformed urls', () => { + expect(() => validateVideoCallUrl('not a url')).toThrow(/Invalid URL/); + }); +}); diff --git a/src/videoCallWindow/main/ipc.main.spec.ts b/src/videoCallWindow/main/ipc.main.spec.ts index 2845258c52..d05e56a112 100644 --- a/src/videoCallWindow/main/ipc.main.spec.ts +++ b/src/videoCallWindow/main/ipc.main.spec.ts @@ -317,9 +317,10 @@ const loadModule = async () => { const open = async ( openWindow: (...a: any[]) => any, callerWc: WebContents, - url = 'https://meet.example/room' + url = 'https://meet.example/room', + options?: unknown ) => { - const p = openWindow(callerWc, url, undefined); + const p = openWindow(callerWc, url, options); await flushPromises(); await flushPromises(); await p; @@ -849,6 +850,32 @@ describe('videoCallWindow/ipc — PR #3359 hardening', () => { }); }); + // ------------------------------------------------------------------------- + // get-provider-sync: 'video-call-window/get-provider-sync' (ipcMain.on) + // ------------------------------------------------------------------------- + it('writes the current provider name to event.returnValue', async () => { + getServerUrlByWebContentsId.mockReturnValue('https://chat.example'); + const { openWindow } = await loadModule(); + const electron = (await import('electron')) as any; + const call = electron.ipcMain.on.mock.calls.find( + ([channel]: [string]) => channel === 'video-call-window/get-provider-sync' + ); + expect(call).toBeDefined(); + const listener = call[1] as (event: { returnValue: unknown }) => void; + + const eventBeforeOpen = { returnValue: undefined as unknown }; + listener(eventBeforeOpen); + expect(eventBeforeOpen.returnValue).toBeNull(); + + await open(openWindow, makeCallerWc(1), 'https://meet.example/room', { + providerName: 'jitsi', + }); + + const eventAfterOpen = { returnValue: undefined as unknown }; + listener(eventAfterOpen); + expect(eventAfterOpen.returnValue).toBe('jitsi'); + }); + // ------------------------------------------------------------------------- // same-conference reopen: focus the existing window instead of recreating // ------------------------------------------------------------------------- diff --git a/src/videoCallWindow/validateVideoCallUrl.ts b/src/videoCallWindow/validateVideoCallUrl.ts new file mode 100644 index 0000000000..804d616b92 --- /dev/null +++ b/src/videoCallWindow/validateVideoCallUrl.ts @@ -0,0 +1,19 @@ +export const validateVideoCallUrl = (url: string): string => { + try { + const parsedUrl = new URL(url); + + const allowedProtocols = ['http:', 'https:']; + if (!allowedProtocols.includes(parsedUrl.protocol)) { + throw new Error( + `Invalid URL protocol: ${parsedUrl.protocol}. Only http: and https: are allowed.` + ); + } + + return parsedUrl.href; + } catch (error) { + if (error instanceof TypeError) { + throw new Error(`Invalid URL format: ${url}`); + } + throw error; + } +}; diff --git a/src/videoCallWindow/video-call-window.ts b/src/videoCallWindow/video-call-window.ts index 6b24484452..b890db7946 100644 --- a/src/videoCallWindow/video-call-window.ts +++ b/src/videoCallWindow/video-call-window.ts @@ -12,6 +12,7 @@ import { InternalPickerProvider, } from '../screenSharing/screenPicker'; import type { ScreenSharePickerModuleType } from './screenSharePickerMount'; +import { validateVideoCallUrl } from './validateVideoCallUrl'; const MAX_INIT_ATTEMPTS = 10; const MAX_RECOVERY_ATTEMPTS = 3; @@ -596,26 +597,6 @@ const setupWebviewEventHandlers = (webview: HTMLElement): void => { webviewElement.addEventListener('crashed', handleCrashed); }; -const validateVideoCallUrl = (url: string): string => { - try { - const parsedUrl = new URL(url); - - const allowedProtocols = ['http:', 'https:']; - if (!allowedProtocols.includes(parsedUrl.protocol)) { - throw new Error( - `Invalid URL protocol: ${parsedUrl.protocol}. Only http: and https: are allowed.` - ); - } - - return parsedUrl.href; - } catch (error) { - if (error instanceof TypeError) { - throw new Error(`Invalid URL format: ${url}`); - } - throw error; - } -}; - const createWebview = (url: string, partition?: string | null): void => { const container = document.getElementById('webview-container'); if (!container) {