From d1b25f4ee32b1aff965538880d64383a923a4631 Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Thu, 13 Aug 2026 11:32:56 -0300 Subject: [PATCH 01/17] feat: log viewer, downloads, settings and document viewer as separate windows on a shared native shell (#3444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(log-viewer): sidebar filters, native window chrome, paginated list Rebuilds the log viewer window around a left filters sidebar and a unified toolbar, and makes the window honour the transparency setting. Window: - Samples isTransparentWindowEnabled at creation and applies transparent + sidebar vibrancy on macOS. `transparent` cannot be toggled afterwards, so the sampled value is passed to the renderer in the page query rather than fetched over IPC — an async read would flash an opaque surface first. - Uses the toolbar as the title bar on macOS (hiddenInset) so the window shows one header instead of a native title bar stacked on an in-app one. Traffic light geometry is derived, not guessed, so toolbar content clears the buttons. Filters: - Levels, contexts and servers are faceted checkbox lists with counts. Each count reflects the other filters, so a count is never unreachable. - Context tags are parsed into a list instead of a whitespace-joined string, which also lets contexts be discovered from the file rather than hardcoded. - Selections persist; "empty means everything" so a stored selection stays valid when new levels or tags appear. List: - Entries are paged in as the reader scrolls instead of being capped by an entry-limit control, which read as a filter but was pagination. Copy and Save act on every match, not just the rendered page. - Day headers are virtual list group headers, so the date stays readable at any scroll position. - Multi-line entries fold to their first line with an expand toggle, search matches are highlighted, and each row can be copied on its own. - Metadata tags sit above the message so every message shares one left edge and one width. Adds specs for the parser, the facet toggle and the paging advance. The paging advance in particular must settle once everything is rendered: the virtual list keeps firing endReached while the last row is in view, so an unbounded increment re-renders forever and wedges the renderer. Co-Authored-By: Claude Opus 5 * feat(log-viewer): distribution timeline with drag range selection Adds a histogram above the log list showing how the matching entries are spread over the file's time span, oldest on the left, each bar stacked by level so a burst of errors stays visible inside an otherwise busy period. Dragging across the plot selects a time range and filters the list to it; a plain click selects the single slice under the pointer, and the range clears from the chart, from Clear Filters, or with Escape mid-drag. The chart is built from the matches of every filter *except* the time range. Feeding it the range-filtered set would collapse the chart onto the selection and leave no way back to the rest of the span. Facet counts do include the range, so each control still reports what selecting it would yield. Drag listeners are bound imperatively on mousedown rather than in an effect keyed on drag state: an effect only runs after the next render, so a drag fast enough to finish inside one task lost its own mouseup and stayed stuck — which is exactly what a synthesized-event test caught. No chart library. The part worth owning is bucketing log entries by time and level, which is here and covered by tests; the rendering is flex boxes using the existing palette tokens, and a library would have added a second styling system plus hundreds of KB to a secondary window for one histogram. Co-Authored-By: Claude Opus 5 * feat(log-viewer): inset log card, live transparency, restore on launch Shell, matching the main window's concept: the log area is an inset rounded card with a hairline and a soft shadow, and the toolbar, sidebar and status bar carry no fill of their own. The panel colour lives on the window root, not on each bar. Painting the bars individually left the card's 4px gutter showing a different surface, so the card had a halo and its rounded corners read as a cut-out. Now body, root and every bar resolve to one continuous colour and only the card paints. That colour also has to be *recessed* or the corners look like a hole punched in a lighter surface. `surface-tint` gives that in the light palette — grey behind a white card — but inverts in the dark one, where it is lighter than `surface-light`, so the dark panel is mixed down from the card colour instead. Transparency now applies without reopening the window. `transparent` cannot be toggled after creation, so — exactly as the root window does — the window is always created transparent with a vibrancy material on macOS, and the setting only decides whether the renderer paints an opaque surface over it. The initial value still arrives in the page query so the first paint matches; changes are pushed to the open window afterwards. The window also reopens at launch when it was open at shutdown, showing itself without taking focus from the main window. Only a deliberate close records `false`: the `closed` handler fires on quit as well, so it checks a `before-quit` flag first, or quitting would erase the state it is meant to restore. The sidebar toggle is a filters glyph instead of a burger, and ghost instead of `pressed` — the filled state read as a heavy block wedged against the traffic lights, and the sidebar's own presence already shows whether it is open. Co-Authored-By: Claude Opus 5 * feat(downloads): move downloads into its own window on shared chrome Downloads leaves the main window's view stack and becomes a separate window, built on the same shell the log viewer uses. The shell is now a shared module (src/ui/windowChrome) rather than something each window reimplements: window surfaces and the inset card, the macOS title-bar toolbar with its drag region and traffic-light inset, the sticky day header, the status bar and its items, the sidebar filter rows and sections, the facet toggle logic, the link-weight button and the transparency hook. The log viewer was moved onto it, which is most of the churn here. Opaque surfaces now come straight from the main window: `surface-neutral` behind the content and `surface-light` for the content itself, so all three windows read as the same app. The card carries no hairline — the shadow alone lifts it — and the toolbar, sidebar, card gutter and status bar paint nothing, leaving one continuous panel. Downloads window: - Every existing entry point already dispatches the same action, so the main process listens for it once rather than editing each call site; the root window keeps whatever view it was on. - Rows are list rows, not cards: icon, name, and one muted line of server, size and — while a transfer is live — its rate and time left. A finished download's name opens the file, and on macOS there is Quick Look beside it; both resolve the path from main's own state by id, so a renderer cannot ask for an arbitrary file to be opened. - Grouped under sticky day headings, filtered by faceted server/type/status lists with counts, and reopened at launch when it was open at shutdown. Both windows drop their sidebar-hide toggle and their shared "clear filters" footer; each facet section resets itself instead, and the destructive action sits in the status bar beside the count it affects. Co-Authored-By: Claude Opus 5 * feat(downloads): grey document icon for file types The old FileIcon drew a fixed white page, which read as a bright block on the dark window. This one is inline SVG, so its fill, outline, fold and label all come from palette neutrals at low alpha — one drawing that works on both themes. Deliberately monochrome. The icon identifies a row; the file name is what the reader is looking for, and colour-coding by type competed with it. Row hover moves to a shared LIST_ROW_CLASS, so the downloads list and the window chrome's filter rows highlight from the same rule. Co-Authored-By: Claude Opus 5 * feat(settings): settings in its own window, with remembered positions Settings becomes the third window on the shared chrome, alongside the log viewer and downloads: same toolbar, sidebar, nav rows and surface handling, so transparency and the card treatment follow the other two for free. Sections are a registry rather than one long page. Appearance is new and takes the theme and layout settings that were scattered across General; telephony and video calls split apart, since they were only ever grouped by both being "calls". The theme and layout options are now picked from thumbnails, drawn with literal colours rather than palette tokens — these are the one place in the app that must not follow the current theme, or all three options would render identically. Search matches settings, not just section names, and names the matching settings in the row so it is clear why a section is still listed. Fuzzy subsequence matching is applied only to short labels: over prose it matched almost anything ("vibr" hit "Video calls"), so longer text falls back to substring. Certificates merge trusted and untrusted into one list with the state shown per row and a filter field, instead of two lists that had to be compared. All three windows now remember where they were left. Bounds are saved from getNormalBounds() so maximising does not overwrite the size to restore to, debounced because move and resize fire continuously while dragging, and dropped when they no longer overlap any display — a window restored onto an unplugged monitor is a window the reader cannot reach. Co-Authored-By: Claude Opus 5 * feat(windows): in-app title bar on Windows, and room for the theme options The secondary windows kept their native Windows caption while also drawing an in-app toolbar, so each one showed two headers stacked. They now hide the title bar as the main window already does there, and the toolbar draws the caption buttons into its trailing edge. The glyphs and button styling come from the main window's own controls, so this is not a second set that drifts from it; only the wiring differs. 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 — one registration serving all three. Maximised state is pushed back per window, so the glyph shows restore even when the change came from a double click on the toolbar rather than from the buttons. The toolbar reserves the buttons' width at its leading edge too, so the title stays centred in the window rather than in what is left of it. Settings opens wider. A full row of theme thumbnails did not fit the old 680px minimum and wrapped to a second row, which reads as a layout accident rather than a choice. The new minimum is derived from the option's real width — 178px, not the thumbnail's 168px, because the selection ring is drawn whether or not an option is selected — through a metrics module the ring, the grid gap and the thumbnail all share, so the three cannot drift. Measured against the built window: 876px wraps, 878px does not, and the minimum leaves the Windows scrollbar its 10px, which the macOS overlay scrollbar does not take. The downloads sidebar shows a placeholder while there is nothing to filter, instead of a search field over an empty column that reads as a rendering failure. Co-Authored-By: Claude Opus 5 * feat(settings): fold About into settings, and one spacing rhythm The About dialog is gone. On macOS the menu item now opens the system About panel — every Mac app has that item in the same place, and its contents come from the bundle, so a hand-built dialog was a worse version of something the OS already provides. Windows and Linux have no such convention, so they get no About item at all. What the dialog actually held moves into settings. Update channel and the logging switches that had their own Developer section now sit in a new Advanced section together with hardware acceleration and error reports — the things a reader reaches for when something is wrong. The section list no longer needs a developer-only flag: Advanced hides its own developer parts. Checking for updates leads General as a single row: the automatic check and the manual one are the same decision from two angles, so the toggle and the button share a field rather than sitting apart. Version and copyright move to the foot of the sidebar, small and unlabelled — worth being able to find and copy, not worth a row of their own. Spacing is one rhythm now, 24px between settings and a hairline where a group genuinely ends. Three fields had been setting their own block margins, which is why the PDF size limit sat closer to its neighbour than anything else did, and four more hand-rolled the Field markup instead of using the shared wrappers. The telephony shortcut was the worst case: it dropped the className FieldGroup passes down, and with it the group's spacing entirely, so its rows had no gap at all. Measured against the built window: every gap within a group is now 24px, every gap across a divider 49px. Sidebar rows all carry 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. Keyboard focus draws a highlight ring instead of borrowing that fill, which had left two rows claiming to be current. Co-Authored-By: Claude Opus 5 * fix(test): drive the clock in the downloads indicator seen test The indicator reads Date.now() twice — once for the mount-time seenAt, once when the button is clicked — and a download counts as unseen only while its endTime sits between the two. The test set endTime to mountTime + 1, so that window was a fraction of a millisecond wide: it passed when mount and render landed in the same tick and failed when they did not. On the Linux and Windows runners they did not, and this is the test failing on master. Date.now() is stubbed instead, mount and click ten seconds apart, so the download is unambiguously unseen at mount and seen after the click. Co-Authored-By: Claude Opus 5 * feat(document-viewer): open PDFs and markdown in their own window The viewer was an overlay inside the server pane, which meant reading a document and reading the conversation it came from were mutually exclusive. It becomes the fourth window on the shared chrome instead, so the workspace stays visible behind it and the window can be sized and placed on its own. Every entry point already dispatched SERVER_DOCUMENT_VIEWER_OPEN_URL — the page asking to open a PDF, the main process intercepting a markdown download — so the window listens for that one action and both paths redirect at once; no caller changed. The document still renders in a webview on the originating server's session, which is what lets an authenticated URL resolve at all. One window, reused: a second document replaces the first rather than piling up near-identical windows. Fixes a viewer bug found on the way. PdfContent announced its webview to the main process on `did-attach`, but getWebContentsId() throws until the guest document exists, so the call threw every time and the announcement never arrived — leaving the main process unable to intercept link clicks inside a PDF and route them to the browser. It now announces on `dom-ready`, guarded so navigation inside the viewer does not register the handler twice. Co-Authored-By: Claude Opus 5 * feat(document-viewer): download the file, and read markdown as source Both viewers get a download button. The bytes are read on the workspace's own session, so an authenticated document saves as the signed-in user rather than as an anonymous request; a blob the server page created is read back through that page's web contents, since a blob URL resolves nowhere else. Markdown gets a source toggle. The text is already fetched to render it, so switching between rendered and source costs no round trip. Also drops a `bg='surface'` from the markdown viewer. There is no such palette token — Fuselage logged "invalid color: surface" on every render and painted nothing — and the window's card already carries the background. Co-Authored-By: Claude Opus 5 * refactor(chrome): one title bar height across every window The secondary windows' toolbars were 52px while the main window's tab strip is 40px, so the windows did not line up as the same app. They read the height from one constant now rather than each carrying its own number, and the macOS traffic lights stay centred in it because their position is derived from it. Co-Authored-By: Claude Opus 5 * fix(chrome): stop the filter checkbox toggling twice Clicking the visible checkbox did nothing: Fuselage draws it as a label wrapping a real input, so the click reached 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. Clicking the row's label worked, which is why it read as an unreliable checkbox rather than a broken one. The checkbox was meant to be inert, via pointer-events, but that never reached the element Fuselage puts the handler on. It reports its own change now and keeps its clicks to itself; the row handles everywhere else. Co-Authored-By: Claude Opus 5 * feat(chrome): let a filter be cleared completely Unchecking the last option in a facet silently ticked every box again, so the list could never be narrowed to nothing from the sidebar — the one thing a reader tries when they want to see what a filter is actually doing. The cause was one value meaning two things: an empty selection stood for "untouched, so everything", which the facets need in order to keep taking in servers and file types that only show up later. Untouched is `null` now and an empty list means what it says, so all three states are expressible. Co-Authored-By: Claude Opus 5 * fix(windows): stop secondary windows opening full screen A window created on macOS while the app is in full screen was given full screen itself, so opening Downloads or Settings from a full-screen workspace replaced it rather than appearing alongside it. `fullscreenable: false` is what refuses that, and it refuses it outright: even an explicit setFullScreen(true) leaves the window windowed. Maximise, minimise and resize are untouched. Several richer approaches were tried first and are not here for good reason. Toggling the workspace collection behaviour around show() left the main window in a full-screen state it could not be brought out of, and making the secondary windows children of the main window turned it black — both worse than the problem. This changes one constructor option and can do neither. Co-Authored-By: Claude Opus 5 * feat(downloads): show the same download row in the top bar panel The panel had its own item component, so a download looked one way in the downloads window and another in the panel a click away. It renders the window's row now and that component is gone. Making the row portable took removing its one tie to the downloads window: a `surfaces` prop used for a single divider colour that is the same palette token in every theme. The server name is a prop instead — worth a line in the window, which filters by it, and only crowding in a panel listing a handful of downloads from the session at hand. Progress no longer changes a row's height. It was an 8px bar in the flow, so every row below jumped 12px the moment a transfer started; it is a line along the row's own bottom edge now. Drawn directly rather than with Fuselage's ProgressBar, whose animated shine is an absolutely positioned pseudo-element with no containing block of its own — it escaped the bar and swept a white band across the whole list. Every row's last action is a cross now, so the button nearest the edge does not move as a download progresses. They remain different actions: cancel while a transfer is live, remove from the list once it is over. Co-Authored-By: Claude Opus 5 * fix(test): keep the downloads panel size test inside Jest's timeout The test opened the panel with userEvent and then searched the whole tree with a regex. Both got slower when the panel started rendering the full download row, and on the Windows runner the pair crossed the 5s limit. It clicks with fireEvent and asserts against the panel's text instead, which tests the same thing: a transfer that has not moved any bytes shows no size. Co-Authored-By: Claude Opus 5 * fix: address review findings on the secondary windows Two of these are user-visible. The log viewer's server filter could hide every entry. Making an empty facet selection mean "nothing selected" gave the pruning effect a way to empty the list: when no persisted host survived, it set `[]`. It falls back to untouched now, which is what the effect was there to guarantee. A profile saved before downloads and settings moved into windows still names one of them as the root window's view. Restoring it stranded the reader there, since the sidebar buttons that used to leave those views now open windows. Such a value is ignored on load. The focus-request branch that returned 'downloads' goes too — the downloads notification opens the window through SIDE_BAR_DOWNLOADS_BUTTON_CLICKED and nothing dispatches it. The rest is hardening. Each window guarded on its module variable and then awaited the main window before assigning it, so two opens arriving in that gap each built a window and the second orphaned the first — visible, untracked, unclosable from its own channel. Every caller now passes through one gate holding a single in-flight promise, `restore*` included. The document viewer's listener catches instead of leaving an unhandled rejection. Reading a blob to save it used a per-byte loop and btoa in the reader's own workspace, freezing that window for the length of the file and holding it three times over. FileReader does it natively; checked byte for byte over the full 0-255 range. Co-Authored-By: Claude Opus 5 * fix(windows): restore minimized secondary windows on reopen Reopening a secondary window only called focus(), which does not deminiaturize on macOS or restore on Windows. A shared helper now restores, shows, and focuses in every reuse branch. * fix(document-viewer): surface save errors and stop dropping opens Await the save-document result and show a dismissible danger Callout on failure (cancellation stays silent). Queue DOCUMENT_CHANNEL sends behind did-finish-load with last-request-wins so rapid opens cannot be lost, and key the content webview on partition:url so a same-URL document from another workspace remounts under its own session. * fix(chrome): keep out-of-universe values visible when facets narrow isFacetSelected now takes the facet universe: values a facet cannot name (like normal downloads' 'All' status) always match, so narrowing the status facet no longer hides every normal download. toggleFacet sanitizes persisted selections against the universe so stale values cannot collapse the selection back to everything. * fix(settings): settle update checks properly and keep search ephemeral The check-for-updates result effect now waits for the check to actually start before acting on its settle, instead of consuming the request on stale state. When an update is found the settings window closes so the root window's update panel is visible. Section auto-selection during search is a non-persisted override; only explicit clicks persist. * fix(app): never drop the last persisted state write persistValues used a leading-edge throttle that silently discarded writes within one second, losing pre-quit window-state changes. It now coalesces to a trailing write of the latest values, and before-quit flushes anything still pending. * fix(log-viewer): surface load failures and save feedback Load errors now show a danger Callout with retry instead of the "adjust filters" empty state. Saving acknowledges success on the toolbar button, stays silent on cancel, and shows a dismissible Callout on failure. * fix(log-viewer): give the timeline real slider keyboard semantics The plot already advertised role=slider. Arrow/Home/End now move the selected bucket, Shift extends the range, Escape clears it, and aria-value* reports the active bucket. Mouse drag and the clear button reset the keyboard anchor. * fix(chrome): tokenize secondary-window card shadow and radius Card radius uses --rcx-border-radius-large and the shadow uses --rcx-color-shadow-elevation-1 (Tile's elevation-1 chain) so the shared card follows theme and high-contrast instead of hardcoded rgba and platform px. * fix(settings): give sidebar tabs real keyboard and selected weight The section list advertised a tablist without the tabs contract. Arrow/Home/End now move the selected section, only the current tab is in tab order, and the content panel is linked with aria-controls. Selected labels use fontScale p2b so state is not color-only. * fix(chrome): tokenize hover fills and announce copy and save Opaque hover and selected fills now use surface-hover/selected. TextButton keeps an unfilled look but gains a 24px hit target and a focus ring. Copy and save ticks are announced on a polite live region, and save uses its own label instead of "Copied". * fix(log-viewer): use system tokens and distinguish display toggles The live dot is a StatusBullet, level stripes use bullet/badge fills at 1px, and Display rows are ToggleSwitches so they no longer look like data filters. Row-action fade respects prefers-reduced-motion. * fix(windows): confirm destructive clears and tokenize progress Clear All and certificate remove now ask through the existing Electron dialog pattern before they run. The downloads progress fill uses ProgressBar's info token, and the width animation stops under prefers-reduced-motion. * fix(app): persist immediately when the throttle interval elapses elapsed === 1000ms used to schedule a zero-delay trailing write instead of writing now. The comparison is inclusive, with a boundary test. * fix(document-viewer): surface rejected save invocations A thrown save IPC left an unhandled rejection and no Callout. handleDownload now catches and shows the existing download error. * fix(log-viewer): drop stale loads and hide logs on error A failed refresh no longer leaves the old timeline and list under the error Callout. Opening another file clears the time range. Overlapping read-logs responses are ignored via a request id. * fix(chrome): share section labels and Cmd+F search focus Sidebar headings and day headers use one SectionLabel. Cmd/Ctrl+F focuses the local search field in logs, downloads, and settings. Day-header blur runs only over a vibrant window, and scrollbar thumbs follow currentColor instead of theme rgba. * fix(chrome): drop extra icon colors and literal list chrome Row IconButtons keep Fuselage's glyph chain. The document-viewer title icon matches the other toolbars. File-type labels no longer force weight 700. The certificates list uses stroke and radius tokens instead of a 4px literal frame. * fix(ui): close leftover CodeRabbit polish notes Register the PDF click interceptor once and remove it on cleanup. Restore vertical padding on the title-bar downloads panel. Mime filters share one key set so the old in-window view matches the downloads window. * fix(downloads): use font-info for the row progress fill The 2px bar had copied ProgressBar's status-font-on-info token, a text color used as a fill. It now uses font-info, the accent token for progress fills. * fix(settings): answer the secondary-window IPC invoke in window specs --------- Co-authored-by: Claude Opus 5 Co-authored-by: Jean Brito --- rollup.config.mjs | 90 ++ src/app/PersistableValues.ts | 18 + src/app/main/app.ts | 11 + src/app/main/persistence.main.spec.ts | 122 ++ src/app/main/persistence.ts | 58 +- src/app/selectors.ts | 8 + .../DocumentViewerWindow.tsx | 224 +++ src/documentViewerWindow/constants.ts | 13 + .../document-viewer-window.tsx | 71 + src/documentViewerWindow/document.ts | 32 + src/documentViewerWindow/ipc.ts | 250 ++++ .../main/ipc.main.spec.ts | 324 +++++ src/documentViewerWindow/saveDocument.ts | 131 ++ src/downloads/main.spec.ts | 101 +- src/downloads/main.ts | 38 +- src/downloadsWindow/DownloadRow.tsx | 322 +++++ src/downloadsWindow/DownloadsSidebar.tsx | 167 +++ src/downloadsWindow/DownloadsStatusBar.tsx | 42 + src/downloadsWindow/DownloadsToolbar.tsx | 26 + src/downloadsWindow/DownloadsWindow.tsx | 433 ++++++ src/downloadsWindow/FileTypeIcon.tsx | 83 ++ .../__tests__/DownloadsWindow.spec.tsx | 110 ++ .../__tests__/fileTypes.spec.ts | 32 + .../__tests__/grouping.spec.ts | 88 ++ src/downloadsWindow/constants.ts | 9 + src/downloadsWindow/downloads-window.tsx | 69 + src/downloadsWindow/fileTypes.ts | 18 + src/downloadsWindow/grouping.ts | 60 + src/downloadsWindow/ipc.ts | 232 ++++ src/downloadsWindow/main/ipc.main.spec.ts | 179 +++ src/downloadsWindow/styles.tsx | 17 + src/i18n/en.i18n.json | 170 ++- src/ipc/channels.ts | 19 + src/logViewerWindow/LogEntry.tsx | 303 ++-- src/logViewerWindow/LogStatusBar.tsx | 72 + src/logViewerWindow/LogTimeline.tsx | 323 +++++ src/logViewerWindow/LogViewerSidebar.tsx | 251 ++++ src/logViewerWindow/LogViewerToolbar.tsx | 142 ++ src/logViewerWindow/TimelineBar.tsx | 72 + .../__tests__/LogTimeline.spec.tsx | 110 ++ .../__tests__/displayControls.spec.tsx | 126 ++ .../__tests__/pagination.spec.ts | 31 + .../__tests__/parseLogs.spec.ts | 119 ++ .../__tests__/timeline.spec.ts | 219 +++ src/logViewerWindow/appearance.ts | 31 + src/logViewerWindow/constants.ts | 17 + src/logViewerWindow/ipc.ts | 141 +- src/logViewerWindow/log-viewer-window.tsx | 2 +- src/logViewerWindow/logViewerWindow.tsx | 1228 ++++++++++------- src/logViewerWindow/pagination.ts | 18 + src/logViewerWindow/parseLogs.ts | 97 ++ src/logViewerWindow/styles.tsx | 74 + src/logViewerWindow/timeline.ts | 136 ++ src/logViewerWindow/types.ts | 3 + src/main.ts | 25 +- src/public/document-viewer-window.html | 42 + src/public/downloads-window.html | 42 + src/public/log-viewer-window.html | 9 + src/public/settings-window.html | 42 + src/settingsWindow/SettingsSidebar.tsx | 185 +++ src/settingsWindow/SettingsWindow.tsx | 192 +++ .../__tests__/SettingsSidebar.spec.tsx | 236 ++++ .../__tests__/SettingsWindow.spec.tsx | 114 ++ src/settingsWindow/__tests__/fuzzy.spec.ts | 93 ++ .../__tests__/searchIndex.spec.ts | 75 + src/settingsWindow/__tests__/sections.spec.ts | 43 + src/settingsWindow/constants.ts | 38 + src/settingsWindow/fuzzy.ts | 62 + src/settingsWindow/ipc.ts | 244 ++++ src/settingsWindow/main/ipc.main.spec.ts | 189 +++ src/settingsWindow/searchIndex.ts | 56 + src/settingsWindow/sections.ts | 119 ++ .../sections/AdvancedSection.tsx | 62 + .../sections/AppearanceSection.tsx | 17 + .../sections/CertificateRow.spec.tsx | 78 ++ .../sections/CertificateRow.tsx | 75 + .../sections/CertificatesSection.tsx | 138 ++ .../sections/GeneralSection.tsx | 44 + .../sections/TelephonySection.tsx | 15 + .../sections/VideoCallsSection.tsx | 17 + src/settingsWindow/settings-window.tsx | 69 + src/store/rootReducer.ts | 8 + src/ui/actions.ts | 16 +- src/ui/components/AboutDialog/index.tsx | 337 ----- .../components/DownloadsManagerView/index.tsx | 10 +- .../ServersView/MarkdownContent.tsx | 32 +- src/ui/components/ServersView/PdfContent.tsx | 37 +- src/ui/components/ServersView/ServerPane.tsx | 41 +- src/ui/components/ServersView/index.tsx | 2 - .../features/CheckForUpdates.spec.tsx | 125 ++ .../SettingsView/features/CheckForUpdates.tsx | 167 +++ .../features/ChromeThumbnailOption.tsx | 111 ++ .../ClearPermittedScreenCaptureServers.tsx | 2 +- .../SettingsView/features/DebugLogging.tsx | 31 +- .../features/DetailedEventsLogging.tsx | 33 +- .../features/E2ePdfPreviewSizeLimit.tsx | 1 - .../features/NavigationLayout.tsx | 78 +- .../features/SettingGroupDivider.tsx | 19 + .../SettingsView/features/Telephony.tsx | 32 +- .../features/TelephonyGlobalShortcut.spec.tsx | 3 + .../features/TelephonyGlobalShortcut.tsx | 66 +- .../SettingsView/features/ThemeAppearance.tsx | 54 +- .../SettingsView/features/UpdateChannel.tsx | 69 + .../features/VerboseOutlookLogging.tsx | 33 +- .../features/WindowChromeThumbnail.spec.tsx | 136 ++ .../features/WindowChromeThumbnail.tsx | 338 +++++ .../SettingsView/features/thumbnailMetrics.ts | 28 + src/ui/components/Shell/index.spec.tsx | 6 - src/ui/components/Shell/index.tsx | 2 - src/ui/components/TabBar/styles.tsx | 4 +- .../TopBar/DownloadsIndicator.spec.tsx | 38 +- .../components/TopBar/DownloadsIndicator.tsx | 42 +- .../TopBar/DownloadsIndicatorItem.tsx | 198 --- src/ui/main/menuBar.ts | 39 +- src/ui/main/secondaryWindowControls.ts | 61 + src/ui/main/secondaryWindowFocus.ts | 15 + src/ui/main/secondaryWindowState.main.spec.ts | 77 ++ src/ui/main/secondaryWindowState.ts | 91 ++ src/ui/reducers/__tests__/stateGroups.spec.ts | 49 +- src/ui/reducers/currentView.ts | 24 +- src/ui/reducers/isDownloadsWindowOpen.ts | 42 + src/ui/reducers/isLogViewerWindowOpen.spec.ts | 61 + src/ui/reducers/isLogViewerWindowOpen.ts | 42 + src/ui/reducers/isSettingsWindowOpen.ts | 42 + src/ui/reducers/openDialog.ts | 13 - src/ui/reducers/secondaryWindowStates.ts | 41 + src/ui/windowChrome/DayHeader.tsx | 52 + src/ui/windowChrome/FilterRow.tsx | 112 ++ src/ui/windowChrome/FilterSection.tsx | 45 + src/ui/windowChrome/NavRow.tsx | 92 ++ src/ui/windowChrome/SectionLabel.tsx | 18 + src/ui/windowChrome/SidebarPlaceholder.tsx | 38 + src/ui/windowChrome/StatusBar.tsx | 36 + src/ui/windowChrome/StatusItem.tsx | 25 + src/ui/windowChrome/TextButton.tsx | 46 + src/ui/windowChrome/WindowControls.tsx | 93 ++ src/ui/windowChrome/WindowToolbar.tsx | 71 + .../windowChrome/__tests__/appearance.spec.ts | 50 + .../__tests__/copiedFeedback.spec.tsx | 57 + src/ui/windowChrome/__tests__/filters.spec.ts | 104 ++ src/ui/windowChrome/appearance.ts | 213 +++ src/ui/windowChrome/channels.ts | 6 + src/ui/windowChrome/filters.ts | 62 + src/ui/windowChrome/styles.tsx | 109 ++ src/ui/windowChrome/useCopiedFeedback.ts | 99 ++ src/ui/windowChrome/useFindShortcut.ts | 17 + src/ui/windowChrome/useTransparency.ts | 35 + 147 files changed, 11873 insertions(+), 1606 deletions(-) create mode 100644 src/app/main/persistence.main.spec.ts create mode 100644 src/documentViewerWindow/DocumentViewerWindow.tsx create mode 100644 src/documentViewerWindow/constants.ts create mode 100644 src/documentViewerWindow/document-viewer-window.tsx create mode 100644 src/documentViewerWindow/document.ts create mode 100644 src/documentViewerWindow/ipc.ts create mode 100644 src/documentViewerWindow/main/ipc.main.spec.ts create mode 100644 src/documentViewerWindow/saveDocument.ts create mode 100644 src/downloadsWindow/DownloadRow.tsx create mode 100644 src/downloadsWindow/DownloadsSidebar.tsx create mode 100644 src/downloadsWindow/DownloadsStatusBar.tsx create mode 100644 src/downloadsWindow/DownloadsToolbar.tsx create mode 100644 src/downloadsWindow/DownloadsWindow.tsx create mode 100644 src/downloadsWindow/FileTypeIcon.tsx create mode 100644 src/downloadsWindow/__tests__/DownloadsWindow.spec.tsx create mode 100644 src/downloadsWindow/__tests__/fileTypes.spec.ts create mode 100644 src/downloadsWindow/__tests__/grouping.spec.ts create mode 100644 src/downloadsWindow/constants.ts create mode 100644 src/downloadsWindow/downloads-window.tsx create mode 100644 src/downloadsWindow/fileTypes.ts create mode 100644 src/downloadsWindow/grouping.ts create mode 100644 src/downloadsWindow/ipc.ts create mode 100644 src/downloadsWindow/main/ipc.main.spec.ts create mode 100644 src/downloadsWindow/styles.tsx create mode 100644 src/logViewerWindow/LogStatusBar.tsx create mode 100644 src/logViewerWindow/LogTimeline.tsx create mode 100644 src/logViewerWindow/LogViewerSidebar.tsx create mode 100644 src/logViewerWindow/LogViewerToolbar.tsx create mode 100644 src/logViewerWindow/TimelineBar.tsx create mode 100644 src/logViewerWindow/__tests__/LogTimeline.spec.tsx create mode 100644 src/logViewerWindow/__tests__/displayControls.spec.tsx create mode 100644 src/logViewerWindow/__tests__/pagination.spec.ts create mode 100644 src/logViewerWindow/__tests__/parseLogs.spec.ts create mode 100644 src/logViewerWindow/__tests__/timeline.spec.ts create mode 100644 src/logViewerWindow/appearance.ts create mode 100644 src/logViewerWindow/pagination.ts create mode 100644 src/logViewerWindow/parseLogs.ts create mode 100644 src/logViewerWindow/styles.tsx create mode 100644 src/logViewerWindow/timeline.ts create mode 100644 src/public/document-viewer-window.html create mode 100644 src/public/downloads-window.html create mode 100644 src/public/settings-window.html create mode 100644 src/settingsWindow/SettingsSidebar.tsx create mode 100644 src/settingsWindow/SettingsWindow.tsx create mode 100644 src/settingsWindow/__tests__/SettingsSidebar.spec.tsx create mode 100644 src/settingsWindow/__tests__/SettingsWindow.spec.tsx create mode 100644 src/settingsWindow/__tests__/fuzzy.spec.ts create mode 100644 src/settingsWindow/__tests__/searchIndex.spec.ts create mode 100644 src/settingsWindow/__tests__/sections.spec.ts create mode 100644 src/settingsWindow/constants.ts create mode 100644 src/settingsWindow/fuzzy.ts create mode 100644 src/settingsWindow/ipc.ts create mode 100644 src/settingsWindow/main/ipc.main.spec.ts create mode 100644 src/settingsWindow/searchIndex.ts create mode 100644 src/settingsWindow/sections.ts create mode 100644 src/settingsWindow/sections/AdvancedSection.tsx create mode 100644 src/settingsWindow/sections/AppearanceSection.tsx create mode 100644 src/settingsWindow/sections/CertificateRow.spec.tsx create mode 100644 src/settingsWindow/sections/CertificateRow.tsx create mode 100644 src/settingsWindow/sections/CertificatesSection.tsx create mode 100644 src/settingsWindow/sections/GeneralSection.tsx create mode 100644 src/settingsWindow/sections/TelephonySection.tsx create mode 100644 src/settingsWindow/sections/VideoCallsSection.tsx create mode 100644 src/settingsWindow/settings-window.tsx delete mode 100644 src/ui/components/AboutDialog/index.tsx create mode 100644 src/ui/components/SettingsView/features/CheckForUpdates.spec.tsx create mode 100644 src/ui/components/SettingsView/features/CheckForUpdates.tsx create mode 100644 src/ui/components/SettingsView/features/ChromeThumbnailOption.tsx create mode 100644 src/ui/components/SettingsView/features/SettingGroupDivider.tsx create mode 100644 src/ui/components/SettingsView/features/UpdateChannel.tsx create mode 100644 src/ui/components/SettingsView/features/WindowChromeThumbnail.spec.tsx create mode 100644 src/ui/components/SettingsView/features/WindowChromeThumbnail.tsx create mode 100644 src/ui/components/SettingsView/features/thumbnailMetrics.ts delete mode 100644 src/ui/components/TopBar/DownloadsIndicatorItem.tsx create mode 100644 src/ui/main/secondaryWindowControls.ts create mode 100644 src/ui/main/secondaryWindowFocus.ts create mode 100644 src/ui/main/secondaryWindowState.main.spec.ts create mode 100644 src/ui/main/secondaryWindowState.ts create mode 100644 src/ui/reducers/isDownloadsWindowOpen.ts create mode 100644 src/ui/reducers/isLogViewerWindowOpen.spec.ts create mode 100644 src/ui/reducers/isLogViewerWindowOpen.ts create mode 100644 src/ui/reducers/isSettingsWindowOpen.ts create mode 100644 src/ui/reducers/secondaryWindowStates.ts create mode 100644 src/ui/windowChrome/DayHeader.tsx create mode 100644 src/ui/windowChrome/FilterRow.tsx create mode 100644 src/ui/windowChrome/FilterSection.tsx create mode 100644 src/ui/windowChrome/NavRow.tsx create mode 100644 src/ui/windowChrome/SectionLabel.tsx create mode 100644 src/ui/windowChrome/SidebarPlaceholder.tsx create mode 100644 src/ui/windowChrome/StatusBar.tsx create mode 100644 src/ui/windowChrome/StatusItem.tsx create mode 100644 src/ui/windowChrome/TextButton.tsx create mode 100644 src/ui/windowChrome/WindowControls.tsx create mode 100644 src/ui/windowChrome/WindowToolbar.tsx create mode 100644 src/ui/windowChrome/__tests__/appearance.spec.ts create mode 100644 src/ui/windowChrome/__tests__/copiedFeedback.spec.tsx create mode 100644 src/ui/windowChrome/__tests__/filters.spec.ts create mode 100644 src/ui/windowChrome/appearance.ts create mode 100644 src/ui/windowChrome/channels.ts create mode 100644 src/ui/windowChrome/filters.ts create mode 100644 src/ui/windowChrome/styles.tsx create mode 100644 src/ui/windowChrome/useCopiedFeedback.ts create mode 100644 src/ui/windowChrome/useFindShortcut.ts create mode 100644 src/ui/windowChrome/useTransparency.ts diff --git a/rollup.config.mjs b/rollup.config.mjs index f80e5c8fe9..36c5a020d9 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -288,6 +288,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', diff --git a/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index ecd6d13b63..9232a3e8ab 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 & { @@ -257,6 +264,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, 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/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..5283aefebe 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -99,6 +99,14 @@ 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, }); export const selectPersistableValues = createSelector( 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/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/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/en.i18n.json b/src/i18n/en.i18n.json index 3792d7633b..8087df8ed7 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)" @@ -170,6 +173,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 +192,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 +206,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 +214,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 +253,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 +277,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 +308,8 @@ "other": "Other & technical", "logging": "Logging", "telephony": "Telephony", - "videoCalls": "Video calls" + "videoCalls": "Video calls", + "advanced": "Advanced" }, "options": { "report": { @@ -390,7 +446,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}}", @@ -645,58 +704,51 @@ }, "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", "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", + "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 +760,6 @@ "servers": "Servers", "ipc": "IPC Communication" }, - "server": { - "all": "All Servers", - "label": "Server" - }, "exchangeDebug": { "all": "All Logs", "success": "Success Only", @@ -722,11 +770,39 @@ "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" + "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 +878,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index 0d5cd66c59..ee90d9878a 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; @@ -136,6 +138,23 @@ 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; 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..3970943245 --- /dev/null +++ b/src/logViewerWindow/LogViewerToolbar.tsx @@ -0,0 +1,142 @@ +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; + onRefresh: () => void; + onToggleStreaming: () => void; + onCopy: () => void; + onSave: () => void; + hasSaved: boolean; +}; + +export const LogViewerToolbar = ({ + fileName, + filePath, + isDefaultLog, + isLoading, + isStreaming, + onOpenLogFile, + onOpenDefaultLog, + 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__/LogTimeline.spec.tsx b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx new file mode 100644 index 0000000000..31fb3312df --- /dev/null +++ b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx @@ -0,0 +1,110 @@ +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', +}); + +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__/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..89aa9c34a8 --- /dev/null +++ b/src/logViewerWindow/__tests__/parseLogs.spec.ts @@ -0,0 +1,119 @@ +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'); + }); +}); + +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__/timeline.spec.ts b/src/logViewerWindow/__tests__/timeline.spec.ts new file mode 100644 index 0000000000..7453a82635 --- /dev/null +++ b/src/logViewerWindow/__tests__/timeline.spec.ts @@ -0,0 +1,219 @@ +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`, +}); + +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/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..85fbe5fb03 100644 --- a/src/logViewerWindow/constants.ts +++ b/src/logViewerWindow/constants.ts @@ -16,3 +16,20 @@ 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; diff --git a/src/logViewerWindow/ipc.ts b/src/logViewerWindow/ipc.ts index 14990149ac..a93cee517a 100644 --- a/src/logViewerWindow/ipc.ts +++ b/src/logViewerWindow/ipc.ts @@ -10,19 +10,48 @@ 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 isMac = process.platform === 'darwin'; + const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); 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'); @@ -83,12 +112,10 @@ const getLastNEntries = ( }; }; -export const openLogViewerWindow = async (): Promise => { - if (logViewerWindow && !logViewerWindow.isDestroyed()) { - logViewerWindow.focus(); - return; - } +/** 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 +142,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 +178,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 +216,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 () => { 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/logViewerWindow.tsx b/src/logViewerWindow/logViewerWindow.tsx index e6705de401..7481296a0c 100644 --- a/src/logViewerWindow/logViewerWindow.tsx +++ b/src/logViewerWindow/logViewerWindow.tsx @@ -1,32 +1,61 @@ import { Box, - SearchInput, - Icon, Button, - ButtonGroup, - Select, - Tile, + Callout, + 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, + 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 +64,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 +90,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 +124,21 @@ 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 parseGenerationRef = useRef(0); + const loadRequestIdRef = useRef(0); + const [expandedEntryIds, setExpandedEntryIds] = useState>( + () => new Set() + ); const [serverMapping, setServerMapping] = useState>( {} ); @@ -88,175 +159,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]); + return labels; + }, [serverMapping]); - const [serverFilter, setServerFilter] = useLocalStorage( - 'log-server', - 'all' - ); - - // 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 +270,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 +325,286 @@ 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 matchesSearch = useCallback( + (entry: LogEntryType): boolean => { + if (!debouncedSearchFilter) return true; + const needle = debouncedSearchFilter.toLowerCase(); + return ( + entry.message.toLowerCase().includes(needle) || + entry.context.toLowerCase().includes(needle) + ); + }, + [debouncedSearchFilter] + ); + + 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 matchesLevel = useCallback( + (entry: LogEntryType): boolean => + isFacetSelected(levelFilters, entry.level), + [levelFilters] + ); + + const matchesContext = useCallback( + (entry: LogEntryType): boolean => + contextFilters === null || + entry.contextTags.some((tag) => contextFilters.includes(tag)), + [contextFilters] + ); - const filteredLogs = useMemo(() => { - const filtered = logEntries.filter((entry) => { - const matchesSearch = - !debouncedSearchFilter || - entry.message - .toLowerCase() - .includes(debouncedSearchFilter.toLowerCase()) || - entry.context - .toLowerCase() - .includes(debouncedSearchFilter.toLowerCase()); + const matchesTimeRange = useCallback( + (entry: LogEntryType): boolean => + timeRange === null || + isWithinRange(new Date(entry.timestamp).getTime(), timeRange), + [timeRange] + ); - const matchesLevel = levelFilter === 'all' || entry.level === levelFilter; + /** + * 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 contextTags = entry.context.toLowerCase().split(/\s+/); + const levelCounts = useMemo( + () => + countBy( + searchedEntries.filter( + (entry) => + matchesContext(entry) && + matchesServer(entry) && + matchesTimeRange(entry) + ), + (entry) => [entry.level] + ), + [searchedEntries, matchesContext, matchesServer, matchesTimeRange] + ); - const matchesContext = - contextFilter === 'all' || - contextTags.some((tag) => tag.startsWith(contextFilter.toLowerCase())); + 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 +617,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 +679,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) @@ -464,7 +713,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; @@ -509,19 +758,52 @@ function LogViewerWindow() { } }, [autoScroll, userHasScrolled]); + 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 +814,8 @@ function LogViewerWindow() { if (response?.success && response.filePath) { setLogEntries([]); setFileInfo(null); + setTimeRange(null); + setLoadError(null); setIsStreaming(false); @@ -549,6 +833,8 @@ function LogViewerWindow() { const handleOpenDefaultLog = useCallback(() => { setLogEntries([]); setFileInfo(null); + setTimeRange(null); + setLoadError(null); setCurrentLogFile({ filePath: undefined, @@ -584,8 +870,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 +881,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 +898,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 +941,208 @@ 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 && ( + 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/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..2e0b637e44 --- /dev/null +++ b/src/logViewerWindow/parseLogs.ts @@ -0,0 +1,97 @@ +import { type LogEntryType, parseLogLevel } from './types'; + +const LOG_LINE_REGEX = /^\[([^\]]+)\]\s+\[([^\]]+)\]\s*(.*)$/; +const CONTEXT_RUN_REGEX = /^((?:\[[^\]]*\]\s*)+)(.*)$/; +const CONTEXT_TAG_REGEX = /\[([^\]]*)\]/g; + +/** + * 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); + } + + currentEntry = { + id: `${idPrefix}-${entries.length}`, + timestamp, + level: parseLogLevel(level), + contextTags, + context: contextTags.join(' '), + 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(); +}; + +/** 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/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..3698e970ca 100644 --- a/src/logViewerWindow/types.ts +++ b/src/logViewerWindow/types.ts @@ -10,6 +10,9 @@ 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; 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/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/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/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..1a551aeac1 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -33,14 +33,17 @@ 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 { 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 +58,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'; @@ -93,6 +97,7 @@ export const rootReducer = combineReducers({ isMenuBarEnabled, isMessageBoxFocused, isShowWindowOnUnreadChangedEnabled, + isSettingsWindowOpen, isSideBarEnabled, navigationLayout, isTrayIconEnabled, @@ -106,6 +111,7 @@ export const rootReducer = combineReducers({ openDialog, rootWindowIcon, rootWindowState, + secondaryWindowStates, selectedBrowser, servers, userThemePreference, @@ -136,6 +142,8 @@ export const rootReducer = combineReducers({ updateChannel, screenCaptureFallbackForced, isVideoCallDevtoolsAutoOpenEnabled, + isDownloadsWindowOpen, + isLogViewerWindowOpen, isTransparentWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, telephonyPreferredServer, diff --git a/src/ui/actions.ts b/src/ui/actions.ts index 963fd5de3d..5a7d77e20a 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'; @@ -102,6 +101,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 +190,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; @@ -272,6 +277,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/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/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/main/menuBar.ts b/src/ui/main/menuBar.ts index a958ad589a..7f47316573 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, @@ -108,17 +107,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(); }, }); @@ -938,21 +938,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 +963,7 @@ const createRocketChatMenu = createSelector( (): MenuItemConstructorOptions => ({ id: 'rocketChatMenu', label: app.name, - submenu: [ - createAboutMenuItem(), - { type: 'separator' }, - createDisableGpuMenuItem(), - ], + submenu: [createDisableGpuMenuItem()], }) ); 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/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/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/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; +}; From 69a476f5c89b155c3e50bee21d7480d1b90eb3f1 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 13 Aug 2026 12:35:17 -0300 Subject: [PATCH 02/17] chore: bump version to 4.17.0-alpha.1 (#3456) --- electron-builder.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/electron-builder.json b/electron-builder.json index 6dccb08ca1..adff6da359 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": "26081", "helperBundleId": "chat.rocket.electron.helper", "type": "distribution", "artifactName": "rocketchat-${version}-${os}.${ext}", diff --git a/package.json b/package.json index 9b01d02dcb..4d2dad9543 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-alpha.1", "author": "Rocket.Chat Support ", "copyright": "© 2016-2026, Rocket.Chat", "homepage": "https://rocket.chat", From 78b57a0744f5c9015ae4b75b7f01e5c0085d3c5c Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 18 Aug 2026 12:34:14 -0300 Subject: [PATCH 03/17] docs: correct update-channel metadata claims and note admin merges in ship-release (#3457) --- .claude/skills/ship-release/SKILL.md | 14 +++++++++--- docs/release-process.md | 33 ++++++++++++++++++---------- 2 files changed, 33 insertions(+), 14 deletions(-) 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/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. From 150b36c07e67e193270dfd872f048a1aaa6a7387 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Tue, 18 Aug 2026 17:08:56 -0300 Subject: [PATCH 04/17] tests: raise full-surface line coverage to 71.19% with quick-win specs (#3429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: raise full-surface line coverage to 70% with quick-win specs Add and relocate unit/integration specs so Jest discovers them under the current testMatch rules, exercise main IPC and preloads under coverage, and extract small pure helpers for testability. Fixes ErrorView so the failed/reloading UI short-circuits correctly. Documents the new full-src coverage milestone in docs/COVERAGE.md (70.14% lines, 11876-line surface). * test: restore dropped video-call popup/session security assertions The video-call ipc.main.spec.ts rewrite in the previous commit dropped popup URL-scheme denial, shared-vs-isolated session permission-handler wiring, session-partition resolution, restore idempotency, and same-conference reopen coverage with nothing replacing it. Restores the richer mocking harness and those assertions, merged with the newer lifecycle-channel coverage. Also replaces a coverage-padding test in preloadCoverage.spec.ts that only asserted dispatch-was-called with real per-call assertions on the actual dispatched action types and payloads. * test: address CodeRabbit review findings on coverage quick-wins PR Fixes all 5 actionable comments plus the 17 minor and 20 nitpick findings CodeRabbit left on PR #3429: - Renamed main-process specs to the *.main.spec.ts convention (buildAssets, mainEntry, downloads/integration, menuBar, dock) so Jest routes them to the correct project. - Fixed import/first and other lint failures blocking CI (i18n renderer, logViewerWindow, documentViewer, PdfContent). - Replaced weak/no-op assertions across outlookCalendar, setupServers, preloadCoverage, screenSharePicker, FailureImage, TopBar, TooltipProvider, moreSettings, menuBar, dock, PdfContent, and ServerPane specs with real assertions on dispatched actions, DOM state, or IPC payloads so a genuine regression would fail the test. - Fixed a stale fs-mock reference in setupServers.spec.ts that left the app-server fallback branch fully unexercised. - Split an overloaded preload-coverage test into 5 focused tests and consolidated 28 repeated eslint-disable comments into one file-level directive. - Restored security-relevant coverage in videoCallWindow ipc.main.spec.ts (popup scheme denial, session permission wiring) that had been dropped in the original rewrite, plus a real get-provider-sync assertion. * fix: resolve CI lint failures blocking PR checks CI's Lint step (yarn lint = eslint + tsc --noEmit) was failing on all three platforms. Two root causes: - 33 auto-fixable prettier/import-order violations across specs (fixed via `eslint --fix`), plus one naming-convention error in settingsToggles.spec.tsx (a `Component` prop key/destructure conflicted with camelCase parameter rules — renamed the field to `component` and kept a capitalized local alias for JSX usage). - 5 spec files with no top-level import/export statement, causing TypeScript to treat them as global scripts rather than modules. Their same-named top-level consts (`handlers`, `dispatch`, `select`, `watchCallbacks`, `getRootWindow`) collided across files under whole-project `tsc --noEmit`, even though each runs fine in isolation under Jest. Added `export {}` to force module scope. Also fixed a real regression introduced by the eslint --fix pass: it hoisted the `setupUpdates` import in setupUpdates.spec.ts above the `autoUpdater` const its `electron-updater` mock factory closes over, causing "Cannot access 'autoUpdater' before initialization" at runtime. Restored the import to its required position below the const and mocks, with a comment and scoped eslint-disable explaining why the ordering can't be "corrected" by tooling. Verified: `yarn lint` exits 0, full `yarn test` is green (217 suites, 1972 tests, 0 failures). * fix: resolve cross-platform CI test failures on ubuntu and windows CI's ubuntu-latest and windows-latest check jobs failed while macos-latest passed, revealing three real platform-dependent test bugs: - menuBar.main.spec.ts: a new test asserted on the darwin-only 'about' appMenu item without mocking process.platform, so it failed on non-darwin runners. Switched to windowMenu's 'settings' item, which exercises the identical show-if-hidden-then-focus behavior but is registered on every platform. (selectMenuBarTemplate is memoized via createSelector, so a same-test process.platform override can't force recomputation of an already-cached platform-gated item anyway — confirmed by reproducing the failure with a forced-linux variant.) - setupUpdates.spec.ts: isUpdatingAllowed is computed by the real loadConfiguration() selector directly from process.platform/ process.mas/process.windowsStore, never from the mocked store state. On Linux CI (no APPIMAGE env) this is always false, short-circuiting setupUpdates() before it wires up autoUpdater or any listeners. Pinned process.platform to 'win32' (windowsStore: false) in beforeEach/afterEach so the allowed branch runs deterministically on every CI runner. Verified by reproducing the exact 3 CI failures locally with a forced-linux variant, then confirming the fix passes. - logging/__tests__/cleanup.spec.ts (pre-existing, not touched by earlier commits in this branch): asserted unlinkSync was called with a hardcoded forward-slash path, but the real implementation builds the path with path.join(), which uses backslashes on Windows. Switched the assertion to path.join() as well. Verified: yarn lint exits 0, full yarn test is green (217 suites, 1972 tests, 0 failures) locally. * test: rename remaining main-process specs to *.main.spec.ts AGENTS.md requires main-process specs to use the *.main.spec.ts suffix so Jest routes them to the main-process project. Rename the four leftover *.spec.ts files under documentViewer, servers, and updates. Co-authored-by: Jean Brito * fix: adapt coverage specs to current dev APIs after rebase Rebase onto current origin/dev left several quick-win specs targeting removed or rewritten surfaces. Drop the orphaned AboutDialog spec, update log viewer / ServerPane / setupUpdates / settings specs to the current props and action names, and fill in store/electron mocks required by the separate-window log viewer and downloads simulation listener. Co-authored-by: Jean Brito * test: finish leftover CodeRabbit main-spec rename and dock no-op Rename preloadCoverage.spec.ts to the required *.main.spec.ts suffix and assert that dock.setUp() registers no watchers on non-darwin. Co-authored-by: Jean Brito * test: mock app.showAboutPanel in menuBar click-handler coverage The full-template click loop invokes the macOS About item, which calls app.showAboutPanel(). The electron app mock omitted that method, so CI failed with TypeError on that handler. Co-authored-by: Jean Brito --- docs/COVERAGE.md | 8 +- src/app/main/buildAssets.main.spec.ts | 43 + src/app/main/mainEntry.main.spec.ts | 34 + src/documentViewer/main/ipc.main.spec.ts | 197 +++++ src/downloads/{ => __tests__}/actions.spec.ts | 8 +- .../{ => __tests__}/notifications.spec.ts | 4 +- .../integration.main.spec.ts} | 23 +- src/i18n/__tests__/renderer.spec.ts | 49 ++ src/i18n/__tests__/resources.spec.ts | 44 ++ .../__tests__/LogEntry.spec.tsx | 88 +++ .../__tests__/logFormatters.spec.ts | 45 ++ .../__tests__/logViewerWindow.spec.tsx | 261 +++++++ src/logViewerWindow/logFormatters.ts | 61 ++ src/logViewerWindow/main/ipc.main.spec.ts | 263 +++++++ src/logging/{ => __tests__}/cleanup.spec.ts | 7 +- src/logging/{ => __tests__}/fallback.spec.ts | 2 +- src/logging/{ => __tests__}/scopes.spec.ts | 6 +- src/logging/{ => __tests__}/utils.spec.ts | 2 +- .../__tests__/attentionDrawing.spec.ts | 93 +++ src/notifications/main/setup.main.spec.ts | 236 ++++++ src/outlookCalendar/main/ipc.main.spec.ts | 455 +++++++++++ .../__tests__/outlookReducers.spec.ts | 61 ++ ...resolveStandaloneOriginWindow.main.spec.ts | 69 ++ .../__tests__/screenSharePicker.spec.tsx | 221 ++++++ src/servers/{ => __tests__}/common.spec.ts | 2 +- src/servers/__tests__/fetchInfo.spec.ts | 80 ++ src/servers/main.spec.ts | 99 +-- src/servers/main/preloadCoverage.main.spec.ts | 732 ++++++++++++++++++ .../main/resolveServerUrl.main.spec.ts | 88 +++ src/servers/main/setupServers.main.spec.ts | 202 +++++ src/ui/components/App.spec.tsx | 32 + .../CertificatesManager.spec.tsx | 73 ++ src/ui/components/FailureImage.spec.tsx | 23 + .../index.spec.tsx | 121 +++ src/ui/components/ServerInfoContent.spec.tsx | 66 ++ .../components/ServerInfoModal/index.spec.tsx | 77 ++ .../ServersView/DocumentViewer.spec.tsx | 55 ++ .../components/ServersView/ErrorView.spec.tsx | 55 ++ src/ui/components/ServersView/ErrorView.tsx | 87 +-- .../ServersView/MarkdownContent.spec.tsx | 97 +++ .../ServersView/PdfContent.spec.tsx | 45 ++ .../ServersView/ServerPane.spec.tsx | 103 +++ .../SettingsView/SettingsView.spec.tsx | 80 ++ ...learPermittedScreenCaptureServers.spec.tsx | 48 ++ .../SettingsView/features/MenuBar.spec.tsx | 60 ++ .../features/MinimizeOnClose.spec.tsx | 67 ++ .../features/ToggleField.spec.tsx | 72 ++ .../features/moreSettings.spec.tsx | 244 ++++++ .../features/settingsToggles.spec.tsx | 152 ++++ src/ui/components/SettingsView/tabs.spec.tsx | 125 +++ src/ui/components/TopBar/index.spec.tsx | 58 ++ src/ui/components/utils/ErrorCatcher.spec.tsx | 26 + .../components/utils/TooltipProvider.spec.tsx | 90 +++ .../utils/createAnchor.renderer.spec.ts | 29 + .../components/utils/getServerDomId.spec.ts | 22 + .../utils/getServerInitials.spec.ts | 24 + .../__tests__/rootWindowGeometry.main.spec.ts | 156 ++++ src/ui/main/dock.main.spec.ts | 112 +++ .../{menuBar.spec.ts => menuBar.main.spec.ts} | 294 ++++++- src/ui/main/touchBar.main.spec.ts | 116 +++ src/ui/main/trayIcon.main.spec.ts | 124 +++ src/updates/main/setupUpdates.main.spec.ts | 168 ++++ src/userPresence/main/setup.main.spec.ts | 51 ++ .../__tests__/screenSharePickerMount.spec.ts | 63 ++ .../__tests__/validateVideoCallUrl.spec.ts | 25 + src/videoCallWindow/main/ipc.main.spec.ts | 31 +- src/videoCallWindow/validateVideoCallUrl.ts | 19 + src/videoCallWindow/video-call-window.ts | 21 +- 68 files changed, 6433 insertions(+), 161 deletions(-) create mode 100644 src/app/main/buildAssets.main.spec.ts create mode 100644 src/app/main/mainEntry.main.spec.ts create mode 100644 src/documentViewer/main/ipc.main.spec.ts rename src/downloads/{ => __tests__}/actions.spec.ts (98%) rename src/downloads/{ => __tests__}/notifications.spec.ts (99%) rename src/downloads/{integration.spec.ts => main/integration.main.spec.ts} (96%) create mode 100644 src/i18n/__tests__/renderer.spec.ts create mode 100644 src/i18n/__tests__/resources.spec.ts create mode 100644 src/logViewerWindow/__tests__/LogEntry.spec.tsx create mode 100644 src/logViewerWindow/__tests__/logFormatters.spec.ts create mode 100644 src/logViewerWindow/__tests__/logViewerWindow.spec.tsx create mode 100644 src/logViewerWindow/logFormatters.ts create mode 100644 src/logViewerWindow/main/ipc.main.spec.ts rename src/logging/{ => __tests__}/cleanup.spec.ts (94%) rename src/logging/{ => __tests__}/fallback.spec.ts (97%) rename src/logging/{ => __tests__}/scopes.spec.ts (94%) rename src/logging/{ => __tests__}/utils.spec.ts (97%) create mode 100644 src/notifications/__tests__/attentionDrawing.spec.ts create mode 100644 src/notifications/main/setup.main.spec.ts create mode 100644 src/outlookCalendar/main/ipc.main.spec.ts create mode 100644 src/outlookCalendar/reducers/__tests__/outlookReducers.spec.ts create mode 100644 src/screenSharing/__tests__/resolveStandaloneOriginWindow.main.spec.ts create mode 100644 src/screenSharing/__tests__/screenSharePicker.spec.tsx rename src/servers/{ => __tests__}/common.spec.ts (98%) create mode 100644 src/servers/__tests__/fetchInfo.spec.ts create mode 100644 src/servers/main/preloadCoverage.main.spec.ts create mode 100644 src/servers/main/resolveServerUrl.main.spec.ts create mode 100644 src/servers/main/setupServers.main.spec.ts create mode 100644 src/ui/components/App.spec.tsx create mode 100644 src/ui/components/CertificatesManager/CertificatesManager.spec.tsx create mode 100644 src/ui/components/FailureImage.spec.tsx create mode 100644 src/ui/components/SelectClientCertificateDialog/index.spec.tsx create mode 100644 src/ui/components/ServerInfoContent.spec.tsx create mode 100644 src/ui/components/ServerInfoModal/index.spec.tsx create mode 100644 src/ui/components/ServersView/DocumentViewer.spec.tsx create mode 100644 src/ui/components/ServersView/ErrorView.spec.tsx create mode 100644 src/ui/components/ServersView/MarkdownContent.spec.tsx create mode 100644 src/ui/components/ServersView/PdfContent.spec.tsx create mode 100644 src/ui/components/ServersView/ServerPane.spec.tsx create mode 100644 src/ui/components/SettingsView/SettingsView.spec.tsx create mode 100644 src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx create mode 100644 src/ui/components/SettingsView/features/MenuBar.spec.tsx create mode 100644 src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx create mode 100644 src/ui/components/SettingsView/features/ToggleField.spec.tsx create mode 100644 src/ui/components/SettingsView/features/moreSettings.spec.tsx create mode 100644 src/ui/components/SettingsView/features/settingsToggles.spec.tsx create mode 100644 src/ui/components/SettingsView/tabs.spec.tsx create mode 100644 src/ui/components/TopBar/index.spec.tsx create mode 100644 src/ui/components/utils/ErrorCatcher.spec.tsx create mode 100644 src/ui/components/utils/TooltipProvider.spec.tsx create mode 100644 src/ui/components/utils/createAnchor.renderer.spec.ts create mode 100644 src/ui/components/utils/getServerDomId.spec.ts create mode 100644 src/ui/components/utils/getServerInitials.spec.ts create mode 100644 src/ui/main/__tests__/rootWindowGeometry.main.spec.ts create mode 100644 src/ui/main/dock.main.spec.ts rename src/ui/main/{menuBar.spec.ts => menuBar.main.spec.ts} (61%) create mode 100644 src/ui/main/touchBar.main.spec.ts create mode 100644 src/ui/main/trayIcon.main.spec.ts create mode 100644 src/updates/main/setupUpdates.main.spec.ts create mode 100644 src/userPresence/main/setup.main.spec.ts create mode 100644 src/videoCallWindow/__tests__/screenSharePickerMount.spec.ts create mode 100644 src/videoCallWindow/__tests__/validateVideoCallUrl.spec.ts create mode 100644 src/videoCallWindow/validateVideoCallUrl.ts 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/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/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/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/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/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/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/logViewerWindow/__tests__/LogEntry.spec.tsx b/src/logViewerWindow/__tests__/LogEntry.spec.tsx new file mode 100644 index 0000000000..6c3130fb05 --- /dev/null +++ b/src/logViewerWindow/__tests__/LogEntry.spec.tsx @@ -0,0 +1,88 @@ +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', + ...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__/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/logFormatters.ts b/src/logViewerWindow/logFormatters.ts new file mode 100644 index 0000000000..945e50e743 --- /dev/null +++ b/src/logViewerWindow/logFormatters.ts @@ -0,0 +1,61 @@ +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`; +}; + +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); + } + + currentEntry = { + id: `log-${entries.length}`, + timestamp, + level: parseLogLevel(level), + contextTags, + context: contextTags.join(' '), + 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(); +}; diff --git a/src/logViewerWindow/main/ipc.main.spec.ts b/src/logViewerWindow/main/ipc.main.spec.ts new file mode 100644 index 0000000000..4e90ea34f4 --- /dev/null +++ b/src/logViewerWindow/main/ipc.main.spec.ts @@ -0,0 +1,263 @@ +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), + stat: jest.fn(async () => ({ + size: 128, + 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/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/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/main/setup.main.spec.ts b/src/notifications/main/setup.main.spec.ts new file mode 100644 index 0000000000..4862ba1ed1 --- /dev/null +++ b/src/notifications/main/setup.main.spec.ts @@ -0,0 +1,236 @@ +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), + 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 }, + }); + }; + + 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('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/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/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/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/ui/components/App.spec.tsx b/src/ui/components/App.spec.tsx new file mode 100644 index 0000000000..699b2c33d7 --- /dev/null +++ b/src/ui/components/App.spec.tsx @@ -0,0 +1,32 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import { createStore } from 'redux'; + +import { App } from './App'; + +jest.mock('react-i18next', () => ({ + I18nextProvider: ({ children }: any) => children, + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: '3rdParty', init: () => {} }, +})); + +jest.mock('i18next', () => ({ + __esModule: true, + default: { t: (k: string) => k, language: 'en' }, +})); + +jest.mock('./Shell', () => ({ + Shell: () =>
shell
, +})); + +jest.mock('./utils/ErrorCatcher', () => ({ + ErrorCatcher: ({ children }: any) => <>{children}, +})); + +describe('App', () => { + it('wraps Shell with store provider', () => { + const store = createStore(() => ({})); + render(); + expect(screen.getByTestId('shell')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/CertificatesManager/CertificatesManager.spec.tsx b/src/ui/components/CertificatesManager/CertificatesManager.spec.tsx new file mode 100644 index 0000000000..5286805f3a --- /dev/null +++ b/src/ui/components/CertificatesManager/CertificatesManager.spec.tsx @@ -0,0 +1,73 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import ActionButton from './ActionButton'; +import CertificateItem from './CertificateItem'; +import { CertificatesManager } from './CertificatesManager'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +const invokeMock = jest.fn(); +jest.mock('../../../ipc/renderer', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +describe('CertificatesManager', () => { + beforeEach(() => { + invokeMock.mockClear(); + }); + + it('renders trusted and not-trusted certificate tables', () => { + const store = makeStore({ + trustedCertificates: { 'https://trusted.example': 'pem' }, + notTrustedCertificates: { 'https://blocked.example': 'pem' }, + }); + + render( + + + + ); + + expect( + screen.getByText('certificatesManager.trustedCertificates') + ).toBeInTheDocument(); + expect( + screen.getByText('certificatesManager.notTrustedCertificates') + ).toBeInTheDocument(); + expect(screen.getByText('https://trusted.example')).toBeInTheDocument(); + expect(screen.getByText('https://blocked.example')).toBeInTheDocument(); + }); + + it('CertificateItem invokes remove channel on action click', () => { + render( + + + + +
+ ); + + fireEvent.click( + screen.getByRole('button', { name: 'certificatesManager.item.remove' }) + ); + expect(invokeMock).toHaveBeenCalledWith( + 'certificatesManager/remove', + 'https://remove.me' + ); + }); + + it('ActionButton renders children', () => { + render(Go); + expect(screen.getByRole('button', { name: 'Go' })).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/FailureImage.spec.tsx b/src/ui/components/FailureImage.spec.tsx new file mode 100644 index 0000000000..2c65338592 --- /dev/null +++ b/src/ui/components/FailureImage.spec.tsx @@ -0,0 +1,23 @@ +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; + +import { FailureImage } from './FailureImage'; + +describe('FailureImage', () => { + it('renders an svg with default colors', () => { + const { container } = render(); + const svg = container.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg).toHaveAttribute('viewBox', '0 0 1366 768'); + }); + + it('accepts custom style and color overrides', () => { + const { container } = render( + + ); + const svg = container.querySelector('svg'); + expect(svg).toHaveStyle({ opacity: '0.5' }); + const strokedPath = container.querySelector('path[stroke]'); + expect(strokedPath).toHaveAttribute('stroke', '#000000'); + }); +}); diff --git a/src/ui/components/SelectClientCertificateDialog/index.spec.tsx b/src/ui/components/SelectClientCertificateDialog/index.spec.tsx new file mode 100644 index 0000000000..484a73e553 --- /dev/null +++ b/src/ui/components/SelectClientCertificateDialog/index.spec.tsx @@ -0,0 +1,121 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SelectClientCertificateDialog } from '.'; +import { + CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED, + SELECT_CLIENT_CERTIFICATE_DIALOG_CERTIFICATE_SELECTED, + SELECT_CLIENT_CERTIFICATE_DIALOG_DISMISSED, +} from '../../../navigation/actions'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children: React.ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +const listeners = new Map void>(); + +jest.mock('../../../store', () => ({ + listen: (type: string, listener: (action: any) => void) => { + listeners.set(type, listener); + return () => listeners.delete(type); + }, +})); + +jest.mock('../../../store/fsa', () => ({ + isRequest: (action: any) => Boolean(action?.meta?.id), +})); + +const cert = { + subjectName: 'Alice', + issuerName: 'CA', + fingerprint: 'fp-1', + validStart: 1_700_000_000, + validExpiry: 1_800_000_000, +}; + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +describe('SelectClientCertificateDialog', () => { + beforeEach(() => { + listeners.clear(); + }); + + it('renders certificates when dialog is open', () => { + const store = makeStore({ + openDialog: 'select-client-certificate', + clientCertificates: [cert], + }); + render( + + + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('CA')).toBeInTheDocument(); + }); + + it('dispatches select and dismiss actions', () => { + const store = makeStore({ + openDialog: 'select-client-certificate', + clientCertificates: [cert], + }); + const spy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + + act(() => { + listeners.get(CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED)?.({ + type: CERTIFICATES_CLIENT_CERTIFICATE_REQUESTED, + meta: { id: 'req-1' }, + }); + }); + + fireEvent.click( + screen.getByRole('button', { + name: 'dialog.selectClientCertificate.select', + }) + ); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: SELECT_CLIENT_CERTIFICATE_DIALOG_CERTIFICATE_SELECTED, + payload: 'fp-1', + }) + ); + + fireEvent.click(screen.getByText('dismiss')); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + type: SELECT_CLIENT_CERTIFICATE_DIALOG_DISMISSED, + }) + ); + }); +}); diff --git a/src/ui/components/ServerInfoContent.spec.tsx b/src/ui/components/ServerInfoContent.spec.tsx new file mode 100644 index 0000000000..268df6d95e --- /dev/null +++ b/src/ui/components/ServerInfoContent.spec.tsx @@ -0,0 +1,66 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; + +import ServerInfoContent from './ServerInfoContent'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts ? `${key}:${JSON.stringify(opts)}` : key, + i18n: { language: 'en' }, + }), +})); + +jest.mock('../../servers/supportedVersions/main', () => ({ + isServerVersionSupported: jest.fn(async () => ({ + supported: true, + })), + getExpirationMessageTranslated: jest.fn(() => undefined), +})); + +describe('ServerInfoContent', () => { + it('renders url and version labels', () => { + const { unmount } = render( + + ); + expect(screen.getByText('serverInfo.title')).toBeInTheDocument(); + expect(screen.getByText('https://open.rocket.chat')).toBeInTheDocument(); + expect(screen.getByText('6.5.0')).toBeInTheDocument(); + unmount(); + }); + + it('hides title in modal mode and shows exchange url', () => { + const { unmount } = render( + + ); + expect(screen.queryByText('serverInfo.title')).not.toBeInTheDocument(); + expect(screen.getByText('https://exchange.example')).toBeInTheDocument(); + unmount(); + }); + + it('shows loading and error fetch states', () => { + const { rerender, unmount } = render( + + ); + expect(screen.getByText(/serverInfo\.status\.loading/)).toBeInTheDocument(); + + rerender( + + ); + expect(screen.getByText(/serverInfo\.status\.error/)).toBeInTheDocument(); + unmount(); + }); +}); diff --git a/src/ui/components/ServerInfoModal/index.spec.tsx b/src/ui/components/ServerInfoModal/index.spec.tsx new file mode 100644 index 0000000000..f407cedbbc --- /dev/null +++ b/src/ui/components/ServerInfoModal/index.spec.tsx @@ -0,0 +1,77 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { ServerInfoModal } from '.'; +import { CLOSE_SERVER_INFO_MODAL } from '../../actions'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../ServerInfoContent', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../Dialog', () => ({ + Dialog: ({ + children, + isVisible, + onClose, + }: { + children: React.ReactNode; + isVisible?: boolean; + onClose?: () => void; + }) => + isVisible ? ( +
+ + {children} +
+ ) : null, +})); + +const makeStore = (partial: Record) => + createStore((s = partial) => s as any); + +describe('ServerInfoModal', () => { + it('returns null without server data', () => { + const store = makeStore({ + dialogs: { serverInfoModal: { isOpen: true, serverData: null } }, + }); + const { container } = render( + + + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders content and dispatches close', () => { + const store = makeStore({ + dialogs: { + serverInfoModal: { + isOpen: true, + serverData: { + url: 'https://open.rocket.chat', + version: '6.0.0', + }, + }, + }, + }); + const spy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByTestId('server-info-content')).toBeInTheDocument(); + fireEvent.click(screen.getByText('close')); + expect(spy).toHaveBeenCalledWith({ type: CLOSE_SERVER_INFO_MODAL }); + }); +}); diff --git a/src/ui/components/ServersView/DocumentViewer.spec.tsx b/src/ui/components/ServersView/DocumentViewer.spec.tsx new file mode 100644 index 0000000000..7be25cae99 --- /dev/null +++ b/src/ui/components/ServersView/DocumentViewer.spec.tsx @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import DocumentViewer from './DocumentViewer'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('./MarkdownContent', () => ({ + __esModule: true, + default: ({ url }: { url: string }) => ( +
{url}
+ ), +})); + +jest.mock('./PdfContent', () => ({ + __esModule: true, + default: ({ url }: { url: string }) =>
{url}
, +})); + +describe('DocumentViewer', () => { + it('renders markdown content for markdown format', () => { + const close = jest.fn(); + render( + + ); + expect( + screen.getByText('documentViewer.title.markdown') + ).toBeInTheDocument(); + expect(screen.getByTestId('markdown')).toHaveTextContent('file:///doc.md'); + }); + + it('renders pdf content by default and closes on back', () => { + const close = jest.fn(); + render( + + ); + expect(screen.getByText('documentViewer.title.pdf')).toBeInTheDocument(); + expect(screen.getByTestId('pdf')).toHaveTextContent('file:///doc.pdf'); + fireEvent.click( + screen.getByRole('button', { name: 'documentViewer.back' }) + ); + expect(close).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/ServersView/ErrorView.spec.tsx b/src/ui/components/ServersView/ErrorView.spec.tsx new file mode 100644 index 0000000000..4fb64b35f3 --- /dev/null +++ b/src/ui/components/ServersView/ErrorView.spec.tsx @@ -0,0 +1,55 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +import ErrorView from './ErrorView'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +describe('ErrorView', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('renders nothing when not failed', () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows reload button with countdown when failed', () => { + render(); + expect(screen.getByText('loadingError.announcement')).toBeInTheDocument(); + expect(screen.getByText('loadingError.title')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /loadingError\.reload/ }) + ).toBeInTheDocument(); + }); + + it('calls onReload when reload button is clicked', () => { + const onReload = jest.fn(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /loadingError\.reload/ }) + ); + expect(onReload).toHaveBeenCalledTimes(1); + }); + + it('auto-reloads when countdown reaches zero', () => { + const onReload = jest.fn(); + render(); + + act(() => { + jest.advanceTimersByTime(60_000); + }); + + expect(onReload).toHaveBeenCalled(); + }); +}); diff --git a/src/ui/components/ServersView/ErrorView.tsx b/src/ui/components/ServersView/ErrorView.tsx index 8bb59793c9..576e86e9be 100644 --- a/src/ui/components/ServersView/ErrorView.tsx +++ b/src/ui/components/ServersView/ErrorView.tsx @@ -56,55 +56,54 @@ const ErrorView = ({ isFailed, onReload }: ErrorViewProps) => { return ( <> - {isFailed || - (isReloading && ( - - - - - - - {t('loadingError.announcement')} + {(isFailed || isReloading) && ( + + + + + + + {t('loadingError.announcement')} - {t('loadingError.title')} - - - + {t('loadingError.title')} + + + - - {isReloading && ( - - - - )} + + {isReloading && ( + + + + )} - {!isReloading && ( - - - - )} - + {!isReloading && ( + + + + )} - ))} + + )} ); }; diff --git a/src/ui/components/ServersView/MarkdownContent.spec.tsx b/src/ui/components/ServersView/MarkdownContent.spec.tsx new file mode 100644 index 0000000000..88e5a2a523 --- /dev/null +++ b/src/ui/components/ServersView/MarkdownContent.spec.tsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; + +import MarkdownContent from './MarkdownContent'; + +const invoke = jest.fn(); + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: (...args: unknown[]) => invoke(...args), + }, + shell: { + openExternal: jest.fn(), + }, +})); + +jest.mock('dompurify', () => ({ + __esModule: true, + default: { + sanitize: (html: string) => html, + }, +})); + +jest.mock('highlight.js', () => ({ + __esModule: true, + default: { + getLanguage: () => true, + highlight: () => ({ value: 'code' }), + }, +})); + +jest.mock('marked', () => { + class Marked { + use() { + return this; + } + + setOptions() { + return this; + } + + parse(text: string) { + return `

${text}

`; + } + } + return { Marked }; +}); + +jest.mock('marked-highlight', () => ({ + markedHighlight: () => ({}), +})); + +describe('MarkdownContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders fetched markdown content', async () => { + invoke.mockResolvedValue('Hello'); + const { unmount } = render( + + ); + + await waitFor( + () => { + expect(screen.getByText('Hello')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + expect(invoke).toHaveBeenCalledWith( + 'document-viewer/fetch-content', + 'https://example.com/doc.md', + 'https://example.com' + ); + unmount(); + }); + + it('shows error message when fetch fails', async () => { + invoke.mockRejectedValue(new Error('network down')); + const { unmount } = render( + + ); + await waitFor( + () => { + expect(screen.getByText('network down')).toBeInTheDocument(); + }, + { timeout: 3000 } + ); + unmount(); + }); +}); diff --git a/src/ui/components/ServersView/PdfContent.spec.tsx b/src/ui/components/ServersView/PdfContent.spec.tsx new file mode 100644 index 0000000000..98cc7d2af7 --- /dev/null +++ b/src/ui/components/ServersView/PdfContent.spec.tsx @@ -0,0 +1,45 @@ +import '@testing-library/jest-dom'; +import { act, render } from '@testing-library/react'; + +import PdfContent from './PdfContent'; + +const dispatch = jest.fn(); + +jest.mock('../../../store', () => ({ + dispatch: (...args: any[]) => dispatch(...args), +})); + +describe('PdfContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('renders webview for a pdf url after delay', () => { + const { container } = render( + + ); + act(() => { + jest.advanceTimersByTime(150); + }); + const webview = container.querySelector('webview'); + expect(webview).not.toBeNull(); + expect(webview?.getAttribute('src')).toBe('file:///doc.pdf'); + }); + + it('clears document when url becomes empty', () => { + const { container, rerender } = render( + + ); + act(() => { + jest.advanceTimersByTime(150); + }); + rerender(); + // The webview element itself stays mounted; only its src is cleared. + expect(container.querySelector('webview')?.getAttribute('src')).toBeFalsy(); + }); +}); diff --git a/src/ui/components/ServersView/ServerPane.spec.tsx b/src/ui/components/ServersView/ServerPane.spec.tsx new file mode 100644 index 0000000000..ffa3e5fdf5 --- /dev/null +++ b/src/ui/components/ServersView/ServerPane.spec.tsx @@ -0,0 +1,103 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED } from '../../actions'; +import { ServerPane } from './ServerPane'; + +jest.mock('electron', () => ({ + ipcRenderer: { + on: jest.fn(), + removeListener: jest.fn(), + send: jest.fn(), + }, +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('./ErrorView', () => ({ + __esModule: true, + default: ({ isFailed, onReload }: any) => + isFailed ? ( + + ) : null, +})); + +jest.mock('./UnsupportedServer', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('./styles', () => ({ + DocumentViewerWrapper: ({ children }: any) =>
{children}
, + StyledWebView: 'div', + Wrapper: ({ children, ...props }: any) => ( +
+ {children} +
+ ), +})); + +const makeStore = () => createStore((s = {}) => s as any); + +describe('ServerPane', () => { + it('renders selected server pane shell', () => { + render( + + + + ); + expect(screen.getByTestId('server-pane')).toBeInTheDocument(); + }); + + it('shows error view when failed and reloads', () => { + const store = makeStore(); + const spy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + fireEvent.click(screen.getByText('error-reload')); + expect(spy).toHaveBeenCalledWith({ + type: LOADING_ERROR_VIEW_RELOAD_SERVER_CLICKED, + payload: { url: 'https://open.rocket.chat' }, + }); + }); + + it('shows unsupported server when not supported', () => { + render( + + + + ); + expect(screen.getByTestId('unsupported')).toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/SettingsView.spec.tsx b/src/ui/components/SettingsView/SettingsView.spec.tsx new file mode 100644 index 0000000000..69edbbfcf6 --- /dev/null +++ b/src/ui/components/SettingsView/SettingsView.spec.tsx @@ -0,0 +1,80 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SettingsView } from './SettingsView'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('./GeneralTab', () => ({ + GeneralTab: () =>
, +})); +jest.mock('./CertificatesTab', () => ({ + CertificatesTab: () =>
, +})); +jest.mock('./VoiceVideoTab', () => ({ + VoiceVideoTab: () =>
, +})); +jest.mock('./DeveloperTab', () => ({ + DeveloperTab: () =>
, +})); + +const makeStore = (partial: Record) => { + const reducer = (state = partial) => state; + return createStore(reducer as any); +}; + +describe('SettingsView', () => { + it('is hidden when currentView is not settings', () => { + const store = makeStore({ + currentView: 'downloads', + isDeveloperModeEnabled: false, + }); + const { container } = render( + + + + ); + expect(container.firstChild).toHaveStyle({ display: 'none' }); + }); + + it('shows general tab by default and switches tabs', () => { + const store = makeStore({ + currentView: 'settings', + isDeveloperModeEnabled: true, + }); + render( + + + + ); + + expect(screen.getByText('settings.title')).toBeInTheDocument(); + expect(screen.getByTestId('general-tab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('settings.certificates')); + expect(screen.getByTestId('certificates-tab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('settings.voiceVideo')); + expect(screen.getByTestId('voice-video-tab')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('settings.developer')); + expect(screen.getByTestId('developer-tab')).toBeInTheDocument(); + }); + + it('hides developer tab when developer mode is off', () => { + const store = makeStore({ + currentView: 'settings', + isDeveloperModeEnabled: false, + }); + render( + + + + ); + expect(screen.queryByText('settings.developer')).not.toBeInTheDocument(); + }); +}); diff --git a/src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx b/src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx new file mode 100644 index 0000000000..d0dafdc818 --- /dev/null +++ b/src/ui/components/SettingsView/features/ClearPermittedScreenCaptureServers.spec.tsx @@ -0,0 +1,48 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS } from '../../../actions'; +import { ClearPermittedScreenCaptureServers } from './ClearPermittedScreenCaptureServers'; + +const dispatchMock = jest.fn(); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +jest.mock('../../../../store', () => ({ + dispatch: (...args: unknown[]) => dispatchMock(...args), +})); + +describe('ClearPermittedScreenCaptureServers', () => { + beforeEach(() => { + dispatchMock.mockClear(); + }); + + it('renders the clear action button', () => { + render(); + expect( + screen.getByRole('button', { + name: 'settings.options.clearPermittedScreenCaptureServers.title', + }) + ).toBeInTheDocument(); + expect( + screen.getByText( + 'settings.options.clearPermittedScreenCaptureServers.description' + ) + ).toBeInTheDocument(); + }); + + it('dispatches clear permissions action on click', () => { + render(); + fireEvent.click( + screen.getByRole('button', { + name: 'settings.options.clearPermittedScreenCaptureServers.title', + }) + ); + + expect(dispatchMock).toHaveBeenCalledWith({ + type: SETTINGS_CLEAR_PERMITTED_SCREEN_CAPTURE_PERMISSIONS, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/MenuBar.spec.tsx b/src/ui/components/SettingsView/features/MenuBar.spec.tsx new file mode 100644 index 0000000000..6de9dc5803 --- /dev/null +++ b/src/ui/components/SettingsView/features/MenuBar.spec.tsx @@ -0,0 +1,60 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED } from '../../../actions'; +import { MenuBar } from './MenuBar'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = { + isMenuBarEnabled: boolean; + navigationLayout: 'sidebar' | 'tabs'; +}; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const renderMenuBar = (partial: PartialState) => { + const store = makeStore(partial); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + return { dispatchSpy }; +}; + +describe('MenuBar', () => { + it('renders checked and enabled when sidebar layout allows toggle-off', () => { + renderMenuBar({ isMenuBarEnabled: true, navigationLayout: 'sidebar' }); + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeChecked(); + expect(toggle).not.toBeDisabled(); + }); + + it('keeps the toggle enabled for tabs layout', () => { + renderMenuBar({ isMenuBarEnabled: true, navigationLayout: 'tabs' }); + expect(screen.getByRole('checkbox')).not.toBeDisabled(); + }); + + it('dispatches SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED on toggle', () => { + const { dispatchSpy } = renderMenuBar({ + isMenuBarEnabled: false, + navigationLayout: 'sidebar', + }); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_IS_MENU_BAR_ENABLED_CHANGED, + payload: true, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx b/src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx new file mode 100644 index 0000000000..a494850c1c --- /dev/null +++ b/src/ui/components/SettingsView/features/MinimizeOnClose.spec.tsx @@ -0,0 +1,67 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED } from '../../../actions'; +import { MinimizeOnClose } from './MinimizeOnClose'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +type PartialState = { + isMinimizeOnCloseEnabled: boolean; + isTrayIconEnabled: boolean; +}; + +const makeStore = (partial: PartialState) => { + const reducer = (state: PartialState = partial) => state; + return createStore(reducer as any); +}; + +const renderMinimizeOnClose = (partial: PartialState) => { + const store = makeStore(partial); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + render( + + + + ); + return { dispatchSpy }; +}; + +describe('MinimizeOnClose', () => { + it('renders enabled when tray icon is off', () => { + renderMinimizeOnClose({ + isMinimizeOnCloseEnabled: false, + isTrayIconEnabled: false, + }); + expect(screen.getByRole('checkbox')).not.toBeDisabled(); + }); + + it('disables toggle and shows hint when tray icon is enabled', () => { + renderMinimizeOnClose({ + isMinimizeOnCloseEnabled: true, + isTrayIconEnabled: true, + }); + expect(screen.getByRole('checkbox')).toBeDisabled(); + expect( + screen.getByText('settings.options.minimizeOnClose.disabledHint') + ).toBeInTheDocument(); + }); + + it('dispatches SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED on toggle', () => { + const { dispatchSpy } = renderMinimizeOnClose({ + isMinimizeOnCloseEnabled: false, + isTrayIconEnabled: false, + }); + + fireEvent.click(screen.getByRole('checkbox')); + + expect(dispatchSpy).toHaveBeenCalledWith({ + type: SETTINGS_SET_MINIMIZE_ON_CLOSE_OPT_IN_CHANGED, + payload: true, + }); + }); +}); diff --git a/src/ui/components/SettingsView/features/ToggleField.spec.tsx b/src/ui/components/SettingsView/features/ToggleField.spec.tsx new file mode 100644 index 0000000000..19257827c8 --- /dev/null +++ b/src/ui/components/SettingsView/features/ToggleField.spec.tsx @@ -0,0 +1,72 @@ +import '@testing-library/jest-dom'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { ToggleField } from './ToggleField'; + +describe('ToggleField', () => { + it('renders label, description, and checked toggle', () => { + render( + + ); + + expect(screen.getByText('Label text')).toBeInTheDocument(); + expect(screen.getByText('Description text')).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).toBeChecked(); + }); + + it('renders optional hint and children', () => { + render( + + Child content + + ); + + expect(screen.getByText('Hint text')).toBeInTheDocument(); + expect(screen.getByText('Child content')).toBeInTheDocument(); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('invokes onChange when toggled', () => { + const onChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('checkbox')); + expect(onChange).toHaveBeenCalled(); + }); + + it('respects disabled prop', () => { + render( + + ); + + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); +}); 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/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/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/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/menuBar.spec.ts b/src/ui/main/menuBar.main.spec.ts similarity index 61% rename from src/ui/main/menuBar.spec.ts rename to src/ui/main/menuBar.main.spec.ts index 87b96f5698..f455d12a2a 100644 --- a/src/ui/main/menuBar.spec.ts +++ b/src/ui/main/menuBar.main.spec.ts @@ -2,6 +2,7 @@ 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, @@ -15,28 +16,36 @@ import { 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(), + buildFromTemplate: jest.fn((template) => ({ + popup: jest.fn(), + template, + })), setApplicationMenu: jest.fn(), }, })); jest.mock('i18next', () => ({ - t: (key: string) => key, + t: (key: string, opts?: { appName?: string }) => + opts?.appName ? `${key}:${opts.appName}` : key, })); jest.mock('../../app/main/app', () => ({ @@ -52,32 +61,60 @@ jest.mock('../../videoCallWindow/ipc', () => ({ })); jest.mock('./dialogs', () => ({ - askForAppDataReset: jest.fn(), + 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(), + getRootWindow: jest.fn(async () => mockBrowserWindow), })); jest.mock('./serverView', () => ({ - getWebContentsByServerUrl: jest.fn(), + getWebContentsByServerUrl: jest.fn(() => ({ + reload: jest.fn(), + reloadIgnoringCache: jest.fn(), + openDevTools: 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 => ({ +const createServer = ( + url: string, + title: string, + extras: Partial = {} +): Server => ({ url, title, + ...extras, }); const createState = (overrides: Partial = {}): RootState => @@ -88,7 +125,7 @@ const createState = (overrides: Partial = {}): RootState => isMenuBarEnabled: true, isAddNewServersEnabled: true, isShowWindowOnUnreadChangedEnabled: false, - isDeveloperModeEnabled: false, + isDeveloperModeEnabled: true, isVideoCallDevtoolsAutoOpenEnabled: false, navigationLayout: 'tabs', rootWindowState: { @@ -103,6 +140,41 @@ const createState = (overrides: Partial = {}): RootState => ...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, + 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 @@ -114,7 +186,30 @@ const findMenu = ( 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({ @@ -265,6 +360,31 @@ describe('ui/main/menuBar', () => { 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', () => { @@ -449,4 +569,140 @@ describe('ui/main/menuBar', () => { 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/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..fe676bd61c --- /dev/null +++ b/src/ui/main/trayIcon.main.spec.ts @@ -0,0 +1,124 @@ +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), +})); + +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, +})); + +jest.mock('./icons', () => ({ + getTrayIconPath: (...args: any[]) => getTrayIconPath(...args), + getAppIconPath: (...args: any[]) => getAppIconPath(...args), +})); + +jest.mock('./rootWindow', () => ({ + getRootWindow: () => getRootWindow(), +})); + +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, + }); + } + 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(); + }); +}); 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/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) { From 868d12f1498f47406e52188cf794cf5cf25ca35a Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:44:51 +0530 Subject: [PATCH 05/17] feat: SAML auth through deeplink (#3458) * accept and apply saml auth params from deeplink * add unit tests --- src/deepLinks/main.spec.ts | 145 +++++++++++++++++++++++++++++++++++++ src/deepLinks/main.ts | 45 ++++++++++-- 2 files changed, 184 insertions(+), 6 deletions(-) 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; } From c5bcaf0ba1ab35f557342990c9067f0d036e73d1 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 26 Aug 2026 14:18:26 -0300 Subject: [PATCH 06/17] feat(tray): show workspace presence status in the tray icon and menu (#3466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tray): show workspace presence status in the tray icon and menu Surface the active workspace's presence (online/away/busy/offline) on the tray icon and turn the tray context menu into a presence selector, on Windows and Linux. The app-icon unread badge is unchanged. The web client already exposes presence through Meteor, so `injected.ts` reads it directly and no Rocket.Chat-side change is needed: - read `Meteor.user().status` in a `Tracker.autorun`, plus `Meteor.status()` for connection state, and push both per-workspace - write with `Meteor.call('setUserStatus', status, statusText)`, which sets presence and custom text in one call `setUserStatus` is rate limited to 1 call/sec/user, so tray picks go through a trailing-edge limiter: a burst sends the first request immediately and the last one after the window, so the user's final choice always reaches the server. The tray menu previously rebuilt only when root-window visibility changed. It now rebuilds from a shared refresh driven by presence, custom status, connection state and login state as well, so the items cannot show stale values. Menu state follows the ticket: options are disabled with a "trying to reconnect" line while disconnected, replaced by a sign-in action when logged out, replaced by add-workspace when no workspace exists, and hidden entirely when the workspace does not report presence. The presence checkmark reads `status`, not `statusDefault`: both write paths route through `Presence.setStatus`, which moves `status` and leaves `statusDefault` untouched, so `statusDefault` does not track what the user picked. macOS is deliberately excluded. Its tray assets are template images, which the OS recolors and strips color from, so a colored presence dot needs a separate design decision. Refs CORE-2525 * chore(tray): generate presence tray icon variants for win32 and linux Output of `yarn build-assets` for the presence icons added in the previous commit: 48 `.ico` files for win32 and 96 `.png` files for linux (48 plus their @2x pairs), covering four presence states with and without each unread-badge variant. Existing filenames are untouched, so builds that do not pass a presence value resolve exactly the same assets as before. Refs CORE-2525 * fix(tray): read the custom status message over REST `statusText` is not part of the DDP publication of the user document, so `Meteor.user().statusText` is always undefined and the tray's read-only custom status line never appeared. Fetch it from `users.info` instead. The fetch is cached and runs when a session first appears and after the app requests a presence change, rather than inside the reactive block, which would re-run on every presence change. Because resolving the fetch does not re-run that block, the resolved value re-pushes the latest presence snapshot itself. A failed fetch leaves the previous value in place and never throws: the custom status line is secondary to the presence indicator, which must keep working regardless. Refs CORE-2525 * test(qa): add QA flow pack for tray presence status Eleven flows covering the presence icon states, the menu radio items and their checked state, the read-only custom status line, active-workspace scoping, and the disconnected, logged-out, no-workspace and unsupported cases. Two flows target specific risks rather than happy paths: one proves the unread badge and the presence dot stay distinguishable at native icon size, and one proves a rapid pair of presence clicks ends on the second choice instead of dropping it. A third pins the disconnected state to a real disconnect, so the cold-launch false positive cannot come back unnoticed. Steps assert status changes against the workspace rather than the tray, since a tray that looks right can still have failed to send. Refs CORE-2525 * test(jest): exclude the presence preload spec from coverage runs `yarn test:coverage` fails this suite with `EvalError: Code generation from strings disallowed for this context`. Istanbul instruments the module graph with `new Function(...)` counters, and the Electron BrowserWindow context the renderer specs run in forbids code generation from strings, so the suite dies before any test executes. Ten sibling preload specs are already listed for the same reason, including the `userLoggedIn` spec this one is modelled on. The suite still runs and still gates on pass or fail under `yarn test`. Refs CORE-2525 * fix(tray): read presence from the webapp's presence store The tray never showed a presence dot. `injected.ts` read the user's status from `Meteor.user()`, but Rocket.Chat does not publish presence into the `Meteor.users` collection: presence lives in a standalone store fed by the `stream-user-presence` DDP stream (`apps/meteor/client/lib/presence.ts`). So `status` was always undefined, which failed twice over. The presence value was empty, and `presenceSupported` was computed from whether that value existed — so it resolved to false and the tray hid its presence options as if the workspace did not support them at all. Read the store instead, and subscribe with `Presence.listen`, which is an emitter rather than a Tracker dependency and so lives outside the autorun. `presenceSupported` now reflects whether the module resolved and exposes the API we need, not whether a value happens to be present. Store entries already carry the custom status message, so the REST `users.info` fetch added earlier is gone. That also fixes its staleness: a status changed on another device never refreshed, because the fetch only re-ran on login or on a change this app itself requested. The snapshot computation is extracted so it can be tested without a live workspace, including a regression test for the specific mistake above: a missing status value must not report presence as unsupported. The `window.require` specifier for the presence module is not confirmed against a running workspace, so a short candidate list is probed and the module's shape is checked before use. Every failure path reports presence as unsupported, which the tray already handles by hiding the options. Refs CORE-2525 * fix(tray): seed presence with Presence.get, not the store The presence store is populated lazily: it is empty until something asks for a user id, and subscribing with `listen` alone never backfills it. So reading `store.get(uid)` to seed the first value returned nothing on a fresh session, and the tray kept its default icon until some later presence change happened to arrive. `Presence.get(uid)` resolves the current presence and registers the subscription that keeps it fresh, so it is what seeds the value. Verified against a live 8.8 workspace: it returns the user's status and leaves the store populated. Also pins the module specifier that actually resolves there, `/client/lib/presence.ts`. The extension is required — the extensionless form fails with "Cannot find module" — so an earlier guess based on this file's other specifiers had it backwards. Refs CORE-2525 * feat(tray): show presence colour on the macOS menu bar icon Keep the shipping Template assets for the no-presence path. When presence is known, use a non-template PNG so the badge colour survives, and invert only the black rocket glyph so Liquid Glass still tints it white. * chore(tray): generate presence tray icon variants for darwin Non-template PNGs so the presence colour survives on the menu bar. Template default/notification assets are unchanged. * fix(tray): recolour the shipping Windows/Linux badge for presence Drop the extra PresenceDot slot. Presence uses the original unread Badge overlay, same size and position, with only the fill colour changing. Combined unread+presence keeps the numeral inside that circle. * chore(tray): regenerate win32 and linux presence icons from the badge overlay * style(tray): satisfy prettier in the macOS glyph spec The expect() assertion wrapping addRepresentation's buffer was formatted in a way prettier rejects, failing lint and blocking all six check/build jobs on the PR. * fix(presence): address review findings in the presence pipeline - stub process.platform in the glyph spec: applyMacOSMenuBarGlyphAppearance early-returns off darwin, so the suite passed locally and failed on the Windows and Linux CI runners - keep the newest call when a deferred timer is still pending: a late timer flushed the older pendingCall, leaving a stale status as the final server state - read the connection-status map with an own-property check, so a status colliding with Object.prototype ('toString') no longer yields a function - reset module state before the no-callback preload test, which was invoking the previous test's callback instead of the intended branch * feat(tray): show a distinct icon while the workspace is disconnected The icon kept painting the last known presence while disconnected, so a user who was Online saw a healthy green dot with no network. CORE-2525 asks for a variant visually distinct from Offline, which is a deliberate user choice rather than a fault. Offline is a filled disc in #9EA2A8 — the same grey as the base glyph — so a recolour cannot carry this. DisconnectedBadge is a hollow ring with a small warning tick, distinguishing the two states by shape at 16x16. The mark's amber and ring grey both sit outside the inversion window in macOSTrayGlyph, so neither is bleached to white alongside the glyph. Icon selection mirrors the menu's existing predicate, so the two cannot disagree, and keeps the no-url / unsupported / logged-out precedence and the unknown-connection startup behaviour intact. Also renders the unread badge in combined macOS assets: buildAssets passed badge for every presence-*-notification-* asset, but the component never accepted the prop, so the count was dropped. * test(presence): cover the capability check that gates old servers hasUsablePresenceApi is the decision that drives presenceSupported, and therefore whether the tray hides presence on workspaces that do not expose the module. It had no coverage: injected.ts has no spec because importing it runs heavy top-level side effects. Extracted as a pure helper injected.ts calls, so the tested code is the code that ships, with the tri-state resolved flag unchanged. * docs(qa): correct the tray presence QA pack - macOS is a shipped platform for this feature, not out of scope; the pack told testers to skip it - flow 08 probed loggedIn/supported, which are not the state field names (userLoggedIn/presenceSupported), so it could report a false result - flow 11 waited a fixed second for a server-side rate limit; now polls with a bounded timeout - flow 09 allowed a DM or a mention but only cleared mentions - flows 03/04/10: add the refresh trigger, the second-client prerequisite, and the comparison evidence the steps assumed * test(tray): name the macOS glyph spec as a main-process spec * fix(presence): require the full Presence API and make the QA pack runnable on macOS - hasUsablePresenceApi now also requires own get/stop functions, since injected.ts calls both; specs cover the missing/inherited cases - every QA flow spells out the macOS menu-bar gesture inline - flows 09/10 compare against the shipping badge asset instead of an unreachable presence-unset baseline * docs(qa): spell out the macOS menu-bar click in flows 09 and 11 * feat(tray): drop the unread count from Windows and macOS tray icons The Windows taskbar overlay and the macOS menu-bar title already show the count, so the tray only needs a badge-present variant per state. This removes the 11-way numbered matrix on win32/darwin (and the identical disconnected-notification-N copies on linux), cutting ~190 asset files. Linux keeps its numbered tray icons since it has no taskbar badge. * chore(tray): restore the untouched linux and macOS template assets The asset regeneration re-encoded files whose content did not change; keep the bytes that ship on dev. * feat(tray): draw presence as Fuselage status bullets and show presence only on Windows/macOS Presence now renders with the StatusBullet glyphs: filled circle for online, clock cut-out for away, bar cut-out for busy and a hollow ring for offline. AppIcon takes an explicit cutout so hollow shapes and cut-outs reveal the background instead of the rocket. Windows and macOS tray icons show presence only; the unread count already lives on the taskbar overlay and the menu-bar title, so the presence-*-notification assets are gone there. Linux keeps its numbered presence badges. * docs(qa): lock the tray presence pack to exact base and head refs * feat(tray): group presence under a status item with a submenu The tray menu now leads with the current presence (label + bullet icon) and opens a submenu listing Online, Away, Busy and Offline, each with its bullet and the current one checked — the same shape Teams uses. The disconnected and custom status lines stay below it. Menu bullets are 14pt assets under images/presence so the @2x representation survives. * fix(tray): size the presence menu bullets like Fuselage's StatusBullet 12pt at 1x / 24px at 2x, matching the 12px bullet Fuselage renders next to 14px text. * feat(tray): draw the disconnected badge as a filled amber bullet The grey hollow ring with a thin tick vanished at menu-bar size. The badge is now a solid amber disc with a bold exclamation mark cut out, sharing the presence bullets' footprint and cutout so the mark shows the background instead of the rocket. * feat(dev): add a Simulate Disconnected toggle to the Developer menu A non-persisted store flag that selectActiveServerPresence reads to force the active workspace's connection to 'disconnected', so the tray icon and menu can be checked without dropping the real connection. * fix(tray): match the Windows unread badge to the macOS template look With unread messages and no presence yet, macOS renders its template as a solid monochrome disc. Windows still showed the legacy red badge; it now draws the same solid disc in the rocket's grey. * refactor(tray): drop the unread fallback icons on macOS and Windows The tray shows status only on these platforms; the unread count already lives on the Windows taskbar overlay and the macOS menu-bar title. With presence unknown the icon is now the plain default, and disconnected uses a single asset on every platform since the badge ignores the count. * build(rollup): mirror src/public into app and watch it rollup-plugin-copy never deletes, so assets removed from src/public lingered in app/ (and could ship in packaged builds), and rollup did not watch src/public, so regenerated icons never triggered a rebuild. A small sync plugin now registers the assets with the watcher and purges stale files inside the mirrored subtrees. * refactor(tray): make the Linux tray status-only like macOS and Windows Linux drops its numbered unread badges; the tray now resolves to the same six states on every platform (default, four presences, disconnected). The unread count stays in the tray tooltip. * ci(pr-build): stamp the installer comments with commit, time and run The sticky comments link to fixed S3 paths that every build overwrites, so their edit date never changed and it was unclear which commit the installers came from. * ci(pr-build): show the installer build time in UTC-3 as well * docs(agents): record the tray icon rules, the simulate toggle and dev-run pitfalls * i18n(tray): translate presence status and simulate-disconnected strings en.i18n.json gained tray.presence.* and menus.simulateDisconnected for the tray presence feature; propagate them to all 21 other locales so non-English builds don't fall back to English for the new tray UI. --- .github/workflows/pull-request-build.yml | 12 + AGENTS.md | 37 +- docs/KNOWN_ISSUES.md | 25 ++ jest.config.js | 1 + qa/core-2525-tray-presence/README.md | 115 ++++++ .../exports/qase-import.csv | 248 +++++++++++ .../flows/01-presence-icon-states.md | 52 +++ .../flows/02-presence-menu-radios.md | 55 +++ .../flows/03-custom-status-text.md | 53 +++ .../flows/04-active-workspace-scoping.md | 54 +++ .../flows/05-disconnected-state.md | 58 +++ .../flows/06-logged-out-state.md | 45 ++ .../flows/07-no-workspace-state.md | 45 ++ .../flows/08-unsupported-workspace.md | 51 +++ .../flows/09-unread-badge-regression.md | 58 +++ .../flows/10-badge-and-presence-combined.md | 54 +++ .../flows/11-rate-limit-rapid-clicks.md | 54 +++ rollup.config.mjs | 89 +++- skills/dev-app-verify/SKILL.md | 68 ++- src/buildAssets.ts | 203 +++++---- src/i18n/ar.i18n.json | 15 + src/i18n/de-DE.i18n.json | 13 +- src/i18n/en.i18n.json | 11 + src/i18n/es.i18n.json | 13 +- src/i18n/fi.i18n.json | 13 +- src/i18n/fr.i18n.json | 13 +- src/i18n/hu.i18n.json | 13 +- src/i18n/it-IT.i18n.json | 15 + src/i18n/ja.i18n.json | 13 +- src/i18n/nb-NO.i18n.json | 15 + src/i18n/nn.i18n.json | 15 + src/i18n/no.i18n.json | 13 +- src/i18n/pl.i18n.json | 13 +- src/i18n/pt-BR.i18n.json | 13 +- src/i18n/ru.i18n.json | 13 +- src/i18n/se.i18n.json | 15 + src/i18n/sv.i18n.json | 13 +- src/i18n/tr-TR.i18n.json | 13 +- src/i18n/uk-UA.i18n.json | 13 +- src/i18n/zh-CN.i18n.json | 13 +- src/i18n/zh-TW.i18n.json | 13 +- src/i18n/zh.i18n.json | 15 + src/injected.ts | 205 ++++++++++ src/ipc/channels.ts | 4 + src/preload.ts | 2 + src/public/images/presence/away.png | Bin 0 -> 381 bytes src/public/images/presence/away@2x.png | Bin 0 -> 650 bytes src/public/images/presence/busy.png | Bin 0 -> 352 bytes src/public/images/presence/busy@2x.png | Bin 0 -> 637 bytes src/public/images/presence/offline.png | Bin 0 -> 402 bytes src/public/images/presence/offline@2x.png | Bin 0 -> 799 bytes src/public/images/presence/online.png | Bin 0 -> 319 bytes src/public/images/presence/online@2x.png | Bin 0 -> 539 bytes .../images/tray/darwin/disconnected.png | Bin 0 -> 622 bytes .../images/tray/darwin/disconnected@2x.png | Bin 0 -> 1263 bytes .../tray/darwin/notificationTemplate.png | Bin 497 -> 0 bytes .../tray/darwin/notificationTemplate@2x.png | Bin 946 -> 0 bytes .../images/tray/darwin/presence-away.png | Bin 0 -> 591 bytes .../images/tray/darwin/presence-away@2x.png | Bin 0 -> 1162 bytes .../images/tray/darwin/presence-busy.png | Bin 0 -> 613 bytes .../images/tray/darwin/presence-busy@2x.png | Bin 0 -> 1167 bytes .../images/tray/darwin/presence-offline.png | Bin 0 -> 632 bytes .../tray/darwin/presence-offline@2x.png | Bin 0 -> 1298 bytes .../images/tray/darwin/presence-online.png | Bin 0 -> 582 bytes .../images/tray/darwin/presence-online@2x.png | Bin 0 -> 1167 bytes src/public/images/tray/linux/disconnected.png | Bin 0 -> 2981 bytes .../images/tray/linux/disconnected@2x.png | Bin 0 -> 6173 bytes .../images/tray/linux/notification-1.png | Bin 2836 -> 0 bytes .../images/tray/linux/notification-1@2x.png | Bin 5887 -> 0 bytes .../images/tray/linux/notification-2.png | Bin 2921 -> 0 bytes .../images/tray/linux/notification-2@2x.png | Bin 6013 -> 0 bytes .../images/tray/linux/notification-3.png | Bin 2963 -> 0 bytes .../images/tray/linux/notification-3@2x.png | Bin 6095 -> 0 bytes .../images/tray/linux/notification-4.png | Bin 2822 -> 0 bytes .../images/tray/linux/notification-4@2x.png | Bin 5889 -> 0 bytes .../images/tray/linux/notification-5.png | Bin 2933 -> 0 bytes .../images/tray/linux/notification-5@2x.png | Bin 6012 -> 0 bytes .../images/tray/linux/notification-6.png | Bin 2921 -> 0 bytes .../images/tray/linux/notification-6@2x.png | Bin 6070 -> 0 bytes .../images/tray/linux/notification-7.png | Bin 2846 -> 0 bytes .../images/tray/linux/notification-7@2x.png | Bin 5913 -> 0 bytes .../images/tray/linux/notification-8.png | Bin 2938 -> 0 bytes .../images/tray/linux/notification-8@2x.png | Bin 6111 -> 0 bytes .../images/tray/linux/notification-9.png | Bin 2922 -> 0 bytes .../images/tray/linux/notification-9@2x.png | Bin 6063 -> 0 bytes .../images/tray/linux/notification-dot.png | Bin 2883 -> 0 bytes .../images/tray/linux/notification-dot@2x.png | Bin 6073 -> 0 bytes .../images/tray/linux/notification-plus-9.png | Bin 3054 -> 0 bytes .../tray/linux/notification-plus-9@2x.png | Bin 6135 -> 0 bytes .../images/tray/linux/presence-away.png | Bin 0 -> 2916 bytes .../images/tray/linux/presence-away@2x.png | Bin 0 -> 5953 bytes .../images/tray/linux/presence-busy.png | Bin 0 -> 2848 bytes .../images/tray/linux/presence-busy@2x.png | Bin 0 -> 5833 bytes .../images/tray/linux/presence-offline.png | Bin 0 -> 2950 bytes .../images/tray/linux/presence-offline@2x.png | Bin 0 -> 6080 bytes .../images/tray/linux/presence-online.png | Bin 0 -> 2785 bytes .../images/tray/linux/presence-online@2x.png | Bin 0 -> 5760 bytes ...tification-plus-9.ico => disconnected.ico} | Bin 372526 -> 372526 bytes .../images/tray/win32/notification-1.ico | Bin 372526 -> 0 bytes .../images/tray/win32/notification-2.ico | Bin 372526 -> 0 bytes .../images/tray/win32/notification-5.ico | Bin 372526 -> 0 bytes .../images/tray/win32/notification-6.ico | Bin 372526 -> 0 bytes .../images/tray/win32/notification-8.ico | Bin 372526 -> 0 bytes .../images/tray/win32/notification-9.ico | Bin 372526 -> 0 bytes .../{notification-4.ico => presence-away.ico} | Bin 372526 -> 372526 bytes ...notification-dot.ico => presence-busy.ico} | Bin 372526 -> 372526 bytes ...otification-3.ico => presence-offline.ico} | Bin 372526 -> 372526 bytes ...notification-7.ico => presence-online.ico} | Bin 372526 -> 372526 bytes src/servers/common.ts | 6 + .../preload/__tests__/presence.spec.ts | 130 ++++++ .../__tests__/presenceDebounce.spec.ts | 225 ++++++++++ .../__tests__/presenceSnapshot.spec.ts | 168 ++++++++ .../preload/__tests__/presenceSupport.spec.ts | 159 +++++++ src/servers/preload/api.ts | 16 + src/servers/preload/presence.ts | 54 +++ src/servers/preload/presenceDebounce.ts | 79 ++++ src/servers/preload/presenceSnapshot.ts | 73 ++++ src/servers/preload/presenceSupport.ts | 65 +++ src/servers/reducers.ts | 19 + .../reducers/__tests__/presence.spec.ts | 75 ++++ src/store/rootReducer.ts | 2 + src/ui/__tests__/selectors.spec.ts | 45 ++ src/ui/actions.ts | 11 + src/ui/icons/AppIcon.tsx | 55 +-- src/ui/icons/DisconnectedBadge.tsx | 41 ++ src/ui/icons/LinuxTrayIcon.tsx | 29 +- src/ui/icons/MacOSTrayIcon.tsx | 57 ++- src/ui/icons/PresenceBullet.tsx | 56 +++ src/ui/icons/PresenceBulletCutout.tsx | 13 + src/ui/icons/PresenceMenuIcon.tsx | 52 +++ src/ui/icons/WindowsTrayIcon.tsx | 31 +- src/ui/icons/presenceColors.ts | 8 + src/ui/main/icons.spec.ts | 387 ++++++++++++------ src/ui/main/icons.ts | 84 +++- src/ui/main/macOSTrayGlyph.main.spec.ts | 101 +++++ src/ui/main/macOSTrayGlyph.ts | 77 ++++ src/ui/main/menuBar.main.spec.ts | 30 +- src/ui/main/menuBar.ts | 46 ++- src/ui/main/trayIcon.main.spec.ts | 171 ++++++++ src/ui/main/trayIcon.ts | 381 ++++++++++++++--- src/ui/main/trayIconMenu.main.spec.ts | 339 +++++++++++++++ .../isPresenceDisconnectionSimulated.ts | 21 + src/ui/selectors.ts | 61 ++- 143 files changed, 4645 insertions(+), 406 deletions(-) create mode 100644 qa/core-2525-tray-presence/README.md create mode 100644 qa/core-2525-tray-presence/exports/qase-import.csv create mode 100644 qa/core-2525-tray-presence/flows/01-presence-icon-states.md create mode 100644 qa/core-2525-tray-presence/flows/02-presence-menu-radios.md create mode 100644 qa/core-2525-tray-presence/flows/03-custom-status-text.md create mode 100644 qa/core-2525-tray-presence/flows/04-active-workspace-scoping.md create mode 100644 qa/core-2525-tray-presence/flows/05-disconnected-state.md create mode 100644 qa/core-2525-tray-presence/flows/06-logged-out-state.md create mode 100644 qa/core-2525-tray-presence/flows/07-no-workspace-state.md create mode 100644 qa/core-2525-tray-presence/flows/08-unsupported-workspace.md create mode 100644 qa/core-2525-tray-presence/flows/09-unread-badge-regression.md create mode 100644 qa/core-2525-tray-presence/flows/10-badge-and-presence-combined.md create mode 100644 qa/core-2525-tray-presence/flows/11-rate-limit-rapid-clicks.md create mode 100644 src/public/images/presence/away.png create mode 100644 src/public/images/presence/away@2x.png create mode 100644 src/public/images/presence/busy.png create mode 100644 src/public/images/presence/busy@2x.png create mode 100644 src/public/images/presence/offline.png create mode 100644 src/public/images/presence/offline@2x.png create mode 100644 src/public/images/presence/online.png create mode 100644 src/public/images/presence/online@2x.png create mode 100644 src/public/images/tray/darwin/disconnected.png create mode 100644 src/public/images/tray/darwin/disconnected@2x.png delete mode 100644 src/public/images/tray/darwin/notificationTemplate.png delete mode 100644 src/public/images/tray/darwin/notificationTemplate@2x.png create mode 100644 src/public/images/tray/darwin/presence-away.png create mode 100644 src/public/images/tray/darwin/presence-away@2x.png create mode 100644 src/public/images/tray/darwin/presence-busy.png create mode 100644 src/public/images/tray/darwin/presence-busy@2x.png create mode 100644 src/public/images/tray/darwin/presence-offline.png create mode 100644 src/public/images/tray/darwin/presence-offline@2x.png create mode 100644 src/public/images/tray/darwin/presence-online.png create mode 100644 src/public/images/tray/darwin/presence-online@2x.png create mode 100644 src/public/images/tray/linux/disconnected.png create mode 100644 src/public/images/tray/linux/disconnected@2x.png delete mode 100644 src/public/images/tray/linux/notification-1.png delete mode 100644 src/public/images/tray/linux/notification-1@2x.png delete mode 100644 src/public/images/tray/linux/notification-2.png delete mode 100644 src/public/images/tray/linux/notification-2@2x.png delete mode 100644 src/public/images/tray/linux/notification-3.png delete mode 100644 src/public/images/tray/linux/notification-3@2x.png delete mode 100644 src/public/images/tray/linux/notification-4.png delete mode 100644 src/public/images/tray/linux/notification-4@2x.png delete mode 100644 src/public/images/tray/linux/notification-5.png delete mode 100644 src/public/images/tray/linux/notification-5@2x.png delete mode 100644 src/public/images/tray/linux/notification-6.png delete mode 100644 src/public/images/tray/linux/notification-6@2x.png delete mode 100644 src/public/images/tray/linux/notification-7.png delete mode 100644 src/public/images/tray/linux/notification-7@2x.png delete mode 100644 src/public/images/tray/linux/notification-8.png delete mode 100644 src/public/images/tray/linux/notification-8@2x.png delete mode 100644 src/public/images/tray/linux/notification-9.png delete mode 100644 src/public/images/tray/linux/notification-9@2x.png delete mode 100644 src/public/images/tray/linux/notification-dot.png delete mode 100644 src/public/images/tray/linux/notification-dot@2x.png delete mode 100644 src/public/images/tray/linux/notification-plus-9.png delete mode 100644 src/public/images/tray/linux/notification-plus-9@2x.png create mode 100644 src/public/images/tray/linux/presence-away.png create mode 100644 src/public/images/tray/linux/presence-away@2x.png create mode 100644 src/public/images/tray/linux/presence-busy.png create mode 100644 src/public/images/tray/linux/presence-busy@2x.png create mode 100644 src/public/images/tray/linux/presence-offline.png create mode 100644 src/public/images/tray/linux/presence-offline@2x.png create mode 100644 src/public/images/tray/linux/presence-online.png create mode 100644 src/public/images/tray/linux/presence-online@2x.png rename src/public/images/tray/win32/{notification-plus-9.ico => disconnected.ico} (71%) delete mode 100644 src/public/images/tray/win32/notification-1.ico delete mode 100644 src/public/images/tray/win32/notification-2.ico delete mode 100644 src/public/images/tray/win32/notification-5.ico delete mode 100644 src/public/images/tray/win32/notification-6.ico delete mode 100644 src/public/images/tray/win32/notification-8.ico delete mode 100644 src/public/images/tray/win32/notification-9.ico rename src/public/images/tray/win32/{notification-4.ico => presence-away.ico} (70%) rename src/public/images/tray/win32/{notification-dot.ico => presence-busy.ico} (77%) rename src/public/images/tray/win32/{notification-3.ico => presence-offline.ico} (70%) rename src/public/images/tray/win32/{notification-7.ico => presence-online.ico} (72%) create mode 100644 src/servers/preload/__tests__/presence.spec.ts create mode 100644 src/servers/preload/__tests__/presenceDebounce.spec.ts create mode 100644 src/servers/preload/__tests__/presenceSnapshot.spec.ts create mode 100644 src/servers/preload/__tests__/presenceSupport.spec.ts create mode 100644 src/servers/preload/presence.ts create mode 100644 src/servers/preload/presenceDebounce.ts create mode 100644 src/servers/preload/presenceSnapshot.ts create mode 100644 src/servers/preload/presenceSupport.ts create mode 100644 src/servers/reducers/__tests__/presence.spec.ts create mode 100644 src/ui/icons/DisconnectedBadge.tsx create mode 100644 src/ui/icons/PresenceBullet.tsx create mode 100644 src/ui/icons/PresenceBulletCutout.tsx create mode 100644 src/ui/icons/PresenceMenuIcon.tsx create mode 100644 src/ui/icons/presenceColors.ts create mode 100644 src/ui/main/macOSTrayGlyph.main.spec.ts create mode 100644 src/ui/main/macOSTrayGlyph.ts create mode 100644 src/ui/main/trayIconMenu.main.spec.ts create mode 100644 src/ui/reducers/isPresenceDisconnectionSimulated.ts 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..25189b5eb7 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,14 @@ const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.( this: `process.getuid()`, `process.getgid()`, `process.geteuid()`, `process.getegid()`. +## 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/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/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/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 36c5a020d9..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 @@ -541,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/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/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 8087df8ed7..8ac11f0bc8 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -475,6 +475,7 @@ "developerMode": "Developer Mode", "simulateUpdate": "Simulate Update Flow", "simulateDownload": "Simulate Download", + "simulateDisconnected": "Simulate Disconnected", "disableGpu": "Disable GPU", "documentation": "Documentation", "downloads": "Downloads", @@ -640,6 +641,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": { 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..5179f6fe65 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,6 +308,28 @@ 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); @@ -468,6 +537,7 @@ const start = async () => { gitCommitHash: false, themeAppearance: false, userPresence: false, + userPresenceStatus: false, }; // Per-subscription unread state, accumulated from the @@ -749,6 +819,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 ee90d9878a..2dbe442df0 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -160,6 +160,10 @@ type ChannelToArgsMap = { '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/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/images/presence/away.png b/src/public/images/presence/away.png new file mode 100644 index 0000000000000000000000000000000000000000..652c407344ed23dd98f09a9dcedcd73e25a59fbe GIT binary patch literal 381 zcmV-@0fPRCP)Px$H%UZ6R49>ckxeT^VHk$5`#r}PNePX7gp%YlvJ*Rh0XsXtz{XD?KfuaLvGWIf z>?|Zn7)i-YVPV9=$b6m0;^;VLyu16pujhTA?pIVDyX|bF=tcqX?NURJukl}^#=ys%4qmh@7 zu-0^M2PHd2>Z%Px%L`g(JR7i=nl*?-qQ51*2Gm}Oeiqg{JqUl7f1!-s^bfMtFMZvX?g|Dt$D5BP_ zAYJw^5X4QWNH-!XxDXXrt`rp8)PbUcG)c83r5pRu*vxSe@}9{IwY|H`J@@<0@4`K> z!%Kn`scj9i$snQ@ps0~S{@RlGF}G!brmaqpagogDEqqc$Z+ZWHfla~f&WIF^BB$rpitkYbffZ8)7F)i33RsaBx=0! z=<>cKGuKK@#8SXzmzp9t$0eD2-;_YVcI`ophpzBaFDo^qynBh5h%u!fXloL%(QyaZ z=grrq=kzE%M4NTJPM|hnl$*f-fh1#f0*cWQMB2BH&jGcqcvXNoylwFEa^zT5pxW(w zLB*-ryg+rWwSa?Ffk@F;AXJ`E64|$eI0~9=VF*_R=#qZ|9n*B|Mnw|O*t8dhfp;Da z{j3#y@lPNoa97jTq}qf5WMDCmIe!B5dGiHhj`B}dyQhQA$O=;ZAU<(EqQz5^T*v|F z=iVevXu2lt8lg;=#3>l1xcOB?9|eO0$S|0nbjBJ1z%kF%A2TKK(!8q7BAt%*BIftt zDZIkzI+4PD8Cc9yJ1XQzZUJ$w{udlo3}>_qr*T|MB0C9;Zy7G!lA+wQI;LysOzbR> zLf9LOO&-yUlE}PiJaV0olAM1+c(Whj9;zEH$g2nw>{{!0gL@{ZL)bkXIFWNW+Skz9 kH+E+p%K(4;7(2TD0nTpA)4JfzW&i*H07*qoM6N<$g6`WhiU0rr literal 0 HcmV?d00001 diff --git a/src/public/images/presence/busy.png b/src/public/images/presence/busy.png new file mode 100644 index 0000000000000000000000000000000000000000..ce7d6151110a67e2e8e9817a1923b8a72ed6fd36 GIT binary patch literal 352 zcmV-m0iXVfP)Px$8c9S!R49>+kv~g^a+eUKzo!9 z(JVniLk$g0K}*mONJEJf^-@N^+hQh$(HZZ3&N&yZ=#tMDM&6Q%DS))wm8yI7&b`{; z;z|^UunDXq4YbCuI6IlXdQp|id)x4mFN{lxbHrqKBY?YKM{l0fS*c+FGTZG114zoo zX>9|@^Q*(LJ|B?O%h2j+W@^pB~@sdyqaZ3$QN@8lnu zLce@rX(%Su_}58EMolM&Pl%&`6qHptc(zv88cwZeHhz=&2pa$f5FPK|n`sXG@)zu0 yjbu5UmLKW-^tayg%h@1}0m#E$x>~yJ+Px%H%UZ6R7i=nmA_9@Q546&-`iTsqtTEkx@e*})y>I)K|(QcR6~qBjFWLN>Sjok zk$=H~1SR~?#G(!km{MGbg9DSnkcP+~?kUUD^T{hv~Zn%Qnz;M4M zmq0ePH#H{tG^Wo<&3vp8&}H{>{q@4s;l0Kz4i^MRE>s}$!ORtpW0j92@8 z!wUdb!vG+nC+OIOZRl!rX*$zLAb;uRyf1yK2^ez*)luc>)vKEb`85Na(@0w0on;p zZNa3Rvr$Nw1-SCo6Y#Qa0^kfkCwH)Sl1(u*3Zc*jy65EckIUTM59j^#hJZW&g_cBU zgv-gz8UX-^rDvZ(cC2+c9yp70Z}z%_ADqOzu8sv9PQXde zzUoq_7t#|*fiqC5dK=Psmlx>`g4bO^1>@pS%nKd54w{n{l~L=_@K%xw3;F#R2Wo!- X{0GZn>MA4c00000NkvXXu0mjfPx$Oi4sRR49>skugh~VHAa*^Cl%ywM(Is=vJJ9)<$t?shgvNgal(s)J58V(-tzc z$`?zG4sMR>5G+k2C4+8eqt`cgFM|o`cAR=Y z{3mGm0T7#X5RTj4!@mSz1ISW{K)Z1s>ot5J6HaEa0@b9+!Vc?xaN`dK8A=l_WZgcC~ysZfI>~A|<#t9eJ3i$(i wKMfE?drw>8_6@PeB-;uXPU&bZo4x8V_h4XolVhKN1ONa407*qoM6N<$f-P;bu>b%7 literal 0 HcmV?d00001 diff --git a/src/public/images/presence/offline@2x.png b/src/public/images/presence/offline@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..97fb7fd04430b617ff1d41b340e23c5f26825c0b GIT binary patch literal 799 zcmV+)1K|9LP)Px%*-1n}R7i=nm0w6xaTLeD=XYn@NHoeo^G{_DNeL1LN+={m5BgFF*A~OIh3&1t zhp>Ngi$dr{(Wj+2ZB{ZE3Bh2{OM(wlK@liIy`+g?1&XHb?%vZwZr;01+bGY|<$Hd= z@8@^U?{a>?E(h(D^!r~N(IvV-Ks|s8W|RT|5oQ^fCh)k#(wNI7f6WTu_rKhyi;YJh zTqD4;(;*m$f$=0}7q`8#y!2PVz_ZX{($ok6$9FPL1DerA&bc;c^W3(8Kp<2U<>-3? z6{YoHp8B(kPNc1#@r`tG+U=GX;*yAe^oGM$Raal7n)4*HII~iaXZN@ql^&O)a-|^8 zE_A8`@Yb}HP|T7x=JWY-;!>i&dl#2FK~$Y&-hH&R@Mc3pL(HgNZ*1HEFcAy}>sHqE z#~|D=P--l;ygLAtM2q@ILM1{|KNG=fH21VR+S9ZuB?gB_drT1Kb>4$xR~?Rd0RWO7 zyKFRPU?$flb!1`=0Hm*VfbnHZARDP@LHrJwxyiJF(JjkzG*f`v?Oq4aW7L?b35H9q zGA1>`c&6rwLrLO|S>P%F5X?+O$loI4XItx~>1+XRr~C=bKN9|+1I$eE3fMsYm92T= zaKJ{gCWwf^_--JV3HQMuNv}a%gQSrWC|!Nujm57Pyw%Mb`gMaU=2PU zy$_(%w3B&IwM*^k#xOSfZduoR2)JU-&^=FU%R>{OHypO+E-p`iV4g>eMG$vEND+@y zo&tcOq30)cLFd7A3xtDabDmV!mg~JPx#`AI}UR49>+(Y;CoQ4ogVcP2lDSlU=*6A;3p78dCY+KAr4LWsTOZoG&f;tg0@ zh*dTaVuGMp7}O$#T|~p4GZq?IqOd#Fz&sx_Fv3!W&*$}Gk~9HaL9XvA?T7SS7R}aE zcUMeK1sZ8!5Tn`|1+Dluus!J>;<*$=mp6Wg%$u+`t{&gW=JaA!-{<#&tpA5on;!j2 zX-8$2G_&x}DH}TbY$*6J3#VeC3Vh@bfGP}fV;JbLR_+<~_n z$SJ@mXvI$K0JG^|YcEdh=(gJKlm0Y7{q=0EG-qGU0st>Ep-{XUSE9Ft`2sjqOm$PM RR`LJ<002ovPDHLkV1j)mh6Dfr literal 0 HcmV?d00001 diff --git a/src/public/images/presence/online@2x.png b/src/public/images/presence/online@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..68b028203eb8bd7348d6a2a3cc9e8241fd2570cc GIT binary patch literal 539 zcmV+$0_6RPP)Px$)k#D_R7i=vmA`8gK@@<$H+OqKq7f`aJvVrQg)t(U*v2}IM4N-tpr!s1f`y2X z%GiiDf@lKi>{8pL(u5CAY@AM)AL-fWPaGDtKtPkr`Plp z4+LR1S`9%ZPeaY3lsuqeTcF71Te;ne`e5F+AYz79q%*eZ5p%nWddw8cS8H(9Z>fAR z84DJUUjL%-$d*9hVddD(bvoao&FrCZ(03O2bB%OnB1&SxTH(s~cBXv|)%C<^WkVe^ zTJI~xeYImAEEaC9io+04O@Ob8qk{`4%C*i#vwK&?9jhY@t1|H?4XBEz%43A#-P4T! dG4%I6egf89tH{@nk$eCE002ovPDHLkV1j@?`gi~U literal 0 HcmV?d00001 diff --git a/src/public/images/tray/darwin/disconnected.png b/src/public/images/tray/darwin/disconnected.png new file mode 100644 index 0000000000000000000000000000000000000000..e7022040f60031051dabe382886b45a2de449a4f GIT binary patch literal 622 zcmV-!0+IcRP)Px%C`m*?R7i>Klrd;iQ51&1dlOnrOehZ3iWL=}Zh~kPt(_`V>Reph1Vs>$f`TrN zg`k6j;-VmKI=HmxP^36W5sDTk6|ov=W1yChn1-6X>yY!}$>XI7>ewG1=iYPw`=592 zIrqW_+90OJ3E&bi0_+8RAoo9md4LOjUw{k$B5FeON#Ilj?+LI8NXgs*bHEhv5~y_= zaCbx&?Y=SWT;+A;0$md$O?BE7-}beLda{t#fT$}LK3*A_6@fp zb_02!2psJ+U4_dMBoEjq!agC4|Alyf=c3UcICP|xA_bTRO2AH_PYU`<)=rDqRmGna z+bDQkEe5UjQ~7$trh$soBq6?Uz<%Jp;%CDt=>W@O56J}s7?i(RlDP*I0E#z{#c=K+ zh66aa<}8mVrWeL1p5)`7K?Z7(1PIryQEv*DDf#N_NRbF;I%wPx(sYygZRA_2%K7 zbGBXjvcF`q=gfTbo&UEpXXcv&88T$ZkRc7+1Z)L50O=@afv*IpcKef^&`N5Dt`ip)HEEJr1qo~2ONc< zr3ZKiSenitUP*Hhc8LPpfP$$GVD1G9z%ISO!$Aaky#qGgz|}tNdVnrfdVzlRn+wbW zW&;a=BH&tJhKH{fUJr2WHF7Bt0lqajT{}g>b>;z`hUXnj)!}B%Q9n>MwVI9WG6c~W)JY*QtU0+5 z;%!0g^b# zw*+!m?)uJS;{U1Dtd6hUz%_mYcmr6Yu7}m|pK-AbgsdkN&8=)gh&3Kod-I|>cZvG8 z5#UPT2(TU4;5UFGZC-w;Ud&1HcT>sCa6D%}LKG*rmIGNKQIWImo1cMIz~^dtABWpZ z+@(grW;yWOB(wvN6_3;!#D6c54G0d5uNIfY5^|@y*ifi>kB1IE2L(FTct5fi7U5<9?BcttUdr+fwR>m@7SIil2=v;<*0H z#pY&UB~Yf>JCukkd_2p{j?%DOlWT(_0^H!ZHzicdfRDAh)9M>gFHi&g3bG4Ck={8( zxESaee64(e+s84n>d>g0z+@xvpvqlrTDA6d2Jxr^`&BAUr~r_JJ~4_Da`#X}(7i@-rx7GD*wKp_)IaibbSeB)y6E+F{%M;B1vUJUcAxQu&+JX8@ z;69av^%+*EcHj=+QJ`LZb^8=p2;}5Gec%s?*lbX+EWfVfF_9fELkmJ6JM-6fOh`kV zxV-}(7`}@x_4pFe2-JB!2p~VZZ(}qzYc+`T$y{PgjsV1noy#UPE@2Z^*4@c`nwBSF z533JOs?}$&PkZE+>inoR_63MiRdrNKS_PCLxD`Mmd$Yn;dE}|DPNWh5YR9edIB4tx z-dCAoV5CG0ZLL}Px$t4TybR7i>K)V+_5K@2YnNJx|ljYcIB5mERPbc!t` z8h?OjcS9i|B7$T?qae8nk=S(w@huXrV$REW@B49sa-U=}GiRPT&&)Y9hh?;2Wj5~M z6b@h;eqiZ;ifzT9)SltQzc^h{n$O`-1@8*hpd0f67BG)n_&%!O7Q?q_Yj1HL>qixN znQeZESse6dz9w)Km+(Beud%ny`Tih(!%4#-til8?X8ZQl5y#QT2kaWbaT%vWq>r&G z!uBAX`3vU-u7!_Hg&|k!W~2hw@e$M5C?)hxtQ`qwXEXj>^uH3mDqAd`+NaF6F8WD) zm2%Pv+ah-0QO568U8}%!oak_78;_G0*nrhB-ifQR=MB!qL2k%Glw(Z#xjpS~dXMTq zaSu~!lFaXC`lLnRW{toAGc9|)z`JZdDHXr`Pke3=IF>srUuClt^9Pypc|69KuutKA z!vSA430#Q2FO`9(QoMV(8#mjH%MAhxbqjUndR8h2Io9HK1^;;2X&%>(RQtP99_~xU nt_d^J!&s6+x!A&A#xQ;Y<(hr3Nry6500000NkvXXu0mjf7*XF_ 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 9dfc165e4beba7449ccf7d83082c53095bfb1060..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 946 zcmV;j15NyiP)Px&Y)M2xRA_67B-6SO^LktQErwep3yGT9s5C!!RJ!DW+6h$e7 z4?Xx$8A(x6B=r#XmZ)Tb+{Ufv;Y6b@yve5m%=bSo+>sKS4evcsWyhUamQSC zV*4C-qGf+!C(iD72UeY?4z%nT9!?S1X%4h-7?)P?8^d>*9m7QSIf&(0fr~JVwK%5( z*B)%dRE4}<@v-tJb_iQ=ehuCYct;pMMcm(DO^@2G6rN&Cepvq|-!i;hkYgH0g*R6V zOYlVj>E_<_mSQzNEy(v-UBVY5q6w_;S!Q?a(%y+3sU$-<6_K0{@h$Ap-ie_Ug6E>( z$NMa>8_vX!k%nz41f#jYAF!a$0;^!7*&R5P5}Xfws>EjvJ`!r09atE(ZxiOtKJEL+ zdZ8xz1y6L)F%fC0gt(l(y~J-{9j7RO)l;qeO`L%7lRffjNlK-!d ze&1R09j+AilvdmT-oRbBL|CqiFkV4lA@aSxuf*qtoX-iNjBa7r?AO0Did|Y!$9^@2 z5d4}8+SS4D25iEG1#Lrk61NnzEf-e0uOop+@Fp(K{4T>+YE0tSbEH%HTjt&>9BjH( zrmZ=(S}12;N=Y&tk-gUk;c%7m;;qQ;&Ehr)b0rC{MP$$Prrkm_!=b88;JmsC2Zb@2 zC)`w9K93~`&)Zu)d0wUcUA!z=Myj_6M_Pjdy{uP9)97PF;h2X=Q7nR2!GAwIy3xWB-EL^y`7 z%Gm|Nym=t=88uTTRKb)`P8>~$0q;tCO0QPry;j;>sCDz5a=rdDZcF4}hwr=ajth%J zZEVKp9TM!tL%k#rPGWIsF@|R)tUBFxxTQh?wirKU3HpsiS)P9~YtW!UgZ~l!1Ed%d UDxRB}`2YX_07*qoM6N<$g2|S{@&Et; 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 0000000000000000000000000000000000000000..3b4cce26b0c02ca8e4e27a11481e4d4934f74c17 GIT binary patch literal 591 zcmV-V0Px%2}wjjR7i>K)IUhoQ4|O8&rdB$Q$naH^qEl!L4rd;P(u{-Z)|93YblC|2B(G~ zXp4{tT54-*RcWY(hKeG}7eWn^W?2dSGoy%nx1sxb*T;J*)#w*4_nvdV_k8cU=Wp!4LE__*nk4w{!g*>2xVgwNB<>gR%Z8Me+AxkEJ0JW10JCtcQ8F)!Hx=9%r!pZ zJX+=}@-8|21ijc(RGzBCJoW|3SD@f@W*CLFBflgAo115n-{Shiy;iPZV7|!x5Hp=D&*ToLq2!m z%#o!la4UVH^q17&1%6^PK2*C&7+l>cTl6ei7qORY9R{+l(PMZSTg{9b6mS@8aeuZ{ zWp$@ZYJ~v|MQdNk=W2?<%LT-uzQM}G*BqN)DchjCOmR5b>qvLX1=hqjf1CvGkA?ge z{cU6ur6aXHrn9rS`r8bXvk6>^^F(X|BiM%F_-voirN*_=gcu1%h!fP$lL#m)! zyY7wcUPx(L`g(JRA_q{WI>{H`pjX=u=%XiG|LKN2vJaEB&D(FjfzTo zfdVD4NkT89z|vSru?Q11KhziVp%tri6zti?7Dz20CTOj(tfe1nrw?BDcJJ(*`{j9Y zU+zB~?sLv{o$LIs`@CJ}K!OAb5+o=C)&g$;M*!_hBfxjSR$$pwlXO8Dq=V8i;K8XT zY{SPfoX|i!aOqSKRROXi!0H&bI)PV!Rlo{hhSZ-0h9vzC{3z3Ks7UUi$iy6lxTzm# z0d6m+6FbW6gqQvXb^@1_+JUO`0T0I^7_iKQVl9;_HAv)(gA<%q_s~&A=X+J~rE3z@lQw&y&As8t*IN;=2rZ z&EZ5|My?>FfFCSIH&=~H_CT7@&NU6rg-fx09s{Bz7gnl^l>r*KP&uR-|x`b?$a5TFxo?%G5kA?wo!w& zj<6}@iiJ)9^CiD0Ai%mohW~)fXFn=|^%5JknGJQWx(1~ifTf_8fF3~8iyFcHE;Gm<|e!++YL8ymVS{W7R+0U9kTlYdMKi-8>Q4)CI>=~@FVzI;ml zLP?;z(jOY+`_Q@p^q1pR>QSAm&Pw5D;CbMEn?08UJu(U&)&nmUPkvz&s79mqu&U2z zQF`x$YQ?SRoCV-LIh!USXcyXIG}_^tR`JQ_susYst&;_^I5ne_Td79fm0l5KH^_kz z;BKStsfc_nn0}wNT7_Mzlxr7+2oL-Yf9=(W~*9d^R%TY{uf4ziRl|A#~8 zCg369Iz!(N`@4}h0;!P$6BqIrYJ$jxIgkRD`^vU}>Mr0LiQ_xu9y=`Sr+xfds2t9# zJ9YQM#j1Abw94eyfqu(S7%s+v2PAg!aLAB92YhB}HygM@!h~~=W18cBh+7d-q0?2`N(U_bC?ka58^08Ce{ZWT2Pty*<1{gKYioeBE4 zago~PZWr%q@eAN{`Ky~O`t5QJt@5S$vYRymXLWb=b~Iap2u+0Er!x5sg*fmjaBuXE zXqMA5oKh}*6s1;$E7H%XTCT4BHTD`%F~|=9>!OkeQ1$uMDE)e%lGeMqaHKwrJOLxH zS*D5WqLBqYDJDkJy$fpjx8iZ20k{%4j@DmM@!xJ$cOnPx%A4x<(R7i>K)K7?wQ5XjB-?>Qc7-1s|GHOgIQvQ^%7=M~6rL5Gjv!NteC>F9} z!(tZ7M%hdjN)|Jr5Ee)&4JB%l84RQGZ_r@wJr>T@aosT!mY(`L-+9mTJh*!gZ9!YuHfZ6k|Pa za11lYYw|Mt_%7PfT9vOlY{N?s!GVaD&PK2mNln8s!42ZL2iifCRX1w6(Gmgl5u3~y*_EGaztWg*6kb(cDJpf|OepK?&fcFf1E z(ZpdyI9PKbgqGoTZH0<>wSkn>e$2{Z(^B)xP8*h_E$=Ku&4s9$cphRQG~|ML4q#rY zmTQ@K7gpml9$=FgUkO2s_LRc+jvJz-0tVwFEYu&%rPisJPj`=IP)xnQ9?x+* zHM>#qdHvQ!rD$P{-WSW0ZicI;-u{mNA2sm}4?Cv>87p}%00000NkvXXu0mjfSI`!j literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..974343baad6c1bdc89781514ec11bb2906daecb0 GIT binary patch literal 1167 zcmV;A1aSL_P)Px(Nl8RORA_fQE7-qRmGZ@xQw&->%v zUH8xXg~NT%InVQ)?{lB?<2eTsBuJ1TK?QI-@D9)eXisr~gTSl6lF24%gEB}5m6Jfz zWE1v7A%+tg=msvI45BKauLoEW!`2btbznKL45*U!QQ)kU-+;q14bPUzJrp;^j6&Sf z59|W&t)vrgR@e!*`~z$UE}Liv=3OEO+%gC}5{F>W8gOv}SQJ67ALx@Z2n@+@2Dk#4 z4J-iafg6FD0k-x6d0;q(FIIdL@Q)n_UIeZz!R~5cw@e>D+usB8%OyWY{zcRHi3wbM zvw*iOPW&v9D+x1zZ+(n5PY`hvR|CC1;~gs1;2VaaAzoqPJC=n1FNc|ao5JKY?628ZxLc}^1N&ur9|PMW?vp6ESO>gZGHZd8&9_7$ z&xL>Xt_#ir@PQwBLhb_@;5(qs)7A{U7mKxE_|E)QdaC**wxTLFWZK&f+q#3cQ3rUy zXnQ&~0YPz!T3?Eu)+yUA2*8vO{XU=*aOYxOh`bdFy=rX0_19=MK2=uw8c*P z8|DCVFL3d@Mdl81?wB||VhU)*EHC|ZG5y^1&X0feGv?$Pn=b0!5`l-$KdyV3tV@UD;EOexpP zQUY`rC1BBH5HYyuW0naAc8@Qc#fnCF8&7Im+#qfHu#Y@aWjBQQ-~lto?t4q zBd9;l)8bdamy&!n`p9?7HFTb*%$4126ENyz^IK5rm8j&k=ChiP!o#IF@G0<6)Pdwa z@In+pCztO9wLF4ofAvUlnd)u}TmybnNW~yO0IZ2h9w4)?b%oOU?LZ-|cUBKqEe#`2 zKm?j)n%FKHoj{vpiX~w*x1sJ|Qcs}CD!4(TPb)Z}&#Rp^2MR}{+6jAFtd>Ps1)P@W h!vqNuBuMZ-{sRNCtw#EN&{6;Z002ovPDHLkV1fZ$A`1Wj literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8da3482127cb0650b8c6c2eda95c29071950ae67 GIT binary patch literal 632 zcmV-;0*C#HP)Px%GD$>1R7i>Kl)-CLK@i4&^D3yRDMbvDN=1bz80g7MX}zdmMeyLEdMF5~AVL&T z5Rv``f(H+Rph=9EhIr|r1u0cQK}4_>4}wCWwza7#Rji6yFym!j*}lAxpdS0dvb(e2 z&bPBOyRe2jXmZ98;5={`*bP*G`Tr@F05s6v184umsRZW3z|j!iePAOHwYdT>ff3*d z@VimLogq=wYoCGZz~)9pK02FU0XKl2K)#xRJ|F|k7)?WklmRb{u*V1&|H8SC2j-*QH@HeshvZ`n_y%kT;+D{7wsyjt zU3U1x)~_UdowMkd+IPp+YJCgv({d6q+bqxlymk09!H`^mMeFz30|V%?)0AwU0KNcZ z?+x}S^a~OPP}PZ2X)Zl*<0T~eGp{gENG>u4W zTYTG4GMPLFdkM=jXf$ z*(JiLyD^pOT@cZ6MBTkr0z0f)CLHG@mdGDwZwFmMiB-oYF%NNzq)OnX^=118rhsl> z5_oP2?f@CDpr`K?i!0@rxjT3a*o(*1DoJOY2)X-dU_jS~0-IFzHA0&p zTU2@6f9m9cS^^RK`=h`d@WMX3P~>vC4eCX@#iL6rI!}}LG}YJprWStPx(%t=H+RA_Xjr6!tMtx!U0OKL*3iUwOEme?vbR45Qz_V_So+wnLv zJG1PTm;I8HIWy-w|M|X|bNSCe8fm1FMj9!=9l%?_UO<}4Nnj7~5^&vIi?oB#L_3v1 zV9i_$w!$_KCM3`c zxey;ir-g|^F9BZFIzOH|t(M3I4r?0fyaX6Db^Hm)NS#(&R9dWfu3_%D&W2M2>{K~T z1CXh;*Qt5aZLyCmQ902O;0XsEqnZ|1(9`LAY3esZzN(^J*36Lm@gpZbd=1c zwLyO$@EGv5THak50C-2sdtL-ypOt~;wjIFTO5jJizRcBrzkkZiX>4lUB!qa`;BZJp zr%J1yK z501Iu?~c_Ykw~^3&&@{WGu8jH;oJ$-05@xVLyVL{7zr964!gJ~Ldajvp5-NMzz5u5 zI=4!wmIFO1kMC38v14lf^a4df2#J)x8Ezp0Dkr6GALDwJ)y;uG;R^fB>Q{g&$F?&zCOwgj-t6iHddv3+*^kGAEKXLK0*1*zeG9m&#Bt^&%dirYf-KlrN(MDVrqncTM;S;PZ^AT;xVPE`BZM zs7aUw{HlH*CKX*>UGqjpMsl*UvZnoh|3Ar&pGF#Kq>)Px%07*naR7i>K)Xz&)Q4|O8&r=~98Hrp(!iWaeN>{bWiU@+E-gDmJ zKbAp#@}9#n?7>Eq@akWRbs$tclQ{A>NwW%n5c{j}u3FUHbugTKS*B2jhLfQ4x~!?gjB{Y%%c_SV?po5wSzI)sf0fm?XQA2B#Y%% z`;^4iMB9Wfu_n0~dxPHIT_#QomB5Eq2SpB>7M*Px(Nl8RORA_2*Lnuz?AVIDj~8x!1XbFbpX!;*8x`p<=Q?13~Koc_+F>s;262*#JrfJ5VvH2 zO~B$}2C<>YL3rgDuo0M4=m2WYQ2<`)2Of+=Fl-HY=mxHcpqBx3_Fge86cAx?HH%2T@>5DKx(YytMn_{&bte^_@)9c zIfD38M=mcUf$svGK2adzHZBMD2K3jSufrEDM+3lu!g3D8+W|o|#dT5*3|mgl1$htK z1A?fItmq4a5b&?-qykaUAsc41Wa@#pCKkL%@y~8GggZ9Xcsshvqa*zywRa zUe`@)z&Wx&bD~4Qqai#7EH05?r_;AN^ja;whkUXnmaM}kn+Wt-@@pJ@d~NBCI(VZ@ zjei{NH(3AgaMS_j>zU%&od>+DTk?%Sr=yR)2>b%!kNSRx&I>-BBbr8gNL$0d(Xx%2 zw6*7&Latn>7nrW~=8yzyp$`8iI-mWh2I@68YPAj12U?a%)+|BfPOr@vc8T!O@P%gc z{)-!rs_$mKzIFq1q6Y9Puu|*8I{Zf+y0I`Pv$ZP0$t@x*a_JaDkCd7E&KZrJz%1Y= z;5FcBTi3M)Hu>sF{R(47-NE-4CFI19VdMcssO7Zmt;@752Yv&d1l|hLbp_C+qu`+) zcxFuO0Zy6Kf>cG&1V|cT?YzFtmB5>NHjP5qF7&Krw81y6Vq8(tmugP&mC6q{gLV;FvKy{L9RSExG&!tpNk9(kB%cc4~$?BMy0h zuGG@qL*`@};T6OzA2kM*jrJ4QTfO|x%WiD h{g+Te2_+O2{{tNpskSN_DOmsj002ovPDHLkV1kJdC%6Cr literal 0 HcmV?d00001 diff --git a/src/public/images/tray/linux/disconnected.png b/src/public/images/tray/linux/disconnected.png new file mode 100644 index 0000000000000000000000000000000000000000..13283b0f8274b7d5bb997b175cc31cdc7bf6aba9 GIT binary patch literal 2981 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 VjTP7$$!-7u002ovPDHLkV1jukUx@$! 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 406db5c7dd6ce55c3254c41ae9f36b0dea5cdf0b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2921 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 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 7c56ea2464a55a647c103b197d15a492ca1bad55..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2963 zcmV;E3vBd>P)Px=O-V#SRCt{2n|*ZC)fvY>&rRCW7DNRF-Nuk33hI2@6m=@xDMjsiIGTrAHIKf)ms}~WA>T7uI z=8)h2=KiHQd=P-}(!@1bzNwTV*3jcj>SI$Ib_I@l=o5jUTEIzcoBH*HyDuOd~?EhjJAYhiS?1@=E+5g zxgQ_^O|keIK`ts@Sp8VlTu~pGy}o!chX(@C*tn{~Ti&)okgpUuddQZUN6j8PQIVri zCZak{gwd*cFv!6oTGnH($iD?%Ms=$P-4+PczFbs3juiKyv882fNw;|gVf=76IuIzl z1Z)%JWs&rj;N03LiWxs+9T17eLn_i#%m#`Lg*ITN$I6m<^JYIk)Oa}yp>Qm*2{>)2 zf%gkkw<{9%Qu<&ZFzds?gvjZDrs(qXMfB;Rv)iB0cA!DhBkl?Yzww`ec;<8fKvOKS zUEt`VaND0y{v+remF1oWpU>A>h>KeX{9NG1;<5up-xK6L&|MDd5=D*>7!Q;WsU6iF zYPe?p+?wC#vvur%NGvf+;o+f>05+p+6m&}(w#*OIY$?<+5{XZ=hEssAfJ{S}CNOfK z1QeQ5os|n0ESTBhX5-ia4?Wa!yfx<4fwiW)6&}Nwhf^IDtNUwoFbKzz(;+?e*sDdvSD?$* zGEe$!r%v!XSqt_@c|$6105^pK)sGfPqG$*OX14;Z;bqCi*!2A%GxE3`w>#xo1)QC& za99V#Zv**Bf6-LA&)Ci%2ZO=g17bc*2ro@stCl-O7}cMhq7T;B)m+n8meB!COPBjZ zb$LHAHd_eIud5!|{IDMoi6qBckNu^8gCf;Z2!a`$>N7sB$y&)1Ev)eW3b!1=kvfz(`NJ z>-Ij+uw-fSXrp$!lY=U2>jTwaFFw`bML3pt0JzpoZ&gp7AE?=40E~5>^%Z?*gg0G1 zOdN+5p}OkpRCSY^UPP`3kjBQw67Pu7A36mJ<7^$Yb$}1vQI2l-IEVQ8|%@2Q3(p^-=>ULs;9Tl8&Dy6lO3+t|IU)ASjLv`fl98w}R|r4VjL&4X6#0!O9j zSrU#V)&u9|=+d3#h0OZlXkr0E15nx{Q|0+l<>n%v&)1f1|6nXJ)q}1Enbd2KeqfAT z5u8)~r)>L0ix!y)M;*Hok#F=7D{xz=uKK6>_yIJ<5^o9|l`U6gok8_;4vMvfBp->i zOvz3jK!mTh?N0y9$;YF#^i3W>ka3#USTdO`&9DU{xlHcnBw=P*0|5`qdd!emP zQh|v^CHE*yME_MtSZmA`WF_C*B+Sm1MI!Nu2q!xR6PeuJWlwd`!NVDLCJYnRh>6U*%09jR~<4g5=mxkThktKwFj^^H=e3Y+4r#nSb=J_RXvrIPH5YC z&<%y~RAGmU%Hw6P^4Ki$6ABExLX^v0v@ZmMzW4h|O6gjaqO=Q_Q6nkMEy4)DMR>lu zqq1eNH3lObl>0RUq91t$eHSF715`9;!f?W@HTpy^uJ0GDQB$LUJ z9UXSuym{4c6k@Nc@=)`N6DmvD>ht;1`Rvr|zEJ5*Qw#X^!9PIOe&&Qj!VB=e^;FI3-DmFq{YTh@D& z7HMvttZB~+>>CMogc)OeK3^NkYKIULnqu+#A+aAK+GhA~R*~d@lLru)Bt};VO-qxXCqTM#G}YP9aa9` zGmiRz?~fw7SXJ+beFc&F!0h#^tj%#3Dp0#J7K>F5p2Wb>c|q;DiriJKc#7QBc|q;D zJI%B^7%D0M2EDP;ba~2`EZgNU*S%~)>=fmsNbDQY8 zt)Ck|%Hqn_WsCZXOy7r4UG?{!UIhR|CYw~(osPO9LBD)WiHd&LOU zw=}rNM{m@uFFj|?pDH)LlFsyIL>w+U$u!sKML@@xwi89oLHX=b8#a<&y)HxMnOTMR z+|&E@6OqwfRsIubZGNRkmeTDc4<)`W!jriXn3#{;P#{*$Qpdx(y+}R$1eA}! zZrx7$rEM8HbB5DK(jkV*nSgr-0EpMTgB{ZHdr(lJ-&?BPD<20y#vY32q^TJ?IqXn$ z=PuIQ9E;n13^Uts-u7A!b0kP%b%H@d8S8vpn+fmqX_PFe&5`Gw1ot9iDeXK0)Sm1z z6!X^MVzEaKPtmnTp;S~i3GyNyHV5ZaZ_elYzkp*0C|v3!7bT}=*B8-f;+T|nJ&7<; z07Onu2w@={OMvpeqVI|Daj#Peb3--zw!uO{BP_LHBlh*bWym};tMHsOmC{?jlW!Dc z6i0G8U`aH73PP2Wrx;wXoGghZPfuajh;XQD)53T`GA}oDn@Pam0YrJPze%v4E&Yj^rpL57k%f?m*=OpXSStt+~W%5U6kJ8Kq_dd__=O+B}X&f0~pdSm6+ zLS}8OcLa#HBaC}7^vSe#Ul@rd4!5FH(9Sv)DkR@;R)LqL%l4$F1CIfe%bwfYwe(wV zqoC`y2D#@`ot)on_Vf z%GTy^w%9~s@!H}huUouu`e2g>sBCQxqqt4RgCNCXr{n>=s&aoJtFMCEQC(ags9BWX zGCwL?mo4g;;oqe&gsPBsVfk+5y5T6={dHp31J%5{vCKdo!75uk3K;d;@weh4M2>Shbb|6E?K+56*cT}`C7xZ5!$3IS5 z>6&g;PwXo<#;8^MFO(e^&(`|6zSjnnL6I<+gOLL`fCF&Fe*q!xd72<*C9ePg002ov JPDHLkV1km;s15)C 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 032968d3a0d14ac75c4c89d85b5d23360ed43608..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6095 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 V-Nk^ATTB1|002ovPDHLkV1lMnp*{cr 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 443c2325ca846324dbbd19fdedcaf5abf6e5aa23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2822 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 YFJ@0OJ-9tx6951J07*qoM6N<$f(bdN`2YX_ 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 32c03f957d0af43b5b2894644b8840f8f8e5998a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5889 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 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 3c2d40031cf64350fa553e503224068083651baa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2846 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 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 d72a6c04b88c8b5534dde225d4c1eb68f3269ec8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6111 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 Utz41JrvLx|07*qoM6N<$g2pkg0RR91 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 7e6e2495531df1e7b423c262331e635fa21e6cca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6063 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 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 c3878afb921ae16abab94e86e9a68eca1d5c3362..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2883 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 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 b13c205019a85ac7fc239ef519acca8f5e4a2e15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6073 zcmV;q7e?rbP)Py1b4f%&RCt{2oq2p))s@G8zbiRj;-qXTgi;z{pb71?6lf_7OPGYP423Br-Z$Av zLtDBrQ)s7~yAEwXWm?JrCXr;xi)7QVBr`yhP(qi1(3Dcj7IwnEwISG!9qZlS{9%*g z*plCSmMxR$`;Ql$d(V;X(S7gTd+s?vo;-Q-p1jV9*V z0M3GNHpw#p6lX<~uua5mv|M9L_^V}QWq-{|zj-DE0bpf3xxheQ0C*;W!=^;DaJPV) zQw4U_imIyDrbK6+Q83z;SSaz1+?t_I8vY_wQ*n20?b!QZIG+3qfe+@|5C9PHfRX+y zR9_j%wJm!KjKq_D$kuf}O9*&eMi(ussk}Ycw(RK`Nsr~)h}|LZ55{ssB$h}l4ZFv#udWTwD#;!sOphio3bY` z!U0-Z)|Lc|dpAv&$HMezI}kXpv99dSbP_EsYfB1Bdgsf*0miTpP!XUa3lu>t0#F2~ zh@=J72eKEIeS+;JO&^Wk5S>@lU;1iO)A?^s;hURMMlA(LIyw$VD)2`T`{cqPPgqtJ z1cDoP+SGg+&4`w2tCT9fB%6B^t{ngXBC+IV z5XWUp`D7vC4MHype38&g0G=1{HxfM*daN-$M(DA^4nnhKP)dtZk)?(xm2{whxrF8l zIEdi;a{ZomAv|HBrOWCov$8%D^eDd>m{tz(kbwIkZ85~7jslN6{lyQgSn-LzY%5yX zzWNa3q>iM~dVZeLC@x;YVb(Q};@$NhjiFbVs&<`i7bOLM@ z@M{CD51?>EUEKvaza*`Sb$=`X=X4Q^Ae;iSXbRQepuysXiChvqIY78ASxuqcuQIO) z=pk?igmnR6U44DUqf@2Z58d6}GkW?{Cj;h82unbG7#=@k&q$BJ!IH+t%4hvF_vQet ziSuZ9vmZrX5Y$GfwXv@1*ORSlGKoaG=P+QGTH@kK-Bc4^WdP^-IauS(0pf`Uf>EzZ zT(VWdH7YjO?TP0G!?Es@L3Pb^#S><&(+2cJx_LqbOJWT0ZjrEnk%x1sSmA1Ck zb9ZXz*9m@ZQWS`_jrHZOFVn6aU{!0^sifUNvcR8ZyCFR&W4_y1Q+EAit#VCg+S*pn z9khd&0a}se8%GDQOWa;QT{%EB+BshVZUQ)aVzyx@s+am!Lv2O--Z=2P6HX)!#vs2- zpaDQ2JH4vc3a7YPD;PgPM6~-L8M_(8dnRU+UMKjyP<_SEC)Cd#%F1|hK>+v>fKOzl zL*Z2$EcocM>Q6nBNi3rS+;PVp!6&!A^c#{VPRJHvT~W}f$RFq!pGYimp}+`7xbbryda&opCfbiRTAc2v{g@6Ai`}rlgMlp| z_xGhyxUawzoKsu-sTX~<&$EkgJn=b#S52rB;iUj(e6YU$g4cI{Y0OJNiaW3JJ@~-Q zv^!4B4}NwS+VC+8htC@LgE%O~f$xv~(r!dz$#X%h^`l2XYg1iCK7x1>h_oe-g>=V+ z9>tCMKx0kWLnHq6_CK1Nn~jM7^3%6~n`RZ6WxkEelaa>SihDtvJ$z2l7qZSzcmLmr z8DLdBSz!Rrp~(_%ZK|)xXYrTLOh zE_f@!#;zGaP_5^Vfad(*Pdy}MtSN^y>aorZ)|g#6p$q`Tan)_LA1T)5Ep)6eRO`KkWz%Ie;VzJjKHQ^4|cs*K0csZ}OSe zj;<5D#I_6AGORSubV0bzYZC$sTDrU66|`2z(MxEc4`ptNwkJO>s}nU8LHk|;k3xKm z0dr@lrtB{h?f=SKZYiAk${S~b_%O($A(a4lfG`gSS+Fh}=M2J$#KGKY&Jgqg0t-aE z0qG$b{Q0>jw%pp>+-xVRV|Rkw**>TlKPOS>sa>Bvf#G=KDlY+wHc&8cyeJc|L?YdD z6m;UubSV;64ffCcqTf?Lt6IBGrBQ3Tf*1mNKzy#Lwj%DQe*lQYyDkOPj5OKTCEYKw zdNQ(zMdHad@E%R{Z3FRW*W$uU*&h6r?W+$#z}}T{@Bx5WR#4o#F&w_xbEHhTHF2IX zTA!&J3V~7zv2ZNmXEhX&c(Mi1kB}ktKFJRm==YI$_i27~WQ6j3Sr%}VA#kYc21HhK z2gYJI>>qG;-Ui|bmrXv(f*ot3(WvW3)@WN|A&D*kGu+6N{B}5&{Hojjf#H;Tz^C0w zbGBsHM%$Ag^`c|kUdgmsQ`4jU4S<;*T4Xh2Sc(HnL4LqvvxNv2xjri|@vA=8I6mLq z-R-J~Ml^ckJ|Mr~rFRmu0zx$vfAHK$*w4s=15oJktR!=f7x`%=@k?&xR>r#z z1F*zv|0ECI-fz!$BWnW#4e(KMML?CG%UQ-gr5C)&lQ_V5i9WT=o&&VCt)5HZ!+zQd zSmZ`7KzkN^9?m3iwo7B&$?e`hS%6^QVn1!u_X$r2L?!O;IzQ;QPzx2D1Tu;do`d|XC9y6yCkx24bc%113Yz@lVM?jYo z2RXl`Wo=0|r@GQ&bLpG?PLSYdk>LU+VSvQF78Qj_Cw=LMB;3 z&nSSVrrl!avxQDvT8vpmvj!ZeGzFC!1xf+H(&-6;J%u|5-$V*Myz6?;d4kCu4~a$X z5Nlkwk?v-sqvLQmfhSzQ38JQ{wxYwUf#VU0Cr$u(Ie^pfHb)SUf^>_qx@;nq!kU|# z&HD~}e}g5zF?vrxf0J-!s=xHd*}jhV{&V%&F!&_Ma{IMgWl6o`2)b==ZeSK%pw$P2Mp_o3D}yKYK%2J}F-%geX6b|0sJ zGYbF&>?|z|Y%VJ++nKHYcPy$p-62{fnnK*xQ$pnt#Kaw}c};vMA!wN*^z1cpESr6dG`L+~n!Z8Tq-E&Q4d0M|(Pb zi>faiw1Im#bnh}H5IAkn2JY!wRDEG4xyJg+E&`)M_a*cV_RVYuh8-Xj3iXTZ_FzMC zFlD~($?81}ow&5PcX7=xWO#ix(z$qr#4H(J-@CZ_N*%p4ZLGsHPd@T>5V!8uw*9S} z-Cc|EhSpOGSOQ-PwTn=*i_>xl$c~Re@nzTpt8tW=I5n3tn zJ0qIyE*Oa=9tN554s(=l@Vro6c~@)_2@E)@#vdbe~Wt zv~8sPZurRvE}+K*%qi2kM(#w}v9MyE6Ab=|)4Zy>gl$L-EGXT0y{oFEqarez8Y*@D1nZf1duQJJaYz^4Uy ziZlg=L!OD7qS1ug9mZ*kg*=@SnobB9*Y=DKu%fK&4O!#6#EA{^9bIZx3o8!*_|Lhv z!#_Mnrz{jo}sDF00~-gTa!HJ;U5oCLu4Y1>@W8#K#EWT!&PFf1-Y;P?-L zhaCdGZzzOMPd^Vm{T!Ul_d`2;=YbqU847k3&Zq{E_Nl;C;mA;3`Py(>*M;=>cnIPh z{a8pKtMz)#(MO{2vWvjE?;iQWDMLT>;wu>V(Y3G-J(e|{0u~~J1Gqk!(NtT$8qk%V zluHMBk)KWopup_h`~?FjXk3b-E50#$@ZSdKz8ghXd;<9!MVSssCw2@4}@AW^3Wc9CbJf%0B@Z7azAVD5yLS=Fo{%=y?l*`}HnddSF_0 zo;nje$L6)PtW6)6Hk~Ob$jX*8o6b#-%HOpx4sQX~^(iJUM^_&v^lJ zeOksh)?~#20>^$3=HT?xemczIhazx{`{XSdZ9w|k4_;4adi+q7^Wx5aF|=8kxy*up z@Za_b*U4KlA|P#Kvd4P>c)O+xuX>Unhn4Z<?~t2;#t@-vMwqFY55%4%ooW07l0?82eH- zW&r2zKf%86isvT*`{c85?#s>@RR`(o3b=lUf&cR+ks~k@^ys z6aK1~d4&;@zhWq-+xOy4W;9#FLwwo6T5Ryp42| zn57LN0QRBB(0|d_hqi;~9Rki9-VT1|dFZLx0V1(PIRGDlo#qE5Gv4xs+qymlt1o~!H8)@Skx;r2z?fxljJ4Own0H)I zC@^IEcrs&t{wv#89};i||Kj7@|7>G@<(T&iL%E28Re+Ym`#r^EBTIACvf8pyD=%8O zFxVSBN}&RSwLsj44(pw__gr{ z9~kR!Xgv8X0N)uyb{y0z;ENF7Pv9dWK28^zs{F%mHrADYKi#X5NcSAhu=8vR`x?VS zKt+IxEKmfo2tW~_B9azRpMby3DhhO_OBU6;xO(kyrmyLbbo0y&9slY^bc6#$TDvM? zH0?c|P+h35vTZ7L?pR!Vs000Zx}Oh}u+60Ok0l#BT%G6G^%M|^buFImgD>bc$(%IR zgAV}38``!S%5^5STtV0dw}TH1J3u6!I03ZN`rNyu`ML4uZOd2?5H_@bzGrdGAc-&M*qk5E+|cejWnWOjqnym_mL3rB0HB{W)mMgdZHpU9 zH?&{Ux2XF6EE$>VT!9kaG*V;9?H%5Z>+J@kZHZH)T(5Vl(^zwLLqmC1VhE?2{xhnM zOu?+2syqS$?hL{%FIwL@;eBHQL)A5{&`QvEl`YQ*cMG_=fHNZ5DB3(7=v!QMfyi$H zJZ5q<=srr{E7{QLIo2W_BQE}}tzG}*7+XVN2|!nMgj^$R6LA}e|52g2by->2U$ff( zG@@rw%~_8j$}JMYL!e{Uo0-CsvX$G$tA;K^3@I08qAm`Cw2FnwJb z34H*bBlV2L$4G4f%oaL!ODI(NbheG3?({FJI@~d)LDES8k8`_Px=s7XXYRCt{2n|*N9)w#z%&)H345)cX~Xe&jgC<;}vR$JR!kp@~iwZ64W!jdez zLAk9R`{Io4+}q*8bvnLQ9kjNoWY=9tc3IR`p{CMUv{P*h({e=yYiVsoi&~Y6BpXQf zcb@wPB<#y?+0ACdv{Sw_ndJApo$qt@{7%k!&RIBt6Zp4=6T#w~TDmqJ$(~5d2e*?Ni1}fhSEnO6^0I%Z)15%k!PMCtx&TuTT zCDPF`qarbn0|cNw7Vi?|vy}_0@2Q%rS_6x=R4(S|Kmgj>Hr9D-d!7;Gq6$Y3*&6eh zIb^3Pawf_&R40otPF2T)j2F?GL32g^A+Q70T^{tsKw$BXit=%#cm!>oos+5t%nJxp zM!V7BK;e1dML~9mINO8Gi+@(h_*v_KNHiW&k@iY9P-!Ui01tbttXaBr(X%6sm$wiK z#}ZqCkB&6(ae?Y?MZ#XH9}EPpd8eEZc^%LmU4OZV{$k|pjwiGiSS`*;cLal1zdIby zybb_pk0o{se5fMajwh6N1--4l*0b8@^YxYDQm6y&5%_v#*@2?33-UVX0T*?ZBBu#V z0cuCoj_O`DT)(Wj>8HhPT{|EWOI)My$Vf;4+faTh=ynITFAFqnFV!&;iBGeJIlx6A zmmpjsFlM*}6x!2$^*1hGenqN~jcW%y^ibz4Ys}8!wWbFZ9>u+m3cA?+z|A(8_{S}BNKQ3aQD=j|%r-r+#wErh9EiJvJ6DuF#==ui5 zUMC{H5?yxHc$`nVb%NK;TCo3BG^Fxp;HFTZ@v#y~R1BfOqHdr&ye_#4>)Z=6zlh7Z z2h*O7z$bGRF6)5!Eg;`HRy0-aHn#7-gTdgzVKE;igx4m%td`qF7<()`MIUT!X}UgB zmem36YuEckb^S47Y_kwr*3vk<`QbPq5=l<69(%vQwZ}A6Z43pPhH}i>3wQ_2%Y}MT zm8XpD`}{x4r(Ou{(Zu&f_(~zYLXS07=Ulhs+SdTgpu(FrD~^%;=CQTrihuUx0a^o% zHz*XWaR`j@IQ_R~Jj0r`$qyN|yWJdA+1wgv{7mJkjxNHn!~?*W3+b)uh06j>+YNxR z?z6t4ubt#|8b^uas3O$T_!U*%T1YPKi$M6Pl(u+Ua&Lu)!7`Jg!c z4PeF#&h}t)(-slYLiVelc%r8Mt^J=9F*7W>$K%xetk^d8@aok07$Uurw!aJp7yqFY zZbr?6R_tlWYHAEOCwp z-2^gy$R2&u7`ZCAr13Yo_A6JeG*iz!`(Z?`&JZhbOQ@yse~R$~Xpbdc7C19kuF6ve z)%Uw7HkXooB+@xEH+cXNKGAc~`L3IfN9oK=9zc-E>S!#POjhUGPn~}D*K(3S>;fy+ ztc!oT7{|lVZa1mGG^3J#6egl?mlD<*a}_zs4>bvka%GW7d>X<9uE9iR^!D2eT{L-d z24NieQYk!9aeZABPBkcb@2aZ%CMg#pe5{}jnH-5Ev$m~{M+({lSeqYDRc0RfGX-ow zwb80xNLr`%>_7QyrSMc?ZvmCZ%VFi&Ipn9777Bb=Q7F$1Feyn_A>B&!2dG;hLi!`K>=IMC$RM z7BtNqQ28ioCL44jfJNRx?3-1h+iEt)^WOJJB>7?W*k1sXvbpepgro5EWSpOJCW>vokgMg zvZ~xL=Baf|(plospF||DwZygc)5u#_l|7I0Q4m)`O zk?CS|Q)<5dra@8A@87Lzz5w(o#FqJd`wW00ce@5rS>3)idA6%=l#y!ie_fEAP0aw+zn zi_gb#WJjxHNSWL$rPKWn(;ZF-jb zeEZuUN_C-WEz)5=Y*6o{2e)#b3RX2p%jgz@zM?`qP&+Lotxe!r9c+kTKGt4-X8 zOc;+ycEGgC$AK3HpYuilfOySojts8IZt>PV5(hWobHa*CT+DXmUWHk3(&vyx0c zh1#{*%aiIAH&P7@Jp2R$KRS9J(>w1{yz?$CwUw-;wEN+x+6&xcE=pd_E)≻;gjxKZ!6+ z07Pahgs>2fB|v#Y(bq+IZ^)^N=1|j-ZLlyj!t~9zNG)gvj=s-sbHKp*O$@BhU&#;p zxFD-IlGg!iqVYKh4Q`%dD0t;$O*DCN8rvnpghDn5Qv}Jr+%RCK1Fr&z^1AEDQ$6x4 zdrx0=^=B}jxCs0EoeXq7R+10!_soa94lp9C3eevZY;JnmO>y{2ja^3>cY72)2Y3cR zjXsA<4;;GXKBUTv{cFzL{Qk*!&YOvudOGI9?8T5wbie=YMSqq3oSDel_*_-4a3!jm zcTSK@CdV8&=-jPvooj2Q;Y#3#0CleoywH1k^?nf6*J4m#fuWJEOsLFct4 zljD~AeEVhBhW^xPOMa-rw@UGKwmuK3KJ3mvy6SV6!p6!EGFO3+(E;L3g(+MNebUi^ z1(9gtR4Y0Y?Q2n?PTa2;WoB2>W$Btj&Sk*k0JU=H)?Tgt&)iW^uoHIv5vM>0WI|Q< z*U8ai@uL)h_GtXOB2xTbP>E1wzlgaq)Z%-l9RJ=1e~3!oSypYW@9qfaicKUIUtHPb zb-NcXE;o6A`tFV}N}2Mm wjj|Wx+11+Ydudo1R0!o9jGVv;oIpYRKQvXZ9$R&XjQ{`u07*qoM6N<$g2Bk?P5=M^ 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 b03717045bf508e14fbad9775e9bf5f7a87087f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6135 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 JPDHLkV1g;@+>8JK 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 0000000000000000000000000000000000000000..95e51f04a4f98964061e153070dc6668fa49d748 GIT binary patch literal 2916 zcmV-q3!C(bP)Px=9!W$&RCt{2n|pB7)fLA-=k6vU5b%KtY7O9spi)F@MXQcFT3^+QugwO?F4-U% zwbj;I+i5#i$8oH0OVud@*<}|Y%ZeyseK1+b4b8lb|_Tc{-l1+$4myb-Eou?U?WUcMui-<+_O}utZ zXvU0p2i?;Ff&hdU#phtTOhu0pR=-p=)9Qj%tBYrIU?2eV=B*s+EpL5ZkmHJMJ!H$w3iGuctH?N% zv8av`VJ}tP8)R=0E$b9l_sBZM2n}WgV>x$}+ql>#RZ^@GVN_Lv{2%`tO(L$im z1Z)yyok(hJZO!zjifO;wJ|Gfp45>&%F&ij06k3609xDrG&8m8-zxHxALg85aHQ@XG zH9RO#eM^zBm(uyc;KiTz#Y9dAG(?x4E~0-eii3j&-44u^)R5b2YcKku5Y3zp0BDHC z-x4^iX#NcrlrIGR!?1GCT%XU^=0+u72izm@v*P=w9|-aR=vNNx5=9Oa7!8#7s~y#C zYM3*-rur|v$vSpGBo@C|;o<%`0jxoJRnWC5SUWpdz1FQ_B+@w68jb;u1DT32RiL6! z2NW8TZNsjvufJeNJ{iXjSh#S>QP!9Zg|(*b3XfsT!^s^(AMdHrz91Y+oC>LnQSlYF zRO_o{9((SL88be2;yO=(XyQZ>`?5Rxysm~jOSI$BKp?Q)y<>exIJ)#q#a=EVK9?#R z%RH%*ojSqmBrVwA^lDOh2lz!O=wIQ|iJ~DCta=7`Cj4OHCT!|nkRSGJxulh&;R8Wt_} ziR#iGY^<>mnjP>LmLCQIkw{{+_1OCaF6mKF)gKC0cg5K4Ea2T~-p$vGsyu6K+xg#= zUp*fhqVfAhxH2DKq1Bp_BQBqL$yU2c@sosvU>D_7S1Vpw=(!NkzJN;j7d^2k1Te0VHqM9WsnuPITY~!zBm6!pKjk37?({~UG0{2OH1m&Bt94 zJ>x_b7;98=ufjz16E|k9G1Ibx?`jgNvT2b><5+|#j>bgBZ||_jI&gCAbi$ah0QflCKR%-!~mRQkxE%psyc%xNbVQM_-MVRlt|I z9QlWYqm7p$@=#6#%Jp?Y{~bLU&O{=SsMxW?j+!;g|Bf4ZRh5N{ADT3*l#Tg<%+Xn2 z=|jbj>N$vXFPy6GKzIUWsT|$BEL%4cSu#;Q<`Ez>r_@Lyk*H{Gw@y{s=1{;t#*JeG z4P9UPQ!Kwm}*JF5nLu(zmjAJ9{8=8x|!dIPwM< z`o{hhAg|;NUiCFhatg*CPx=DY3Fnb2?{-B$YgW}u0(YtEy>u2t>Vj3PRaup-Ey7UL zE{nxt!-^7BAxM=+vX|#9+#!?RZ<0w{)&M=^DmhNqkGVgS($Us(bLTz9?urohmyYT% zX{VO_&CRM%END|@5aiMxg~aYI!Hc%^Y>+E;!y#Qoo%;Ivt~-ma4%qb=;V#FvM2<=E z^(zY&KRn<`QMVm56vV~qR2BHnc90p{e^LNz$tVD0N9*UWo`%5 z`+O~`;;Y!VVx*gG-FGtl*$up>Wt;5A)%MVD>@uWhj zsJ4BD0 zv&%#@8b3U#9Zw^S6#$V*3Lz|nV{uSERP+Op-6yk@SF0|cKK-Ng*UnF&q(}cV(4ZR* zX_ZM^o(7PkKiUplrcIUCAaZYBt)z4t#W_=8LA3E0gfpFV#gO;O$%1I&_$0j2W1gL+o3u~Uzrpj(ldF1HkKjP6-Q0M6?Q?_=if8=z45xFT( zlg+g?)z3LGBC+_zM(lGU?CUy!PN;O%2xZ)R?I?vkuNApR*Ny3RV3VVoUj)6{LHfyT zLboXpS=4x%Di=8VshV?7kVqsdzG_e1t#ElkPG{N`&LQ&-LRb!*UZ9zfHdT(NGDL$ul7i60aO0E^0r0BIy}gZOw@}ra>Vnlb7Q>cI{NyWLS2-2ve@SfI z$9b>$jKZeAyV-^1DoiO>@STv!&7aHU&8LCA7RCJ@0o?*xFOxU_D6<~u*an?9yl|=O zf16A17T*RQH=fi3wKHZEW2pAt=K9K+D3?0s2G#8dk05rre0S?3Zf!dLuP*&_C1vTL*G1TKiV0Iic-d>UF-iI?72O}WF0yH O0000Py0`$?Ze;||Roaaf-llOhkInVPPphSrhB}$YiQKCeN5+zC;Krov?v^8F6`j|(P(0v z4d4<8my-N6fFXI&B&-whAT8(Fq5R|IQ0Ra2(r<~JAOK8{C&wG;Mt~O+IJO{~g~tTk z-&1C1OsTD1TM(Tk_JGmW#Q72*F0L7xO~c=YCsjXITssat*b+}ZOW=dWHUt0!EHTn= z!VNW%V%u_{z(_o~DR1}rJR#sk8GU~8q?-RJwk`WRM$!w#Hez=Oyx&;WEBE-X*Aw`S+PyoUolPfReywVU9k;G3o%mAe@EiUyNYef{>YAX zfRMYN*`AYWbTTYX5-^_7c#y~BO^>t%z#4#WiC9ITlfdF0VEJ|R)ywmyrwH`B z85C`u{b|W8EWA1UD8eE@Pm6d=;6EnUS1&2HNnT028yJZtZUOnN!kV+cp!EPA2lzJz z%)Ic#(4t~nl#a9x(9zK`=)Em=5rOfAHEBOYcuBxqQgfQ>YaTDQIXjav9w6G*ahg*0 znZlWK03f_3C}wQnx8ZPTb+OIK8W0kRB_klhg)!&AK~gt~yBP@lvY5AstPaqUNQ`DT zUn`6`2P%3T*4)WlRSQWCgbw3X zkOngc!hjO#A$T~5>5+Kyspzca*#o1)BaukQNCfR$17So7j|el=GvsUe+?(+10O*|( zJ_+LVd?_C&B)m)L4S{bES`FYe0q>C5Kw*P5W`hwnSYb1v5i%G~i{T=N8)CSmBLy5q z=qLfB2#ziGXWD`Al7&7yxuGU6>oY+|`6q$B$^n)M=!CSw5YML+crmqQ$dV~juGo}s zMbl@^8DmmCC(!6*2`5503BX559Gq`m86l20;L|Pf#0|~$HFxITodc10`*#5SG*_iL z&?Vr{26`xf!GEf+zozK7q#3b}GXqFnAmURHJ`S?Hfa>R}dq`t0w*)5#XlYGOq%g~` zGFJp_An-7Rg#ln;LqqlRh0^VZj*gB&8@BYE3z&-`Tm)i*>v!yo^c+&SsHv%Dm7nHr z4lpxuB@OTQqsRq8t%PPa)z|)cpmhx-kx0i#2JB}oal?RastIctz?FUyYup?lo@gW( zbyeb#E(!moA?Ek{W8Pp(tm9nJexAU@0aIdG>$;rm_zaN$C1C-}Dc#i6P;*Z{``ue< zZJl$}Hf{SZ!RrS^fmqknQ04hH?b!il%xpi8w0|O5=G(ZPk(SDso0}$u?jERBuDzMo z);ULY+wN}xnv&-S#}r^M@_PI9s%IUD1HU^hiNt7h z^JW5#00Q~xRU57FaW88H*$YHOJ4VUaCqW#Yn@xI~;E%!$)wku;&wk4EcyfFI_&I

)D>I2@bbyB+emMAY*XqBJJSQhxgoWk7RCOt#BRi2;;xhsxBo4~8 zFG5;J2C^sxK}LzBig0YVNT#6C~xl)JyV1 zFL%9}ap5jQRB-Yt5cm02F6a$oculG3gMT8ih~7V!)?_ z+Rf*b20uUaZupqU+J*6@b80AANQiJGj=b zK_%R1ZmeD?;X8iYOZ?H!uXkJlBJuW%0X^u{8PFq{k4l1&67EQ1B;Njr-zaUS z?D)O{8#}H5g6f<*0;ZM*zwnThv8D>r9@9E6SYvk7gfajSkH?2my4I~5y*#|!+~UW8 z61fu&hgQSzKmD{NP&aMbv@-NLKni8oxNk}7x=<*T@?$`W{LoZa{d-BB&JBr?!SWH8 zqt5{({F_UAN%QvgaIO6X$$ZIAI|989Ac+Dmaa6j;e6mDwnA}+P7XVMYw$t`aJ~Oke z{VbQ*b^$B4D=o3NApG9734!s`Iyw#xTC3CP5*p}3nOCB-l4r~69Q8)f9z)=Hh%Yc; z9t}?lJ)3L)w=Y;Qc-UL-UJPOa$PYjo3SbFgmIqn3FdumaEs4ZvZZn@2bP9p-BHo3x zOa_bp_0oz5rcRw|bJejsL2jGXtwFbu80^$;lRb+q@x&c20g5(IHZEI~i7Syv$4CWJ zab~&|2{XF44EwU*te+V(+s~uX%yb802-pDP`sTXoxS#&fyO#1zKvUCIUzgM=a^gT# z5sSo=^L#`T{lGx{fM;=Gb-o9G`m8x)5U~HqIQRfSOfDO;@sXC6`<)|YT4pA$G)50) zIt_)ua0;=OSi;Y0C?fIXG(bPkRC=FerGfq)iFbU$kB*E`&bMU&CmI43o*NK(tsNMP z%{?rT+V&ub<2^R{2+KCli$xP|#Q7xJ0SxjYPx1#XvE;YC_K&`jdcfzrNppl` z=SOEHKjNYzYp-P5s;TMGVFti3hZcFQ7}mqBp9Oh}W3%%SEccu%FY((x);PY@(b3`Q z5RGW`-uHpL(WQ412Pvhz<^_Z&RsYkuk#MMyg9A|5;H)HbkDvF`O5#_%$W4!T91Gwg zXRFB3>$ljeyvW+X)c5Oe>2Z4m}L zUGId|K?3UmcyfS%aHxOtcICYz-}lO}HS4#!E?xk=>1E+?*h6TPR`^ZN7vQN3yucL% z&f5MmZY^7iwo*!sbo;z|+X(QSiY?+0f0pgZjh9`vmB2$DMEm0pUW9sv1pW-bSHVR? zE$}2r&VxA50;e|BS2q-MnOty;xb;6U5U%-~eA^h(^VvI;@>(D&JgNFI2yV%u z#7zwip)RlD-CKs=3~8xXc?p|f0-mOseN7x>2aM^_)_OmDi9C7QwE08* z7?_UXL(De?-I?u+l&}@R_nPagZ*}Q^%9JZMA!x4zl(kO3pp^>f%H3n<_XWy>Wh)^# z9Rf<}F-F?jjzcQ&lGjEeCNwB+~p+!N3{5^nF=GW-_U70|H}U%u!HaG=$Lr0OEBZ^%|_+z+qg) zWB%pqxuvD${)0GZO9awqyvQ@9V~O5upjSYQ^QdP*Q+>5l1d$7|SVx65spI-yZwqKi zzK@rmIkV$*1yTp0*TJ?89~^ix6bfz2rw#zn>!XKaO`s7p7eYD{q{n--R`nh^gN3Z@ zn(nGM(we*h!Y?x35~R1A8fp&T?f{7vkWF3{q=I(Tb+xr`x*B$%przwRp-0~UaUFmh ztE%XK0o{o*dmG2U<~a&DI;-PUrR*FKC-mQ~?rmzQxyAs1C7q^Yfo^`oMgBlROUI4U z;;}Q3(i6vk!j`Qmcpwai!3K%|!zM#-RYV&h?c_WN{K}(LQ_9EWYP}5FekI;Pyo4Pj*n}t3H2#3R4 zM0PlsG9=hzzUv^r-=bw>PEcpXKLPwUKfm}akcY#XggVDA&~wL(&L+O{<>$T&;(=Ye zz^uN9-5rbZhSsSBEP=1Iv?hJ+=It+NX~ntdHj4myT1d+?mtw0~q|VVNXA+t^b*k+i zY-)Cn<*?H4`u@Me0m5~)4|!ex?t>P;^B}fgq9qj{Lk}JV>2qB~vp`irwidu_UtJI* zp|VG{Y)tUkrsF-?@C;uGztM*UV852AmyjZU2Fz+50?tc1$HK7l8D) zJ4*WnR(93#)UFOrSe0Pq%88I%Rv&lj3rCb*5lN+o4BH%uWKiswXZbV{< z05X(+quk^A zcC_UOks8{XNo4oIM}H2`+*mUYl2hSk0>hO`J(}-G*S(H+%&qVl#EbPb;vU)hun)s+&yL|(7jy(sU$Ix$|*gPCALHHllIWxw1x`JnPfGMHSyRydj5hpgteg3G~-sM(0CzqrpKbQIJ$lLC2 z*0}7+ja7dkc%>g3XtD1<5N@M$@hsou(>aJEwB+auva9q!xV~z>NKa8FyBV?XPYdN1 zw3-w7zCEOZSp#=GXJ$0lRm}l(yNiBFo~I*wBU*gqFp%dK+Ya9lS!ZKzsX3(Q=vr(~ zvUKWyZ-ZsU*yYP7Sr@IG9DsszY+k;jhzpDHw=TaIp=ZZt%;j)$013`@0S7Y5wg4RG zLB@?jO#^qJjKw!xvojs<=_>l+&bdMNyv1x}deS%cJ^;SU!S|2GWH!eI!tI_etZ|ah z!t{7@;LhLe7X=Hcji;_!h=9(3kjFYqCZhv5eLw*xI$giWynT>JYw`;a?ux{dkn{w= zCkZ}blzOtUvDWF~)HC9?LSL|XO4m|7Gww7PI}hZ?bMH|ZYa=;10EJIEl@yfz?$Uoc zTH=Y%L%3^)9psq+&II&DOM{kpasz~?2|XcW{uxN|rPScm8_hK}P9;uyc`g?C} z!i?}kudV}do;oXj19&TklU^S)o;Bpz0itakrzvGeI_Z}An~QvZ^!@TP$q*P0;yi%o zQGzuByNO6V2|;gCd;`E*5gRCN==~QPKyES?@y5pLbg6LzaMmFk)mgEcm0ej6ePCtR zSGBm}8IZ9Y6lVMko*h6bd$p_PfHviHmVlSR&j!IG!QKcSqIbKJJ6-^mS_UAE1L&2! zUjcfp`nDF2Jrjd^LfNu?IH4=9&Wfb~<^w#+S(UMSL;>ycw%2|HebGg)goWO!c=S(8 zJo%Me2j2@cOh2INwnHMZijtv(|0j!$gu2`mE(xck;yq=u@ z7+khFm8np*b6A{jh|{ob@H1L+%*B}nP4-#_(ngF3+?!1-qXTSB1)?B4r6cd8_=TqW zYEP$*M`Fp#TjI&*$UKHz`G8DcshpKv??5VC5h&t)u;xK^j{RN+DKFJY6Fm0StPkO7 zr-6}Jq6&bIz)tfMk{KggT3Xw$h1HE9&MWXjTfYdrP!iMHP5ugiG%7eK5NdHK&ty$AHV)pO${~uJ}>mvA_~?5y3WTl zMFG&+P4zWt3rVT7;_m>96ym0P04#%eCW!3kI|*|+VWlUB?e_{`Qr&INgWp>~>Zd!u zj>HpZsdxGKYNU8ZzALH;noeOM48}*rRNGRdnSj*lQ#kQVgKTp&_)4YhIkTz4~aONz;O3|yu$A{)mQyE-Mf)U$4Cyc z<7^KOF@}Qyl>;ibKsm&60Of$nNm@Xg1gzgu9%#Ql6iS<1sb|OD3BhSWtO(r1%9Wn8 zymxVc$jtT{82QUpFCc{K!}T>86CnqJ-Wb8bodEM6D3T9^XEI3eKL0^$ZT2E8AVj-!MJ@{~QL+BsHw1X2~ zdkj#hm^Sna?G>!-dfB;Iw5{y~+Y|Vw%i)oQM#8U~8>+uvY&&*Fi;wvtXznbw9lhAA zS-HyB57?+DaM#{C_yS%5%=vrU-~#|Bthx&<_}aj3=p86^w%@@AfFP)I24>IOKayT2 zcyn_@^|WH!k_n!=a;`cn&H!*<@6KK!(X$BjT+UNo_gp!>KY|jTC-I^D8Rj1WO90*4 z+)&d}Y+Jm-%C4uh{OHrr9lRYtZ9$Yuw{b#OZlPk^VYD^zafwAf3|g~|HFq^OR^=sz zuvlpE=##**p8;@=p7I277qI!}otK7l?{fR;npT)e(Dx}@&IpeQxW9~pBKbJ9d2d2X zj=lgk@N>00000NkvXXu0mjf*-muK literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8e95ddc6bb3a90f337a60827caa9b2d2356dd01a GIT binary patch literal 2848 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 T_{Oc`00000NkvXXu0mjfcUMM5 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..225315ff514055f2c757186240b697a24311c30d GIT binary patch literal 2950 zcmV;13wiX3P)Px=KuJVFRCt{2TYGR+_KABc1KmdreB(8*sD}-_)!J~4H5_|yQNs?;+9I*U#%OEn zw32WR0|)@&So}$X7nP1yz7SFK>iv})OUF4hAOM6yYe%{(+Mg$QYKhr{rbn$-Uzt+` zJO+X(5Sd8gFcBFCa2zR~K63#-Bd`r3+g*@1{r-j9O6rfJi@gZ7wH;A*K)nuP(oj!Y z2n2Wyz?%fOk;c|Q?ZT%@$)B+gi0bj6kl|8JP-+O!4&ZT@$>zGc$`=RAXWIxxVu{TF zP8%%out4M;0V8gdFZcWBe_9NMEdgPD>I`bKlEdhTZ@XONsCm$005Rfk&)MWx5MPL$uib2gI zvRf3cY^bgJYc5;I1Vm$r`2wsOj1vI1K=2A7TMb}qgTHEPz5}Dt_!LuN7JyR$o&n+v z0;3CcK!9+nbJTT>jdS;BvvEwoiWO}qno7M<*lf}zz#1sEDz$&)x`8Gw29a3uT)>zQ zp;p*ewY=hSnHN`ASAXWDb$$i(IU>XkqJU_>f6S`8a zwE)hsDjfC!@!J93IuM%(?pJ2#ErCFwtC#>vjYv!4YB6ySi7^A&31oS_uj%2iwOi9eBQ#=hhac8nw(_1%!ddp7|>9}8}wK8$Cz;za34_bXUn1po>gY& zC100MJsZM$;*TV*$)*>e-Be}AFREFv3qAFK9=KC#=>WlR7*nCe_X z90W$YjPASAo`L3;GpjB$5`becveB=I}VBs z`2-)0w#~2x4*(?2ZtpVgbQ_CaZ?C1zkH&wR z%g5e`o^g^An4*OCRv0OtXpdhflt0K;aBr8aqnNa=Fo?oNjS`a-PJGQE+J%?Ms1xwkKUuj_$Q7ly_(QuVG4TMHniq z2ITclkLyp19oCe5pop>dj~M`EuOj3Fo9h(Yg5iWQ9+eOg$4ZYf)s0q}&*$q9k-MDi zAqWM7RsXh%M|)H-SX*^(vBCFp+t#^W`EEw^co<~Rs&h&C5&4vwLv3wGxKrwG2x>@p z0BixwUk7~)9V^Qs!bnS^0l-p%-xa}LKpvPndD308XU|UMnlBn{n*kS36JnwW$D?P> z`V^+xTzt81RACk%B=>7Z6 ziFI|}_wun95w2)`Z04wPY}Yi+7|1r1OdeL&WiEk81IX_U$P3v)aQT4|>Y+wW+t(+L zM%!kHOZ^!@`kGS9WHLFry~}i}((VlUyhrB~%upg6i`TQKgI#E)xKoI$>TA8RK7p<$ zjx&hA0XPx>%n@q5(lo6df^`lAlf$ui{h%Wl1j2fJ8Nucv%j6Xl=wUtHf6(*$y>Ba% zmjO5+K&(O2_A2yrW$$;S5H^KdlG7b^LySml<&7i<-02DMT5t0-D@fiMj;*}D@42q7 z@FTZb=?X zkxMD&9Uw>8hMKsrzRtS=079YA$i~LT{YX!MuD6|JP&sB(M7Fx&IltHjWFZKJ){ZQz zXy0YMwj+X#;w}p`R4>>u(D>%oRYxnMyTyL*Nj`Stz*Or8*e5P-dFHu-YlBAXT9>h&a{<0iIE)BZZ(@ zPo8co&WKv<_iH-~jsA>B|c&#HVF{MyP3IS<+-zp3G zEA?Xef_|^P8e}qK4qE~MfNr&`*hvQk!M8IX@#f$u@owg`WE}q;69BVkr(`F~uTD@b zXsb=gpzkCT@-K@ZBgxLqQ87;z$G$Og*5P8YCnluO{iFcp6xmGhA8=typvJo;*I0qL znbxi2ATLUwr=H+uS}c^zqqt)N1h~XWPJyjvmkwP|9G8;rr$J010FW~U2*Ly+u>>I4 zBajbCe9FZqJ4aAu%wbD_B5%vWw=+;% zwaG~ljV0zQnwv;`t6(pM2Wx~>uUpjyjjIag>_bY~T4Rbv#_iLL1Vmfn=K+}O=%^T$X*P6+K>YmMD@f3lQIL+=@Vikov#?BO_<+h=}}|%T?k+e0LTb+ zJG+b^z_jue&kE~_=L$Ov;Rl%-`K(;odlfy`<@NZTBy-;j$`c~kN2*JMKJEED%x%be zO;4mtz*|JAHP+*Bw6%4bnR3k|S3#5mIALI)ve!2m(Gwqo&O5(CAtJZc`>U3ff(t!Wp<4mDeULsat@l@DF5}@qb#<2G zb7|1p+Io0bN?l7}QLeH$fSsT`SWe@Snwpv~a@acmaMEP=xu(cT>2jr%m|}hc!EPwm w_WD}wtzv>1ECyPy1dPzhP4lob^S$4l-C2@(|C7wlx%Zse@7cNE@44rk3p8lZph1HM4H`6P(4aws!w6Or z$n@tITj7laPZBVn!10n^M{tgSIV4^OX(x!y0yh)bEa4>pkC563nGLk|kz}&%@u~Sp z4O}?L0Wz8VeCy#u5Y8p}K>)LAqDgo~#Lcu^V`uXR-O=d()MVZU6M_J+GFMn=picli zhrmfwqFK00z)d4fws(1Z`%6=zv%x_y)1O}~@%H+fp;a{e*W|L;UG=r&@Pp}G;a3Fy zxZZ{UfPe>#^v}sen_q8R4j1U>3fpV8&esS5kI3kw-OJkktKPO8>KI86*V~8#A@GOB z@-{!4FZh0OZoTa}lrf~aqTWW7g=~dqf1SwB39E$9X1EjNs&yx86^);s&`teN9UbuAW(%;wHl0$|40zv6)eu!kWe&z%~c%0Ur zn@qMpRBuxb1@?1*o}RU{BeS;MJzX9P%cISJcR?y1y|bJ|PtV%fO|!QxPzlEv!Dpr5Cib<;AY93%5+|=bJhX3I+RvgC}Fe zy9dN;>tm7wmhDYm9=tRj zd$4AD>cF_0L7DzlAC$~3Q*X|z6yZKV_lmen;Lp0_u?OmHQc%k72Kw3j)gZ5&T5}E+ zv>CwN0DnTytVu47-dAsn%2Cz`TRb zfXra=?JC({PMtZ20m3tavc`JdlFkIY_-O0HNsXa>>xBp1}(H`5!qsh z7D=xca4ex?1;2QcY3Hm5 zmM{PC_F5}iIk5T!QyO^_jTT6FGlWwBoI&C>wboS;;*AEpH=WC0-W6~Aw~0^ZfS()s z9H6gFROtkGQos)lbgPG%x5eWZ)qP3o%@*I~p>&pr4?uVy$mS_je}jf+bxveSaB_fj ze_<(wfw0P45wMlO?GSG9fLjuY*l(svcNmJr;*703N8SsVb091M@iy1ru`|*`C}Bw| z)%H}F;cgDlm%o69H-%B;f}nmveW`f+4<}pKWa9h9x%BKMmbiRUH`Rm}>EVJfg*9#t zkjr-x%(yBE$deNOU9-%04#m2`bhh|j(0-J_(n(Wd+3p%ncDx6ueMnfqgOt9QO0@k) zE$5xC^!KkmcDHtap5SGZqCo6OC0YY7(}5kJw{Pf7(%we0DYS9>BK=y%T$NfDy?(Ny zT+^BU{?*40+uH-mor)bm$Q4*bS{4x!Ea7z65%-yGyXV{xaj2r-x_xb7~$@3 zhQ9c~O|(1S-MIL*VRXaCEZ$RV5DsGA2zPyT{I?Fk&lb)Hu{Mkz0exNZSR;dY67c&A ze+udL2|bD%^On@I=!X4%wC{@*D^?g0{~Ts+0XH7mY`Q}m*PtS)j@Z2*&fQa|7z)|a zKOOk9{k{QubA^}zoKuq}e7`FZYjp9L0@8hjB{cX!*zu%YlI&=|b?o(i-v9(wgqZV5 zV`sL9HK>6bU7fL8Bz!LHc!^)x_x0Xy06#Z$4xpQzCIdz!^PcWR^ygs>YT%9}`njPy z!e(i+WEYM#*x36GAgIH6B49=1;!iy!Wvpq1bkJj65UeqKdqNoi$mMcJP`cP{8a>w1 zY_1MtK!b^sOh%uB;opWCOQ55tr>6;H4p2hVMebXYx-=S%mckg&pf;pBV&9Q;zw<)(O*q;{_EDQn|Z_wo6Z68HjsY=X*Pfd2=icsO}Esd&LEx7AIIJ1gM!{d zV4;XjkT%HRzn^||ov`a?>yp@VXJ<6`-aY>QD3&daiZHyZ*c1|C4v zw0B@Od&ALQY4^<_-WYJmJK3~jO(v5G{K%T=&o3r11YkxGd6HjBXA7SVIzPr*>H&Wd zOqz2fyEZdWc&Cews$M;ib~IE<)i$FLFZx&-7~9EUANq&aY{yu{Cjh;jU7 zu~-arh(;#!-PeNrgv;zCj#NoCtqVvli~YiRknlPq2M3_A)mcg989y3kl*CU3kz1K7 zo&;ctvsL7An|Im^gUDKMS7(?LR|K?&Sd*@zD43wB9_VG91y2rL?=FV;=E9qSLG1+w2M56*F*%p zMDUm(14@v~{X%7i?JMw{{OshSxh@Rjhj*?c9C6!gl$Uow1w!1V&JfAX;nKc0+d z|8Z>*W;%V}3C~3cYz7d>0X*S#;mzBhEw6v=Z;yC!^Dfum1u$A(mP{rCq(*6D*z$Y< zf!e?e+(=;2o|idmnXX%;l$z`IdG)dp5V#au#8KfaJD5+Lcit`nw+0X$kM9K$8kyz& z5J0Ggi-uYkNRV6uag7Dunu^B~^$e2>j+xxVOA&yN0C-0*1+{H*Ko5T}JGv_s6bV}* zF#ixlc6l_q$Fh75f64yBqlh)OPt z-37s|Sd{o;A`yKusQB>Cma8EBI;gya?J!=T$IP)I4ziQR%1q&O*T+!-Pex?zLtwyl zgIv(lvvzhZyKuG4GFNQbS&9(+c%?@+3A;#qr7Ir$7nk{$FaPj%e{k>ul*}rCiz>+q zdO{w$x(>*luN9e0rdc}T$WjC?GcfaGz31`5vrx(zV5x$IJ= z(>EQ-89RMQe;!1hC7nc!Rs;PO#QZ?U0qaunm{SIk3)yV(L~BYXkG491wFW=W! zJWXEdNQ^qz?v|O}`e-z|yH@l2{e>i~J`*C$JuZ^&C(JdeL~Ayn{>veJqvDbvy_`z4 z9ka&)@@bH%pazv9cHX7!?av35KV0y0`9%VIC+*lh;T|J?va_@Gr`1F=1I4$hWLKA6 z5~S~@5^Wb50I;Of^Zji827q@>>Y0ngY1Zf`ey&jL9b8>80y&yM zqrWRFV0bgel0v=GjvJk7&dAGpR;>yYJUY}#XA9@b;6acdnL@RfaAbHxI$Nl?50Of= z4G}mfbzj2v;q6BZV9WuM$>dIvMF#_BMMli$Ll}NY;rAClMq=F*w}%Brt`1csJ_;vuXLk>Sz_$q)a>TSpW!7o&Dg5}ZZ zCTquXD0KI}?c?^Z4z?^i$@qG4dZvHqOCccky^N!BW=sn}T*W26whuz&BkL>%igB&1C zM{ogKjc49BozKXfC@Tk6pJ43nUk1&cz-VZBsJgjhu(Fl5W24|cW zROh&{cR!oI9%P_!;y$W3)9~z2$%(H5(z*Qi2nLFPO4vl23sdoEh)mss@v}o`3%Vg_ z`ksIryW+7xkEP?Lvjw}(&lUouRZn-)+5ATU1S#U! z5t06^tF6r`HQR+mBKnN+yblMvo0H)b;VpgWAy z7zV4q7ov$oyL+<+N0LjU_sJ?2%p_E`y`lpwk487in$Sm_*a%MRy8>R zxl|$+C{M8#bal3^fmF4fABiPB#bU+M|EhV}-JPvJB6xwTkr<5>zN&8shpiW^I>9UKRm&u8eTDtTx2))(h z2vqzGfh#k*I$Bo)x-O_ZMl)IFxyVm92vqHUYNeG^W6e0aVXEsEDt>#rmFAFvvum-Z z$kJ*4X~fEb)hATTvX!u@R%iHzVYEl1DwgY!nBCvM`q*;sJ2?Oa=iI!Wp0%@G$4m#B zMs`*2n{>cFdp!_XwY%$Xl*)D(cX9v;&V2zV;~?j$pu|Y^w(Qb``xZ?ULsqpxwtN#X z*Xm5iHC=T-+&L_ysyYA-YnrVb1}j_D27~gS#qk~hZfm;mqLX|TR^|$mcmD1HC_wAH zFiT>s31UMfXO6fi6YyrIT3fPa4W<14!p9(d%g+@c>E{5~6a2YRX?#z%=;M|K>0Dte zgnJ47T*myuEAi3N%+j+HiOUPzT(#_vczxLUYlgA&s+EKrl**nr6xabWgT=S2Waqlr zCYhhO$dAX^Uw$PS0xclU1b8MTSR-(l@N)$SdYS z09M>w^#+OIdK}f5sy38R-T?yN1Pc1Ni&YYC2~*`dMmksc)KsKh(~SQY)Fe~&n6{uv zB9qKH)dXf#_$+}Oz|R)CU2@?@C)SXQ{Qmm#R}w?vCX*{%60kM=z13nNWgTG82MUqcY5tz9 z9GXh^4_%zj7VZSGmcS|QI%nyU9XtHh*f3O*@FpVDaq&f+gz)uzlPQ zy>ejn30`UVS0VoTpGqa##=Tz{?L`!62XtwOdx|MQr>Ej=caQf@I#<}hAT9EQXK2kk zlF8_ElVM>tTRgr5yFSQLDd2&wc6Cful-zpkw(MEA3=%l zsC7p*K`SZT0)zPy<^z~dVg6{pBRaLUCC!HbjOzdfyi!p0`vKy3YfAU@X7lIQI#HxI zn?H2~rSFl|BYzkz_YbtJ9$XxnD|`{am&X^cgtiIzB*aq)oGRjU0xjino5X z+`GPCoXZ(@z8%3)#&9N}Ws20R~HQ zm39J*r4lix8q0EIa)nDI<$^b&(4DDx+u|US`#FH$H`E5BRpHE>Itay+@wWb{)S1cT z=gY&r00Xznl~hz@#uZ_Y*%{Tze+u%rw^t|hk~K3<4VtL>e}_kBht38Sws-H8Am}B? zyPzu({aHQq_w^0E#~Ov$;L>$~SvS06_K&M&NcHrrojqgrmNTu)c??k4agZjNGhBW@ zqiy1H`9%Q!%XQtHN+jLS63$Ph+MEtWs5R;SLYzXNetqMkyCFOT@?i)M0BQown*=S` zbE6qLmEHnv-SJq#WehWegKx4U-Y;AZkDO{GT+@|^eWu=ajN#|%|xFTfi)yP8S~lQt`IGl@#Jed2*2S{8Lp>w z)dESs6R~KbkDA69e6n_Cg+;53<$Js0vA1@`+tT&6#)a-g+b?H&rG*l%nKGr)KhE?@ z3u|!%EwSfxO)K;f41LO$Gs0a0ZffETzZQo!PX{uY{Cw;29|>GMNh+?#h(1%xC!zfU zL4AEg?2)JDK;WXWz!Cfgpb zwei!POlH*r>v1u__fuFLG~YqcvlQ+Cc&CiHV={UUjS=chwZXx`qjsQqj;GQbTf%E1 z7}*?&=&7YkmpXP9JG_|8BV!sgXwaZRg9Z&6G-%Ku82=0UxhPx^Xng* z#Le#AWV6}L=#<}o_j{e+_wBvsp7Wi%z%d-d|1~65;PbsWJ*mj{}XpkSm zs@vTa6+0)KX@(#G;Asxt3XNMRJVzj>jOs;v24J&@tamv|H%tW4OhG*Uz#D-4Fx3DA zcY*R`jnn@7^irD`h{qo|0Lby#JuvsA2rY|fHl4@a%vvh0vVrB*c01XWrCR=m6Ul+)QX^!sJIcl9cq6-Nr z0#OKZh6tvSJPwfKM3^&ZPRLgRYzMi+0)F6hmTylhA5)40XlQFYG3%)6gkWZ-2OSFt zybIt1fovBsI$TxdFQqg7uyugf7jRRgDV+nP8w9!mykODNSY2KE)_CK^ErdM&;5Gmk zjW_Uwfc%IMj}_S~oz6x3Qwb4QfF|GSc_RFCS_n=Uco;yv7?XbMa^3j#SUlqj002$? z;70$P~mru#H)Z1*f-eg=772tOQ+>?HO{9GWP13YA+&LZR#0cHZo9oIPI zUMk#LTUGX_(QHi%;PnR=5m-AO5&+skydz+T0d&+l%Q}*E^m+q@TH$;E7Xoq#1eXYq zH&y}!n!>$P?y9R>(38l_zGSiwr0>02?BKqy*;^QAwcp{7ae{?t;hm$9kIy}DB^9j9JL~KdA z?8vbgSC|#SYGy6a_m3Kq_yoW&+)n$tBuS(VZfEH$0ABI5gqA@YD*(BA6qmF6!j=sH zu838btOEiM0rKmSqLFw?>D~uiE>~YF38WjI=HPAAcuWLSN3tWhvc^$%YqV@w0h*du z+k{*_LX36|xN9BuvCR(?0IxSRQ(N>i0^BsBA!&Cz%Z73swimD-RXY<^k;JP?_g?>P z`PCDl$rpT91h*&B6X@0|>#W6t(!+V3Ecu*V@l zp2g^YH0l~?Y!02Fs6R4uAhD&!X}=~zd@}}*KlmJg+Y;$Dc|onStV02S(&n>1!9A0# zhCP!+GX=N9emlu+iS#0J2LMPzLqnEz(zN|%x4%Gs`d#k&dRjtfL+kATEU`KQ-OA`a zNm+C9uS@XkzxG{D9st~xwjCfE&2$3fQRU9JipWAU0}V7JJ5Xcm+F4@s&j-|T0;9uK zRkl?`n8?0<)25s$|LyvTh?=dz-4-L~rO~#rk=3bjDMSuSSij+NmH#stZi;H97F}nm z6#hUw{@_*sSH_Hicx2EQ*Yi%}LcdY$NfAA~|xDk-khwQ<9 zO36Z3rTwq5_Dh#8RRu-GFF@qRC@}#%TL8~q!?$nH;c*GQOgO&C#F2e7j@ z$MXCgCUZ(MP=fsUpBaGgfD-VtIO@W<3BwFib5xL|X{SeNb#JW9;cy%#`KXyahz7U2 z>_4&Myd33rSCu`HYVrfIm`Cjq(0!$7Ur>dUp_CII=vx!^hR_yRXWWKBF#+*{+cKRz0vL4-n~yq+FCqq^Gu zNiy~%WmW5%xl^*S!)CJ?BiWYhYCRsrog`~TMt(d=?iP{8-l=^0qfP{OjWu~hHaqzcxi_0e z?V!x*wEs(Kxe>ro0{&W??SMk$RQ4&;D3to9=1{SzF2gAKwB|RuWrceMK-C)Gzq`{xSao@mBAhEA!T2}GS z6Kq}4@}ZX4z{dzrnmLQe`38>srLlEw#wW$xgK~emlSkxko-w)W=+9#1D*D3hu>aER zQ5c9YTZQ`{OE0|Z2NCo0BG|`h)AwakSau&@d8LpkF`pE-&8)02KA5Fkvq>EufNNYniwLH2MPPLonaWSrig}a?nWv^P&U(sd|P zYXJh?2dvZHjFt`E0ZmQorjp`IvbGB!A@KV*|;9effAKo+5xIU*KU88U4GUqz6Nnh`Pb;u)Us&f61=a zTSx{4V!lopD<4o3%JzO?d0f|7dHsR%^d`>^D{yhD$pb*=tco>4JdiFAzBw=SItwQqTQF7l69K$gj0|4NEI-oaXM>g{`00000NkvXXu0mjfgis>i literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fd50ebda154354746c98e95c42966129600a27b7 GIT binary patch literal 5760 zcmV-`7Juo9P)Py0I!Q!9RCt{2oqK#-Rkg=|zde&Cg|x_{A07&(h|*UCMR^EfQRH>CD9Ys3JSHg? z1(EB$3iyBsqNw-!mP;*}=}a=6$zYKd1q88x_~9uaErmiMX+aCLC@rN;lbQXyf265R zlbM`zGRY+AeE(@@&f0se$zC&ipMBO^8z@kqK!E}U3KS?%pg@5F`w$#TAlw?CYlZg^ zoGze@z&j-!L~yErsU!}9)CFRzz^w$fO4tlw9jUdDSwm~r1%p+cqw|ppxM6?;gv0SN z>%%z^&L#Ot0F!f~NqAGlU9?Xvg=WL$O5dzl9=;8(QtM1LWEfXCh>4kh7u{Q*cF_wP}MdFE2C^;?P z_Dp09X}+FsBQio(z-ONcN88WKwafmT222=v5!~!J{O8}Gr zDj{hBZ3p=_EVm2xZPIL~(H1fNy1Gg?H8q|8*64n|(PO|?a3~f#8Y$mjLA)bBCb__} zy2$6huiK_(&}arMjukMI&`gj==1h;&1z;1v*G0Tc;AsMXO99VbR9E?I&h+GgK_3Q% zTbF)PGIx)@IpY-JDL_w%cv#>=3+gIY=i4NwWPBPJio|aKdBy0OGf~i10FMBCkUp~_ zSQB_E-xg&eqXQ(9$w_Z@*{2B199@$pAi|3RZY8y>v99Woe4Dc;S>pl1vE=be+b2iQ zoP7Y{?}8%6`hF1%242m#IYS17gd&L$h~Vg$v+p3Ohr})P`F@noDPl+mXo<&&3J-i{mqz0V5nCvo~n8dIP@f;LmUDp*(1LQ*54!#EYB z!IF_Mpg?8_9s*)IoSCLB8edT$9I z2XR8Kl#dh=wh-DVa3i5t0sLLSzesGOu+18?%?R79u!GQ48I;nZRAi|kN+lgC;BZ2R z3pkA6yYu~-b|buKp$iw(SLI}X#_v-8abQe2z#0KhLt1Ny=hO17OLt9Py>Q{Dx93{X z;8JzZ!mbE(k^2uLAVF;VK;l z8wC8xK==4i{KvYw3-UfC-5g1t=tKGp5ubqYQII90sQy;TYHX}}*-LYG z4zMIXkA};=C~||KRzgb}>#F}W(z-^HP$)T#K6{}hE*;T_YQiS^FwaY3jXMX3#v2HR z-70a&1_}SC$>!G+F>kOXk~|Hx7Za!%F(sDOZnu+Nt^sntB`n}sN>?}5SN$fJ{f<>y zTbCW)t?q9UyktZah_#LN6^^HA#}07wlJ?U{`$v*Ro{ifR=@}VwZR7mFEhDwdHI`{@ zU3Pen?YRQb!W=Ig(||q8>GbKy0m9*>GvwnV0H+SyJfV}+)eW_kZTsTD?@dcQegt}W zErA9AzTEVxx2^C|Cwm1$7l;Ta50kNvgLvogY|MKATup2UxjsrT?W3uRcKXlwsK-++E^NR~8aFh9(q=e_mjS#FXLo zCF#kdk2>tcGtM|8l}RS+!=R3rU%O#AgD)Tj@QSAT%CifD?~KO!s^3_e<0K7xtC!>l zUfS?l){VO?QNfAxLEPq5xuA{4@Pb0o2k(R;iJL$y9Bx0>=*_JcY=By#J)Y3y7J z@?cLIg{O*4(RsDCpWEoEeSzJyMB|qb{N=DZ5nlCS(wzGG3*OlK(x6j7irv5TJovzJ z+8w7920u6SKJYPfcjX#*gE%b3o!=e&(q4ojiBEyJ-HRRpOPcB`3lYR4K&UnGK}aiy zbt`Vn?8fJ;@x&>l(Pd5AC2 zXYLQq53Cw)|3AOuj^h1a-*PsH<3P@Wv>$-ggn8D_qPugEXV4OlAHi<(NkOv-%oMQ& z(i$23?UfhT-r3yTY=^63Z~W|T>(Qj2ktlZ6Zo55+Ez$Td-2^CFUs2gmQ6_E)g_6_c zPe++;DiUt)>DvD*UbB8~UebO#jh19O5JSK=5SKL7Rz|(_kKVnMuLEk%RDE62(;{m| zqKa53npoi>n&=t>ah79o;niFZ{^GV}?}E=hly&d{fLKs8`R)5!T9&(xlxbNKpJ$Bj z$#xnFfl>;QmPp*oZYV<0#3DdH$X0rv!oXC^BrX`ZN(rN$bE2#&3!I?Ct zO7`|}TjIlRbPU-mnXzhWW^}Ltu)m8IIjtC$;?4^}&UV>sF8n2qbLAyo>0yuK%gJQY z(IFb)@NeG%@-jEQlbE8ka+((qoL~8v>qf#sMlKwH!Zz1RviEqgmsS!lcOthqnw$>c zEZ0_%M{Mn~=R1+LzMTzTPFxXC?PWU4+NbmnH}WJ7F>azyEpz7pt*y%rCvcpXwgNuk zM9xQhj@RT&0_Qq3#+lsS{gXxTZ$HyZ+su8!Rf4F*gB_QM0RSKPwri_0kYbPLq6-8{ z!{Of!UCi2Q7&;ubC5DjW>mqI>ak+^3vN#*W2}tRvmybHkQ{+`91is=%p2PuuL7NF4 z;-o%KCh6lM5~*xrxnwfa?H!$eDuN7CE2~*UR=km2!%NxBM_a zaw5Ah5ZDrm#DDI-?7i*@)>i%5iDXl4#jOCAx$A#qAl&9}@(p81$7k-zEN{ z^*y%d1rYCb+J@4@48>wcBkg<9X(JKyn`$d#ZVeoYP&9rLz#9O3Y)?lJkb-oFvASY7 zCxtaPH=B1)KeoY=-x~N#K>w2P^Hf*q4|D0j>}%K_poARC?9llI%gE~>76&8hsp!t4#L^Ni*707vH7J!zGw zt5%oQ{(dOYa9grmX}c`rlpy`KvA*g80|1tEbvx$k;eWf4pXki#Xq{W$+4hjJX8DK> zJ^+yAg!$c^&b9|+3J8pB8b4FgWXz5Z%n@1C!@{YDE zNwX5*h*a|)41%W`@xj@hv2SIQYpkzoComv%U&8jD?fbW(&jEtLV3)|G3mYc;Q|6l< zY@blfUf=e8ka;_A*#Os-cgC*ECbRyf=e`Nz&OP5F(bl)v-MtxaXkC?nC2)C5Yr@mh zym61jIi0Z^jp!EUkrsz!q!!Vkq>;p5^2EX9rtm{W%!`adNn|{TdGj zk6X&u$1Vgh_D)nKu9&l-ts>Lq1q~Gs2)Iq+clLb0p93^DRKy{<+H8^39FDfS zSm7o7YZRGmWS1YaF*$9piwhRiuG{;)0S?gAP_+V*tHR9$N|jFEpX*51u|!E%_b*7~ z?nwF(g2a&}UELWMg?lq-NKU#)mjZHG*pX9ZwKCKi|A@PGW1pED5_3pqoL|QpBr9gW z((33AZBPfesJi+!0+)JFy}vB)2}Rm-8tj;FeChQ6PH$m{cMf>#LhouOp1I>5p}V2iBre8h?Mb6g)ad(0bcQz1GEmm7-+IQi&*CbOpg z4;h!eprPUcg7dr>K#Os`AS~X>^GIhog<}yClO}KJ$(p$0C@?=*S8=;YM^PsIEx@?G zEhO}reA_TwhWZSSGc%fME0zKJxf|t@4s#LIJy?Q zk}O^IKZcmqx#9Q*apx`<-Z_%RL_jB83EMSZ5_#gaTWcw_x z96*BWx_~2bkn?EZPiewLJo&A1;{X6$&gshg;m%%>GXK%W8&>Izw}LL$0B|>_3y$L1 zow7KZ7`gLz_kso!#sLgW#+*UFn~ns$&sDB1nX`gYq1MC~A>0^>CLrlifR7V=)F}OU zLqoNz#c6?mpLFq3ZY#KmIG=4FJ8+Y45o(SMXKwq*nXo)7aL3o1DqcY|( zUz#tbi_;sMs;XR-OzCI4#a5>ob7LI~o3X06V+RPwlE*7;2MsjHgk&CcBR?2@-~4Pc z1WG}i4)AnJuts1H5sD@t=rxKP0c;krjncN>f3XeZc4HB3Xs8^J8b@;L7|Ht_Hio_} znXWl@0Hy6`-75BJ`>@Uua5DHqLGVxzhobM%CwJ9F-SzFZ)EWQ-93bWA=Aw+}ZH_Ne zh8a&BbG!%?^d&b+CEV?-ipSuzL=%?}ckrD+!$I$COpoz?OxqYE+h=j)0HH`?fzvud z|L$1rZshyxi{C(aJH5;`8vNFFP1&@~5HkLBju#|tT~jtK;|89yDS*hFW0nb9a;w`W zu~_WDohe@wbXPTGI9PD-U{;=yQb*fe#P|sX%fOwW$HSumpe2&{DqNJ4-No&};Ab6G z+3F({O@3@=%C}}T2Oqsp%g$Jo9Q>KNK+k6}rv2xwP^Rq5yGp+$bk$43FNA+_1 zW3DHe^#;17wf%Fjx(vkWV_~mF;F*#*xZU;!z?_{AyM~V)@>I7$|Ne{Hmc7fD?pfvG z+y8Q7ebu1rh2C03{%SxMd01170y?>|uIiD&uFUC(-9=(tIe~;fte##o`ach&?grf@e5$|RHlD5Fr;TknXj%G#3V8~}ql z0R8$J(i~3?kWTx2j;go}JU%{}xEjEa;lu;c+XB7<@mK;M5OFerQuqBhh2L(htN3oF ztD#VG8YkH@o5F#{uozGYpb`s|Kr8`J0;q(f1+-nj)~*s?`z3+EpcT>QbhIVLO?HqJ zSv7rrgA>ty4iH+>UInA2BW75RAXFEut7;uhoyTlUPAlo^`RiEE50vn7S4q(cPD{(} zz6%IN+Rq&8gD+^aWIi(5gAV}f4i9XyH0OJ*Jv>V6u)sX0gO5H32u0&3fp$!9dPsVj zDSWW0KJZY!Y*2TVo(#K!8A9v%0L> z!@?c#gF40Qp$a2~q<;{+wyC~yQNC>%6jILs-!0z|TLr770P_<1E@>+aYF52_{_lYK zp8D`hc%DSYx-z4QfYpG0+EicFl5bm_u37zy z9Zo;S7n03`&d&}^$!qHg_%v1RS4LG~SrAp{)Xz%>-uTqCY`v zX!wm)N7dxK;!4KVUn~|ousiKP&B~rY;5{PBC{716pGzZQJAhY6t(W)$skMMv%d}k^ z3|4jK+W4_fdB@Tjfc*@B&xkyc4p-X(1mQ72x5=+to;h-0dAD5QY55S zNibZ-Om$E9+>PNQlCquQU@NW`_F!SDs>ma4nA1-Pb1xj z4oIaAKiu#?Kb1P5JC$0qM)$wyHL29L*QHYFwBg^sD|Odd0Ej=rf6>AFrXIJkEA=Ay z0SX~{IGofPlwIvnJB?|+vw8CqkmuXex$H-c{NqSRaV1~RL%Q|+<{$ju#qR->bp^`& zL!nT3k{(n4|H63oWyrS|bYDD`8#w}SccZSZoy=w_wyu9u<0G#{x%V)CA*b~*G0OiY z{AKXrF~FIa8p|GI<|k!fYE=6V#QjjFn;y%2L>7edf531rKj3^D>bzIxmGWDicS(LJ zgTVJn{7#;j$UfhyV~D?A=0%$OK*LwfxXW^!T=4zzpm&WY{j~wI@%~h5*%^p{=AVOK z7T?`z{dX|hnS+nTH#B3#Y$|mI;?m2Ib~*Bt5r!Wt>_Gg`T#5DvPU=LoX>tFtP9Upa zM*U2JpO8sycycWJZM6YyTotZ+)*innezeeA7wpNxw*`rrt{h3 z&1ucwJwpqJR`hmwVPMtJ!q=t0Z^Q0GWLnBH^+3SRWqt`gTx%(FGyLz=Y1Gn`XXNcd zd-PigZ}r)a98^^v;+RI30r;Ob;P0T^@CX0pRrDBofO6ijYW?Ej61L&~2JX$~uvdP4 z;iVFOp|E)cVC^BinX!?#%J8YN%>RI%1%%&--&W8uSUCUuC#@Qq`=}XK@5Y4}^H&wN ze(^*Jhc4jUF5nyOk~z-$ZH0g2(EQVn*|hMCD)_?-m3E+z&-4TKUcyIv`EDybtAFL$ z^Q+9Tjy(IXE7-4@!&aXC@G9mLyhgu7*wFKDSz+1wA%kf5|19BAc2*57u9JSor3&R` zn>L}{&ugC&c;~dMJ;}ULVBhf0Bg|!(VXxe{csS(pXtb{%mhBW}(2Of+Igv8SF$BXW zCpSNxe&ji+k`8qAtefk#>KFTV;9P=SUX9;8@h^;NFO>O=cw=ml*}!n5TQBHDTDEz5 zSms>l;M)qB%rj(Oy1?yYI!`hW0{=yRPmYehM5bYSg#TGjll>^eQSbj{Nm$vjtt z5kKSwkH0PR(w!O04lo?$JcPJg_3!L^&1nj`+(G94f^mi1$Wc?-?7=eK`1tq%Y{RYX zK6vVpE@b<6OZhOC#n{e_ALRw{qJ5L;YFy9_9Dj!JTO>bg(&V))`E7>HZ{WJ2Hm$dD z3Cfy>h`}H@}zxypLg7n~Ds%tL>PJ1}4=t`yWI}5+-c6X)L;YDNVvIGLT3#-t&McpiDjvNI`Ih5{31Mm=l_E_12%F=ugYjE~ALoJmOFu<8iTkfm z7v420}tc7PBxw*Wb6FkTw=9%_7g}5<}=biG}h4*ZF_<-J_ z#p^5mrasRsGmLT8JZPWpaw=bjLr;HN7#lgnn%C;@J$vqrrgHz*JGl5d{t)&z8IHN) z8go9*15u7_KRIW*7r&p#vZV`NJc54VU92m%o$O!HKAk#)_LuGOA)Cs}dxsY0WI31{ zUn$Gsw+v^RL(}EQ$vC=38(wKEwoZ(6v!4VWUu3xw2Rj!s^LfHpH8j68j@Ehpx4z-| z4~Ud>tX(fGT7xVlv{>)+WTf&zv zXgzMJ4DB~EoUSoe{0{hiC-^aqbY~!anm>-4*QEcjzl3XYuaz(m_7hV$*FDP2Bjf3E z{IOJi1~WPDe?6PZ2Uy|5?^^z$=?AO&UiL?_e9$@Py>zs34brcV`R}lvFmg^rFqlmQ{frE!RmRtFZ^V0qr zZQf0&=kuUXw@Lb~us3hsyx&sYXg6^#Bhw(?*&-k2a9qEV;gYZHb41^RK8SshjGxYL zc`@=~O_Ss7%(bX*G0y?K%KD{DJCoC1Epao_pUSw4bGd&ohXco)%!4-JJ7}Lj1pXew z`L_(0F2}GkFX(>=IEZ-=zVDN1q|3Gs@G4_O;3&pzoRjt0?YmnL}&<gk7$L2iOx!Wpi$=k13X9 zT+ChXGtaqY-bP)N`z3SPD05uGH0DuvSK`GQcJ;b~OzYRx*PyiV3T*b@SWnC6^G`Ix zqP|q6(bN5WCZBziIi1(7S=87+62O*ZrkQDaIlHH8)aPw{reSM?lDwk*!oSG=bE1r z-g^19V@@j^zkcw6`vRwPJ;k-`y!JuAuLJno-xZDxbld4sVwBGy^#V9GGSL_0ll zf*gNCUd4Qgb0x}-Ri54GHOB$&ow5uk7iq4SZG|~+7~gEy(H7W_uffm!WIpqnFv_V1 zgJW#2l?c8@*ap|bBkp*NSITFW_w=U*W>=)TOIVQTIy0SGv+NrF(p_gJ`yC;<$aD!m!H5r75@00LYSAbsMl!Uw(9Mub6m`lY z12XN`TjxcXxBcQ6tW&wc0^a(*giAkL(|%)Y?X@4WEgO1_b*uMT@ZAGH_x!G9SOK@tB)m<4A+Pm+Uys zaib6QckLzFtmhS2teFj3aif3L&?8*z6@UD;OVc;F_yw89?6Qx;SgkVOGU59ru@{52 z2jFR%pIG$-ds>{Y=d>Te-C@d4o-D0*a1YTe7v}Fe{IBE#3^VTX+q_O>D*x9qo~}N} ztiP^m$`Aar8zl^^QBt;-|JCc}Unt=pJ-qmGr*!DMZQB#@xn^wx`uJ0> zw@A2+x~tE54Y1? zd4`BT+qR#1W%=eY3;Ar47wE3Gz`Z(|HcS_7-#_#XE&kF8N9XYQYnhk0aQz;7dN#j} zb@!_F2D0=q#zps6?&=v_d=F&VwVxHVa9?I{-sr=1jL^ns$nS}X%s-1WROa>)<1g#^ zAS)d_=UNyYJ(A~ut#Q(?^JwG)yJ^%JWbGd4`Qv0-QI0s*S=9&PEYX(A z_<(7#{?%Wvo+V7_g7;!iN74Yjy%+e1eI^-(v#|dv(_3BYj7(SUj+`LV@+@t2oIwZX z2Qm#^l*@BS+$+k#eGB&%>aA5G?F|;#yFk;NISe>Do+08oggLG8y~n&C#Qp-|f3v3J z**7!(DEHc$G*}-nr?Gwmx2mxv>jPnxeMSM#IAd?kEB(mxLvy*vKPcm*i+cTHbsFub zR$b@%Dbg&_iSuY~`#Y=~Gfe#(b1BPs6Y~6BiHDs_`R3UOoDca7XsMao$UHprhj^SL z$-Y^h3zTuv-8#PIIVkTG{7!Uhrd2)jz+XFVe$)3UFUX`12c=K-|eOMI$a z%b8WQ8DFo8mtzXkU7k2;z`E=)Xj^b+qN*R%$7vY<*x`co-?vN8@u}Z4#CGt#gmIl4 z<$cu-4(2S=yA7%vcHG+{+tG8@(c3;*15xh5EU4o_)L#yuB0Q;+(!O*qDoepC?;!bn-in z9VNfxb2%;=@=~8>TAbP+Up#AoM)3Lply!mm&WIU5w{OaCTzFQOBlh^$LB<@%f^K*- zj%`vP`INlY=UYSIAMO%k9VwUjIdJ5;BXRy2VLT599l(7%-owQlmgj%`%7+~t9G3(B z&#HOipqmn3vx~g|>)76|oT$Rk*UbdNo%df{=4w&lYlVxt^uK?KbCvb_L2Eft`Sp36 zey7a69>gBqIZ>V0X_P*;S=^svUkaHLXV~eYU3%Ze`TRdN3SZ@KPY#H~LfnxQf2q_> z-R1Hb@TZoS_wwo!ju-6~*Pv46*#lSLu2ZVta8oB9h}F+x2}g|JmQKT!D4ZfLeL1z} zK)AD~cj@jxxhVXGTN)^rL_U+t{OK|-U_BXk-|1r2epwmi_QKu0=X4%oT0VPP|20Lp zrNf_tYg)oxVRmOvd(Mg?)9rZ`4@Mz>xh&nZfGbW=pq%nv>?jro%4J>=D|Mqlx;$hB zJZ8a(6zH7gP5f@U_yZqGbio(8ypvyqi&IzNhqxYw|N8+Mj^HF5To@C6z@=O4*IoWk zzjS#{YYl#B{Ny>UR4ga+P@wu#9B5Gv=xZ~$D`)EKM1RL~bJ%~v_i=9Pu~CzKFz!xa zf9GXA8x~*RyKeq{{PCM%2dB&X2Pq$8=MU@SXYAXU$FQ#7T))yb@=QgLGJWMi3%h## z{4qG|b)9uT%j#zurbR<2&Ue%+2lJC3d8gTN{J)iVlq4kK=% zcm4conTOv9pRbpX_w8jKJJ&dKUUlX~$^q)@i`G11Poa@>(t;1#C1$udM|@vBI!y7u z$2Whr=Yp)^kbwuxW!Zg$-peJ;7WZzu^b9lha4R+mEctBtSxJMjhlDHai8n;dD7v~et0sUI`_HfI`S9=h!JkXN4XsjYX~bwvLt+B{)bFHgc| zchCCy|1sn0^bI{8vckaG>S3q!=J1|%^PjQiwfe1hoFy-~-fm4Brav+C>vqj*5dR z3TK^N=f8f($e90{@2lbrd6oT1yL%2o{xQ#O)>>zs^$#b#uno7ae_*}>+q_G*D=}uR zxtm4$G2Z!L*mdg%9@nnyWZ6#`*U-#m8uu~(j`3-Z;he)-+d9MVINvkFi}L$;+I1dU zcpG%V)7ak37qkg~>RCVcCc=<7iup-?X}yzcGicY}VM{;mLCSnyT){`!U-FWcX|PY% z#d~SUSHaIQZ;;W=?MXglVh0!HoncodaKZgB-5z&UJnRHHfT{|{p2xeZ@|ngB z;On(jiQzHsDlFY?Fr_CP^L-DQ7xw^iv_(`XoL^;sP{w<4ZSNB3^?VQHw(6dZzyoWu zp5R*V9a-qHo<*WPASZt5;o7IHxxNx_;jfd<$h^`8o}adbfx73oz9tTH zAeqEEEjN2wM)-1zNI14xw)u|Z`s1oZA)yo zi}QMi4fK2%`Kx&V+pM6)R$q;K8&x)}C@ZvuW=8|>C4n~=TX5t(#WU>E+l8TxyUUVa zU)#yy-4Nr>W^Me^-8nm$<}l6+LBj{Rzr((deVjSo`Yq0u;v50a)P0V+X}+^-O)LGh z_by}s_oA#e(`6bvSDeGQ>W$$CytAt8J65OLZU?W`!q`UC)~&HOmCGC}Y=+wO0nEcY zZmmYI8DHc51qXZg+Je+^Cmn!H-xKCNeKT%xeCy*Ir+r9mMB>1+x6U%+tuK(UlH!7% z3mj>JY@6r5#2IbmCdc&X57C~xt{WzW?z*%H+H2qX67v{`c0lk2eTCGQB++Wc1!JA_ z9qUoF@2~-Z`?=P9^ou+EB8`*I@SgX`OYQPDJ51a)q1|D3{5j5gryH*56KMy*cJHSW z4qL@Zk_F6fK7x9$na5P{HR$0u(2aH(hyK8JJ*)~I$BwWMAoDcCW&eiyx~d89{A2 zy8Z@aPQ1ZY4<8wiJlFf7on<-LByevNYUS<*;Ce1?Zd&_Vmf`j%?%ZI00DgWQdi?|R z%>nO@Y9m4F0_AO#{0q}X`@{3ayti7k`N3|7n5P9hM`YqZ_;xRCin#IEEe?3#Y;&DH zAc^t3*9+Sdjf~YC_kxm0uYnU}X%WA(yv(EB>YZ_@cdIQMzj_~AdafCveK^hQT75m* z*;XZA?%5V*IfaeM>yN;yr9_iuW&h58ny@M#9-gpluF**V(CU@Ou8) z=np{WCEzLEO3J*G=MfsA$xmL;USp%Mk-9}&G{<(-4-U;rOMW*(t4-eRvD^s?iD9TK z7rQDx#sC{Z(S&C<{TUWrNb@t3sz77Px* zRaWw3Kl>`&8>!?I#`5u_i!thl0hAl$C>iW00T<1oXD+3s}eM4C@nQ55d( z6)^13m0rGk#cY2GFV5`dLo_z7c%TO1bY}s#WVpp{8gfXjpxuMrsnoO6Ko8IE_NB|P zzmQ6uO7jSCPwXoHa?e1ztJqauHrqYBBE4*H*KGGdnQm!CH{DceMauB&E*{h&rAXIB z_sre>*NFFd0g;sRZ*Pj_(b9wN?&%^vzyui5on1zI4=@n1d{)?LNaI;xc=x8dVeEnk z!v$lDsea(j-vEC2qcIHrEhBCCo1q+09KJM%1FZ@X!4zZhD8cdFS@^<4&sGV zo)!4$-**4M2=E9KMk1E%MC|Xnea+PBPjNtTKyg5EKyg5EKyg5EKyg5EKyjdo1KfA# z*;sMUC8r(DHGRK(@l^mj!h`#dc*m+I{qO;O8|I$IwiI&{Th%%KM)naZdBr=G z+7!=J)q6Wc;?t@w?y()zJGgj-`QBn9-;jmNX+tL}e$f7n*zb{g9EXd$XyS}R8`&DB zzC(Av(MVmpudl`^=8-(7M&DuQ*Z0 z{9D5sBcOk`z2{nKw_WNj{4}tw)a1MQ_3OGi1?{fjPWm;z>d>#92ix@X*M`v+K=*3i zS_;2jeVn&|yz!hT#+nuTan&48j*oqE5F4(BtlYC|Xfewl{PzTrAKF5I&B%sq^@Gp0 zHsP8r1A6(|U5(fqjZ#v2hUSm>Hjx)`}Zo{ilet)pch(ki@}i3&aZB z&CfGk7>8Bvq9>!?E$s#F<+#xhcbrFCPIfkH?spr2eD|z-*!KQky)qfkyGLez zC-EC&!-sf>%L_eGq|wKE3lICL6LVVXcRjp}Ho&g!s2hf{$irM?tXGCpys(8G>v-SN z&U-A|`Zll=J)wrfUIo@+t~b}G&+q%a;sdz%`-EMQ4dR#ru3pQsri0#}9z^%qe{k-G zvBY|POd0fUn>U%jy7HH;xSRcady@8fY4eop*tq+5Jmm&Bo(vp6f!}A*r+fvpehIjr zO5ELS5%mnseKKgB$b3=jzbpsyf|Ja=@cRwk1bRCIJUqo*mKaySzBV9R6hBqE|_72XEVXXE(@xwR6WW9yuHrfX83_!4cKs!$I&x?z;fN$q`zMB?y zTDEoec=G*;`Gv6RBYrsZ3ytm*p>FJLEV|d%<@Wq^4i#@x__P5Mk{s;j+W>Z$tv=_G zgW|8V`t7>$%OB$T$ z^BRsm;3U7gMw=5};ak(8JwDrSdBM|X*ILV$e$1o(7)6}`=I^jpcgoT`2Meb#EQ+$Y zM=0rO#KqkDAzwW9XlvIACyWa}<*U4)ZNgaAUO!-8K8pI_dnQgadwJ_52u>-Jt$e4l z>xH8(eA|;~)RvyGe;2e~J?3*7`*E#52(R7pKoBuNoGSJ8x^U@r{ypP<#o`95# z!bJ9Pzxp^cI`S%+Kd9TN44~b50R7i5@xwU>?Gn^C&oW;KcTmW*!`lu=Y$`v)tuBMa z^$ssM%rlm_!}(wk*lu}_zM%Znr4oXFfrk(JkpZl)*SxJ6q;%BRDemQBuK{b;tW(-4 zq;2e}DeYN7%944G8hqq=ZafVVcl7Y$%O!j#w|8)^CkSk}Ja+3%PUX9WLw}~+>!D^2 ze}x-7KXK-E0CM$H)c5&ZYotv}KXAOJUAb}ba5tU?iCevHXI+`(d&WUvyXC=|lf%8z z?Uqiommd37$_e!GW;b|#fePPyI7(kaCicCyMc3v+6&z@n1gsHox^B#7=P%>OYG4U$g4JkuzcDx)ApC ze6;_qtp8jyaT`+wiECEPsDG|uhrxGo zX2YlMLso7KqwHqE2z&ph8TB8={*Qis()+l-(ndJEec7z~^jmJ&`@hYo|3>$JxrSS* zPrv#^*U`ltYxCKv1byrGzsrvIzsE*K4zZ(4h6TC* z?RIZc!fhv)HWs9=CC#mO&WGpUaBk*}PW6HKTYc$1Z1_3BYUl6(l>ls`)Au0NQ)cS?v-FV7%Jk)K)&Y#`8#nAnn zzxvlUx|P`paX72}hNjEF@ZxLTc%8<$75nzvEOootoQLQ1G46ZBlE!7cpV`HCRn0Iu z^}A=?TyL{vpm+WJ>Uw;K-IKL7^@=q*^SPYq{KyG4XcgHt-2@X*fm%ks@`A(uNy*dwP+a{Qv`XT<#w13uTQ3xz_c zHnoo$o__)BDl9o5te*9A&o}eQ?Hm35$5C&)Z0fGkr=$M=LKw8mN2%Qv7dO^WH!qu4Fmsi9K<`x80X$q4WD+TGJi!GX>*?#xFB=Jba|iN zwL1o#H6tocCJm-RcJ(rK#}|*YUJd8J@#&u5?Yhy#L|p9Z%tT5dQd0 z8)J=})7&~X_D^o@y_Ht_S@&GOvx~C}L;Bl1)9EoGa6ZSbEax!oQyxK!WRzFneX}#X z8adlp7|$H)r;H?|oz8CQ^-N<$ueFV64}IL})5gQ375jz$an5P;93yze19k7)mIT_0 ztGvQTI$OJ8KJtf_6!q`g=iA7~ROGf1L<~?gz8=lFOXy1k)UDtb6(8i5MgC zT@*7cr*EE%#(9^^Fo%ArQ<}KKq07IwspHC+&l(3gFXHF@t*2b}qhZmqZQG{h*o$DF z?uegb9Of%t@W7bvI7b+D%heq1bePMwtefV-uz6sH@8{dg7{|;H_KVPeA9h_vPVj?| zTUw6i&A9me_F`KE@^zYDxTf@%y5TS5?061)l7GgY_<6Kx#(9TK8@21ZJ@sjCEzEuX zKz+YSXT8`L3)1(?`e)ndrM{s5x63?sE@c7xWam%A&Jf?eqdjWO&tJi}47P7i@9U)u zuW5kGx9xBUFDyL$7xNWUefq;ugI&Y=f8g^+{J}N%MOv_ryp364l0lTzb;1}{+o(K4r^(1p0yM23;bf^da{byf{Dk6Lklptc{bTko>2c+b}fdvRCL z=NU2y7j4HSYCPz~*>J*nSHz~C*IrQ z`7W>JsdUa9K)w0fpR;Qpuy4;gbuOhg26gEBtq*hC1ibI#-cl`O72=fxsDp2sc^dRP z=$$r{Hx#r?J)ia{_r$%@tJ3Kl;JHPt&sOZUG*k!JhYrfu?c2BS3p{?(Q2C-i(7wmF zorB`hN%=6oe+TCG-p}OPwf9CCWr%gy2>-awh<4~tjmm&ntIRpPwc7}SZIYL7+kwYH z&hXgJ{jtU$)>$pC7dATntjG`UZgIbnYnW|Pbq$fh^Fo+!jetLUDm-%0X)kF|SBrKw zz8XQ4`^;!h{_drGFKN&o+|0LHJ5>&h`}*fJvYz9>=RHyF%|os>IFxDM26>WW?N;`A zjmTyzBNZshCCtskEAEMrvs0NyA1!Nq6VyScAXD2zNUD*T%Xw~*70Mmry5kKMCw z(SC1~GzPN`Ub8+}mmbJjKB_h>O8!`3^IZEh?#!d!Z}q7Qb$L_;4`aPJ)>_26CES2_554mbedlewOB@F;u$J=$ z`+D+@Z-e>ohAw6KHceRP^&90I&%eN~*yk}ex+;RPT)lQ+Ymoa;L1_+aJ+eU=F!SUV z$OG?jq$v}ui$?AUx$%Yf)Mj$ptMxnOu$zQ45BT{yHy@`x(y;L0rtHU6C!+3>fZ_Y@{o@q4ezVY!h zNF6jK5ACq$v`=`oJ!5U7qwwqdQ>w0_ln2f;qF%cVDjVp3GEvgrsj#`1ih7?&^1GSD ztyAkKGP-e=26g{+o~sF}lX2wZT6X077N)UoWc*G-8MLv6b9m7Ao;d0*j506=^=&u3 zt?ST-9HE})@Vg59I6cYjyOW#*dJEDAf7(t?u-6BD(J)^>IV$^JPIVP_xOfjJm)X$@ zb;L0<&(l=)Wpa2+1on7Q7k>;Bf53Z0z!du*ctaO+bG)nfHlEoGoBz#($GtQ3`M(L1 zcTvD%`&!S2qM!wKws~KccFR#;$Mf8&&{52>At#^3I1F~+GG79`Q)x?sXQvQ$oUny} zyDQX%X05k3Lk>XuOceDMDom`AIN2{~2G821Oz1;kn*yQMugf?Bdivv}tckWuy^{{^ zF~n7e4U}_1JMlazv;?#J6XSX?&NlL_MpF3IZg`;24`e^C0X_D71_ER5X`VZ2H=ZYn z9@L|wz3MpX*uD(y_d)M0CyBSMfJ?IGd)jY!KCcz{)-E{meGGdYjIWQ!xPGG9=bPH4 zK7-J~bu6rlT!^;duGnNDm${B}&M4k@55fbLN9TZe6CCd{VO)3_WFfq{}%xE zq?Q#?l<@Z>es^kl36RnZ;H!tTsTKHFF49kcCHj}MR2`#Ys(Uw3o29>NPijSx@yh__ z0EVQx)AV=2j~Ly*yafNA)N&Alct9xv!M&+v0<(Sw>VAR4t8_mQUM6s;_|shihtp*| z`~+C>7gl@nA<)`fC3Gyl(M~ zCQgJmK>VuwX^THq?MFV59yBmN;E38~MGHTwRgX`nmXqJ8R_O=7MSbZ0BA6iRuiN79 z*Zn2NFW*rT!h#@C(JSsR8N~~xr@F5zNnrySs>fvAbMcL4X5H15#I%MKU3skX3{kR2RVoBwR-S0FXv{ z;U|~6r5`M!AAt1uJ*h7GNvKFK{h-Y7i}b+03ZB_tW`0mA%VYUz_=_x`_yOM{D2E@# zrh)j1atU9$ztmqYb(d$6GA-hPnDGaHg6!mne5l2{b{dWrn#*Zm>^ zyoLw}2t{}qUV3{S}hBHL?2lP1U*ZHl7 zG2R4Dr0-rzF5nNC{J__Q4I-*aGbN1a^F<_pmU#D}b1;3W)WHpzjMibO{}l%m2NVYs z2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs z2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2NVY)=RmnDl`ij-I=;M2 z{P>MKevud3p#|jg`6ue{4Q_cc?Li9LW3W%@=FW_cuAa>04x^1RWmm341t%ccYtq~I zJ!a$1f9e~aU)49X@S(o-^IGrV{9X7id-;j9$RqLs24N9qKy*4H8{cmbHUV?l&%@^E zw+vf>zoiX9+7b-o2m6Hg!uH?~4I6}~vOM)}L`R61JCW$WR}SxbZtuFeclQo0T;)Z_ zgQOwsp25ZU9C`L%4-(@CcdXhr7H>#CiEnekruXNe*IV(sI}H5}3Ipv;2{uZlt3yc+~Lx3p!!$z_+>3?|v4# zb`A7&Pf%TrC!h8x#tJ_LzU%P@(y^WN)?3rnGGYW)u3tQ`Z*cybjp}dM`Fq#TZ#d?x zg|>OqW^!`NGx>HR^gS=$$P8%=ACLam&Nn!304*o0wSZ^mYgD(PEBp*7WFT%Z}Yb6Nn}P!rJ-@=x!k?`Wqr11=7|; z*4h@$clxkSHrsUlOVpSEdm7`}m9`91fst{bZ)oxVg{W_y_WI*2k9!66LdEel|rE%(FQ_BLaq&W!{Bi{T>R^J=S zS)p*-G`wGq^NLBRuXe+sXK3M2td%|7RDFziEzp6s9jgWxUfS&XME}1KuCCbkrR`7# z+RB-&<6EB7>^f_g@>dNlzSluF;u*USFWlY7UvuDLajn-8VFMdK2_+7W#fEejn4%?dcTod)3gwM-hH8aGH%Bufe&mHp0tB z@?wsAZ9DYMO`zd!tPQ-W5#D!ZUYy&-S-F^XY%luR;Wg><{`Gji=KKergmL)=z;ADy zIAU$^8_hT?fO9Y(Yl7}1aE|YD9AK=O;B2JE`w;cyO2wyA#}~@WaCY~GIQ0r;-Er99 z15fnJ|D)H=J{5WIixXF#89JVKl4JgZ{@wxKb0Ff0&9UgRU6P)8-V%L&(s;hYU%6SFn?U^IyAI! z)1m3|2W;Fa&~n67Wb+|w}Uf3z98(>plV z6BbVis~I$qcR|)J8>QR01H3nEedt7ZXakBJX>*{AeZIkkUo}NMn_)7bcNB;BWZ7aI=|(`g|U%C zwr8_-&&t`+!81PC!=7teU#YhNk8#XyhhLDeo^uwT33{3`{;%+HaQ;5>HVFQW zj9njyRTp{2K9hMa@uvNM!`AQzFpmB@*42N|>UWkft{MzdC%k*ac?8cLV=QqJ>f}ps z&jq}Tz?UC^FTaO7A3|OItc>}45FGn3R;|eFQhyxJeI9dp8y5P5;BS`)=Lp`A2%4HS z51=iK5M;{dnO`kyR~Is@FrIxGbm2tcbpd|Ajvv3q&Iv$Qrz9=Xy*Qu$NA}0i=?~*~ z4(PiQx_vW#e~FVml=n`FlOK1@(EQUAp(khuaNhs(esFh7OIS&vi#&G2HL5rvlQG)~ z;5gJPJsh`UeDGDQx8E73t~62>aQ!iKO+%mkf8g(qMqq`Q_s*bwfA8Sp6-m((;1THD zzg6D2|3+Aep=)sdN-K_W`O#-x5~7^d9S=Y^MuX5M&NAk;wZOHh?~=sHtFX$N8Osg? z;duhq{A%YIIJ+B!7IPlx{e_9qePez@n<-`-JXF zj=uH#UdmEwGJCmhXvfETVK}Sui=^oZ@Og}H0M^6ZDV}$S5=ay82wmlbbJXEtewxq3 zb~Y&J7{JPh!kG3#%%{%_X??8j*ri3=lI>Q$dGYsc*nLP6^+d;M@eeqMw8je^<}`i7 zi?2ogKP8o})#p5NkQv`NzoC<7$0^_30|<*gz6FMP_nC3x-zsI{J=OOG;d9^M-1m~I zCk9@7agJ`tll>3a2r=&b#cKbc+c?v;-aAi z%K3YtaK3LlCgPoK^S)cMzi+3VuhqkIz2@`5{Kgu8pRfToxNxgqU2Il*@?Oew+^&9B zM0+$Qx!x7k8=iRM@cn4ZV%=YhiD$m%C^+vOXk9rm`~Cjg9iHdmnO^>yd$z|v?)qA1 zlgI5DnandlM|;0{SZD7x(7J!Rybs2qJDb)2b$YqveJY*a zCv1K2123-qcJ$GI^n+KBv{)ZJ1?|AD*xIx@IvFP(g?y&pFV7OW=NGguZPk3Rui&@d z!c0Fks{Kb?avF!dUG9J39MLc0qA4NeJdzD?-XHyMJHFFbdyXKErJt(2qI5W6iJ+VAd)9yj`Q`;l1VG zIyM&DnkV+d-V>3&B|+yaI0TvdCwtEy_P>(cXNQbiGxq^xJAnClEVcr$<`}Cj1m26q z`N2Q6b2|`Z?vMTSlR8Pq5>0CpY=ug_m+r(w_WAMXh<1}KGcH%Wk)F@o7!Q5k#zA4? zQYdWp{Vsjq(EPQD)(QJQb`Nm=1szf2V4c*XE$aBm(b1QLD3{JUkkd|!nif0wn>TOX5A}Yih zYq2wbdwPcEk9MQSE-tBef1KK-B&-3Dk2nudj9Nc1&iJWaoy8Rf`e<{+xqWJ>xNO!h z-&$(lzkxfz(6z+f{dLlR)`{PGj3T{ZlP7B3*vs*F6!?3E30nvo+o25zvi9G*ZtmTV za+s{S^Y8hduh%-Yrc0dd4;S9DPOSJh!@q6YrsWs|EJm#xJa^O#`pji=FCuC>qlO>k z&L8%iztIZ%A7tEbu3vt)$~=H|V#U2#e(VKKMlHAOf15?0xm?^I`Uq%^%^pJ7eC2yj zW_rAQx5C;#Y-IRe*Knd8Z5{6jC45g{{#qye;|_<-o&OA5POb2GMmKJ{oXVTZXWwhZ zFBU)FYt3cuz?|zElvlvG>l<)S$3Der_b$fIK0ZEv zfD>I#;mOwpp{o}<;qNybZOK&9X{OC}zjQ^Fo_BTQkpHOg4+0nM(Q#qZ481MUb{X_~ z1K(q!4$~GoZ5aj8+aPq=e2P|1(MUf8BU*S69snL;nLi{n4(GLq8Jrrq=YR z_Il90(+}NTE5Yx2^vz@FmvNsudp!E=S2U~Myp-!EgGq^7ylK-&}-J$}k5Ok@woH~?>$Wy)NqfUciMJH&k7!w;N9rp3EJ zNzrjPJUUbVgT2`lg`A1=vJsE}pH z$)dP3u<@#c%}l5CzxlF={y!gS|Btanp->2XzPtGxYE2Ay)})!b?WOEa)Bhx$|B+IS zHSox7=(WX~x@1_3xUF#hx6|~Wb&@pk!9KSZb^pJ0Vx&81bhkS`t#JRR)AWDU(EQhv zCU?;Piz3(m80~>3jla#prxou1cAEb8CBO!tRUUbEJ!<`r>Rm7^Ey};dfmX2p(Q*2p z5c?lXQj$tR_wSBc|F>`7-gLWXW|}+Ud(F`N)4i<0H8V}8=|AfvNwW8y&6}SPweDkm zaW+XbcMdM)F1|a_O#QA`c1P*|;KHLRoXbp-{9*5pZ|OyM_D8_hZcP%+orDYYFVSy* zb(H?2E|MgBv@a38?xStN`a+WE?j&5e-c+v-Zf1N(>3@RO{*1eCzV5&WZ7aCH*hxB< zBwCYh1H6;;KPk4q`7Q_UzW+I>KKsrG`&LQPiO#|$*=qnDqyGt+`}3|V`s3St>u>{W z1cjW|*I7E3ENYW{4yaS~KPh&9u@?WMICY<8;q3q8lcf=zg-w#*0PYn1XB{+K>Tu6* zJZ{~`JmbgBrdyQ{`}l(kJDa)A=Tvq_=>OpSea*J{fi?J(;?{kmyfc*kHJ<~0gNt8q z(!pki_YThWgvFh{4GaCvq{XT1o}sxM2j}lfn2tQwH$1<}9qanB#`~Yiy6Z4F ze?xuDqe&so_cgF*AEeztdvle#=NnCZh6NS-0BaJa{~UYVacH{yxS%v9_5JUj;a$B! zX=*ed^!J7!Ivg|)<{4v+;;iz=zyaJp>m8c^ZSvZH#kE1H$GYFf#L+Xja9gwX{IJ*e z@t}GfBp-BtI%wUgeDQN2iR%E+-`%i{ciNGg%lmod2-Efr&U0TAd6V_-ceCdGg>3df zoby=-qQ}AVXqVu8-!iXtrqU&Y1HFTbS0qhm419j=E5Z>2V+%0EvCqt6BVSm2!_>JNwim>W+caW9Zkf>>XYhz}n!kJiEXj)-KViWz(w}wH#iVtzaKCu{!QF-)Q0;fAss2+wn8T7B_|` zg$hzLInX;iKh|y8bYm-(i1*^kDc`(=(X8o^WLO)dv|&z^guY6F6`0pwlKnw^~7{B-E2!J@=K+*w^}br?mmN%XfWG`r!k@ zsA;D;zl|Qd?gjD;o=qNW{~g#*J3e91t6;Pn2WUHF;me)V1}vTy)_dLD+k?*9&iUHt_oKikQ_L2^UwEd&+~$Fr;|2GXQM9PzkqJe z`_)hM@%Px3T^JiV#4jvA>8Hj=UTasb3R4`g;{fb&{3GYctv(hY4Wz}6#-L%-qoe&P#D_@JB7z4glwsXD_@Y?3yzA z=|482pEG&wGgiC+uh0lgx4f|9IcP0U`4tEJaG-bNBL`#tyfyLdz~I6~y=!+I?1wh5 zX*uSHEv`Si>6*v5|FxEhegB6&=JmZci2lE2-|XQ%O5lGA_C+`1Hx5315B~4sJB#01 zjGNxV`w~`uCdp5GYQX!A7&D%Qwpc?wUk<##5B#U_+l23r)4pqxaCXAwm`&wp@ZC$a z1BqED0%u5C=kNUgQO`bu46Wx1;>o=v}?EQ)AgRvTW#RWbb*g&ynS{ zf=eBS&RkiKf41>xR}sFh*>4rGZO1;(SD?>3ZSkxlFQ~I3`-E1gFM(sayw8zm|Mej3 zAHE0u@KsUG5eS>MO;--@dTt73#=)PNeD-A2p|3qYFLjQ2#OEabkoUZoJVDYFMn{j7 zc4&8ig^XAR_(dM?_yq?qNGUp_j&%_OF z-={p&6RgeMHJQsD79}4FW7-R$|Fcojf$`$mQPQAbFLA)I-xaC;3t7QkJlr36T~u=8 z*Y4O72jclEjAsr7?e*99gXz7w{%*k-x*@wRZXCh*<+`Zq&`a;cQb;U`1Ptir{+?M+Bm2f0%PCP15sR%hgGbQ+>!YAS!K~!~ z#-+DLEt{y%Q=$LoMpaHNRmll~J+z~vqT3EG_u#YH?EYbLfNS$1t@V3u_fXzD!lZ$) zCUJ*#GMjx-*l=4J9{LNMF^jfug8py7IAbP~a&nmmlgxj#d*?(#w@aPzOi`FTiP~O+ z9e<$z(_zv;Sm^6+M_HJMWykIAri5KyE5L*PN4EF3RIhl`8~Zy;{_xrx$jNO<=CdJl zp8LH}coeeDW3=7rQ1EhTv#jvZXUuWGG_1Ov%4QF?mI42FI^9;p)L7f%2bl|(w{|*}%iIwrEu4=>Qg?Q^o5DDE3X2Y|i#X8m80ae^{$s6j zHz6#Z5DvztCpqvXG&yA-jXva8k(7gRMVR~u6Bf@tM?$knN9^HikMb@~ShPSl9-Kku^eAT+&~a8ZvXjDsVx_oM`WA;#KgqwNdjZDExNl|HYmD zycYVsFmE!aeJUvLt@#4<|6b(&uV`1UK|Oy9F7~*E?SmffEzow>*x2TOO8T7!w6T%d zg1h!T{a1AR8y4_%^^M4igSA8_0E&f}fuz5ul5()EwCpWptQW$a2(VGG1 zhA?1RVPIZyZNgYug-*Mi|Br6})~?UYlp!;rpZ||(u3K3jK)?6hDq%>V2RKHuFK58W!O&#Sou)m!_nknMub{-SNld(; z9q7pB8L%yKP=%-jezeQ|f41YX$P3uYmm?1Qf8*oh2Y`l% z?xSQLOu9W9DJPJbiz2Bg|HSXbTE5-T)3Odp`1g0X2YNZ{g7sUC(2w-l;3P`*zSZu z19blmwkc8K8zyYpiVcNsFLG?~1-xF2uFO_>Pb!t7jZTi&8ytgPj&aW`!;}fXuz1d$ zV@S}!d$Yd#QOLuc(3_LOJge-R27L|ubkO1D?Xnsgk?#@Ea4z3K^ILW^(}VYsy~Smg z1?s${6XB7Z0bqI1g}f}Mm0aU3jqibm`u2g1_y3Y+yUMT&e!fQ?67T=<-Z|UBq|{YD zpMT-e3^`~mIWS6lTSAn4leNLzrm+zA$VRC${N4M*sTN&kgto; z$9#+Te`yaf3jW0f)0m56-N^r#&37K``$@XczJAnqejhlpPmyIQS8+gbz@7tW*FGDi z9#+AI&Pyz~K2jCuL$^KU!@JV5EafT=C=S?j0AsVrZT?sE)$+bG#!%7R12o!_X0M6a zb6kZg4kUvEoJUr4GwlA?3J>d5R;n?V`eu~nbDm}`Q~4DK6bEz;K=&eBgSYB6bg|}c zF5X^vn>Ah7{-oc#P_n2v&zW%pjKx1_rITkb-SUJHXV>mn@lbxnfllK9 z_TryOdA1!BTOjREX9ufjT&19<0WRTzD%R~YE~9na)r4Il zPgLI+_iU(#i;7nqXjcyK9ClQ?ULOSX_xl4(w*#jd{gm?17116`LC0X zMi!pac7A8`<|lZ;5jd=i3=h*f0NSJbxbNr%CzVEVpdC0cF){H}tg}BDhkjL+fxFON z`Nw-!Re4Q18+JJFae2i{rBfVebq+v(E)83UyuhQ4NI$$2_bVgR`zoBrG9U4Sn@X!V z(25+u`I=8x$$ebQ;fP~?ze9Y%12`i5RW|)v@K|FZsuDs zLGe}j6bF*V0r9p`Ui(dxbia&t=h#Mg66-nu+W@>9m~?wujqp?D?Hmr!?j6tEG)?#O znZSL&tfjcu0mv(~IhV=um8&?=svJPy8`;}>UfL9#<$H4&yaOFuyp$Ja8nywnTNwua z3Wnl9oE#ALR`c2=Vd{f#Sh&j@7R@}Xj5c70ZyXw2FH~(nST(5N#Ki&HPRH5a=ITCd z)=y@$FA9_9>9Nd*;?{pr9&Cq#r%wu#76nUjAPNrfd@W=)wm0Qn^l>O``uIK;)-rzP zQl@#&A?)v4@Rs+VqL4W~TDl_a=!) zXshp>9LxS|RP-vi?au+)>tO$yB)K-@g7XH)C8-vGTLRljZK{yTJR?bbQn)0W1AGGq z>wA0)J2tx)X1(_E4g0~LOClG!7U2c|SkvUR+38&Nlf0LaM4l;}lEDF%_w$@- ztn%q`ov(sZE|WEO*9%(5v~Q;IJMN|)&9$8tm%BnM z^4^Lw`v0jGuLsd)vk$?Ui!ZD9iL5o83_su4KplMyvi9p%mpANFYw2`)pJZ^hF)WO8 zRQ|?2@%HaQa4W#(%i9{`jLIDb2WE1a*Ks{BVfRmh$dt^}R(Ds}mbH3&?2Ny*0Qx3* z4tv|SP0PcOX9dHZ1MC-Pa@woW*I$UfW$ zOkRogj!&yQ$nHAqH+Jjzmgj)CC!;P4;P1n+)v1l1Mp8Cy2L^nf*Do*QP7nRRE)t%_ zRX0J)LEog04YD7Yn$n&XH{V;iJl?Izke_9&$n`Yx#5mFY_gru zgp31o+HcXupO5+KduU6-jc1(}C;E5Hp`hz)Aur8(Uqa5mV{>^%o^}M=wZ3^)xt-hY zMrcFb&!IiQc@%9e?MD~#Tb|dha$u%Qx0|WG5xnDF@yjtjyuT4XwrXC~|CwgKwBtA0 z@>jNM{WJw{%x&2x;CB|r3Gbu*366zZQ8s9k3U_H=A#CGA*D)8m7(BhZDSQn&hl?N| z`0y9(Bfp^)>(npKxO4x}POtA!=-1nYYau%qVZ5*jbH*?7o;u}ddQ5wH+-DN_{sZ5Z z)Ysv&?=tK_524+f;Ft#O{ym}49fAHe8Lvy@t}DMf8)kaQ>Ufj+9ot+k33PHT33%Kn z#t`Ulp@(_+FMv+2M_k(T4`Wk|%g;sqW!dHc&Xs_9E&O-EpCmhvtx>abaqwNxR?yclaV(5y4oyOzsBlQg{zs>^ZMn=HQ)46l9;QyU z5-f~4PgA<5bgu(tU>EO+bozPFjy?C&Y5km{*F4ABs_u-#rW$N&bzr-1mIL+OQSE|u z`(!@znpPU0M~N%$#S7+Z|8PV^!Mx4ds5CV3D*7YP&&}gI?#c%VLXeoQ(xt| zM`y`bF_;VSUWZ!yQaor28Nivxze1kBMB8FYyX$iR>wbxS??bgqs$KRf1Nu2??NhC8 zd$+#EhL^&)=hYVNzE?R>>Ds4FfzCgM@#{9)9of8j^M2uJMrS}$^ZxeHg-&YMFvh== z=k@p=SZ8Qz_*A0Z{B$SDjH0#W+qzx2$2i9O%Y3goe2wTFh!YbNPemI#r*xv_b)xO( zZrHQ`8TY4K)uu`kTNSMNsXP8^y+5&ShtPT4MdMqvNz|WK#)hC>-!e0-Ca1HA2~sd>AmS@9I3Fr@>8YzYTO^H4CFGukRgD{V&6<(jqL=Vi9TZNs%yzU3#@jm|(ng7?0*yf`XZ zRdB=Q0M1LjL+O5a{XY7;KNlvlhr87h*R`g{v%P}%xy;AG|F1!RM$k5V7yaaQ`28sn zb;7l{oc0Tx1N|P)gMhXT?8|}Y2d750|G;|DE8OT*amnTY-?K*D?o5QNwZ)i0yA|in z4+^W+c|Vn72-Z^}ryb2PG~b(m%QoXn(8sf=llZ;>ZHb2O34CwG?+SeX5b3@T{k;rv zlY0A-`3BszJQpv{cIPr5p2}z6i#uL#!#L&NP(~kZJgRl~uxc>5a6})Z-fatQE&@IK ziPD?o%Dcj>ngi$`u0lT%n*4V%Ea?7BHFuRyaUi)IfUa)tWPJ^fcI{ELqo*dfrWIz2 z1JxYB-0ySY$$tlrA99%or^d3!RC8DP6bF*a0j%4e4Bg+W^uNJ&E0_7XvJstJIaip~ zascnizgy{jgL=>PlALyaEq7Ij;y`jaAognWnLSGXgX=%`(Ec)$&%QaiwX85x9H`~M z)cDA2mEH%}dxLjZsI%&|vZX?j&4I~m_C-qf8`FKPnS3nSwXCpH9I)pA&VQY&^go#X zV^9C5%C?lfT&d93-~jrrlG1;F`j7qmzk+Y4v_=gpEENZwIiPgkukLH-sWZIJ@}y!~ ziv#G#+VHNaKb`i{2BOcuesXm5C9PGv3R}eicMd?Vuk|AT9h~L{%=3?P=c0;L9B54r zp#T412kW-e-6o!Yyj-2#ZB3b0So`AuWc?P^2FN*sHp2Vu{y3;oD-N_S2d1X9XJL$c zZwI#lcf*$S=aucb)|Fv}d1D+XWHQgdT;M#|etEQ0+5xP6{BUM$pW8!T6&2BPV0u)0J;nv!LOXDG zE4Br=lXDyJ9)eB0*G0=I1zmBVy*W_GW)FlN$m5|?gJ=`3NvvJaoc05Z?N3#2#k99P zD(afg0oV+9xp)%{_Xy%_6QJL7dU=@)+M&~U?^d19X}&xt3={{rpFlg&e7}k7>KK1t zfI0VfF!%ly^!Be_<`EcUJc2cd-y?ky?l^vHq3q{nd6sZ^KoiKMC2vg-If4C~j!iu{%yBiSs2 zRtS$sno2DqYb-Z35NW^%7IBS3&{jXBTY4}AvQ7-9`_`gDc{+)({cv*kC z4*_MCjI@j&D3^u!;1hrSj5WyD%jk{g-ffZRPO znHU-6lVtkzG|Na#O?(<*xM3S@f3ml%mZ+j#I<%0X>>TGX7*Vnc8RB>vYA1P~8C%VKrHWdg<* zbwM_Q9OD6of3@)?z4Di+43hcxunD7pvoi9835E09-FVZOFI80pKe5Gr#VYvAP~e_y}cr z@GkwC^COh}png{V(s~BN5%FkWmi{d9WS=hCMv@`|z%c$#xMW}VQf{4Iy??F554gma zkl!3#Wv^8Ep7SRH4#8;803y2=ykNo&@ zizoZLk!2dtOyhpTy1J&_qnV<{t7U^cnqKscw-rY}XswcWIeE!%zqUI43yJ$@f8N3a#obA z>;UOezhEic)`BaDB`!QAB~EBldONC@-oNi8qh#ET|^=kU0Kv?%6e?JMW#G@$0>^3R`xmYXsU*<1~00169 zIMPw*Ku0AW=@4F$)a*tyQ!yT2f~NyQbh_!&|BQ+jo|J(Q;mSW#ydma*W>SXQctA15 z>wFgC#m^N&8Nh(7QGiqYTDN$xNeZkfKT*-)BVN|1C7$vks!gv1DaZmE^DieuSq%o0 zg%>O&HkNo0fxjB~mHc|9ns`~h0n`$|gnw;3lX{B>YH7FpdOU^AjK5L8qLfOLSzEt= zjQTAaI&X@nPWOwFyY1tVb3n>pNkGw=ihRICC|2u-@1-z8m$ferKbGsy1GOMFpis$Fiu zjb$Fi6YkaA;eXamWArykM42fgWw-knu|x z>e$IjtHf6T7}*V;8nhy?Ux!{Y;thygHQXNo5ZvhYfso8t}0x?>dQ7!P3l ztCUxjc}ZrS;w8hK;$?*`AzWQRzbM^IP<8x(5nrkfmcEKWImB0L!y&%g052La3Do4u z;vM2E#ft{il+XCA6z>q8U`n63ylWp6c92>m(9Y6)Ooe zvA0`>nOzfm`_19<+r+*B8D@4(+F=f^e3vTtOMR-WGimdrWm|0#V5>TPIT- zKbPVMEHS#b5^v$FOk9b#M}G;vJ$g&={SsoeTY+C4T=Aui1P;Yl_~TG~Mg00J5UW3o n`g5QjY4!5Et8(EvY>xO!HHxNWj#TnkO_()hyrqm5Ji~=QK@w%fcS^{O-Gq_%}0pb%n&!$~bb zIZl6tYtx=+^VjRz znd<;!@;X_@SWa7wJh#)2d>1BfCniSuzl2{0A6^EW;nBhDE>?bCrfL5+I-q?4ao5OnV}qG*%Ysn;?-}mp z2b}ML%s(dcO8u?QThU+YAn?5szXOJcv+uUs7~*e|d6DKZ&~SnkcUg{`3%!(A6J7J|o~KbW;1p$YAy})Z<3D?6qj>ZOeKlk6N~9@=X4g_Dmna zFl#=*{g;$em7B{v0$W;QO>6yLx^Q|gS$Emp-i6b9%CyvF+JS(b%iIP#JjGV#3i!Wj z(5R({^A~=4?}d|ZwZpXfdwZs)m+YdyS(X9#N1E`Tr{3@f|K(Nmn08Re_5Z(RJyYK% zyrcGAs~`BKX;FVZ{>aPvJ%8P()BVnpY1ZWjN%)1riur)Gk?_U``@bZ^M+Y-Ufu5HT zek*>fK*!G@msbG43;PaPtKWa`%ldV{-Jl=*)oFe8#q0G;^6Lfs!mhd0=`^)+tez!qC`TH|^>Asf*{3jlLSwF0|8h#<4=?3gggg-W@_1fXt z{eJi(c#U?6uwmyH+F?2QP5Dk)WorALVw$thM!lcc4i|Xmv}?WSyew~@MN=OJpI)-T z1>YxM>|HqZ5w;uS`RoC*o}v!QymUd!zSK$fA?P0&S@9F2uaOIF)=*|UL6hKF=5&T5-Dxt6bsob;a@r|l z0|WoXZ|fg$JILTk<|*Jm$?uVYf%nTag-qtn2!GN@ll>0EA@{GDd7dB5WZogdh#&HT z#}~`IbjJs?eGErAPb2Pb<2$>LHBBLx+uGV*Fpjo0n$2z_(+v#`y`FWrz1{~8uQqVe zt_7Ya3;1Y%*iOs5L0wVb2%3n0cAt@vm2U-arwkf0S2a@xStEL$>s(pM<2@Cz5w2lkk?nJyU^jc+xsn6*aq&RPez$k|Ij`HZn>Sa=kw}s zrhWHXMwIEmzto&f=s0a;2Wn0#jo-=m-8eHRbqap-*~i+LN^P|m{i$7-MVR;zHv3`Q;I{xj*$+!^0Z#(|3WfeRvc8i) z)`!ia*}GtRE4F2#J;S#fJm@0EnKm6Y{fHZ6f8H&xQ+V&LQ@f#Ed$OkeTr~OAvW1hM zvFAbk^nhFWG8}e#b78RmUG}_oKicT;*OqIHvCHA;*IM(Tefto~k@Y9XOpoICCt0?1 z!HczM7nV|1tUJ-i-2?U05#y+TSr4P{^VxD4=D6O~kF-`C%sRYWPplg5?_xU%JbuV> zB@Rw5bmkbsLcjAtdwCqEvmeR+DSzxs?)N#)H%$J+T0Xx~zx}ZcKc}51wf{``SYN-(}h7p0rM1m0K_7HGcNP8U4w}Ht8oFH6woO*XjDv`>hpm%p=R73tD%t zl%d^WjWhd-zXreW2S3J;?s%k6^T$5IvIR5mV)>_`ygP2)s6SkKSzq~!b)uYKp0-Z( zZ`p_DpDh2YyUtYgkJ$gHl%K&!&imi8PtCm7sr;WEJEOnw{3cK3vptgKgU%Pcm!3*l z$30&8zt3X*EwT)w-&bj`ppRmXM8m-3H9pY)$#U7KiyD0u8Q0r0 zxtwE_8azbUq^K**al|8#^SQ96dnEmK*eh17cx6R4+U*?6$TY}zs>p{i9OtiOxOCmx zAC4hF^91uk&u@Ud#W)A>YU-r%oc1Y+o0u36e}%eAhD(?8 zZnAvv;VJy?lYT3IWqQsVfIiGYYKKalWgO`PJ|n>I$JT!QSbpXE0COeM|3Z$}BrLkX zPmG%c-ucWQMA}N7{!?q(u)m2X%70GM3%brW=NQUuAL2h6cDWKBU=LTy=GfZkQ&eEIV544Jv==8hS6O17^|Ha@U^?0#(IVP%6Hb3Z{iF+e+Kq>!T3OC zZ`PS^IB-k^oDV_TD;;5B-h%va%1_&N%wy$S_4h+4>qgk$U*U3%p{&F9Z|Xef{G7^_ zVSrQ9=d{D|>jxjWE^r*@Q)D?#F4FDq7q(M+z~TA{=Vng#I&LuMM#Mae6HFNfn5d`w z_mcf@>sX1pW7lUVI-SA*_kLN1n~O9z$-2S`Ml)fo*HITZ_OHRu{762tYcue@=0)B7 z8qSpnzD8IFd&MWNbQ}|$sBi2}^-auAb(Jt7k(!%MEtq>frVXygq{Q`lTKrDbi*rxW zi*x4drB`4M;?-MkzVr55zk2%#{^oCvpRSkY;di30Uw1s#jf;I7=N9Lc<`uDAgkLv6 zVNB7b#Te-Ar)NiYddeptGLYir`g&O;pZm|4@FIzZuAjT#4 z){IHac@oB1*Ug;Z18-wpqE0(oQ-7mx?X?|pDwAUv%$3~fhPOSOX_?0f7V7c~Yw+_* z3+Az%VAc!c_#SgokJxc+q2*Yy7K6D5;At73*zE*sTHITa)2@U2yd^*Te)q|8sLSrS8S{}xePeBT zUQEu99l96RU!_f0){!M#y4HCjOZk7ZrZL8tjptP@`GJ4-6bS?N__V~s$^Gu$)B0a- z+-NjlmjAEB{36%InFl=hmxO0`(cihax@<8QP&4iWk5}5$x%o$j1{SiMy+5_?{Z^d0 zCeOLQpL}no%-f+0jJZJ0jWUkE+ix-G`N@%$bo(q``%XLV^s}F2#{+tEmQz~j%J=PQ zo&0jl$vJW>{>u+oD>!`dfH9}aIP#x5BYDlR+_?r#CtC3DU9?v4#0f9hvTgr{cDGYH zh9T`&@q0$}?J>84_HK8sQE)8Au`AQaHTGpa(_fMJUw`!moqG-V_w*B+#2mEPV*q?d zR;(}2OLK0T`w#eM#hu)0NV8%uDx9(qH{y3CesYv~h!5Kn@VY>L4-aSl zQ|zI#;*b89eSeUh4(@X;3=HhZeZclO>E{{;_uX(_Nru^7?ECs0_$swa(!hgwz!i_chWl++5~NdpTCW*lSYWe^k5ohH1q*4)z+GaMxj7`Wsdl_U|PN z^v$_eiFK-v@yBoU^{eKAi96t}s>W6OJ?i`NULX-Sm^si6U;1ei=qng|H%*yA*B*vG zzDlMM@)i3!tNPg3OSJNRG7Vj?6s3UhWH&anfBiv~nwycMyL2)~lzOp8Ha2@yGse z*lSHV_OZw^lHxkeg>xN=Yoj&z=QC@Pf;aKt{vZb&Mf+vMYk7zGpCo|>*1NP*JAMee zO5$hVBO}wYt&(Y4;Trn^w0}x`s$AQiRn!?LRmICbh2<I~*|+h6N02mV zlaGX6*PG|NSMJ|;gOB!T&>er-7tpTdnl=03Zg9MYvmXn&pHm-iQSL2rPNp7gv?bR! z#9!*9(GOjP_Us3oPj9rI%yPxLF8ZV3^?Bgco!-jQM%{GAP7B9g-e5Y|m9Q;wPViB{ z;J$S3eY_RCy&vCVpS~~H7>j_P2iS3R^SkyPCBNfy*)RIJ7kOG~ajSoP@vI3N!Rseb z*7?>mBUb$CeN%tq!n3*@vBv*X=$LC?upZuwW1SR8KP9h?@z&45Kb$4TJQDWz07vdS z68oPK#{F=x0i3tvIoy2aMDG9bs~=8uuwM@Nf3D_|v*idg<$zeS8saY5T2^Qn>T2?!@w~<`3i)-E zY`AFwSKPXRa`a7eM7cVTh?PL^1YK^j0zMPq1nC5k6Qp}0g+K72L>GLa%QN{!xY%_C zeu(2?_`e&F;RsH`!GSU12VASUDLwe0md;6cyg#$_!ysD=bdKP{{JeT z%k)~NpLATO16>a^qT3$NJ&JoToZhKXS&)0(vjBd0Lpy0yo}YN^NBJJs_BYyT^Xf<5 zbyy1(_d>Xp>oy1Ohi+?H@Zc|Fx!il)%JUkI_1m;pIvb_~ta{}FXXiQQy7upC3z!Gi zCU{P&UYYgcV8i=0q{*~*ujvT0UYQv4USyS{?fbW!7sb6=hoKF)0Q5CH{~_{YK2e_k z;C@Z6ts(Deh{Ih#7@y~~-(j3v9333^g2Zh}k3Ju^JK>csujvSruw^-RmwOL@7w1-t zxqO?;c2VXLX$@Y~nG<)0;{bo1^^DW9h0~{crHScyr<>dpX?G9BzQZb5fGufoa?L&G zj`TxbKeXo&YYNTmlLn8W6IV*OOSaQrB`^10kWM&!vuZn?}g-140SF8Lk#3i=O{aF;Hg+ObMrri~wen6dZx+j~~a^Qy~? zeSti)F84J`SMub{bY;(=;6cy2nzO&Me7anZa>JkcVNGlO=KfOHv+uchYdy(+ixd6N zJ+n!WW2c4tB1!K>*~(s9ncpoB+Ldox@pk%VU(ksMs3U9kG+6Vq-9z37o$`t>-s_0= zQPg?jUOm)(qsARggoF0_WQo7keg0X4{6z-Y;_URzdftf!)cw~x zrMHH0P7HZ(Mc?=i@Jr)ej6B0_P2={BHtIr2&k@U}r3%-$S6oz)_gKGS12u`vfEu3J z?qhSp#eT+F@&fcf?UX*OFu(Lm`*)+A;eqJ4+*>ATmu^_JI>GU)|C0V2uGvuC&Y|9H zO8*D6#gbmT>x6e$VQ%%G8!dt8$JwrW__8koyRF_kDEmUF+v??QHZBS6AN!^jT}p zW|4mMclNjDi~AenwWDDR&CGvokW1o=2?Zx*#=8` z!m*zBka>Y$7H0vz>2;r_%x85S&l0da>Y(kMjlctQv)24>-$?r_H~JZF-;2cifa;|O ztyS$5%LRTskHfh$&N&xguQ=O`GZ?s+ZZ+-=cf-vZ?pm&x$FAL%3fT=?^SgZmx8-j1 zIN6Zfe*7{F%exG+OyE+!pRSzF;0O1cJKBuQD_!7uWKCYefb7|?uZhDLNan5Q^6qNn zb<}6zeU?*R;Cx$6dBBBupgyVUmoY!!9O0CnVSG!&sSJA zK+m_3znTZwpUn19rm?!_xs9qjtf(uz3(cBNe)C)scyoyjN8UkxyiC)`g7w}H5y5F%neoZ~N?Gnd2!dbqw|IFvaouLkG0Q2ySTf5Ndgr|9Ls>^-% z+Dp>L-E06l{cxD)^qp{wGR)}Th_=(TY3sMvFU#iMTX!At))q)uNpV5X`K~lUx2@w} z;*2`-Y}fQ?4^f|E_hftg)ulzyUi;RU7{|EO1A;GTE2O<7nG5LCoaZV>QNQC32%OJ# zqCuSD7ip|^QuU2<-u?gWl(*Gk;;f0oJxz9Au>V{SSG0*I+Vj--aaVDYbOGa=Z?i93 zgQrcF><8APUB;n3aGVdTg3rEVF7rj1rxh;SH^}R%D!gnom*dbU^@^kQZJly{SGefU zV;mgk95K&?p>6YXzUMAK?YPr@VU73zyIM<}Scfz-R(F#T*Q6oqv!HY04X#G`$av(r z$q(%;-{npMhdxAA-Dr=$1-PEWJ2&lZts9LpT%5VV_yGJo33mN^v}Xa&j%xiuWP$R| zko*hNMg7D5#XPrKbokwXayd@J+;m;O+WB1L2j3p$og$K-eqG>!z0J+x(31Q-*NeL+ zn(3>#t_3BSAJC;q{7&{Vj&7t2kemH3n;QLUiEJJC&p9L156ivI)iN=9KIX=&4ja!K;0btth0k|3f_3#|7^Ag zpz~7X!&^z2uW~;^Gc@_h3)*Y$C~QWysEb};J?aOCR;4Aso1xVq@0z&I1ckvf$g0l0 zDn9HO{ZgJ02m_}$VSxTWILOd*n+Mw|p$+nU-YQPoyp|=_2gUsn9?!q={v+J)(C+iY zPW)kCLY!5R?*y3VAfY42v0mmr0;hZ`OmRSQp#3<2wMxgo30`!7S7Oq9{H4g73A)7f z0(j*X7dY_dg1P!eTuzV{7dePGWB4x2{Nk44eVF;Bt;HKL^Cr5|seO5U0xnUYJ2Ssz zx)a?c;pYVn46k;@12rO_5ne((Ht)_a0uAAI^CE}T+tWY~c;l59;9Y93AQA&^>Xc`+D&{uZ}is0)rHG0h%j6*wwUS$?)>%P zhd*A1!N0kr4S#$!n19`Ri4I^D>7tA@9D*j_`Dfll0aqXZw_Aj#&6^-51U@3j@xN^W zk1%mbMCG1{SJz#>W@`1PIG{M7IG{M7IG{M7IG{M7IG{M7I8em_uDf$@tT^YA({|>Z zzTdU@DuA=Zvvg4$71BpjdOq-JqF4%m-hOxl!l) z;pI9J&mH8nzhmzCD!4p@^IfcOeLBef$VuQqvp9%%QWs5~fV*FQkGtHS_j=Y1=L4U` z`M}>H{RxE8EFNC-bL|y!z5sHo+Q;9_Izlv6Pb|TIxuJS7H zM(GG^>B6;XVJtShYq@HSe$8HG<91ec?OL~op2k=2vhGN`(4U5 z8i(8+%0PWH9QMACFnQDn*3v~Y@5b5OVo`U@6M>4MfbcvC|3Qzxtw}5#2ttJ_yviPy;*+%Cr@X9oOp#c;G;=w2dp}VHsP;M z_eeO=6J;3qd}7jMFYzI6L1}N@dsLNn-zsmXZ}bhH<{7S_G&Y+LcP{NNd7V^uY0p&P zvwPw$Y=`lglPp>pW`7&FS-E=2zfaHFt^Lg5wM;)S&)BmQZhi;{m|&Q2f4+Hs!~l^;qD3 z1j6yAaqT@VvJBL%ziydKeU1@;>#ro3m^ljH=i>Jp`0otTmq8xawsYB{>Al-U_GLVKroU{Jg`aQUBLEwI-fuks-v7(3 z9PTft%>t(DTLgZ~3o-pBw|Dv$EYM$pdjIcbzWTI9FZoV5d`3U%D1X12HxO)iVGZdY z#NACEjlv6-*F?aOJMGt z%3bkpWfS*CvZxmizSX@4&xx*k(Z-uT*k;pAJY=&x?IZEt1I57@OcMFNILT%?;>=XE3vy?JS>H6iqo}^_`Km`Sb{KTyg`c|r+AB6xy;XG4K>haFN&bBErBOfd z@kd^+svzpwKjn`);J0q{c@0M!@J+wA#ycmx!ndbGAL3NMX(bP2FzJgro5++cVmx^ zYHtftmdwL_mTvN*T!Z`_^402hE+zaR<9}JcH~CNd8jnjJ*j?DqXni@IM>KKErY_Wmv7m^wY^e4cDLQ+Pg?5H>NSq_r62jj9`E!D+eHz7Qaud$}~WEkbP0!GyJf1L8?zLhX|2>E}} zkNlZ7jB;B6BkKO2ll-HY|6$t|TK_Mf-NUc^gWOZx3VGYhjC%a%B>$-9f5I65kw4-- zJs>vH~M}w4;j6B@q@BPefQsvLH z#6iynSmhx0Z1}W&=*q2D8144WeRTC~-Wr$S^`EBp0#ati{z>os{&F4R@g2XU>IKg2 zFSp7#%x|aq-_N*DrfoF-v%it-m)D%bEz8rd{SRti^IKk$((k=Me^aRMb)zpXbto43 z<9+ZYyXbEY%F{w&#eB@YJ{(m3opA35*UzQQqH=@G|9aW~$M!3z?3oY!H$QI?mFIkr+pSre>8|(TbT#_zTmc>%UtBw_96Qh zc;2cRJ|{UZ#sXull&7DviEB_^)_FI&+`iZ9hV~NohXgIN9rEq9VC^=3=7apmE7Y`@ zvub9(4{Nsf)XKO1*uN9sOL5Xko~;g5RuS|o>#g-+Ki(Ejbjh$F=fBL$aW*# zo1D_&4Z=mfWY4)kmvd82@H;C^-2ZfbVsrp^QPo@H;XEE>TRvy$^lmY1Kj%;W+8LcC zQ-9Pf*-3v*67B$l-JiW=JN;E|*z;@~*6ml@WV^zep8NFC?^|n2Q>VY5ndZByRv5GO zJL&g7;@*=}YUGMJI_tiiv3&ntHEHUs^>Cl{Y?ZeY-Fq)yw{6sOgw<`$WRYb$;bIMg zoZRnl7|a|f(;}VbNB*22lzEkFaDe9pvCnQ9+JwJU%c`>G*K~N#)UDi?%(5Es9qV=< zhD=@0Ye43gNEleFdC-UaL)+6Y%eT6PTyAS|A35(N$1jR@Mw|~Z;d8#a-n~y&+`NAC zu6EABVeicg&B~lG@Ehbhl-Ke(#)CYk>d6%He}?dQFHgO6HF3DV-zy$|(wXCvH7IXW zb(z`*tS4SJlFc@(@9LGueh|+jqo4agHGJM9m3a@+(0xjtXFjd17#gv}$`9_p%dfuBw8wmHX~HiIe|F40x1Ahx$V~E9ZnK#|*rWgKv7^Zjlcz+jaWm{PCM%+^fMfPI()Ji9S5; zGO*Iq=$o?V{9U7TG9KygX@rO0c(grdO8DMf?QlP3P+x)fo!($W=g>DU3}v?SQ$`Zf zj%6}Cc&4#z*V-BW&h%+RVbY5A!jJjJIj6nIK7v;~Aba0;NuaK{$}9XNr2{Xgee!b^ z>O4PpGX|=E-FO7s4|dl}E`#~5#((C#mtUTUc@OkGcWGP#+S}pGr^j;H?}XhhdMVqU z2Dbc9Kk|p37xDA{))VNoml@Q*diB}!P}j0e7yS0+V!Yy|T=eO7_XW2K7wr@;Wmx7- z??YR9LR9CGz2M@dk7MNr+eO&Fk9%GE_ws^=mo)6pTXFIG?d5I}=+|<;a4qRQ?VWVt z`90JHuXTtQetAjbyFT@K-&&aK{DInji+y^zUo1%5FXhj=(G$$q8oAFqgW>y?y1oFX&Hu#vk{<1;x!NAN2Kh&t+R|A9+00&4v8RcOq^5 zd4+1Y7G8PNb<0-%sK|T|pUs*0wnZv|Q%*;`;7qu+9y6uQcKLp36l z3NQTdlg4~&z^e}45~R*F_cX8bLMQ6bv>*IsTEJf62|o78NAac|-@x!vesvmc2s*&I zcT2lh|C{f=OPa%S`Bsb2#d>(;GAo;zV=>OL)~@|jXvZCLO`cc9`xZw7o@-kG-TEu` zTKsode6zwqxtCXHsV_rIy5qq2gDw8XdVu@Dt>uRK%`qXzNWker>I1&dLi=<`VJQ27 zFv{Va6YB!V0q_23qi`?7?TXGHk*Rkrj`t1knO_^7@t>r+_`#B(UO=8Y>@%+f2X-D#zU1fgdwvGd^ zMf54_+pAMN@|EWv*?;gmM&{fR=1AtYj&fCUko)+AF5n*T6)RR$?IC49)k#kpgoU~v zKuOd)2MXEjmay4p8py|aJm>J9I>xS__76+jP{{TFKkrzplwokd?6Y6zUtUH#;kK6+ zb-=v=U_Rt!EjA3g6%2y|7_SB1*MN50@%$$t`#lF#)|H<FE9J4)8teGT#IA`*_aR z4O4Me(B~eq3NG5Esz2z)*>J*nR>f~Wy&@m+UiOuZFu`0B-emPZo-p!g*+%eHd@v56 zufGH8?iYf~CZl`Z*Uq`|Cd&DhpY_>b+?ZXtxL(+-?6ICw_kIW~N6mHlU}Fo`9e8hx z`@5_#)psxs_+FoLs=KjnPnr6`05bIb)`zvu0N#&rZOKoWDs43fAcJ38c^dRP=$ST@ z_w%4-8u_#hTobRRO8FXbfcq9PKl_*FcyIRMunyg*Jh%8YYu3C1c>JkpJg`UB?VUxp zvS({J`uBTceD8fvu2X$)hEazo!)AENc}CPjk2I?TVy-fmJ**jAIyo=jwgZoY?BQ{l zZgIVlbC{hhPp`CbzYxY-{ov2WGLP!mw3jrH)uhuMUtV!? zO2>6()FVrG^R_kol19AQZXWyHd&vD`N#;Dfjp;xE7l<8atdD3I;Znk+& zI@gI|lsm>ozV_Vu;W_s*M#VSay{{i!e5QSf>l%eZ{~M#izm;$cIc?{FYwVtFi}&|N zNn^0P!TrxAL(lS2)nQTc#}1qO+Icqs5Yea-9azW_B@St>6@*dBjwmj$uO7&!#Sw*Q29A`v5cN@#yeO?h1g z`>9aR{tmMESD5$%-dX`utbb^i0`~EES8osQ*$kWit%S$5Gqm}4gvq-oV6lE}bcdp# z1!v}XUY7TkLtaa`?^M_*#@Nu4@1q}vd*Cw10^Sk4OM`o-5VpIx3jzBnXbY{HZ*PSj zfcEhy@*3Z`%QvWR3TDQlygv+dL~ex0a8;{Bw& zwm)>?C#WBINAMakW-#_DJZ0&)&*8c>K;oz5ZRysrFE(I*~VuCdC28 z0mT8u0mT8u0mT8u0mT8u0mT8u0mXrK;Q-DSM*BC@CE=Im;cH_m)olc9N~QXAy_8DL zHT*NFRF|%&!8iot4>8C9FN5)yPNn7;e!?-njQj1}`{9gb7IOgR4qD0f5rk}4K zP8c6WIs{lUK7>PziuBJllFiwens54NQmU^=jbzLOc%YW6zH2LO6oTKn^JQH zW){Do@smb85S}ZFD*MxO1S-eLc=!qMN77%MLmo(m;2jPoL>#0bMh5h<>q4F9K^&2PEK!ell5U z9+76mJZcI5gdZv;{Uyc=6`P}XAzt{wB61B7q<;?mB*ct2{0Ok7NAZT=glF~Z%nwRs zc`QE-e^KHGe2YL4eiWOYPkMm9=`VHbr7nE}l%x$?1182F{AGSIByHgr{i!)DrSb>A zlm`PrR?+GQ_qygwfY6;EYV$z zxA^)@iVzTBU20HOtTeXj5WzYeJz<-Vw^>UMfypX`In}@OZrUo zx^4cxYQMp6BaGQC-~@SHmE?jQeHK52Xu$>%W${`P#rU}`3E)kD-JVDtI>>x}mXSH6$;L5jP_a6e@| zcYI*r6C=6Y2YJVsx>s)2s!kxnm+d;WL+`@LuP<9PbrOCB{BFl@3P0WBk7;l3T{LwO z+>-<>zzl@UEXl<88^oP}x$H5xbM!*jAUuNQsdpo0 ziGsP1$@Y5LrzStNv}gJV*y`oIJ(Ck2ZQN^JP)=vUzMN$(T{OAEW48-cK zD^e2(a?HML;nab!xmz09X58@@EBukT5yvJ-Oib31k(F=d+ljFEym%usq&|E+_Fp^R z;J5*_9H8bB5~E_C5ur`>E}A?V{qLvawrjuT88}WJ)2{UZ-&2K+UePK$jiuaN=4#Xh z`xgo;=6hB`l`wV=w3FT2>xptbfOUn~85f{HV?3X^Cl>olR9^ybpWrR&04!5kBIAHQ zXIanW*I+|W*zKy_{1_MfYgy0qS0ZCo2!NCDR$R;L9lS@2F~Z4X0|WmR0!tN$fE?gl zK5YD^cC+)w_)Zr0Uc63R%NdY0k!0gLeV8YkXu16*s!xD5jiKyEB4M}!>c|1^zd?I{ zMq@kjyoO(1w@VUC(&)&576oG6>kr%duZE3G(VR9C{Z{?}@Sgi&5HJ5@BOK z<5|+)YI&mle<@sBac)aHp$ycO7~?MB9uLC-R_`@>FAgpY>Hv zn?gPCv_hsBV_B9T3d^4Zdj)@LY4;a%-pZdUw-bKbz+GgV%ABckK5G z{omr0Z6TL?JI={GuIhkh>Hv}VCvnQHUCKIeNBxzs{i}o9gmZh@x$q{hS+GEl^|9X3 zT>t-IE^tcK0nOC`7*G9K?IEnLnXukH(7Co6P2GgCz+g2OqxX&EGrNX)U&m~%OE9N8 ziMn*R=p~l?5|;K%{Ycqs_>VA#DF;W&gqK zpS)YTaP6n!mZ^CLcvIN+FdN~a4k*sXI|r65nA~z%&(!0}_5<6#Sg*vq$rG(u6By5H z-fN!< zxbcDhPcmI$U|>hyw?39%`M$zn|GQSNUL9yZs&STY>Lq3OA=$mTM;dn?zO-z?%)8>Q z1GqN;eb*;qwMFi+&t%>~T6zD!dDrlM=trM~dG+77`i5wsF}!r zeTS@Veq7Kq`8@ASbkg^z!^HSBpNVa6P|!YroezaU?Y$UJpBvKrSi`e~&%JJKE@fg^9NlEX)rcgnD2)wmPkWO~#7Hp!Ojrp41ED98?hdYjvJ8`e1|!`uoL? zX9CX&wsIbjx-mMSeIYJAjpM#u?wiLxqQAsNQ$oslHl0pyR?pU*;?VZr+G<-4QhrC- zKi(2@l1-dpSW~+;COwXAyaSrfi-{(`zNKzB?Wp z(Qc2EE|w+UNY7_(jfXyO{h%;$DHK-B_sdhPJ(Z-6hv~*)FP&!p*nfGiU-?Cm9`k17 zVd|wDEZj*D``gL+eExMY&T~XC#&@k(p7*N@do7-RN0>Hmg{AC2*cqa)?K=H-zj6#a z{m8(;``v6d)bO0PJZ#*(z+16m#VbL_s!(ZZ^%`IwWHD;{KtH3UYaXDFcaFr_Mn_myHtSbcqFncn!~VmpuNuLMi=I~R1CTmo z-pLc8z6!hB#QwM}vlTAxLO7!n>VP2o{xJ9V%SL)1G(IkS35uWJd~wl3nY6+V>}9TA zefB)`0Vboi4emR#mmRmCYY|b?88v*~kKmVYQNG_Dm%aG4 z)OySIH!A%7z{UBYZ-ds@?n4NhuYB*x54}u_w&vlW_RuI_eD)G>oGtI*@#w}y4`t$4 zj!~sYoAQ;g_0$fJdvv41-!I(Je0Co@ezExZUTZFMAI4nQqr3w8U1z~P4(k-l@jDW6 zhoF8skYh67xD@sBp6oZ#=F#t8z~9i&(Chuu3mcwkWXDeNC*yvdOY-aM{vP~C<9j&% zbnS+^Fj&T+d->ZVls~UNUXAc}i}ylL)BAsnx@g9)9HUClv$}EUe^mGffs6WR_poV( z-IjQF8SMIWzQ;rx=3VH#%P5H52BFKIC!NwabFytOVV4Z9Z@9N7tOP&)j=wW?ah?Bl zqAw)-*C`vjyjtZ)`-6M>qn#s%eI)8lt+4@ZG3cK1LpSG2@Vg0Z^B~$~oTtt%L7Tlp ztK`BZoBF8oNU`mn6m$9?SIL3`m2(nC$s~18wjgkpq-r* zdaZaAhO^tFAAmQ^GCJoeVC&~{o}KS|SktxRH_n>9+Ka6_rBU`Dt^HFrNzo~>UpC_Y zKkO^E^T2-j%D1{&HAeS~tDp3pVEy zr`ms#*8gY==>O*h^!taDSI9Pr31Mpxw{^ zbfo?FO#L<<>3c5)_J2|2_8;SYpx!C(?38G0+WP!&C)$4!?f|rFJ@>9hZU0fd3udQ9 z`70b~_xnFO(f*Ti|3^hjQYqN}Ls8rRnl)=)VJ%aAN6!Iu|Cjf(!*QMeP0IaWQu}_r zV#RBtwtdVmPRR0<8y^SM{h!IR|GgLJZ;DR?8kdB%Kfa|G-QFJoTf4h4CMrb~98mXv zd#eLtYX4)u6ThBL@qUTuZ69?D<`?SWq2i+B0OkM(oX+?(8y1JXbl&*C_3wX3O*7BF z`8oq1w65U#qAXXr@p544!s)%5wRNZb>isWo{fa8yCE)!pY9HU_z}fdFgW9w2e7N5# zUaeQ9Ne7M_QT<(J{vVFaf>;HeP*M5b;(nXW|#%ZTfmDP^+Pu{mm1H~Qxxy*xc+dt+Q z*W!-sJz7P#Dz};gOLo#<1Dkv-D!YseZoAn(+Wtq$`)X;#=Yt(y61(kVZ4vtl6Lsf* zd>l`1NxeOj%j2@ssLE+S`=36%RT_c0w_7}XpRWz$|-8sO$0LywNpO4Bm zlahXl3bAo*bXW8O7TrEE0^+I2rrBb!T7JkDJm)CxHlve_-M&*!Beb{s5^b}9Dv zCF(x&R`9)@%H;j?=$rmIN%qo7xM0rzmR8;I58J;um>mbY1K?Nfr1uKf3J3OCH1*+5 zvbiMD(z|f#BNa}^_Z8cDeq&zo3U#Mfe6o$AB#cpqlVmrYfD3sVg{-{5M7tmPJ$`22 z;#M#Cs5Hsuz~^_=UyC)Wdpg1H5>V?)`G)yGD~wi^d97x-D& z)5}S-sW!srW%4kn+_7HwxuAKOZ!DMnP7JoMvyagXo+@v0Il#RtZDenuoy`k{<|g*} zi1Ru5%+sSeZTFzsfxM4KZvVN=%AokEeC@#j%moaFVh?THkM~PI?^kU`x;P_uhH1NZ z1%VUO{oVfzf}6_IUL07kKz{}93Ao0u4b0Z`81r9!;Ewt$gG!la$Q$yg8A*>1X8W9M*Uw$K z$p2d>7-5B>fAR$S7@Idkr^?%&99Yt$&+n8w!@{kfCX3`vGg2KL8dwM$ec8!Q(bx90 ze`s`6dmHj^a)J?BSd;4#&G1O&Z9NBe+fAPb+bM)=Khd7Mix}4edD4tzjPp5}$K7pI z+b}Y+@~w@^z&+l}-C+dU%Wr=J@QOw_sQAw3z>@9sS7B}NoQUlzS?hVW^$XsejCuZ( z!#L9eyZwDbyM=8x(kz_wd7vSjX66G1GdncGLB-D=4)iXZ-g7qI8-TI?v%R}c?G}cl zF`hoyqs`V`3*;U=k8td5x+g#H@d<-o1*5Y#uyoPPyE*SYTlxZM^KTAoPcPT@V6%>6 zerH?m|8F<#K|OMTbDmK8{rdR8z$e_wR^hXo1AM~{cJWKp0Uf(v82$YX=(9riul!^7Ek!#10`k7GQqeb35gegkG9Oq;jD!abfpvzMp* ziUVFb&`$M0+3rzyd8N;5I=;7uySSdT+E&2t-`dN>`r)$yVKtolNBiHlZ}yCgtauaf zKM3ohI8TLlj&s`Y;QtN2v-q8Ye$y9uUc%1LB>8!t8t~o?ea4ef7i*C7<-q%D;6IAr z+4w$$`$v+5vlA{$ch=v4als+H2Qz8?0pf(d{=vl23HN5h%(c1aL$T4EHnV#5+2#F4 zV}sg_A}rK_d%D>QMvLcmw6sGv~qYj^PjE2)ve4`Lo2sJ zU3D&O-pg2V6Y7X-+vRP=uPvN;Pw%4XpTh1QZB<<$%3e5qDsgd>K^WoYzTQb!{d2Ip zMrlU}vkN3{u+hlg^J1GL%V`IfHVm7&vJw9r<5907{HLvctB7?w)_Hymd!BN{vw^-q zW<|CM?I5poO5Y6c;BLRPXZi@#1DB)!9od~@ChW^aSy-oF*-`Td_3>ViVd(enV;pg` z#2xyc527bt@)QOJc9i8L-31@^1x?dl=xUJWp;o<5&iNs<_y1^sU&ApQ@^6HW{6N?B zIZ2nB9lvF}PVLaUaPsS@4^Bd#{35uw<2Qw$*Zsgu%RD@zaFT!pnD%m_^k16IZefhi znp<~w(*x+ph{PXy|7W*!&4i;3TF>*yvYe#49KVCt7|(~w>vg!->>F{?JU*y>8EwAr zHQ~B+=d{(Ga#oZ3?0N2hbMl;1!L%O`D8^MlrvK`ZfwWFdn$79qREo_#8(WV*{B3qmZK&CT#QN z6)RS}l6P)f!$bYfWzLL)U(J+(y6^B%X|Ao8r@3y^433TR76!HV!u}_sqyzoMQybx- z;=|zp=K4Ppr7X*^aTX8f2YSl>THW8VJNd5Xm32f-#lOLLLWM^ zw+zvLxiKmkTH&G}pzdq)u3mvtvR!8TY~_74-`OQx`;7*6MrXSemv+{ z;2L~3n|*bdJmK7YNOS$3>p+yZXP7h)*4Tjdwef-ePlgS*o#COqz#g+`+a}om>F8&S zM^a8M^Hh@gk9zOSNa(I3Gwvx0lP6K%*Wkn-*#B6VG!Pcrx_em`^znMs1&hOm-;VHL z|K~dKKin|9>5cWBaA6vC7WCwvB=cSBTokms!VLM&W7OSg*Rtx=L(SxYjlK}(e&^9_ zb{hwL{yf|3fziRtQ4W0Z+)wkAm-9wB?T?<)`qd}EPOAO8=v=tGb>depb6=P|fi2z? ziR_$kw}f%*6c!zv7pX(TeYns1gpq-P_lH$pyTC#J^qY0~5{j;}jYb>t*GS4izapu; z=HBN>XtvmhGkk3W&*Fr|7ue1d&gDcsZq)V-jRveMdZghV>_`03ST6UTu*yy{9K7MN z)FWR4*9Fj@68gar-S?>tC6&LRd*7&e>jpopb9Uw$Q6&3)+;A_4d$>o|tL)JR4V2+F ztUt|q4f~r>7o5fU6pTe)`le?kmERciERTX_ zFE9(k+3myNOUuSE)SVFbbwN*xUijvahUYYH$8XG-2f;ZRoLTCW`e1yp|4R-u2X5;f zVTHX{T-GbX)lLaR&!d3R3GKf))1TLT-TmmO164BfI&RPo4@)kA+W&?)XCJ`Xi`A$P zelecU9w6??#GX3NBk+7D`(VGz=U*2V4N<^B9UIwwT;b_KU41z&3ciE^vsL4Nv{UQZ zZx4e8!azONlJP(H9EC=M_wj$YZ6Ef2g+)UYaAI29nMVKXxhU!b>-pj+_|pu`mi3+0 z{C|zu(9T7+|FphdYyCCIBq#uNd!%>q3*H#x|A*`IscswZUMJ_Y(-Oqa8dTK!e_**z zgt`7dlFe=#iT*VlBZSw7sZp*F0#GmhI%>Y4jwx{sFp6>jH}rjCA+WU!f^%3=&|MFv z+W%K(f7M3|>f&{U!iuKe+N~lW0uOk?xlQ->HH0|W=$$CzmMF@>xZ;Kg*b*AZPTBt- z-TJMw?m=fl-~S)eShs9TX!m{-nj%F62slQvE@!oM*yv68U1JRk?K|Q7|0y=ms?PuU zX-6X3)5-f1(I0y%G~KID(?*{Aj>v370n5HgOuXPd(2Gikp#OY$Wc)SSq3O|F|Nk_D zW23y{&d-SZi|F^C+6a$k;(5Oq>anY$m4$Y1(#QSeA=l2L<%2P{!?^3m;Q4nWEbFJu z=tNw3qpSmAH(!l|4$Nol#Qg=4$P#Bc;<*2BXlUs5pdq63D4C~{ejkn06X?uEk;u_D zE*i~c_v1OssLDd0?$45@B)hyXA3O;A{j*K%Tf|AXkY`T3K@eKs5$!`U&Ng#wVBo)? z|D2Z&jRwvw6!MwwIAzgHS=gT**;`yzzH&WkG?RIUH7w-sO}xjv8D7Mdmv`yFUe5@H zeqn>zP~Uv@$_MH>^k*UrI$(#I^_?iMI5eA%^$qKtFld17-^V(oS)Ag^&%0tnq1#LS zY4QcUo{X-{>>hFPuGw<1uVpU#>rM7SFGs(prSBc`eP{L|K}VoFB%u%Y!*0GA=3ZsL zvN7ktj{_ZE-Y%=@D;YhTu!X!Vr=48$ zEsfuShQ{{;oA3WQy{q0zn$3H`&+}+Q;{89KJ7+!EO1!OQ=W$MFESEhDwtSmae_g+G znFmqtoiH-8@~u`l?eYz}*0|ENMP22^yeZm!OTg5cdT2@jk>T_!k#U zb1aT|qY&pl++bndPtt|@^*d3P$u>onrCh}U#Q|pypkDialy+DJ8#XVo;QUBcoLij{ zG#u|r%d(WKIG{M-%mMVxBER#$Z0EN3ozaJi<{Y3|XS8}w%$egVRB<2~9N;*zY@0#b zS+DspUuCBnW2s+8Sw6>U_A-@UaX@jv-~endvN?FWUBeb@&gPEgvwPUnh3!xJxeKL> ziUY0S0PdsOC2U)(2M>Mm{q1ye?`6F_VZ=GrJ9a#jUvXfzaR6)a(VW3`84qxs!HLJ1 ze~jiW5DOlv58%Xk6*gNrfOmdA9!A^h6$aXV*FE`|1Bl@+Hto{cDr-fz;((n4X#4-* z)po;3$9Ki-I7q+PcOBIkGD{x-=NmsQ%T%u7z-;BfcrLRn44d`=W0PCoU&;Vu^vKU1 za;*`2<~s7-7Rg8DDh|}+0MEt24kEwf%UU;}yzA@X#5lZ{@W0kPQGKJ|b9y6ORJ`Iq zr*eS%u%ogqH@L9>gBtPExQ8W{dTCQToyl&*Hx;iq;FSX$|GL>|WZ^k&>enk)yw(ej zz~QHn;bEBvKz;NW*B!m!q|zu3bOHy4hlk&QdG@E`u&=5za2DDt|9H--DzC+6!w%;; zF0Xj0bczG*&H>oZufnz=FYtIrq#xdi^OX_WeHG4wnQ!~SO{G;FXh#lUf6b9qdLNg4 z;heNzewlY;*W9ZT75*3twDis#zx+|@6$kuqAntuWRd$-wcJqU)Ow0YcRdAxMuO7N2 zpMQ%iTe*q@ox%a$MT9Z`BQe^NwcWloC?Cx}Ks@)6pl(5Zb0^<|35u`Er#O%_4v4pn z^4c9OvV9%(&gYupNv!h#tOM|FVAAi?YKEUG@9g0K@4e%mo0i#rJ`=d@mr{y*9)P?; zopYHiU%83{?aBePy^+1G=cP`;UcS$V!8_2g(o1<^rePhxdn?1hU%^luh?4{2zSX>T zX_)rl8y3#;hD9^?Dx(f~-Zu_S&KIgWAS?+gIB{`+cc)`-Z)?YCGoq$b!B-q;!~xj-X|1&b%R6CsAF&a>VZ|46+D@3`+|Wwe#Xg~I zcFVA6Qg9RpymFvW=zk;D!|;Bh#r|6Pozq?%&1SdpihmgC#`D_uT1`L3B2SDD^nWr8 zdK3)B0WTcD8>1hA?YC+#zvH|*#>9Ji!9DCWu)p85x(+bQKR#^Q6g zkNpYG$Jz0W%g=RrlwV3n9q_lpQ1%0H(XPswJsc+ z=KB8!c&tqjZ+_BBeyFlLiv!T>V-uv?72D5eCjH*&6P08}^Vxlp#3R(z_m2!_7ez&{ zg4_8V;C&rzUz4QQR$Q>(VD}_R0k|cwp43JQnarD$#3zMIvN^yva4^5ex3FWo_rhw| zUcPaE@X<-+BIhE!;2&$6oHj9*%O1va8A;@s!YLUX;Qn@;arp-KnZ~N09_RVWIOQ^# zWN@q&3&F#@c5xY3zqCb|9(}oc#s>yIQH!+-ne7}H$>lzX{`AdB)LSb~sGF{8hjxs3 zTJcWSH|wXB@0;y9ujo&uaHec)>|@I&OFy0W6rpc@eLkOmT|3ClI0vcSo>ab~u74SI z)No<2|6T3CPlaQ+9N<|}=;ry*gY`+(Q7c~1)tlPw3_RB+tTZI}yMR4i9}8E*6tpCA zU}Rw6{jGR+uPNQnWo~RY+n2I$itY&K0-W*EZXeBM_mj9Qw{ti!KB#>ed-tw&mb-I* zI}vGdhgZAWKD7sR?cIssqo2A&yv?Sa(&(M^&asZpq-11d#hYOB%M-LewY+!7lTGBb z-^2D_)6UWX|5tmW)sKC~oAwH<1vhnndOORn9dXCm)SWrE)8=|tXh+`LaYp+;+U9Z) zbvD}&?78@ndY{NH;bi#v#s*|`Fm&y2?XGWFr`FQx^k&K6Y;#zc`>6c&d*W^1gWy)c zoiBSd#~GD73=WLvGCiEnOW667AUY-Ubkx}u)@AKpAG_nPy#)G3xDR{v>a*vCq0b6N zeGaf)9M5T=LR)_U+Lou>Ws~4JJ%{#9tVeg?+%sg<()Ukd(+}ML2A#YT^BqU3GsyLA z*l+Brp_N;Kw+BF$1@QOT*ktNpr;(J+y8{Eh&+C_$ai@p<-xvwc;*w3!a?myzeS_?N zqodl};^uohm&fxrT(5)8pNqczy`8K-X!jrG`T2I1S3TSz!xrBYnvi~APWwCR`13Gc zUBhWy0#fkPEV<_1ADbSZzy)Plh-?6#eBhPyTJ5}D?tK7+TcQdp>_Aj75zi^|M2l<_}H#_A^#JteCfn*)a4&;SNXIAZ;Wl( zCg686`Uzj>{S)j9x1(r-| z)-d}tsP`WZh3*LKuf=%%DsEZ%wb?M!Lsy4d%eKId{}d&>4^ zp$<&*oXBk74?1i2{miy@PSI-}ov#DhOBm;Vf6baTuZYr~+6lI}_cNDyzVl_T?0YuL7;9VSt{NQtk9M-n zIB>wy->7wa=j?p`_t5WQtmYg=KbzdHqgm7Fbioze-?YEPT;;rcgWF&ZtDdNIol~d4=3hqtbv5rDS+QcpE5oyl*#Sw7`#Z-LW>dX}KK}jOugCYmW{0JQ zPbKQjBWIJ&C|cXTuA9a=#zCH6=6luQTg2>vI6OT32GpT1D4S?|o9Otl8`kXq%=PJZ zy;CKLwF>6^)ER#@-=EmJL)bjdqVX--B-&3q=iDvw=ROsU&X%tY`R~omGuMq{g*0xo4xkda@prHkHF@K`Chvk z&&OL6@b1Z$%U_MT6FcUPL^rsvJeSGvUgX(wS9-iw?X5KO9`){IBr;YuJ-ckWzP#_k z+}#CZ`To6BpWd5o#*qs1D>p0KSN;A_bs(3yjeU9S!}$T)_|J9nn>caUXjElD=06Qp z?)B^cSM0CeJ*nd`2Q2wPe1C0-=Lkm5x++gArsEU<+Q(G zAL#G69|W|W&bAzQ?ms%9eF5`DJJh37#U+~qe9s!Pol1nRbwr;*yBqt>w+>6{JfF%w z1m#r7X*;tI&G#nYvd%ab_INUF65r>eF46EkjPF(WU4idwknU>O?`4P^G3uAhS#VF` zzId^>JD2&^Xg<3S&Uo#Ce#&Q1#!kHBQO&!DCBfvv5p9rqw=J}>2<+@n%5IXY?+UYO z4xoLw3hhK_`ahdt!S=_ixvPAN1Igt8Y<0zKw%71z*EXOYJtDc1R+uRcRC54hzoWy` z|5@CB$Yq`y9n9`h&0XbF97rw)FmHPRY=4uo|0e6LT;~7O9ns0vbA?$g2k@SJud@3l z?Vj@`Iqke!?y3;Qf#h;Ptkvc-8V%m!XXvaG6tf@bn z_EHC;&A(}6VBr1jC0&KB;y`^4K(BA`qW`lv%`F(`?_QsaDpqlzJvo5(fB#u*+iqu@ zxc~8TwRg8Yby{KVj|0&4yHp(@#|&CO&$s*Iph~Sc(7qfP9o623KJKHlxDI#-cS#?k z?w)I39afk($ALm7^Ja_%&c)p?8)i#AfVq!r#s~YqluoBNYmR#=x8gu&aNyExb_?A1 zv=sF~4ts;1@2I)}_n4uda~bIRmU{EKGxSwa5iJMC2DHWK7hH&X;GuS`3vedq9^n0R z+=XlWA0yL?EP2R z?Q>qn5$I#A#T>*xk-i9b2)|Qc?+0*Ao^zFo%ZdYv1BwHR1BwHR1BwHR1BwHR1BwHR z1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR z1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHR1BwHk$N^pbC6NQAc@?bE zsqTu$_?k(jx@1@pYS||}rb|sVKan(*np+*ekzw6NSdrh-Vi?B?-U_hDJvW}Pn<`@q?A+h-DDu2<7 z{1g}B)AZu6%ObwA1|z;CVqN0FXUX;2{AnRMDb;TIA>efb0mOsPve?@AqEUN@^k79z zels3m_*WZWGODj4V?j|xRPcc?Qbt38!@t^iP{w#OpJO~t!^F!q-XyR#9)N{uxs@;D z(;|U;{$fR+3{d!=20+B%fIv};OF@Y=*7ymd26By`Vd9FQYw~Xt(Pc#_$CGd)O8j2~ zKaBXkN&%*vMDaqtgrDSiDIb%B8vJeXrk)w}bt4lqn0e{c#FOFm;!6$Ui`DU_9wQKc zeE?j9Yk^-ApEmW|Oj8q&?B%9~Y2Yva!OQgZ;z_SiY!kjcKjTrqRQ}QkF#iab>@zgm zOp=zd6?^Kc9%P% zlNM!uwB?Mi(hkIzWc)mRqRSz^Tm#M5OI`3(v$tH+vvnxWM|_p`0DhT4-Oll44TNy! z1DR^}0zle85O}BNl^o)WDe*TK%$-*<8Y$$e{FwyI2PX514)Np}{<`&<)Z8Ln+n*`H zKB$soyn!IlLz7X?Z-7a65mpJ%$(CYjPTCajMnNVJX1w4~Hwi7rqbNkon8IRycp*EP zza(NnSs%iYF4ZN#l;e>O;U!5;iRB;_I5bDRz`N9~0SeC%?=N5m85$zlgHc5rHZye8fwM+T!Pl@)=L& z;IAg00gN{-(mmb);2v*?u_m5e6!0D6EBHfhHv9<6TRc!p*UN9j1B)v9P5Fvam|w)# z$`>(^Z_%`QOFV75Tk4NfJaYC){VNG5Rr5dsA?!_BY)laQf>WQ29iRYhj_uVa;C}$b!HxUTnVcB5<2T4-l%}8vI-A`nVQf4 z`fB4CLh*MsiYF4?&iPA>Vtki#e39`a(uKikMD+AUm!;tn-(`Xq6_^BSab@u? z@#W%01!~D>ewK@OiSL%hi~6HBe>u9WUR7x-;GN?u;2p(Z!QNT?Qg*?Y4%?ZwveSWSG^pScf&Z{9P*JFYT=yB|eJfct`Xt?6Jty0-~H<)=riROk9q4M!y2z8NEt;w}e>jmf=?imwjn7flKja{d)8~ReIblUVkzbkd$NdSmH!rytP=cJx@MptTQ_yGzbMmU_* za+KrrSIB0!pUCCjrt5l_Q@jlO$nN?J`ICOSlle?>DxcB$o6Ktu!aXjV%|73n=D@w@ zU(>&0?zaOg=5+q}%`m2!&Sm;U-dyIwsk}BenbV$zdmCWo343C6-HQGdv%g}#{JBr- z7mhq_!kGP)EMqFCZ9tw|=|{eIOyx3vN8an1|J>6G+skrKJaR!l@8r!QKf?~)U4Jg~ zp#1XyV}alHKc=abe`+lIPL%!3WG*vm&nx{_{J;JAO=bM^`SV{1T(3ah+wDA)dFeXw z@A$9YygT^?yyw8P?=anl4QDL{yoW*057O!MHi*BEd}Lf9r|o&-cW!%?l;2c7JB+wT zkpD^i)-ugyLqjh_o*z%;vR^dwk4@(@R9Eu#9Hcvq-~5CBr}#aDvVMj#|6C{(wl!kv z|6dr(z7_elg6@kZbHo1$xC@Zgbrabv)wbr}C{@mAbrIc_fa{sPdu+>8A>!8qgZsnn9=5Ch9U6HXSt?zHjS z3H8iQAI3K>_9qJjIJjpx^{Ac++F*5QNnWm7*yaM6>Fw$hd$Z*L0Kr_$d zlbOtGMHulzUhw$GGB4ff(d;0@QO+ZXyVLm2uCb;mKj25@z1WAm{|8J@OI9iA#-^nWl%1F^{B*m z*>{L zz4c2_)(rIJY^#1-;ZKi@yqEnWSNP~dS=$%DI|B0hBy|@${2F_|#R}WNUG&K)lgc03 zN5Cz&bM|~*{f)HmUdxCw9r%~(+KP_T7IvVzQfd57!tXZ=U8$4tTgE=tmQ-qomFQ3H zy%xWBr*?So-RNV@q?YQX)Cu}=sp6hXkZ&n|7!algQz^p4kFePf+X=tr_{n})dOLU$ z_*W)IV z+|70pczlQDN*tVA=*&@sg?`nat>ypWw_9Ka#^3zhR-+GNmXGoGAFbu{8}-|-%kXpB zxiX&p2*~Kxa-3aPz`p}~^?$LJ&2NtDuetmg{ocEu)sH`Xp`v`oGmk8TE@<6%u?+1t zYn<6vybS!l7yOt)y5o>O%^&*+1Irg)Z!e$zi}FwFf4FH&RoRUD)ZPmf{Ui227R%3I zB(Wa43dp#HZ`$M2pYlDhhP&l`$f6G3s%!{;-00%J+!uNv`hICo?p^ho{ z5rL!Vx3SIv-hu{!7wRFI2k;i>uTWRXaOra1O_mQnJdEEx(r@LjOwV}((1$rl?JH7e z8Atkn&jj%MuC*UOm0$N}z+8v)|0Bn15*A(HC&oHx>~G?U@*k7* zg08d7IfioEhxm7eU9N-&*yD?3b8Kz&DHe5Hj9nkJj=5#tMqQM9w6$!MIVNG6FF<(U*rq5-5i@A>fvZnL;Jw87E(#c%* zD65?q@U=Ug#(IVPy4O{eZ{iF+|0nG8{OOU*0jx9KaNw8-IDZ*wf9?ni^A_ZXQ-0dE zV;(EtD!=bXS-*k({T?pY7|J?q|EA7!&d;e_83s62eNHjKAeK1G(}(*u;oQvWUdIjQ+=!TGae^ts02B4}@cy#@Z5=C7ckKG?M5j|2;NC0CaC4F7 zdRbRE!Du9m^*ZVT$Nn|=nV-mK_HG2e*Sx5kU%K($XT9i}33KD2yq(b^Oy)gSvh#3&tPkDY#a25Ps!( z3fCGAiPT?HIUxI5thdm<9n3jz)ZJ;+5rK?-O8n1 zqkda&)T8cg71GS3E!5b*bc5wLe4uykP>f6Nt{RhI9L_Mt$#o$&*udLZm#ERsR@L9= zTYGJXoXX`G26H91yWwpQXIkcQf`z*Lf-3yH(t>$xCz!RuIKIc6)Zgql*7I{5Z=D;k zmcj8xP#Pe=>n_e_Jp8*V%EWpZz2| z9?+W~I;DlKeAAxR$uGy8oFlj6VfWwldt+|*3;R{9eOJwM_OI9^c;bW?Y}vMdL%Y){ z9mA0JQ~3Q;^zAXXg7$7-u2FC-#<45Y$TjwXzWGnu@wNNC;!jwF?equTxXjK+r*wY9 z!o9^Z&J^d#vdwKS`Zi^1Bx2Fy2N89zV#M$cF>(5&FvmJISAAGKw+kidZ!1)%Z z^g+VBwqFN-*tWK2_UA~mQ~PC~&n53U@4o@bJQ~EZ9Ul<;K znJkBVw&)AmOzkPI)yeoUUDn+}`fno7-t+GvF61lp<$QjNd0|_B6LDW+TvUGtE!Vfh zTEw(hzQb-NnTPnWJpr!^Ku;#PIxqYKACf0N$ z4Y1n>fR9*bl3~~jyTUcUMRqd1;STRF^KdV1Wt>R|#s@MDU6jjpBJTYf67vJt&sT4* z5@~O=!JY?APguhs53v?yElYm09BW$hdy92Fi0uW!H`~*3@0)}}7v)}8l?L+z)-?8S z;8xMMq&yH-US|~W%suw@ywZ<6S6RzN{_o2;=|ZmGsZ68YV7GP7pCZj7o!E~i``QwY z)un8#FpY1FrEL98=<`pl`Rw1+Z`l0@us`G*prvYTBh$0)K|J=6WIrPJ1s z1IjxLzk_PkQ%ui&Db@I6|2OQlDjfS*WEn|uo#w*1j>NUmD*W@AjY+|qcyND^1CFBo zGUBzoL;UxWKm+St+Nm8sgk3K2v+t3SY1vlEG|h00{Q%mZBt8|cZOK2@tsAvb#w}d~J$vEMH@GG3svw!M$53v(`FJWktw?%o!JKZH*RU61zc+ni6Yx1P*$#kwx~qu}+q;MMKk%F-s? zbjD5#$6nrGI@p!4Epblp0l?tCbnbn;8N9t0-(sJ>FW4B1fS-rhadh*$_8ld^<8#?B z`hgdDT4`~se|+(*0UE*Uhfvme)-xkk{Aztuf8)ZlvK+C-e=2m$wJ%r;Z^p4s3Z$Qs z*T#732jCyh5@Q|-`+I;R_Z^A-&j{mwIM@Kr+wmN3K63*1|M=ApCpy?K2mG5VdE#Q5 z5?`x}wE%nH-l?3Z!qC=D2g04_UuuliqQch>7qaxfeu{mS_1ZyuIZ^qIah!3cthOA) z8r_*u?bm6PKGs=WpJQ7Jof3Q4>7rix^hNo6c~5qu<*5Aa#Q|fgJ(wFtAH#ggHc)~1%HmZVapV+?oZ*e>Cp8Ptmm zmu|`Q7YA|Jiup}9jXh4O=i=&sevy_F-#&bN)&@gL^81l<7AQ*4mE3Ij9F**R;xe^X47^ zPrl+6KE@}%@=mjB|9?HtWqK{sPdcvCf#&<_(QS|C9>oKC=l7^r7UW*@EPx;0&`#== z=O-TfQNDq7`7L(Zy!w%MGuA@Iy%28Yy3K+6q1&1kJow#IF84;a^1Oy){Wk5D&bsLU zt5&(d*?Eq+rv1Cx0_K6W8J?4>Rc5U?*!jVAX|n9y@RF7=Yn6#H?}b)5+P;6mc~RV} z^)<8s=Yzhw=RZV#%qPn8AKb6WwKe2D4RN>&2;=jd_DhU&i<6@xACeH9Oc4xfM z%CMZw=#|81mkXzVU6~m&Usod4}7X#_bzzRK`7G z{vo&WWw`x}v*ZQne?+EAw zs#gC8bpAlnYIms@7~38X~>6F!uo%H=D(TN4v}Y4K;Io!8nK2y0Qxm+nEduy z-y8d!wPDns^8Cb&weA2R-LNa%M{M5TCia_X-*wwdZl@#2`Um!eycKknxlVq{j%{NZo~7O7xo4+5NS}5}k2p6v1-Dh= zSVuU^m-e6eu(&hSp$%Xjo^fjxI-T$|?@e{N?_PUK+PIqyK&Ss2<~e;Q9HR^~`ZuEO zG;P}Ytqsevx%bvxN4&KK5>`@N&~u(EP0(%Y_?I}Njy%gXJ=#Om=h!{j8hKe>mlBc9=M8 z;&4xsofqss*TNNT;tBRVRes!6oFrYq_~r}j%U0oOlO_9swP=@dXb&9c!z$pj@0iPc zOy+5Z%k~ZOy1W7}8_nf7^hv$qXnk9!oZl5L`tukE$2mvLGht}k{G9K(!%sWzbYECK zKESRv5hvCmjf~aZWW+UT$ohxSIq?QpJ$z(5@?7tSc9!pQCxJsBqM~lJ$KMEC&*q(* z_O{lIMj0;7++chFex3-sz7p+Oz_X*;a1dFbyfY;K!gNvpaDOq+tri`A*P&dF(=az( zldpC@SNXxW2Y9E5mP=KhO2z?ukbFYOZTR3FZfMX%@eeyo{sk=>p_t zzssgdznUUj2mW);2=&8SuXFYFXlGrO6x!VJU|pzX-R9Tp(dZU$^#A>}E$%pmS$FUp zwl>N0mpq5>hJPdBY$H%N2S4lVpqqj>UiUwn?E&b#82Rv4Qsz_KkI)EBe)59$syhlB zkuBdA!J%1c$?ryJb;!Fht}{Vl@C>r5aj%LGJ4U~hX9U8)DNY!m{~8AwdT#Sz zJ0-M1p3hswNt@TQ#QLDPKf>erH{O4Q`yE<+e%OgW>`REVD)OBG^Bg2}6)u6UqE!N$YLeI70eO?`Z=EVwIQ!I}+Js9qmF5&}BmK1>>Z+zf= z4`A5R8G%R|&jQ1{HPwyFE{HH(Ft(WL0q*<_;)g$8hQYrjqz!+3HJE=rdWjBT73rdk zG#r8^-}z_WL;+VI0Jlekr_GxnW&}PW$nn3O0go_oNyOqk5znu=e9hGAPjNtTKyg5E zKyg5EKyg5EKyg5EKyjdg16+6K-dJ(YC8zDlIeout@f83&#It%&{iXfAn|I-RR@S#O z`OPrC8OJn9UuNPj$GG>V7U{9Jp5oX>~cD)#X= zvW`&BE8eNpCb_4o-rFHw@l9$vzMY73itD|~yIwlNTHU)TvUNw^e3wC5hjo7!CJjy;SD&O zTMQ~!|M_N%EVCY7UgFWdJDziGj!xiir6!-v_rf!4n*QGT{m`~Q>0gHKZ&rGOg)&*T z6_+62u)V6cmYO5`#XT9^=ZU`N=Y!A_XCBD?bWgH!y1OI1uoD}h8GLrU6Rz1hV3e=j zXT3+S5jw-ot9RiZm$MTfd*iL`zPa;i-G5Yz_Ho{wv3M5U^ZuY%^#kW}YS9pP9Qxzu zCr0*WJp!COodI&<721GzC9xf_>KNLD-#gtS;Y3fAVc_!#Nt3nGr+^ctmTP*1@2czc=%^fP)`Bw1z4NdZ*nwqB)-qV@2BwJ9;7dWJg#l$z>4_; z8j(HkFPC?l`*Dx?;wGbi?^FH)*S`5rSY_eooA(I7h9CD^4}kZ7aVv-W%W1QK>G~Fd z-||9Czt!!XzU9mH=b+yIzeeQ$_@i4L_h{A&yyxy`1Ir(4NPi^mZt|!X4q1LG2!Hv0 zU_ID8hY+-m34&AIJT>mcTirXqe&jV>?Y+yfA-9nkjm0$-*-%{~&YWwYJuDtA-!0mroXZz;A>_nTNF!0%TpDoCp4Q72)|Bj;Cy63AN!PsHc zjTd3m{bf1W`tWx7KU(jND_edyxb|?=0l#pg&uci^fY150HQqVl6}~+k`Vgo1EiY(# z-l1$SpMJhsVdrI-{;~Xa(_Fp;w z+A!s9hZU#%NsAMWe!{?0-}gy0dY7KK|E`vfg%LNJ+0BU$cz1VKyUfD$$0>i(;zXmL zu)=tDS3h>ew9_NQ<+n-vlx0oZs1BgsdI;^;E%;%dgLX0OoqL(jhx>i%wA;RQ?%@nm z-ga1V%D;cbrd}&ggMNdD5Bt#p%&%9ytr?_j$m_7$wek)0 zZt4$H-ga1V%Ad5q|fKhdtiz6}Fdjd}F{%x-inTmHhkWKN$v3Q2+By2+!kd ztf~DYjB=X+L)HJ)T!;KW=12ZaTMeZ(LR9~+T24XkPY!ZVadpY@hp6#iHP6L;dOqs^ zR<{3D%V)Q@_u0bq$JzfQE!EQWAAoBSKx3rLw6`zO8k`^$BN$9Mdasuwu7zt*cf zYo+tD{`**D>wlGRB>Uwx=WxsN^lSg4+JSz{OH%p+mg_GM@tM<}g z5tOHe!q76zz5X?*{M+H)53ZlrvTZMM&5}Rcub{GLKJ?%GyhT)=7ec>hUj@hn-;a+8>^(S1P8k$>>LpA!@7UWKuq@Angs zXBPXZ-jhTbHiFZdmGkeCwj9Lua{hy9g1~mmgT0HvZ9kW}(68-7_D}G>(a$bxD zrrda)v~c1Yl$SN$jV`zE)w-cQ#r+{c%WQ>w`!CTq3ICjtQwi`!XRAY#Q-ggZgC6`a{k+G|7@*tS~+f@cm5PN-ZwoQ zxYgn<`t;{x-^@O4X|TWNPG7cPYg=Oso5a2HjrH6G66LAK>{mQL>$!}fFjrR!o6Ws@_+`Mz6bA)K}c#!8@Etx|8|0F!#%Tp^|RUGc`_lk#~bmsVEBg)%aS*G?3 z))Oz8$Y$%-ceTo6KZs|N(a*iF5>tV~!Ue^q~t6;;#1} zF|VJpm=LEg;R&e)+-l)Nzh` zop@jk10JQxvEfk8$~oc5F$3@8;F}(}TjXs6d(XdzKYlZedo`HGDQ~?n(TB%f23C6N zeN*A=f*pZr{pI?vDD zjDhN3Hy*+EgWdI#%V55%{+~JT<(DU7-UEHlz3P{M_I5n;fvH^fi($8mUdpzofi3^Z zkNlzMMf|+K^#nTYWd`+c*l^ZT)U|BW1;2f{7_WFK7k#>YeZj53MLXF`8J2m|d(f61 zAJuteFSvN=<5>B@b`kdP<6f8H{k`DfB@O%YR$Tmkd%0T#`nA?CTuXXSdna9ZeiwDY zYaQZ+UtZGqu1|g5w-)9)f1tMCVxL~_7Yow%OZl^I^u%*7_g9W;zXsePz&sp0{4cJX zjp2@6j7bCE&+3F5>Y^V@{G)Nhv;Xy@qyHKlN9`#m`t4!XJ0tv;rRD7KocyR`e(4{! z(dMyza)KE&%%v`JZ(sTD3;NTZ@y9)IL2+}+2YtQObJ-T#M;=dgb0NR(bx2!#UZE1M zg;(Bm9W(^Grwp=x`Vvihu@hf{h5_aSe)tJ}xfgB5-@WJYH}7$%l!-c zpx(*IsTEHIi1Rwk4qj=MfZ(w*SzcP(B1|8tsyQST$ z{mpmZCCy>Ee5*z1Vl6y!nRSiKu^8uAtJi)OX~!LM4W3uU`xf55c{8zhv>(*8|)KZY?*=Z;lB$MgmS3P#^I9L$ps{E{tX08b&#s zb7EZpIpEzNZ4&M!_?^f#vx`D{hnn{e@?JXBt9%Cu^P8|SzJW73{2{k{#v7$ApU=Mt z<@ujW!`n#y$Kl%DjpD2Fhsy!znXh}J@D*vw|N|ZEuv3Z z+g_dGk*_@W$o_-hF*4_lFh{bad6cV?gWSg_bOHBx4-E}f>>*`8)k#n4goU~vKuOd) z2MXEj_ORK1){&3%c+TNHb&Op<;2)OuOd&V?|GZgTDBg16(5WP= zy8DUXvdQRP_qB6wyn%9l;%9v}7&kgA7uO3Ll|9x|YTgfF<*2zXA8c&Fx&!ZRaetQ; zruq)X0pII$PIWid?I}|~7(j-;-}Crs0A%nZD^G)d2R+k<@_rDs zOe3H64A;afsZzdr9N@l1%+LO=G2WYfIIKh0E6*){-Nr~TiKl& zj{g0A7~gx}lj~I9n_<);%CHe$a-I?O(BB%>0WnvZ%YLm9T-rG=-?jsfgY4mPnd8ko z9M)bf&KEY?|19ed&TetNk#m^sEKjerala78Tf^YbmNJiO*tC~4kkzcy9baB?a!SW_ zX4EH7dnw;b8q^23^R3oS)dO+<1!vzIna^?I^OmUA=Al=oyOil%2YJ$C?M}9NPCD0) zVU#<@M!xpk`r$eEGDgKW;C+xETzsazpX(Zh!tl$Y!oQht3ps7ifNSiYZHxE!MoD9^ zyTSd>B}32hQPp8l^2ZLF``URo0OWqBPh0RTa{-s{vd1vjg8lLRk3&8}RI*cbh@mdDmnn^V&Fgfw`P-$1DFTKHLvm%JOZR zu=eXW$~W$R!M$Qfp>K3~1bw-B^}zZd=b?hq9M*hfh&pr!^nvF%($opcqLDK~wfMqw zYSTIG{l=Mc+?#|w5BT}oHyPdl-Z~xz53M4QD8(^V&De`_RsVUR{jz*MrZc$ndOc@2cv%YWv4|-LS{@ zUUWtDWrEI((C{J((GS2$msu!sDK9eXr2+G42%*T2BIQsWidE38Hmj>Q%s`SP~ zz3!lwXnP((yY#tc&yjNMrDPaPhC%p-{tEZr9G8?e(`e>>7Mj(nWUj(pw1q0bu`-# zv%iEa{t_nsfVWA&6zd<_#ejVr-qrg!_iTpE|7ODD+8NsX+rs2s6tGyo*1JPd(1J5_ zJTJ?8%OS5-+;=K$6k}}Y$v4pt!#!}BqXF-5-lf64QwZBv+=YPs6tsnA&9^s04?z2L z6!HocCgw=o?k{Kt&)TI-*hAnu1wxf?jeZ2|^qQp1iMET}Ne9;$;*wzl<($_}JWmQO z!S4NuaXuJ(8@X2_Dg0_TJkaI`x<9S~JNCRE0)6f&?mKBWo+pVO$kFw_>NsTVT!!|i zpl6no#M@TDC0XM=?KbYuYX!cw3yypr!`TMoYva+cA8+>lrgo8M5IQ)Ig?W+lQ5W17 zn=a%szu}lOiuc`v@Id7;I3V5x$Gc4E7hVEg2(ND-=9INb&e^u>8NYVaA@P1vUi&O` z;d`hbct`L?F=jCKDm}{jLd=inGygz5-=DpibMW|`i+cU5m{aYePPHR%6itc)iUW!R ziUW!RiUW!RiUW!RiUW!RiUW!Rt-=AEEsXYWpUt@aT4MMY zQmJlTPlIs?#vfvk0bT~Bocvz^066C4|Dr_GpQfL$9?lpa zMLGmnGCqVujEeLxF_Lv{Ni8$|3#pedBGsMd|6Tfm8PiQ~Dh2wMijsOv|JKwJftkfG zXnda$4}_P9qRResmq6uM84o`J9wYt5F7iZpI5gi7xv~DX`ASgWw(j2W^7_ zM%n+kkPEzKJjiDmF8)D8*NSKKVm$EUpI`zdleYO&m42iX=}80rkWWb05H$Q-q`IUT zP2L#VAebur6fXXXjHmp%g?G{4WB5yqU;21SC<}rNefWFHEM71@)%}}drT>JYiH<>F z&yuwC3xxHqw5iR8zHYVni$G^fR~qQf4C)I#ETDU#i|7}-^&+qabwC1s=qHnvmJ(@3 zETxw4Pxzry(qCe{P_Zt(8}Y&q7LjX!ApKqRlMplB@FT#Q9>p7e6Q0$tGe0Pm<+1!U z{6&c$@GSyG_)%YEgRNvLLI!+ve|qKW#}5uhlO&XNm4+yv08V zFR?cN1eRvK!yv&Yi=WB$1({kPXPT`DW2{LI>TAiw65}i)EYeT9%)d1K-O^{G*JJY! zR{9No8)3|D0Vl}oUQ907F=+8ah!$)RQ5LTyQH-BElK|etyA7S4=u4$`YUs*n1BUuv zaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iE zaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaUdcN;EdTe zt9H?!w`vdl1*?|p+pg-Yt2%)QAJ}_txBlMQPY1VkldDUbKy?!qhQzT z@w-0^`wj{N^-T$Pl>QiRUVNKx+$HIqWf!$o!3}cYz}|&74)o5S(%(1tKvec#4=&0s z;djECm78AIAUTQ8&bPVH?tT-tb}j65OHf;lC!h8V`U*Dy-_!60(uX_gt+&K0QUeHb z%s$XNcPMP`#(K6HcRa=lzb0lejlXYTZ-K+R^BJ4dc-pCB84HSC2P1ZU8NZ zsJVp1sF-I&XjA8H*w1r`(VD*1lvjA7~f|x)>E0%Xa@9e8E?!8^v zzH`5aydjU`mM!f6MpXv{vwwqc+I>9N9JkzBsl5Ju^p|wf9DndSZuYi=->bhkPT3Z6 zx!2&F%->ZV&`2F1@_sK)xwT4JhwiRF7q)*zaGP*$FWVR1>{ZK`>#;u8JDD5)Ps|0* zsXCysIsoITo75h{%9;u5-6QR5tI^a=7z>P6axr?}L_V{3nD=#bYF&ak)mhY~`$R9X zX<+Ixl^v(TU*?wT#7weUnH+iTTYXZ}G?HiqFuTjv9 z{X)36P{POgWg`LY`-zvm;3`SuWp7cF&8iug*|j6%$&A>HI|PxLymEmiSzDX zmb$ClaOwc^G;TSvJlJaddjoRXqd1pvUt87_xlAl_93Rj8TOp_IQR|L%#!Zh5zmMq( zBO|->zV)g6x;Ga_hhM*8!-hcnQH`^Fb5AL|56SM$J<_=I@Tr023$Ks64&dGZ^j#l{ z)fTzOK9hMZY32R@=3T=FqaS@D=GCuk^)pN8SA9Q-9C`ML;|T6MMqlD{kjc?-&j!2; z!IxiwFMopjWC*g_7z97&!MfG2lg|X~g zVG9QVuk-NxL;UzPwod@II%%ixqI~|9Y>#2npU3Y^(D!rL_U-unGfw&n`Am-;H!ptN ziGNU8SJl~`NX{FO$6jO+b{e6RG2auweyCSE*l$Ju;CRfp-y5f`G*T9Dy#}_eq0Rm^ z`1^PxutLmxmsg%!(;cu}e>u+a|Fc>4pE+~0eo_AESbyI?dLa@UugOF3_@L$bc2?fU z^^G>`;t=($Zhru_F%pC}v6nHgoeW%?`YuVFyb7zV>Cx<95T0YLFI`h_Bkm9U`H#=) zasOR@$uc`nXow}VaoB`jU@0>@7uz=^);QSB3GTd(j^mP(V^8ie1g z_tamCasOk={v#Uq+w0E0*~fWbqLaQy9VW)7`AlqkgM#(}?0hJUYHz}L`kavF$LjW7 zTC^?+H1CJ?>9x(YeU{x(?LX-4@07#6XL|bWy-?5 zqhCH7_x3jL4L(`#bejEx?gyLI2cV9i4!Auoy@tJ>?pH7Cr8nO*{B>M3v_Lse^Ub$< ze6z-fte(S-Ztvz@z-~a?MKp zm6(fux|wz_%kD(`C+}D7rN6?j+`>#B*LpkJ=)Z)Cw-YSP4<3ekU_Q1wt&UB`ipQw- zb|;?H3gaA{nm*U%qK4X?sa< zzx%~a`iVy_)cl=V+N}M6QfG|)c8%_b_m+SC z=;*)7a-(*qbD4pTr~^i`U-Qe$q|E#Kt$V!K|2=m-+jP73MOW|KfBThR*y%AokL9ia z%sGBLY}{+XnSIK{QG{mf@zCe39~34og~HG>zdS|1v*fFdvh-P-nXet~ANw!w_A9?A(qrCiI!wKE zgM~W@Vt+e1pU=N2#(9ni#`v!F%Hw`@VgHr$uWhCcH&S*x+CTYm;NJ7E@hiu$(@%_y zyv5CCLk-VqYs1Ff3%sGBq342*^`X+z>@~n#<~Lqwx275Bo&8`VHrUL(?P~v&iB(3; z`o>tY7`1(%pV8Dc575UuN8)UwBP=VM^{XpMTKD%`m$a+>H}gD@Re!LDhIjHrsILO{ zR`$oO<;MMuyAaNZfzGJ;s0Z2ihq=EW`L(;Gq;FgMhfLzO0jKgdY&dHv`T(<0+XnX? zIh7x07}p}ArZZ~zydS|Y&szEY?sm0*%EYf6qe_ppz=^2!mhEp;`1^s2^Fv<%t+CyQ z5H?@=-jg4CnHKH;Uz5_F`^BSO?VmF7E61qPqfPl_*m`P*$341H;qMpjWInsbj$bT( zzSo+|+=DUKwJ5KEe%BA-9*cE~wfG%@xG$rAI+SBF;J6y~@_y_$(dNy(XMUd{5O{lPu`(arR#|{FeMc+0q zdc2l1IhuJ7?D+|IdbGP?>m#VUK3f>e?BW$}Qqt|SkG?c1wj2bHcDDajyXenLik{F8 z+-)GNet~vYQs}kfQ5eteihcmzFw5wir+}@W!+CbT?_o{Xir+YE_WU4rmy|s1Z2y!^ zQglk}myNjp5BrMkJg{HB@~y6BjnVz$>Lx==QLokKM|))QY&P`M>1a|Eg8`k|gO=9PfoCiQZ1YMXmoO z*Zz~V{zqFt|34pT`;Wdwp-|Yi6YMTYG-57K?f(fdK96_~G%@>sq^@EPJn}pA+F?#z z@~c%`wf{GHeSjqG|CM@-b#5){_J95OaJMW^x$$s7o&QOo{rAoNA|B~`F9r60VdVB7 z<9(psDR1wTXlvB@-^BI*n|}wOUF*4bJ!<=p>Rm89Ey};h0d@aJ65Idgy$8Ig8I@nK z{rjV~|BV|rKBux&<%^mF>i)0f*?&^*|B||Q+0f7nqqcp_FV4vFlp7xh)cv2yv;PB@ z>o1Q_1L~KAwLiY47v0_;0b9GXJ|-$f6dX|ZeB;fro z>I&cGz}feI1hr@1`Eb8gEge;H(QyE0&9>v)lS!!q!hHX;#oqsWcGU;;mqjOOZ;*M` z747j|zHPXHIf6n?+rt}nDpeF5fbHL!6#EbJ{qGjD|3r-aF&F=?v{a>viUX_m(qEC3F+fM!f8X5GYgXz_y;BNv z@Sls@_RaE+i%PE*T<;u6%2|L;w13pSP2KZ9k;}cUNw%*Sa>M`6yOyeyQE_1PirIsb zFbB|y_D|kNCD(dz#U1~-%%9`7f6Os%!X4QkuZN$CkB0-R_RwDd`+u<6bwH=tKid8W z$oqJtTwMz6@S@mlA8U))SD2_f|EoFEc_ICMvul<8$GaDxne%>_dpkUC+edkKV1L1D zI?uY2Pciob4D`)D-po3nliL6O6|;{utL+#2`x;oY4^nTSzPVhT^G$X=Fwi%bR`wrL z9{{{=mNvkCZwz9?LGxgoG1@FzRk_|d!1HJ513cNRIv{iAX8of4)1mx^Pv0EL*@G~6 zgR#~l%{uFcwZ1P0wc{Z9VEa?vHBhB&HV2ZlA1Dl4OCk)MyE?2HaxP@E+hd>4Q$g%F zSRUUYQu9jwjK6S{WzuG&~9UgM@ZJ z@_YQuzQxV{FjJ|M$bpaSuD=j#RCl$qy(J3wzPX#p(`LweYBckOIQsqA*Yk*4-*1M5 zqbc)%m78})n>CXtJ4)Q0-SyWC$-^~$n|6#wO0}V4{_T0N%Liky|1DEv*>~5*M#Z)o z2QU}-A=v-3st%}c=Po>YmOKn9cdXZaIA~txo62Rs7=!KW>|->7r^=gL4m5jTkc(X< zc74AB-v;*ii1Ru5%p;RIZQr2UfxHhyZvVN=x}f-}e67I&%ms{A=yePB!{f%E_p3G{ zU7V3S!?fKyg20LC{_Z~o!A<39Ee1`(S z*&C$o3pba!#Sgrx{P6x2`J*`Rlgs?b4^Ap=>vN!g`TPzT3)~);Ewx%X*uaqN_vqkcz5vfj&jr77s zpLMcR^tC){N3F~wPz+K*1f7;8Mw!LtvifBd-?5e0RF5V4l2I=Ik0LM{drg$JiC+Y0N$O9 zdH$2aIMV~Wy|S*|!nW&a7S8$HR~Jqr^8uro-Rj|>;yZ@}{k`-1HS^wpX4O9!>p$AR z_uM{VNE+kmFL|`tnrngFgXa;By-oMz=RH1Q(5qmy7YA0aSa<{Hy_;o=ahHoW|Aw&k z^m1(vHtRU%cedr>|8UbD)FbCR=Lx0XuTPJRyw|O472eq#;2UVWIx zZj+-gZ??!YQIgI^UA}(-yMNM;?K3Uf_`f=pT^JpHy<(oc>J_d1oU!W0LpIiPn9 z^v!<~{eizkZPg?l`|Z!b^z6$iX>pq1)@vfZQZ@=BlAbbN0QcX9p0YFh!ne_<~Z>xYj9gjIL$ zAMJn3zS%P|G4yiae;C$9ah?kA9OtxO!v9lzXYo53{icucyo8;fN%HePHQ>Du`iv)` zF4iFDOM&+lz<(0Iv+(_8?jK1K&Q7?j-cx@m#sy#IJ(x-B4-hBx^}j?Mop5h7%v_s$ zJQN$vX$u=RoK@a$G&QQdJruYO`vRbI_clsep*Jg|4+jr}X;Pleq*(5$*Zl-)aj3UP6h zK^WoYzTR0^{d2IpdTA#|v&$uJu+hlg^J1GL%V`CdHVm8jc|HC)#-m@Lemx(hxW1e)f(&{ZeR{mpuxoby9y@Bdf_ zzq(^KT}hXl9lwFS=XUGwo&7ZGgA>swzYy-N_|4(xb+10tG7rxvoG4%c zroEgf{TFAm+Zp4t#@5~4^Z+_CA@PUa-{h9Ak#Mv@PxCyoEGMZh$M4`Z#`B@_dNb}d z`%IiPPmgM!K%4J-O}HlAIc-C`oYmw$d!9SsoIK|gdEQR^_{}iH#nGnM(}^D&bNqj+ zDVN4|wY(P?x*vM~g1E!}|F<5kNr=bT{YO#pnl>2+jbeTe^=%Y%U_5YnE7aq0@Hvh$ zrbaS{Mj=NlOxWhLLqkK)<(=Es@KC>VnKR?yS0iPh?)!SEG*{Ql^IW%S1jl-L3!~bb zVE;2w(t-ZsDfRGB@!@a)bN%m(QkG@dIE#n#1AS$GweETuC%UHbd+%5{-182lFqYW` zwAY{CZ=5f8pbs6{TZZVr{3a?HTH&G}pzdq)u3mvtvR!8TY~_74-`Q1M`;7*6MrXSe zmv+{;2L~3n|*$mJmK7YNOS$3>p+yZUzjuy*3^i0;PlAw`@)9X%J9%$ zV2@d}Z4>PObo4W(BPl1Bc{s`ZN4YjENZ?0+gu8VCz*-Q6q; z`gkqsf|X&zZ$)^p|8t!9A8r`l^v3#5xG;@63wm-_lKC!mE(+RRVTOF?G3xHLYgskw zp+@q+MxO|Czw=}^yORSxH+6bFFgcnz(t$6Y`)Qu?a^5JX{n}Gnzxo8&Nwt3$oeP(@ zPW;Mc?g^78u*K^mk)0FnmN1T;!lHxoA~k5Z2lrVYKQS`$maxie6*%ahey#>zLeW*W z(P%?{A4xgrS0t6!-1{5}%@!MRhOa%tvp8Y#1-A2$b2(9u8?}Q%qXFxR9%;A>`w@?s z%H`e|R@q60gEu@@d*n;tx&Zo9LO(d7`#!a?r1BSZ9~3oj-Qb6H&dyvTie$f!8}8+B z5BJD=l|9;^kuu!6^`}{{VSh8~f**1|1!IvHcrB}y(xLtTm~Xy#<)2K4IXc+FBT%5RK$)Q0FJx}YaTFMM-I!*d$9;x}c?gW#MD&MdV{ zeK0*b{ND~V2X5;fVTHX{T-GbX)lLpW&!d3R4(-1<)1TLT-TmmO0~Ip!I&RRu9+q4J zwf}W-&fbT!7aLF?{Lgegdx*Fv6MO17kHGVt?1TL>pMOzUG(-Uhb!=q!afPP`HTC7# zDEJZv%w~=M(M~){)&9RS`zt=0P#1456owjlYqx@c2t42k=QiEj*AU`hqqn1s8>1)( zGJ0i0U1uXj}G4X=;Ku0#tz}+G{ zhQg4DfV9i`f7au%=nLM5jYt$+%Q!3+8bUsU5d}ZYcm2x!mB{$Qd#~Ey?yE**fd2E> zBjc~p4$V*IhX1n>9P8y3cYa3PUqrwElzMnH63_d^P>)?6tt_;=lRoYz54m<0Egy`r z9mZYX1<$`4VOgg(q7!lDjj|4e-FzwzIxwHH2lp35B1@d*h~xghv9Yn2fQE?9qhuaV z`h7G~PoOgwMj}VsxM(t$J(%Y#qbdu1x|<|TNp^W(K6nuJ`)6C(w}_K&ApM|iacDFh>l@ZPVbB2EzlU{7 zqd3KtpLfNELbsRt)8q?yJsVw_*^kG?yGF~wzLq8IuQ%8Sy%hbProMN`_np~?1Ra6y zkc2+m3%mJTn0uA|%Ep`nKNfU&dAqEtuYi1;K*QO51I=&Q%}fv8NA?z%)y8S_l1_w2 zvIl_W!4~qeoK|wpw={kU8tUHt1Dr(<Ent zUdAcQdhh>Ej%XhZb3bepv{~Q(g{+4m`%7R?E%*K}@BY9X$59yHpiaZRm7oFobrIT_ z3vf@+An!wrf`4(rG{@qYHwtm?!wnYJ{UlweU%wb-nQT*JS;|!$P#kdP0P3}GMrnr? zuwnBO3(k*J#JSZOLBsK`v@A=xiUW!R&KyABEb=@5%XV&i-x+TK>* zKKpTdy0HC8KX;*YQE{Ld9Kd~4dxdRlwcw#I{#iSn+~NGdS@W^N-QI1!BQN^#PnXufjT&19<1>Jz=!1USXi^cioeZIe-}MV$&|} zR9P#!6$k7bK-+(fSKAFE9p4qR;~@QF-*r@H$Si#ToNxSqEK|9P1D(o&>0D+Y44d`= zW2;-=U&;Vu^vKU1a;*`2=GyYz7Rg8DDh|}*0MEt24kEwf%UU;}ylZRW#5lZ{@L$$E zQGKJ|b9y~oRJ`IqyK;d0u%ogqH@L9>!|L(VxQ8W{dTDDboyo4pHx;iq;FSX$|GL>| zWZ^k&?y{kw7ka@FIGh?89+r6k)JG3;-O&q9DvjbmJ8)opeEg-DXMZ>j`>H4dXQ93F zkLRo^@>*;*>~Nmr@`{&Ar#R5+9Dx1&By1b<0*`k@`r(~8Um2m@SKvIF`GOzZR9eM> zR^$No*Bnuy_i@=5&Pn^_mw7jK)x9cF;g7LEQ}4|2%O90qalj7;;@;;|VW&B5A3wOt zwA`;-0Vmq}>i&!J`B%!am8&?=E*#)pL>Tk`Ek;|iw%a!c<)hgLi03{M)GerQZs%Jt zLGe}j6bF*V0r9p`Uc0SHwy&e!`EVmViFF=;bpYNCO!|FVjqp?D?Hms9-aGEOX`1cl zGlAQFDW$mQ0mv)VIhV-tm8&?=svJPu8`;}>Ug{L=<@-n&yaOHUyp$Ja8rA{4w=xX; z6%56JI5{BhTg_`1hiMPKVc{%qSTu95GU|ZGedEyJe4(lX!jhnZ6Bh?~cRKd=HrMuX zXZ=JryK|T{PmN{{jobc3dAK_iJl!@-S`;kBfhah@{k729*xr<{VUIrun?Ameg}IEI zYLsbRa|rwU4!kA)ny_h7@DvBa;sDq9&|i(_?NT58Xzr=^y3gM)?S+w%-J4|pTo?EU z%lC`3O0PH&H3tgg*Zd*tmX0^;V9`v{c^swyjZ{St|s;f#PXhYlsr_h+lK?V zH+L2G_0Gj=2k!lW^P=Xgd2hjk&81g6BWgMoe8qu!9Dv=Q)?7QVyc34^5$oX_R(v6+ z?SVPYXPQa7*e8_DZXXs+3XbA{R}K^k!!N^n7~W5`*k3chbJ~-W+3ZeU@ed>2bYA;L zv+2iJkAk5%;DrNtWAvS{{buducbr$pn0P-gxQCqv_V?3f*8yhv$AwLs zf~PoO=K%Wq@x9OKu|L82I6Izk`MEBS@=FP+1O8AL%f2-(+EqE7!-2x6_9l!suTO}c zm-_%bFUKZZ$Ep&cWhR=kt-&H8EGn>$_S75%9c&XnzleQeoe>8JCaBJ{1V&FAwkY6ZC& z=ODFPlgd}r_0OV?8ZV3vzrGdtsc;OJ13XI#-8>I^@N`mj)QT5$_4-yj1JAVyD-8+$ zE?`gByTjEm1ucmjm>3y(OEccxYe@HVncuXU?MvA=M0bR90nT`7w@&7=2TRd(opW1`E_Rd7`(NA3>-e%KIuJ=xQ`&dVNQZg|y^m5qz z+63)SE$`j&WD_~Vbx+Riw7A|C zT9Nm5oYDSIwzwQboy|4`doGSq?-SW2oD4tT*no__1YP?>tLq!qskL-Ey-hMW+ZYz+ zJ}Q6xo_O2$Ah;E9=gY?%#|m_kKOUto&tRn z+=snk!&yti&}RjsHV4=)PUp1uqpd$5ZOg;%vPtlqoiZ|L=?CtA zgHHY&^BqU1Gsv}V*l+Cmv2{Cuw}(KM1@QOL*ktNpr;(J+y8{Eh&+C_$ai@p<|0WWi z#U-1d<)Cdc`Ucs9CnvR6$IbUvE|2GLxLyaFKL>sLyW3fR(C$CL^Yg7NuUfc6hE2XF zG$H-Koc4dHi?|M2gP@Ud0%LjEUM`O=Qx zsLS8cs`6sr*D!G`jAeF7LYt^?NXYs}r`2t_%srE%!ygLM zrdkOW`kZT(?J3*qKpmLpIgw7^58AQ&embq4Q}kNLIP2A!aonkfJGDBn-nZ(3`tGQD zLA!e*pV_;W`sY#N$~7uw|Ellb{yLyNg>mjTH*VbcoG9(7m0*i|KXaMK+h6v|zB^gQ zSlc>h{pjewwvuhefh+cXny+Fo7UH=MHTR`>&=xv?J&%t;pO5CPfa{%*xiGA-w)k~^g_NoKMK5Fgj zt$z1zeTxk*g?Y}aE$V%*dZN;`Pn`mre-{1M4ZL?`XlUrU;aNs!KvLuW_OXRds@KrR zznA;<_#RkiSZerGqTW2BlXOPW+VXYXJkBwW^87O2s}A2HItSwT`1ngvhd!ZfqUCL( z?Z<9dv%iV!)2(`^N)l@o%=xJ^{%XEIv2};Ad7MS#TeL~EpH{|+=NnbKuiE`2=>X^P z&>y`9cTatd_lmZ%eI*%pqu;MRlqA_JTspTdg3VvfzI`X%uhq=@0sa1qmA!SYy)|9- zxy;{S^W%K4U5)4CtqFMdWYguZ#@vY=b4Q{Z+*h8OYNs@KfHDy?cF~LO%4m>|QIih_O^F_PWqEp2sn*)5$8nT^Bgs!zkpFz75 z`^|R@OX@tI%02|;RLE(2vJcJoCg8HpI2!hN5^WOS=bIB#oq2*=JS*J>>8Z$`Z)S2A4C~@@Qz0{?;e%}lM6?*LF(PM(8eOL zv;S3glU#jQm{oEB?Zf40CqmQzPKE{BpRVMt@+l4^mjkfXp-#5f@MzbbK|OkSawV-W zQyi$|0LFgb3Qzw#xc`vLJUls?-K&zj%BMJxTn=E~_7K?qR%QPU)?2yEZ`B>q$<=d( zSv3dno_xQu`v&cv^Cda$+-mNs5XFJyazL!r<}+KA{Rg*ytfBpLI-mVWa;2;=Qyi$~ zz~tC)ud@5#c5m|TXKJr{wQi}9WOHC5o84L2eq*+eIg>9XTgnPM#Q|pyVE@y-*^4Gy5~Dk=N-Xa88we++y(tTiO8uv8py=YX<(zqYTPqxSH+>ywIU zEe@a^Ys0gq{%qPy9f&sn`iYT|x3rdY6}E~4wK)L2zRrvOcW{~;G0xw&HWyW_;y`P1 z0PX*0JJ`0}&NgxXVX{g20h+Zbph@%LqF#d(Dixs=5u@KtD+)W4or<`E732w0QJEA ztymY}OwL`v`vs=?ahHgHoHCUfm{Wf`abG}YZH4fXimElefz`JTQTje zkBYkHa{zY+yj{ErhI0gQ)(NohCyeqk8Pr2-dG1#2&uP9sC=3(_xSqg!qWOLk=he~w zJ`ZE=%P{u-J?!=|FXIUGF*aci;!j9lggb`c$*}iBxF*lJO2uWx0mT8u0mT8u0mT8u z0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u z0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8u0mT8ufp+A8 zuKtq9fzr}NtkS8TMUU~dkVZ!#+&FHA}Gf%azL^qMFJM%dzhDhB^gEn z($bUaW*q;DGKf-Jj2G-+d^w0Hl@l+@xFWukl7D3aWPlJ_8QHQ3%j63Nl!-0th^Y~T z(xdAZUpLUfpCzQn!pxLl2jVRdyWw5L2JF}ZIa@F?8Q5i{SwhY&vB#&OiAIrKh(RDu( zV?6kT#Nw~J{6#PFQ(TBo(~G}ui}=bKjQEm>b%_U`CD*I-r-kICRD0xyfY$>A5Dz}f zVyokeM(rWegB4Zz&3J&}Uv+%RsJ@Df1x1UZf)9j|G8zgT{#D0=GRB+v9OG#kCSI=b zCV|!Q04z+)t$Z1u775(*7Z>%(0EPc)07MK92o$xr6qHC~jh``UAlLW>CSDYDRsJm^ zx~vH0coJ?ziT|tMhY>%xSb!-fQM`~Z;U_s>%Eu(33V&O?sb>a#J;=liW?ni~@nm?d z_)?wtVr9Ik#|XsVAOIKPTHsg3r%nAf(^SPHd%0<08u-h9@G^a^c+x8r+k|h=&v?`? zi+^bZn16&z_8FROCP~ZKMSJRA%x!HkMY&|}V2$_^;;qp-1I$0-Rm69l_zLo@21dNo zHu42QCj4$AzJfe^Jd#$%TNJ3L+&ln~{IdvfaH|kckBl;0vv4^2mO-W-tc*{~1YL+{ zX_e9CrU+tR=&@-xlNM!uwB?Mi&<@0xWc*TnrrRODTmvoBOWp8PvbS8*vvnvgLwtqy z0DhT4JMcmqM8hbE((-vE>DCae;mlP$$mSK1Ws7C|NuX1w4~4+$;D zqbNiyn8IRycp*EPza(Nn*&xD^F4Zl-l;e>O;U!5;iRB;_I5bDRzWx^PMSeC%?=N5mm5$zlgHc5rHZy ze8fwM+Txdr@)=L&;IAs40gN{-(mmb);2v*?u_~Ti6!0D67x9POZ1@qBw|JnIu9e@2 z2No6doAMQx;-=b;rmU!B9kJKNhc;pMGmkKkM;a5JC7vkA!pkzZ z#+#50$1Kh@9>DllF0UfW&EHSU#bk2zOq8O#FwSv z65nlt7ZsQUYH?-pF7f5!MFncfXMUE8cZu(j#f$o*I)6F3tX>sq7Qs8mFM@Xz|04Fz z;+L`m7LMW_ETd=9$3+AevD5X^VsQD(lHDoZn%#l@7UY3Ox(wBvW?r;Yfn+(F)d@V6 zxy{;1B&sM@5@=y>w+yqo7WVd7!{xVyeSRPPB8eIM^mGPJMR*n)M#d5qO`WE(B zWNHCX&Ms>wOB_EJ;|FaqhPNDV-}m`Q4SdEWVORUm-tfO8 zm3mEADz#*Z?%#ITRBBCcDwR$f{+BOEJ$NDj;*apR-R32!mz}X7wJrPrg%CX)PHG9t zvHL4zvs;eka_=jb%M0w{W!PtSEWeUJ>8CrM&lD%}nKFOldF@fS$7ZwHmz&dkdDq#u z^evtFegD##GJpJL7}HGVGQA>iE_2aDUK<(DX&d0)30Qf;9vfb@v~TJ3_1|7TTfQW} zLHxe8_bgycUoXp;$Z2bk=MMUj?*kLL%s-L$Cgwl))WVi5^Vn~%D_=gjLAVl+gLW*x zlzCA8d4Ms;Z|fh^Fg?r9owK2QMPWlZ{jGI^4#rK4WIu?qUl`A2hOK#}pST|Qx!LkX z*$w4FW<#0Z49n-welHl#9b@XO$=wgE(=2W z*D&184>;conSWU3mHJzox2nI?LEw8Oeg}?@X5V48F~r{_^CHcopy4<(?y?*w7kqyO z=w0H*e(hkK@y}Fh;jxH;<(~;Bi(gk-|80YMW}8pr8Cy|4U=i|^5r!WH zwk3Ys|&`It8#)h+>q#ifJWv`_(Z|?7%{$Bsm=`;CT z-aC6Z!_4^r_wQ0pHEu5R57^Q&b6WHF@}AkfWPM|G`+8>glxeBUv;zS)_nk8w7k&#?qOCCHz8RfD13k|p{5JengN~m-E-wLo7uvx;>R=sz5!XAjQo^ANIQI$oM!jT?vwoZ56DDyd z4Wvbu3mPl%3;9epU~eM)iD9kJ3eV~{^XE^uZWOQypWhNb+B(4>!E3Zjgbh2t&8OHsH@N%~@xm-p^}?3A}UKb#8QCmbcH+nGb+Z&r5tOZtwJSeLXWD zWVr!ySsPL*M-^B6Xk(@vTg8v0LuoBx2@ z!8%Vej|2Z{evb_ey+@`gWHN6+_`mct*>5l$a{scC=b7wWO>Y8@BtI^cPtfRFZv z?X=A6*A?}Rpo#cr_Zb^o^(OFkMyDZjRV!ssE`arv#CPr5wW8-aIjnsFVZy%9&i#>X zzZd+e*WmSmfH6zhbgg{`Sw9%@pbc$)PWvX>`yF6|b$t}-E&B^>ccI&Vspdmp7JWN2 ze$*Gli~3Ebt8+m&aJ&`acT0ZOrO9hq^4koX-@x_s`n2x)B`9kO`f|2ezs>L`hlW1N z{*fbm^r6h{3*dbR^7(?oR;Bw==*%CY1xt&NNdKysKYDu#Ol$(F1C}vbKui;ODe+Wjy;4kkK8LIIFIJe_!q?|C@wO zmt{R%da=?!V?Ug86y`L{Bg>!*TKA}yq1|bYGx~~`gWnH;9}`G-EYhd>V;`Y^$=ut` zuu1<@Pi(5pkC^-CGToPUovY~|vHwvmKZB8+`@jB=&%H~+l`hMa^E1SSILSD=*7DgN z$?`$xv+hezrOe|V!anQx_2u7O^FoFHl!aOTOta5!C(8v*x1h~BnQ|=@&aLa;vJWfsBJC5vL5zd& z{iuW?UDkc5V=8?_;3)cStaE_3ph4h;dPwF0yz2ZF>M9v7UCz77^1+A4@w;F8&HR<= zId1^^FbAo9Q|c_^NFVSS1Aad;_v0t>tKJQmtC0Tx3No!%SD%Bzym(c{Wyt*Yd_MmwGc1nzYSJ)$F7tcLb^MPx zo!jrx(b3nA=dwqd?L>#K-D5Y_E96(bt*(3nXXyEpu+Q@+hcbJ!&UC_oV&U4Psu3Q-gICXtaD;%$W@PX?B z$8bJHmSg84-Tq!-+ocB_uAgvj=5)8?24ikS%(K|Rlwp90dU|j#+5a|=m8d&beYT_1 zE(~xVkYzZzNOO~{E9_vj62^KRb%AaF8vM+U43)8733va-*!3~&{xS^aDzvIirg(sDZ3l^74FTotd%eUTq z=k2$C`Sw%%E#4fza=Ek!zvIj0>yO2{adBYd!s4RRq9T@y@aqOBj48Ux_*YI1l*>1; zVEl2Of@?+l;#ZlcaIE2wNd3ji0om7Ly@mE|KhAlh?oOkQIDC9~@KZ8A3?!HL%>74y z@46-CpLHDRR4(lr_1kK_9(8W3kY*Nbp~n8D6D+Uc{XH`WVO(-w-IxU9P=+y1t_wNA z2HyI*M1yv=uKq^f+HE^zS1!jem@B!<32$pS(=v}8EY#%}*5T)t7R+PY!E6-9@jd3G z{$a(jnV;i$^W1>B430Pa(g6A0cxg84dIlDAh}Sm5)j59pS~K4An{6EWYL)SpiI$_p zS`6kMfTwAEVzm>jX>o5wPP-oNGp79P``s_gp)Ol-JNw7$EAv5eUP!u(v!-{l2lG6U zsr*05vgzt$%;xi|ru@J^dy<3!dwjx*&n>@xL2s~Z{=XS<*7U&t?-HKXMSthw+Oox5 zK;5_xJYHo@=j0zB8R}s^<(n43hZ%u3Gmt#)OkvGEsi2dwwwae4Dbe-Ud9bT|y%l-}R9=mi5L)tIl z_oV3CV{QfQ-5y+{;8=`fSEi9`?ESs7U$nzT^53dH_x`cEH=r&bU=g<6A9Uk#D>f_#+tsB`X}yG_kI-AkN8myor@rs^>FRpO5&N$b z_)|7^dG=YluG_9`&i4WTnso`ZZ*}u$c6pnH?X}zNTkUX>`eU}=m9S<0IpI%O*7B@# z-6kDcZB6UfkGAV^D-D+nZz#`9SLeh9;`onYjI*X^J8V}z_*^%)0eife^DTDi{e;=I zU&Qai)9Tx>3x87Ib}_A8`(>TaCGR=!!FJgXo=$nV50(2#tvq$>XWn$Gycyva1_xg! z%ORgl`hqr7d!B1`GCoL`b+@1Xo5-{4>_>u=vB+(qA>huzoLFQr?_vl~||xFn|0;U%zG^n79Mp>RMd2Yf#@;_5z8x;mko+ z_|i|CKwm-MyJ^S_y7n;i@ntfNkgwR+S<}bHUZPd+mTBl>9egKqK69MYH~L~?O-IrI zyL|-sh;=3zhP|*$9rK%HC)4Zh;9fEh_tMtJ8FXNLAk)xAxm+jW-mjHnegOOVn$1-r z?JXAAv!Ll&a~R|y)}qX1$#0fpPHTK`G_MD-y+HVSYdY?IlW^#w+#BoCV1B@y#`+E1 zYWkLx2f`}ri~^pq$KIM(`jO{abGgX>6B#F6$n}S{X|$WIw$Axeq^Z)0{b;hUE#a75 z%Ek;+|HfF#(%*zW|H7Ql`c3_Y-G2i6L%s`I>c%!QJ?kFCV;@QOGjd;`jFax_kyTrv zyo2%Ew^2RC^xT(Hk3aT*!(Qvcv5!TTkrdZ%E}ZK~TpO*!Kc88f6ugNC_XpYFDB3SQ zUd!9W|2PRWu->JeTJb~JRT4kz9vPXIZIw(DhpX=g(Ecj%sc|iPR#9i1P!liv6sEI0 zQPO~U*u4WR@$|b3#ha`zo8YgVu7w^6TR`{Y+V7Ifym7 zGsD`i(<*(ev$#ITwiG%g_OR1Mz4Vn!^7+c1>{iQB`8$gP`g#G6IPou)x}&RHJ{E3j zQF#-OK4E*&oHz!R!T~Ala7?GV4HsLBF#B07>27I7YDsEgDpfjKcc+e7ApBDU;k5lTUIQw2kIq20cETde^*e;ygcnnHNEt*=YPx#)W(R^$jz<>kx-fjK1%a=~?!}~6< zCUO#g{AL*A{1k`2^VjtLGuCa)eb|QIoWIh};GPOUWqQqnwYDR14(budH7%^+GLC7$ zlW)3(kMYU%?rC=H|F7n`Ot)ovNyl|M(Dh(5x~=itqquj^>`u+fg4`RP1@OWf+DWtW zyu@Qa%6G9YztKvYTR-xy$6Bbk7s9Drr#Wyhbeq$H2Y;Bz<=*L3p4)J&-=^Kt*)$zs zH7XZ4+s`pKw0}oiz&x-v#dA`P%4`$|8{V%eO@_U@O-GoG%EXxWBC{MV-@oL%DDKtz zHrjymL0{AJA0j{I6Xp32?$_kn8uFfsINSw<@p(@B6~?*6@!_G*O5B$9mKVczr`*!z zHXUISwk*f$a_<4~;@pZpmv3>|F3LP2t@JY~ZgqpK2f{F34iK`Ij#Ad`%7WZp6BAt^(6Z(Zs@<_qWb-Jjq=sR;Z86! z-ge*Y3)=Ajb!6S126KM4d&v7YySyTd_d23|6m_1sR}XdHxPC_y;h?=fu?AO59p6m; zPWz&3VD`=om~poIW<7651M2=8?9!XVI46d@x1n!*C-|lDE=Hc=Hm7m=MjKVRPpt-L zgAbDaYHe5h<+QK22A1i*G$%Z)XPhN3K>rg?d4dRUMnCD0`)X?`ulgF+|Ky_^`c3zi zN!q0w1f5PWn(063m;0h+eJ9>}~R_b?@oU&rB zu3o?LV*L)8ru5@%S0h~67lGZ@?j4kUA=GV+^0pe+O8e(l{>|Dy_DQWWmwxbNqE#9j z@-1schpeXBqp+ zKS({dxNNh{{eLsQL4WVQdwCJ+!M{7{fba0$nTH75314Tu*Y0e#|J4bm-|*$TuKO7D zV1sX3O*SK702X0NdruDMRM1&0MiKVeDQ>7pNhq$9f>pH{lINmeE3;P>)Ie#Ag z!Uw$Sg2-dJzmId(pzR4sFXv`ZukR`SH7?FUdZA75(dG_YCm3j|7Vume@?n**`Jb2h zZ>F_EF~4ufjuGb1zi=nOB#VQ&rjFv+h-hbd(2v{ou9H}+gO2TYBzcA*)9*#r|r_C z4yrkaA@hP?o5^p#uDs{iS}t(G`7r%H?wUB<6Xd2W-mzVi&qP1?dZQJFaaN&vw!xI1 zaLngDWM1Hx#aVy@-0riK`OL2ESpt?v9kiUY5qMy3)|}tz8)<*$L_fo=dy#k_P^0vq zwWgh7xxkO-aX5FzIp+fG6=$1q1_Srft--zFPPm!F9m^H-*!BBTA-hp?ey4BXw!(=X zI~#J^k6(dddY3_#30x}o(^b;x{NR3bTbq%2r3*a2Q*6p5l6f1syt^8C zZS@&=|I{uoaK62+Jm5k+P@mNF%a|W<4!29sFutW>SB5nVvbfL|hJLriTD!PrXBA}` zpyzAIU&{mRPiA{4)0kc3+(ykER@4>Vg=S7Czj-bRyt%}JBkv$TR;Fp@!mjM^?9Jib z5S&@Cz&7Tot7qq$!RNRTH0;my9kzXJ;~M1=drPs80DJ0=pl!OjXN3JWp$j+{WxX@K zQCVUizO)yM3wS5i-0xT$zqS$Fc8OyhVJ~0Wf9BKT&QO~+fO&YvtzGD}!_&Ao)#1K- z?RjbAPBs9YemKZ;`gS;m8K(DdMB8cDwE0^blx1`8t+S4}YYQZ-q`08xJV%_0cc6>Z}2);x87+*O<;UBLL}>+H+c z;c1a2`+<#UmvLwhZ0Eyj;Ir?T%Y07eiNj_426yTE)>P|A^nlxnnQ|O#{gR2=nG9Gzu z@qMgr7iVrTJ^(*YfL&jM_RQzmQEkwVEKuGV zl7B(EsDHS>nCDiDHoqHDF2`w@n{LQgE1&E9;M*g-Q$+I9s|!4^x4AVOV#&{Qy|{a# zmA;zeT2O-d0bQEL??gA_=w`YAxmoYBsnf4mWNX8J&KaS8SmAcAz8USTtCB*SGajr9 zwXDSq6Eoo#ee@W$=_XQMp; zotGjX-b%`RiTe>+p~*{L&|Y^(VJoslUGyyLQ7<^em6rT&g;txqYoj_76a>#8s|NR~ zc(7yiOL;~h2%MsX0s4P!BSY719&D$CHpumPt0-x6Tb5WK6!%BCJpab~k8r<3yU!2X z@rQj0aaKjX6JVT!gpM4;dYStO?DDBF#R0{E_TvE7DsBI!c+mk~iAnSEmm+T_=n~fp z;FVik;J}*;7M3^Sa)PwD$U(dr!*^j87q=Ae!z?asE#8P(G}V<(?aS*EaESulxy2>J zo$4+LKQCxtc&#fQs1f<}@Dk#&d3SLUXb88P7dfQfk_LLfV<&O{LMnB*aXmt5LHYKL z1I2~K1?7cPT~mwG3pXv8>KZ81EiLY%n<_0%8GhZxgF1LTr;Kz9=pH}UeS>(Pw~RmI zVuej9mdBeOba&$d;sZ<;7J(mceBgZ#VA!H5fk+z90>is0)rHG0h%j6*wwUS$?)(kl zhd*A1!M}y14S#$!n19{n5*@%Q(nT3*I0Q|;^Ut`60 z9%15=i0VBNFK@Vf&D82oaX@iEaX@iEaX@iEaX@iEaX@iEaiE3+TzBW*SaHrJr|rx+ zeXncrH2~*{XZgDyk}i#>yEttMy&TpJC4G|Sv0Z7p@Z%k7H3+Q&fMNio_^y|C*005g4BD+ewT`k zMj>~bGEm=)2EFeiNFFtVwS4K^J8(9)=vS`Z^Gz37W;49p#G`$;J?9#aPT+2(n9t_B z;h8y2U(f8GXxpFjF2m~kr@r!{OqOlN#m_fvukNj-cw}GQlfiwS=xct>4?R)lf!t5` zB|Gaq9hpWPF8 zVcU$)>|_yVnDuSoR^{p@|31Czw)QfI7n^=yp0RJqtnE2qH*`X-$M}&g+YgT2(3dz> z?^F6c+vcu5z`XL&jp*Uqlf0jI0(YKr9vf%>mQinjrue{aD?Mc z)j3^=A@qKQ4IV>+KBi@L+R!qF;gg)e#>285Go0fO{d#Ys2y!y!oX+G zeKtRLHW>9y^E-+f>z=221Y?I`Ctmoi`<>eVL%%fZUydHffKvXb1Agg5pWAS>0S9=s zHQqVl7QQtd`Vc33EzfUy-l1$QpMJhsA$jP>wQK+5kUw#^qt9y?#@6-sY&Wt)mv`+t z;emePVSZ)mB_GE*b~x;{+w5EY$X(`1lKe@F9gSYXz*EonNwj*Gp1A+6k&XosH=NnQ zjt_WucY7&IKW>ubPg?9~^b%GW&2I0-u9$XmXt44&iI=j>X~qjA zg}rkx^Z9UpLY;Qnx6VDBe&jCm*!BO`;4Q05^6q~t4)ihaXVXvUTe_~t%u~PL;Njz5 zbO7_~b#H6>DI4-SxN*5yYrvc}Wy*UBc{ld>xb|j0Wyw6;XDMar$F;M6uXBIroUlHS z{vy{cC0(t!u%Ge9a;O`_U(rf==Dg;50D5&Z|HV)YZQD&?CKPi9q;k@+u*hjSfwQB!?_5bqO z&wG`>pL>dfkYB4XS{?sM`FnN0x!0^!{N+~VAH?{N{1o@;d8q%}+5VR;ncmW??1D%i zhWtrOtM zfOwY7Z@Ed#*UCPi_~h?@@8{UqsyAV*=lT5v-!uC^IGA#^|xgExy(ggZ6C6KmglWn;d7GnVk|J>miKX|Bd&gV+2GyiO8Z`~8`|^S zAL6&ncF4Eal6Bj(G9MI~PGHWemH9rb+1^_(-^OGAPJA!LPA7S`Ix;!>>~l6+>%)G$ zE$rx$VSdhkJMEv9Fgwld@0mS0u{r?U8u1%_`tz}GW_PDF*xz%HC);muZqBKUP6)@i z`og5?0B}0L5pU5y`w(Qi5$;WP>F@^OQctqyT%g0bDLeR`6(;U~IxjIgfV-#~t?_Uk z53;SCv$T7+7`C7DCV%aW&XTD&YL@L(enk@Q0E6A1wQRfc%bc+1**2`(ud&E>r8zzK z>7(Db&XT4r-|rR=FX@c&$y$`RskTh*1*|9jd@P%7THiG)kNqH?Nk%{S!CLsdM=J9)%1E2r z#73~Z#P#huv%4`~eAI(3Jc_&CFJN9TWic(*-ugk`^Hv(|{9K=J*hbj&0fOccZ4Y(T zIl#{%cgvh(PMbrTqrCEi>8ax!_uBEm90oi}<0FHCoRzb~lVb+n$H6x}aJR_&`gfiE z2!H%$824&0ja}YmVWJO@y9~_qH2bFPIe*tIos38Ndz<0mH6CrxnG(J`S3As08Pr$c zeV04f&^h#t3nQ8Byp)lIv=f=k4z6je*tK?sw=;d(NRYH*z3{`Han5Pav5(*u56Is0 zT@t7(u5t@MN$J4LS&#f&jXKZE-Hg8KUnd^H_Wj-UlFMMetNEWX@8y*zV%`IN&t00A zfcADY^NERE_8URBi*CxcrhzU0)rEoFB!FCb$@8MpT!Ith5G}FNTycrj- z-)`;}fqt#<3fGk0)!s=5p5IGda9f9%`O?a_=lay+eQQCk^ZRQ1P4?;Lelb67zmz}g zMmMs6{og0^IJ(2yZxFW8Vdgs{yqBfrtnlposAGQR9k$-)v3;_G={L-wE^=;P`R)t))2{KyJ#c<; zv&#p4y~B0cwfgI-ZZ70ky$$di&nwizHSx-wu7d_&_mn~Q8-A{7ueRfh-!Q;@#0x*6 zFAt#2_^10k-p1a|=s@42$-O@IeM8O*H-jzXLEA6g(^s*}M=&qtY95B|5a4?rW1wvs zk~jwC`k)i{=w$A9pU+*L=$4*Ka=CwFAJl#MLOyHV^!+3b)D@KBWo?^tKr3W^H?HS? z2J75__ev9}uV^8yXH*}&7O?$y#rUmSk zuHa*zd>C)q@eK?&<=3XsMxXyrb7EZpIpEzNZ5-~;@jHQQW|su?4mIx`KMCz!aFSOg+gxd z|GCFnr3``tMxXt9@AA^y38%fZr~{g>W!ASf54l;34T5e3L+1d-Yb*T99__a6`A?@mLg1IET$?APPq36-E&ETtee;hzxe+Sgv&-#~5rtEfKJLkq*DCcKh)@S{3 zV}9l0dSR=w$9hV``ytF6HP+?*jV)Mr;Jq#G?=r(w-~Kq@d40~V?#8-3W$Fb3$k6j! zALcp(ct6UuB`;;Fw6z?73@$M9)bF?7Gi@mEG{0r)`Lq|fCSFUG@-^cC_bp<6_7AP` z-sr<&9lBY0PVsBku6+sc_-)I0V2`ZRJBvxuW@Th@JyGa9CP21h^zfu2CoqzAh~MH|V|Q&^yuUY08vWf3?tLy9dX^8X4hxe%R@mIv&bt91 z_j^3rf@_%zxajLeG1r3q@qMAvQP*BzPd@v^*aJS1%e~Xe-=z6@ml5?AIG+NXZ-?vi z?4f&`LECxncqa4KD0qRnoF7Ch|2jTA1Y64TZJMC=>$l1`?tj6(Vn?EHbX5p_xn}ji zYCq?p{L&oMd}M$+bT{;Y=Qz^T3Cg0CGeV8{!gFeqIql>6nR48lggp=VdD=H0Al*E> zcSs|A!-#|Ye;*_bp}~S&ST908*TEgb?6G{?>AL?HR4zgjNnfqxf>oV9+g?jc^kj3AF#2@h137BI2L%S5PkHx!s zpXZ*`6a_6fGsp9?ytf?kTE=~+!bUO1hMs&M{V?1E zmpKaX4(DAO+&hJ^J;YrIIJ-hyh-JV~9$IEtGR!C-FQfwD`OCC&Kw)>}}*;jim6a)9^r>@9X}! z7VOydehBorC%EsV(|DdFdLT!~`>Lamv3(iZU;Un0P7-h10heTr_q02?Kd&A5)+sph zeGGdWjINDGzkW3C{Y{-BPd{{U9t-m#=c6uoFfv`pWq!voXBh9h`{9AgqjNyK366J} z&@cQsbRoFDftXX)#yMx(sb~B;QHR9)NqKF5=)#XtKk$y=wPMVm?^Sw=^@W%p&u9LH zc)mY-8|UEhI~Vo(w=k#LOP%UO-YA+B2NVYs2NVYs2NVYs2NVYs2NVYs2NVYs2ik=L zI9nL*-&~i3Us{B(jj2?(99 zaP%+ze*~Ay0LPsCUjP6&=H&mPM8lt^pRXQH=^sTp1X$8PghPyq^e@ztE!db^Z20F= zuVF;0E6x8Gl;@0?E_zcb(6>mG)NS}Tr4|azOnyP*$Mkq0yigQX@uwFERF0AH@Dt$C z(qCLa9!h^VG7Hj;c;P3}6@MuO*6Dc=+zsHMZ9u@N_@5DSf!Bx!`7Fc1KY-{)@r>(7G~f^UgmiU5!@p6gOPbN-jjj!Xslrd;;;+bf%CAd!tNw1? zUt;{CXG%g@5TxtFKTAgOg6XNQ-xX{9#}^HBbOO50dxU2{GbzKLX6@QM~Rq;F9E;xl&+} zdDB&ZqJl%90RW&;c8NwQ{&eMlAB^Q6QUVnGIz}joMZvF^$d7K_FA~5@$P6ZgA{>U7 z;L1OeT9h8REXeHdviQ5$dm@YW+ID^)O~PffM9)Rg(*L44C{7 zq6r&BRK#ma6yfK#B!D;ZZboMt`ckQFTDme?hoSyg98erk98erk98erk98erk98erk z98erk98erk98erk98erk98erk98erk98erk98erk98erk98erk98erk98erk98erk z98erk98erk98erk98erk98erk98erk90-X6IAgZivhB(*TeegA70Z^CH(%CUUMzn6 zW*Fj_CZyu$E!ccM|0?~x!By|(eURerG2Bnt$ekP-`si3L_deb+rtX!Sx2ofd@cvzA zcIfMw{!0JSnG^6U;CBapGx(KV{+RZTzNIr4!97910!&}X%#%!fzd_syn9ClCJ4Y`x z?h5=f?-1l&fz$r* zY2$9|f=W6A_GK(<`O@j_=WAa9Z%A&=x4Ceq_mQyc)%ZOWgnj#kf%>L|J4!Fcn-@Re z8+S>1XW2n*Rd9EuB6B{qHBD zwrj8D={Qaw*{<~f-&2K+UJ;j_MpAArb2aLM{R@Sai(Md$Rpucze%dnxxtajCIevAwL-rqa>#n4z4 z0N^CN6&HKGgZF4LMmTX|Xy`u!V5tJ(lLMT~hmGIVZg$=r-|6DsyR8@3a{6RVDB1W< zALhxXVz<9U^$D=1F_QgYC=6FXZ8^aGH)!wAXl_TY*YGRrc1eOs8f_U6Qy|vO&QP{r zKVL|mpg-_yyixy|m_##gie0{R?j5j;KPAad!^8z+f}8j0UAJ`-1T5bCOjg?)$X=mf z>omMyj{S;BkXNVS(AP7&C&v2Eg=ud|gpKu#r$~F;@O)!5%-eU;N@P!IfQ&6=|o#VNOTE_?Ug%Zp%R+?Suo zF?tMm708n~WQsBFjh)ao$3VkFm>c+?IAqq&W%9gT|I+DV3^tbVx@Nja-b5{1?1MQZ z2HQ#C7~f+*z?d_^-bjt-A)=OBJC?V1&-9xx?!8OdzJ0%kydjUGmM!f67F7rMvwxj$ z+JijT9JSoqsl2}3%deSFbNv46xam9h`)K*qQOdTE%e@8XWd5n@fL7`Nk@v?@%B@|> zI%vo8OJVz0`?m@E_Of%~O<%iYNjcKTddG8v|Bbo88C3_gRtI1_b*tJ#SX(n;y?dy0 zZ8e;_31flbS}um~8_Q>Q4f4K@`C6A?PIVe}>F(i6EcqoY@0~eX*}lIzg1n1b!ni}@ z6Z6$>gQlD338I!?lFI8_x~>Pd|Dv*gfA&w_E$>mr%9BTrTdF{LN(O$!#8T*BBZ=v>qsO#}K%Y)utqinx5 z+n4+UFRzP3mY55e2*V!cCCr>Qhc%YZ#UaP2%fxy2L#6I2H<&trJdIk8EDyFC{oa6_ z_7u(~JlK)-L@pDF97jhp|53F?0DafTBDF>CvCm}QN?LjUzj4>_e&|P^ zfO++6+WpKD`c*&iBS)S+;y8l)j?tGm05UlW?%9BM5%_XF`0`h{&jlc>wSMqp9;{nk zuWUb9+pqAmcjgAaa%m+W@4^T`r#$ZY)v}FnE{tT~3tQM1c%6sepQ6XFwS5Ax)p09* zm*n$rWP1#o{u+K~g1#$Z+jrsjWR&z3@|kWcZf^Xz6Mx@|uBx*=p`14$kKM>3=rlqn zBfckq{ZO}bu-}UQ!Eu;xe;`U*X{9XS`fJ#>hBo^*;O{f7zzQ($y>5AKPPg}x^6PPq z|H-iJ|Fom$%9rFfB=Sex!ls`*-gilP3p4Md`bL{|X@Gjxv_Amb81h4#*vpvLP6Dp6 zzDp7%uYxLTayUEShv!)9ONXxe=u=#C*@kjb|E>mZQSgAg_9LTK^IV$EZULM8dr-RO z29B3LfE|64!`c_nwqEU~ER`m+$q&Dm?_7Q@#{Ex6q5I1IF}Kh={S5C*wA1&n!^HSB zpNVX5kl#Lll@Ens?Ohm8pA*phSkt~so7N@1=KZify&?+RXBo==^|{5q-r2*fIv$lD zHhFTC`pq?fplIV;U>J8FA0_^6Qx@hOz4BSVw>P>s_>}!u))pR(s}DdOK^<^cRC*12 zJ>9EbHcM~3XZV|_XlR3SHt@~2W_+{8M?L4S?7w3F<>fuIds*cXm0#a0qP-B2UeAX0 zh9}-Q{3`0QNY~dQ;+fYmiqF3P%rB$r`>Xzc)%M8?>lJ5sPr_7`*yi+9{Y&?5EV@cDd(wlI=xvVTepit+kcy~|LSgp$lHI80q~ZPoou2E z!^-^BibEN(#5jG8|nGXZPC!@t{)U6E``F%#a?-e zerGB2b`JerW&ic|kNubTd6i!n=`n9M8Kho1!NQ#ck-wds&*xti;XFqOV|>SY;BTNRQqpV@5Ff?$gDrcojf7xtFXIG?2ntvjrtpRA)L_(b%39Jf0+BbK-y!} zuCjjz`-e=TwgJ2H)~q>e5&8hrVcQ1x9ody1Wf<2Y!lpB9_`Dy%E6?J5zgyYAz5P=r zUga28daMPGg{`-2f5XDx3tXHZ`Z{Qh>^_8``O5d6ywJ!}qU_vnU&zgM{9`RqPc{37x5z1CdjevG+pKzRl9yM7Ay7_3vQ!0$VV zI~4WPK^&6-$K|M(_hi3`HjjS)68=U;MqcBUUfANCw8yAjxAyO5k6?c0CKgtD>9D=t6UAKcR)?i@MnBT;W^O$=$ff$kYEbaSo*znjoD52Ibi zdFt#kwAnkvCAVhf9<*cmrON)xWy+?R{Gy6Sd-y{ycHk>5`nFNg+2cloXCU!N09()i!7f38b~w-*q7>~^-LcEmmI`9Ep%W>?w2ZvV@cl@}&Sr=oZ- zEJ^gv2VCM_|1;aY{8sj_+kcYQ|7Z*7|K~$(|IxQ76bhTq2fIrWjj+4e_WwwmH@nLI zh5aXH|BuvF%z=k~hh8VlsY`yfiyPtWk}Snq;aX;Jje^T!MlG=Cq%9XDS+x9WPI3>$dZgd=& zZ})$;gZ=Nlr2P8mG@yA&So`B!dg1N;5wNv;nq#6;gu#LNbpLl7*ni}A;y2PM-Y*fp z?W1nN{6ZrW#@RPdXW;$T6`_LQdN$E_zkDsT6Ge zj$_nG>K-6ae zl>JxjA9Zi6d;Z6Ax%b6n`{hDz@c+fH{R)HSOQ-j>j?u&PpLXRu*|Hpm*H`@M3$opDpMCXGYUJ|+OV{H-p3KMnbe{>vAZb^N;(<`F1cWYVm)Ba}< zi%TOg_jY*HwvY1e#{Po0#6`0z*O>#{3((&?{fyNvqw>!;`|n#i{d8Pyzu4c`!kWFG zdIR;%RqC9tv;8F@roVS49hJRX%b9QXPhQ6*bJ*`Ker(up9*i@Fz)HVe}+E5 zb5`4o!hi0G>+9y?taEcg`_DOHeWY`Jl8+c`JrUPgKdki~>er6_-6WV$^hE z&i|IU?)Zo8U+mA0ecb`@vv$&Zg=>`q`z)RLK-6|IU&`z2nfYLq)6soJcAj6KSG+>q z=@p%9!zc-3)Y183lTp!0o`xYSH!#uehklQr(YLtG4L&MOvN`aX9m}u88r8i~*+$2g z(>rq;c^Ze*Cx$a$kD}j?eLYX8_5C;`98Q^g@3wwhv{_RfZ<`_Cc|acS)4Oi#aHP~2 zD(2r_2D^MT0{h=MF_QgoV{BAxyKw+>fuDl?zo_bf>X{vnym*m3^ecC)*L~V=Ugn#~ zWxo-D?U&idXa!G|H@O_(UKNizAIrLWB5`bCpN}}7lg~Ucp40a5s~yPuNa*&T%dGN? zpUT%B9Kc+_a3bw1XL!94-j zwqg(SDKEzSS0A)v`K5lP%rkPZ{f)Bip3G(Xy}*iU+>d?zAM%2iO5461*zcp|SECNN zb3WOzA6jAif9l)4{2ITKPN&nGjpwsBOWPN2E_1sVcoX@-y=wA@ao#7FxxfofDsB67 zpl`|SRu~K1<;O1OLmu*mJZeSKlf&5oJKObgS1$7Z(hf#YVd$SchCasTtvE)E(1B@}?E3j*krWz(!xRvs3i7UF{zlAJ^WD{G04x1QyofxQ5?saYAg$9sh{3}1Wst#1Hc z(F_L_-}xL^wq5yUSQ|WhKGy-fI~nu*CkAn*2X=c+Q@e$2H`6Sf^Lel-oL1%ohBG@f z!$HN*9}e{O%eF3CG%|yYllMpCIT} zFgl9^%a_i*gY({T+GCVuqs_lLs6D-0+k?&8j`{6vdGNoSvGzi>hlW1t zRJIDA-yGl@cCd>JV22&MUl{%U7tq%^ook^%NP8@w*%kKsf)|^`p1u>D%Eo@-2J;84 z)d6Vxi%#XLaK!=1f&PzAzaMqLO;NVT(Udh^8?D*>}J;Te^>9GOvBt%x8WBW+6zMH^ag`o_85>*qdfiGr1b}g6Z-mx5Jx-QTMaYT=AH?}MswQSnl)!t_8UzMYwr&PuFbvx=-dOX(jG_t zvEl3|!4vL-uAHNCFg=6yGP=x3q;vHvnLZ5Cm940ZtUxwcGN!`yK9zqd^o#A;szTH?L9BHIkKE~ zaB0J^nJb&|&o&i6@?O5me5$t)!7SAU70+|)rCbWaR(y8)hcn5d;<-N0q zqaL^n{qNB395Y~FCd$G(1T>)JUSm8TD6iM!UbC-8N%Q2e_64;0p4WsM(w)=Rbjn#x?z88)1J22F zPLb#B#E;($LtGSXdNZB4SfAtnM?<-^uB(;3z|j4``xnF=_W#>vv?d`QWA_Wf;x%nD z3L3@y9_rgL=)id3s&=Tyqu_HCWlRiZ4hln#W|*+e7gw%a`BL7wZ4M9gJC`{#3VyXx z2I{`U0;RdWUY_N;O)EGy%Uc-M-Ua)g3X=}>7f)`6hl&q|1DNaoV3@M3z{XiToFC|| z`0I7o%{b99jn{j}g5jQfD20*CcA&la{C?|vxf^}x(B3jc|K)dK$E66y^v4rw&Yb1BI89ajh-xPsZ zR1x3+`la^-seg8`AkV{L|7Y991re5HLE=8Nor9p!Eex)~XS3Ot2gwu8%?C8s@45~| zd3y#)17S@JXJMCCjgLgQD%)tZA%6^|9P}%a%4_a@4uxiujo8E2Uf@}rp!fpY zdCb0?u*Z$szJbwzbw!sn+>8B)M^EH(?+mK!B*VcQ9?M-^U5}O1O)AWZlXhZO~8!ZqxeHsMoN+8Fj%=IiG^D$Sd5I z)lTWq{(sIhU)=Ifro$W^Y~hJ8%0b`sr%B~E#yl&+pxF(~!f1B;Aovp77>2qN;Jz;C zNzo18Y|`+Y#vS-g=<^^rCxbIfol+l64iEmX4b8sWdRtgQ?-iHzig2})g3$9YV01$J zFV6JmHBWax+Uh`!%-oI}w8Mgui?8;-DbCpkarR;j>VyBA%x4c2_he#E9p@2vzLR~h zU*+?!3W|m>;Gm8T?LMyH^q`@>91{j#f`A#<_#f@m2KL*7pn)(@kHs?n=bod$XmCIN z54P>Y-mjo&2m?+;Ydf>(e?1*WePBLc90q?{ff-xhS$NsmhfIP3 zP`8JA7r)?*KK_5WF`pW?@y>N}PCGS0?5smYt^fO$>r{~I|6|$gwxQ@>(=kGDeV97s z3LpUW;^krU1$9h`Yk*;t1Gs_j6AOT?6%d@m3WM%OFxCFQ3j1q5VyKJP7YZv|dTY0a zfDk<33FkJQ+t&c%V54`Tj9bDe2jhwxLtsl_AUkFMe|YP+_PPh134H&5L}T5GEur1} zMPP~)5+L9h%DSA{)?uSJ;diY$EU@o{@BgRRK&v|c=cOG9X-_BbOGJO{@xXMiF-;qJ z?mHy24FoLvCK2(1_dtg>&cNLwTL;3Bkbrc``G3~qk?0HFhYd*-T+28(5*h+NgAoQl zjCcJi{gu%8!h5ee;O?tdWPtwjVWII?Z--{bbA$ig3XaY4iaS3;?k}R>e{wTCT8ZcV zVyMTi3Rf1|eMukplZRY83zrZ2*bd{aAA#rJ3bCwHTG5H9@`hOl!fw741s#~r*opfK zLXjoTazt_e-^j?wYd}Lt=TS0`C;dJesVC5xi$am3Wn46#%kIZ>mSL5JKHaU7rX;(( zFCRPz`u(#_>{~=hw~%K}yg?9H-x2LYG0HY`Vrb|;q5qth4vYrQEfn&Z?kHu^N?F*S z9@<-6X1;PgYCMy9t2r#-?@heNycJ$Vm6vzvz+TS?gnnUz*+AcXb;}3pIrL{L2s&Vg zn)#h5w>Y$#j`a=eogip{?cdKjrB$4w%FnxE1EJeZ{b}$8yq*rP%hD0=`{9&8~m%V{UqcuV6~prQHwz{dN3cJHdkNwaY; z__;1^NWA~YbLXrF#yT?F7r3kd&iB9t$LFgPP=@AuCYPw$js59c86SL>I3RN6P1_wBftk|aCcGhh^%vV{d##ritFw5sS&041N zD-I|Q=p2CUg*FFowQJa7-PzoUeD?F!bV2)*e(pl)qT)at9Kd~4y98}(jo_g#zQ2`D z?!9c3Cx|$^ddG@~@+%I^Hx6JeKAbbS4&wo?GuZJM^N-=Y1!BTO^#SZSufpal2k_3% zM}lZu-NHcI@3G-ag6$j}T`>w+}LuTp& z;C$mJWSPoU9GI^hn9ODRgRp52Fg7{${iO^rMi2e$A=etQXRagPZIOIbuHrx=4)9zY z>>%_zzRYz4%DbTvPK?8Q3IAix6V^BSJ*PLrMa3%)bSek94?8T|a)Jx{Ke!n`^?O(% zsh2jj)0ymMd{gm?18zCM@voDOh8CXFW-ed3@|A9I1P-T!hKFe$0QJ$MTz7PXlS-pF z&TMU>$&W z1CxHARxA8edFKxYc<&we+{9-4`Hb(jUrH(Jc>wYXbX-A) zgA!{niY-8me`0GOv9%Zu&76d2DiM@MA&HqhKfwxZwca82un@Kd!y}w)5&3 z6YuE;_n_0j{(cd69blAyY|ykRc!~p74xqmu-TRy_`xBgxv*H<*pX>4{zm$+V;7^5- z?0ci4U6nI`I8YeY-i7hzO$pKSN*{pd<#@&*PDzaC2LBs)tV^@225$fs(#)h*?!=hKg?R*aKz7DppNz!XGF4%9dN0Ouf z+!9z%YU71W<_$^Wlfot09N-%`nBU`D*pc0PVYX{G-?%^ch$M26a}jRvk2FnAo0`aF zzs++QN#vQrDH$B#{&t*k`6~CBMyj7K=lLo)^vy}sTQg3mo33hyc8qwM@lMt^>!(%kp6@!Z=uf3^rfh5M zW6LH>KkfGvp>KUdKA(S8JIGBx2dUkWRKB9Fe-U-mXkmEp?d`x%g=4TB;8{}W=6TSA z4N28eGhWcuo7(LRJl7`7G$i=DfIVFw4pzeyv?Ow1Y-s2`alE_NlJ4g+ziT(!m$Gk( z?hxk!?D5j>7|&(*lejClb2um%2o} z&8D5y?49(^v5wB9WNd8Z>tXXN60|?HvUkUoP2{v|VEeCVXX$|dYh2Ol#Xh4=dj-~l zTe?5Jo#oe#xZ`Z<&Yat6bG<9HBk!#^qx~Omb2*4Qn{5d8TpX?5C$dU78GgR80T~?v zUHenJ>l@aowRAeYSu!}=8WzSrDsTOsXxn!`xD{~c%ja9;jLIDZ2PSiwUe4zw?EHxz zosxMv>g)>ZvUabJo$=S62YqAQhrMRaS&M?uX9c4%2iPu7=CqHatv?@a%j3?nN${MW zP5UO+qdRc!88V9X{gcS_1NXl{C$GeO$9L2jgTDQJ zovc4-_aEW;`F569BitdwnC}TqNIx*A{TX%qxfrkZ^Dc=-JezNEqJ7603buX{^d+wM zCFJ-!GM9Vgd5>VH%A0$YJGt&|g*M3kS=0yEkD{H#`=bl_Rd4H5JuuUy-q3)D7NAg|oCf zh`aG&>lh1N0-in;3t#PwH3~TR?dQSr1f8g7a`aFDgxp5ElX{fhG*{4Ch|8O95hhTp()y9f_IWk&We&Kqu#tfX8j34}ta;c9@6%JlN!^h)cWvp>K+Q`Pq-k~XE^T@q;0hWEa**A?gefSJ?XsmPVpUc9DEnF9qcto91A0v?UK+Y zDjX8B{xRR`wp`}^@!`Qw1!+_51Pgu670ULM?af0SnB_T<`Mw`?-tPOEZ|$6-*F45q zt{e3miRw$52SJpAwNWSddoihU_xpU--sXz%2DQMC7`s`sRv zYZA=+-LGt*lWkxg(uV#l_D&tgeUHwXuVOG3;<*kr_oaBy5juc9k55CNkK)~8%DS6# z0P}u{eeXlnOR8RWs{{HzYVELgzk9d2#RiwcIOo+7^}bs@QRzCTPJzw8i2myu-aE2# z<;s@^XBqPYk{b7SjxEfmdJTR22e@C4?}5z^OAVe%)SKU#PdcM$ZTq@z7Uvj;d48Gi zRR?bo^9SPS=;&)vhd!%pqU~*>X^P&>#IZ?wV{vSo%|hs|44*PI2V`G{(ya;zv6xn&~`f8a^SiD_>lHl%opv@ zh)xxkY!2`}Yshvc5xUk9eFp6w>^I*!D5>*&D*F(WQz57A%sw>Vn}EwY<0#nUiL^<4 zpNG0c!}ln@SL1gDzOO~Pt6{%CN8FfRzhr(2_ayF%7kj&NnXirKv-{wT*XPks`6SBN ziFZ7zdH0|sm|Qrb4N~v61vVCeo&8qXO>*^JVOGllv=3LIod`_-=QAwW{$wq8l}~XX zxg3D4uAI;I8XWD~3#dmAPp+gDW{Lx~9KhJ`h~V^p9`_$|na9V6v%A!CSNRkNlFI?i z+a3tp-=yrn#d<53`MtU$I=OnTFstVP-jnZBcHg4ibG{^}omn}+I(iC zvVZ^fk2SO>C-d3QBv;A`GsS^=4vdcs_9(meZ}$f8u26f`>vc0QwqA`KxcEHkj-w1dmxv= zrhbAt;fBQC3!2leLErvx^;S%0>!YGBeh%QyfcJ|x!ElZs$~pn|{j6SICWCrt1<&28 z{W@fa?joCz|g!ab6w$@AEL`z8qukKf-RGb~BDZA7dTnApVNQY4@n-_5-IE6Fetkd~fQ7vuO>ltGkQHD0iT@s%K=R7qTwaZP+FCI2b} z$N(X<3bGXuR>&6&s1RGx5kn&grMp}<`MQ7({!Ag=CT6AtI}mSz*adGD8?a*|6&dMNz2%(J#|%cn_Em#F4;TKAijin zb99*j#vk!2;=4(F4S8k*Bi?BX`GOz=ewQ9!L!LDrNo(Ux3e;U`9so%GnFKhnNr%ZP9KfEz108%Nbvz9f&W<_(kQZE}QsD z4Yasi>Vl`1y_K4ttwV7!;%l@A@GA`JwvVr9AcV6R$kehI0Ma^wz&o|5WD{RZiNA$l z?xK?3NFi7C&mdqiFj-Wzi6_tS*Ik}VEiBTt{23A)fGXL>>j(lpG#QorI+%19VU+-# zY$>J|qz&r=+plY&p+XT+bvRc>2dtu6i=2QCS4^aEQ|q& zWeFUAPVpz{(f09RlT>I^exjnoN4%7%C4P}8pYdc4{_5fxz<9$Vo#S-?&hfe!>*C2p z0pB*hia+FL!H=Na#RIi;qx^b2u&ANmkgq6(`9*xad=Ufr77d#>#nYy{rT*B(Bj7ifv#BI4^26zB=MON{Tbf-f>u6kFm~9fE0wig>{Y zD+dMDdM*)fwIMxFlqxW_sz8b0^*|G-Dw1gtKfnMzzLo>oP4;b;K+F-WGimdrUGlfv9AcwUa51pVjyQON{QV#9R0(6IbHx(XYa{N3R;+ zEg{yr75KHm6<^v&;81*pKMuuL)UUe&vG&7|p9A$stC!zZo6F=Yi3Fwq>nG!#uwz-@%Kv9E&kXA$v7eP5yz~&S=WPq8;5JKwz{l2Q#U0q$(T~%FGUETBgGc~X3 z9l!TG-+iP~3sQ?xTWytsyItzw7o}21r&6h{w>G{{ds!-VAN;o4&iI}{yn|kqO6|C# z`Te$3>Q!B-)RHB}_x8J|QtNtCsdU=>e(8eLeWw5*euTgMb}vf3q+`pqwG&@V-LK8Nf*EAY?fFM2(2y$We>xARPLBpF0?&b?quICFZ4BWz$h3&_2xvIb zio49m%?00I26~rxv0o<`XZ<6UT6h9NVEJdm$>Qls8_#wqXSVw!-eDPwr&6gC5HGzD z5f&kh4nI5;*w%P<#KZE3zx`0A#r>m9psUA0K4ah~bW;1@vEl5;smG0Q*=Om@8~b~w zztz8V`fNTcdS{QMpEVueJ|N{(<>oRE!IqX=<67TW^vv$Vvcl=<>zUnK#-%RP4g~C6 z<`&rDskS^<CmvzQmNGu};4=O*jC1a}DEIT)5d!a=c8wREm-($+I`cm8=~*d< zvfDfTOkdB;`&n;H=CcRNa*8@A)6xYk`%x#^hT!+u*y`8v8)foW_|4z`AJ$*l*Sleb zv@huW7wETkc=+ED_d)38HF%yO{)J)f9Y!kDStFSp1WkfxnKS8+c&AH0mU;9W%W0=h z3=RD!->pC3cCf*d%wxcRn(t#nL+_Gt3YpC7;Qvn}PWEf`hupttrg>^SlXVa);NV+Zd+@8!7$p^cs9G8j5jhe@+y|$_HrLQyvo2u zy9RikBH*L`VLdI=26aVwBWNQ2*_C5sYu*6f&KNXgu52a`$_22Vl=!Y+zh1OFCx^Aq z!B5y1>bXC$?)QT~^%}fB5HMy5o36dhAj=0c9JHa%&uL#reZLcIu%?Ydy=8lW^)7V# zua$IY%c5;(#gF=ea8bU=cr`BQ29AG#{~eN_HF5G!MHW_77{h)pX+;TlNi>2on!svmLe_o+Wr>J1o5wJ`(sV6b4_z@=iW1 z51U1^Z^`U7tjk1whIco3&_(n!Z8~axBW#%MdAGDq{(Za8?16gipXGY9+}4|ZtiNaa z()~nYVPo ziw&q3R!~+fJJH773+2<1lPG^#4x{b!iH3PgULdX&2eS+>mlJD82fJ8L0*~)9Ux|a0 z3!OQJu+XmhyUd?`bL;P)55J(V`bxQt#J=`lt@-mE<=gMd@N?P)GMw!Q$mrH`m|a)E zzc2Ra|0eUMn>*haSCH**wgt~UsnP~y9Hx%#h-!S_ktf2h<5_w zr}?mr(7$Bvt=9ZWzZ_ejyJ2>-e%|-1^Z&x`b5-plwm&NQXV8=L{_Fqf+&irK|MJp} z`a>n7f57}NI;}cy#HV1$`7_Br@EKYt_Fi)^4<_pLn;)|8G{9(67rR?a;%r?pfnBdPn|;TIs9zj`sb5 z&?#0tsmYdf|P&v!~a~*9<#ZACWMm%d!t;Ou3B+97Vg0We)HbGzh#<4#_ltR~f%TStb3Y%W*fE zKltz%o_plCmA^7R#|=Oq#vrw?OPysH=>tAv!0&t3cKk$s%{u{e4dVZ=?5{~!bb+7f zHwnD+ncs@Il`{PY*0^E66Hny-FG(-xI@cUyDA#=m|3KLJN_c=hTFIMzYokq3(Q(mt zeZ<=5mT8-Hk?*nAypiXKglYDp?k|UnG3@Gb1sT_`Ym7l*T)ZM<9WuWqpU=O-3X6Td zsyK|F%lse4I{wER&+Gf>=;*7*bJ=68c4ENS?sV$w74mD|Qj@=lGxYok*ylx)Lz#V9 zX1d|PJ`r#}3~{e;goSYn^1~@TZQC)8m2cJW_am?CVSj&u%Q=R!4%^?U^BnVY%2)aU zPEDKB4#)2|_`rFA<2jxp^Ko(!?*PBBo#F!y=TA5`bEemRgE=-L##x+TN@K0DFrXa4iNUuGQ1xIY}=rJXJ3) zSge;`gfWPhZoB2KJ8t{Z9jEv#-V%?lmlokUN!NdK0_Kg21Dh5W7nK$jF%@GIE8Bohe+zzQVz(r7RxQvZ~Jr18)bJIWyF!=!-JoY z;ZY#DqG#?u`g=Dlu|D>Gq+7nUYm{$mjdIkzu0oty)P)+`mu|59`uF$D9E5(!-8Fp@ z^i}D{FgY*e1{-)A^AdIH*_!ejZELUfkW;?w!(goBb~n83{*22sPOwmxUtEKqS6VQR z?F6%)AN%(hlX}RGW20~O)@);!qX2ucIwcim;#tmhF}j3Hj#2v_&;>8q`H%Xijs zXsea`TNYZ56>~8ddjOu6{)ydAFsH?}6*=uUaG$cIXWQ={nGbc@2{$>XYaN?91iHCGK$L;j{*Ats{>-?^a5BwjH z@a!(yJ3p?@TZ{$N^!vc$HTHOJ-{T`gJoX*ccDHZo?4EA zPGN3-E0=EQcFLRMeZaqJUIO)7&G?y9+D3l+ESr6^6FyG%D{cA6{_3%9nVzuh`Ps+1 zEjqN;9yjPW>aNEm4$_tUx2e9X{jwf*${&2L8QXw8-pKJ5r}#nqoa!&m=~(M6>pSHj z>6ETh{be7|CGUaz?^!Pg!P6}b*P(Kasl>&v%e3i~z6IeI1_xgw^C6!t`hq%BdzN!` zGCWL|Wp|MFn@F?!?1zX8`3ilxi0@)t*w)@e*r5!I>bKt#eJjjGOnT@4^f=QHAJ!+} zb%FdI9nJiQSVLvSAMG#u`XD1TJb?(37_>&>oo zAPjAV@;D8QdI9Gkq^_8_ub1D#&1KHE=VSdAYfZ}Qk80Q6Fs_)#!CGSz?sJ%z{<0N@ z{eAfoeM`<&VxH=QeE5#Ge$_ZIaR%Wv8Q+6uJ35PE7y{;w>#s{o%?C-#>s%=Smz^^>dDBziE z?CojgH_}{f%@^r^D8r-+xqi1gj&`Hn);WHPI2Agv9!<8jB^;|u*;rv3@90a}+MCel zpIg(}->KiQ`;TFL$TvYtP2WbwXW4^rtRu;OO0El(VbWbYvSu6PcQBs)>XlQB&vhxa z_+$My?6t-p>sVwSNpYRV!a0w`xzQT@^O^NY!JBw+eUJl=qW&_%wY)?4_me;a%U#;3 z9Y6S8De<$fk&$s(SIIc7aEhKd(OCBHx?jm`pv` zs7tPGh`-cHqaC^y_1Skgp5ACanfZ!&U9?BR>kGiE+r8zbjl1cLofh`Jyuoy^D`8t= zpWwrQ!FB0e`*<^WdoSL_I(=WT(H8+f547Xx_U+nsl>Cm*WxMDMFY>g~;#U6n;#m_k zg4d5CuM4evMy&YNd#C=!g=cj>Vvhd@&@tDxU_HDU#xg08eo9^&{jD>=KkOyOI1<+P z07tGn66>Gg$MtZq0qnQqKHPlfB(DGQs~=8uuw4%Lf2ih(i)~7LtuE#Q>}`9ee4_G0 zT{jsBckX|w(^rcMUprjL(*OJ^)>SsD2krSp^=umPP6o}%;NkU z>r&{HSi?>i<2Gnb!(Tdp8z+tNZ-t@Pgq_wC$>SQ zut5qd9Mh?8)5X#vjD8kNhFe;dT9R6rN|lZ?+^ORii0`QZy-0uQmdx+s05)4Oz3Ha0 z#wqn;?0wgf4_dVgb>xc?+l6zRjz|ic>d`kG^?<$XDkUu@dN=qRT~Az-J1aAe|s`igZt<@B<%8bio(8+>>8~ zi&ac4#|) z)o4Fs-p1O7t^3aLEA1?xwVNet1JWX_TLzaI8o9Cg$Zg*=h6o zjkM2UE>xTg;g+x47`Pw0t#QGFKThOwZ+FYjt3T#%(_ZOp7!R=OAo{O7EDoceoaPpd~0*KfN0Mt|hKwoSkUY)ONYYpywW zq#yG7u04&IQ)p(LGBxTJUFE9gI1!d}+{G+cB225oEl- z-r7C`g6AdW)uXT!V_2ZZr(%y`=@onIj#`Yq>{N!q0w7OhTj>gj)@ z@{cz0jeVWh6<6t!7yTd7mPvZ;t`pv2`PD1`tonZr_7POkh zmfuw__CflgP4Lm-3|luCsJj+$UmDV3may^9&-gdv+97gJ3h2ATN+ag*`$4~E^^@;j z%X?#;vo?tGQ|_O*soohNq#JgH^N7v!+r)Yk?R#!($?bFmng76=kavTwvfL$&z?u7} ztJdu^47fdN&)4ajvSZy?hG(fax$oI24dSPr;-d_z+J_<2f?r$6_n@vk=h&VvaKZjC z<2>%FFq{+QB`=<_U6sy4Klpl`9frAAp|ZEZ5}$Ca`#oe@;FrZ-fUkI6XDQQJUB|ry z%#S)~+h-&2z}T!cz1utD{=|)b`rFqc@jRe<@j+`$yC;8fhK%8F-)LlomMOQj;HWAs#4Cs@i2t z4>(6U#it+d(s0Vd?gv?1>I^ID z3eQ5b#*^>dmjvEiYQvFdke?vqG;(2A&S&=KaBm3qEZAV1xB)tPWpa4{p1J zv5at*FYQ0`NpWVVLmj|0+~d|RbUNW_o}21&-o5s$v~f2ZfKERc=01HV9HaCz+Bc%^ zG;P}Yt_{k(x%SpwN4(Vq5>`@N&~u?HP0(#?|CczUj6ByhKI%i1=U6@29>2P@2-<7! z`V##ZmvTVx1$BkAmn3rmZJG;QR&I`7m>*0zz@g#eins1y{oFrX9|KfjplL~+N54_wBD^#PVWjA?RoTr;~XRAo-ovHe)jj=;in#VIxnmd zA7ED-h!e|@X8P)GGUA*xWPJ{FPTaxO2p<`aG&lI6o%y?*N#M|is45%n;Wq)-^LXZ_ zy{>hmQTmHLH|QULpC`kve~tPq;NDSfFo-OW-&vA>VY(=PxW1VCR*MeLb;y_fG>lEx z<*S{~HQ(Ud!#q<&^3$&iJg~O8IUHJ&p8I-n_CzynHP^YI1oH#BG>zvJFa797x&XP^ z&$6k}ua?Nxf&UycLiw=D>sWmw+F4d5g*JCQSQctoxAFBxG`fWw?SFrDi#v{CmL1%O zt&Ma4CHLXG;opou>j;$1!S^~l=%(O}*Y(e4eE>QyLpt1*l=%YJBQ!&kpR}O8=8VE- zWQ(%sX_ljYaA;Lr^1B&Y9n!9kYfn%ZJcF$2oU7u)j?pgV9)U1$iW3It|E+@zJ=b}# zof7IG&-<<7q|IwyVt!DZAK`KT8_z$&`3~(qKJ3IFwk5<~6?sp9xepRLay-jrt|M?t zr~DKL6bIUm1DLCH{7vzo1Kbjm=H)L%o=ngsju*f!w>ZFoCl@T#H{o!Cv^dB?+!@1r zVHOv+7Wcy}E^RCBh*>n%l}_!);}dX*0^PaACDWbiE{Sg*&_MrcS6omd(i#3Ggk$mU z;v&!xZZ{8dNWCcy^nk}M;{1hF>PYi=gwg{2mQ4f2g~bK>!l|yQ#p#8c7ff{x=yXeq zyXdA$i&N&e;o?FaT%MyN-U7NO&2?WZ?&sCA`R}Eg(L?WML8b@x%w7 z_W*`1ni7blaV;=DH>bLA*aZ=W3&s{x-N2pC03Q5!7zTd}NgIB=HJHC{y+jAFigb}j z8V*5|@BEo3QNR@l!0i_PY4aqADS?mha{RYF;1MPciKv_t@zT1}*Nm-xiUW!RiUW!R ziUW!RiUW!RiUW!RiUU;~;JiE6#)^F|Ic-;t>HD3FuL3wvK343izq+sIxgB_)mG$mS zzSEC)#xYJ3*g<)6929fzI2$xGjPc-WF*fRaKD?YK;=Y5N_7{vjUkR6caK3^0t&asc zA2|s;Xch-?PwLW{6LI#-uW^>!Q(pJFVSnJ0*dO>S#6OWRn#IFwdd|H<&KE&$RqObh znMWw6757wX<6KkK=_crX! zEe4gV|8&zu=Gh1@FX5=)9rwAmMkjE#Qj7QId*PWiPG8UL-l*H3@h?N;&hfzMkSFuD z;u7Q?wpVl4Qfp*iS(Cwao@i@+H3&U%rh(kg3@ktEIUdcupWF=1;IreIaIMw>BY*8a z>p6PO&>3!8eF4sKIWGaSH_prIow=~y`A79=ALZE@mA&Ym*9XO_AJ~^ukA}Fz&>p`i zF|s$y4&dZz50DeDPzQVY0$51Ey$>|&kCwijv1D{Vyn(QS$#4RZ8t-Ft^Qtw;k z?evbe;p5!H6_mzi)8WjeJteP`>aOUW3A}etoQ3VsKXZ~rEB)+u12-#IFX>nIZrIk( z7+%Zt1LKT+OJ*JS0ehhnay>qXY*~M>?}oO-=|-C}=-xJO1Fgpb_aottJB@44X_0xLZ2eiwWa_hj09=0}@ebmG zH-YZX01pqg<|o<}IA0z9;qO5~IR&^EV{T&a@!`z3@P0m?pX2-XAZ;0>ajiT1m(K1R zRQAVxZ9~nO=JHI}dhQ8_ZwP)CF7>5%_H$NQ_<83!0b%Bw?KL?YV$u!F17$ zE-(IE0}!kp(Dw8KU&cXMz`Ju=H}>Rm$Jf3+({DgqYO`PaKk2BsMxyl*?M)w-4s6000~KF{?4-qo$w5LR<)D-pPAZRE)%3(JGoCj zURz!SmA}y2-r2*PX!GL-KKpL71v#_9EN>d0QB+^|e3c{UI}E$=B1qX!o;%6^`t|Pj zH(AS{)u^{JVNO7Dm``W+x{;;NIOG!;+~VI8n%-v^de|$FDG&-O-O- zG4ABhVEJwmKY3Z>Hmd_Dw;o0PbqgM>bI>k>y>l(|MR0#eopxKd&NZBV;W{|uguY>F7i@64jSyQGw zr;ul3kB@6_43d{j!*!NcnfiHms{gI>^!wht{AYdj$0ZN!F6?K#z8q@$@V~Ib2>NX; z2cTCsLf#i}tdVD028CHKU4PGpK0h+I#!Z_1NlQIiy@s*8^do=R>us)hlAvk zj5J*1?|sj1QsvLR#6kB4SmhwrZ1}W&=*rDj8144Xb#zv_dVNol{gZ}f`EIPA^xp0- zmk}QC@oN^psM2HK{wl9Bx5jglzt{O+r#a!^>pw8|XS*ZWFRwU;Tb8F^`ybZ6=r_M4 z#ou>{{@Nh-bvw!5@0g!8e&F#x+z-BdH~n=%d0Hr}UW~EV2ZPGL6VCnM{JGV(y}Tzy z{>_&CXutWni>N#wuCW|KMvs$HvyY0ewB+_a`9DG}cpnD2Xy`2B($FWv^nC;)HfzaNEyiF7a#okp0u#Z`BN+lbjZPfeFdmq`Sm5C@<^W8(ps7Yjs0= zmg_@;=GhME_F1xFyP)GivFZ!PteP3`!<_A1weqds_wU5}Qk-;>d#huWrO$RJajQ4i zhxK?{IngEkg6#ixTR$sdcADGYGkaQMbpW{4<2Ty$7h&DZ9&T~4zUNL~wqIx8oLe59 z;E#Uw#Yxiv;B-+v-lBc>0mya}+#8(Y;SR#3zGTm_K$m?}PVhU+Pn`dBVPbRuXHnIg z2z-~Y(M8u{@PicB~yRYEZ;?cSrX0wgWaFId4b~b4{~xp!(ljckc^9XnjiUdd{CxUuE7EB7sNWde$)wn zY)-ay*7fzy+{|^!%&QUKF>m)i$kcVe24sGzgn_x5zx$AXXlwdq{#Lh;%WW&pBj=gq zc%rCh#QqQyKF6!;o%>|P&FeeQYUdan*4{kbtS%4+o?*^IdCi}FJjipVo=hSCCkT({ z^3;o06NdBqz2e~~p4mTHkNh@Q=czr9`NW@&WwQ;-yL$Pt9mGA!Xy@Kn4WH*oW&VXc z($+e$9xOj$eY?->VfGgv@u3Tk;H>u_F|D7x7#DMI{h;qzJB?1?oS&~-M>w zcc1+bAHLI%Yc&|hDQzP^(T2xa23C3+y;JraziSjvh9mx6jqvarj=JY;3E!Km9pNVr z>MQWR-5YG^9NNZ(k<1Q$@<>A5iA-iE&oq|pT06_%o<40POj*q`{txHPMsUV1`lNx+XLZ62Wzml%{?WMM+5g(%;eQW~ zqxP&5{dPa=nGyc;(sFiqPTweFe&rvwQRlIKa)KGu&!sGKuU~oZ3)<73@y9uEL2+|R z2YtQObKVx)M;cFMb0NRxEr?rxT%j7Sg;(Bm9W(?wrwp>+_)|@Lr4wI*`T^#{e)tJ} zxfgZDKfI^$*Y|Ej2ihJD&h>F_8**H@5o{R_+J5PszOr3DjBzPX<1nm;0N-~X19j7o z#4#*40G&8TCv%VYbl%EDuk>7+%l#YMpx*Nr@>%bt?I&@dt{@LTbK4vPS|!taaecQl zSmuVjR+{j9@{#u^Tmf3I@th82g%^JKiDTY1;8h223Q}g8Ynq?)LMO`5v>*IsT)(;GHaR{V=?xz*3SJ@ zXvZ0HP3~94{T4?9o@-qI-TD*OTKrd7e6zwqzL!^MsV_rIyyL<5LoMHp^#JFATk{R` zoqa;~k$}_1)CatugZk;P!btYrVdTRxCzb_}1MdCN#^L@H&&ixKyELSGsCn)n&!t1T z%6pJ7z6l%S9XP{-pKv>8yjj}v`TQ%8pZ~Em+>PYFAFkcqEWRpzxEz3<`8tr+_^En`M zO=8Y_@%+f2amVtR-DP^^wvGd^MYJjF+pAMJ(v`;^*?#cbN9NcO#z+>nj&hZAkn{M2 zF5n#R)vH%mts!MQ)k#kp_=UP2KuOd)2MXEj*09+p8py}FKj(0tI{L03^AAgVzK|RI zf8McHF~i`1*=GNZe|Z`8gxgwLlmT}KfO)@{x!5r1Rxk_>puZM)T?6WE$Nis#>~|kf zSyyt}IwhNUIKca?%X|;e?&CgRH%!G|L7!{LD!6EusrH~7XT$O5UKPLX^oo4Mec4wu z!USVUxRcd?f5J$kWgEd);lVh7w*F2iyPpm&n~d&tT|39do5<%Ue&%O`abtd^;(TGV zvd4T%-SZ)=95v_VgY_*~cHq7(uJ5wKRPVt!;Cp_~sqDtQJ!R?#1IW<#T_4sm19(5e zxg|e&s<_o0fDC?Q6l9K-Bnd3vRd>xIzY z8U%kfm3dUhroF_0tfrmL`0|RAQ#{TyqdeK@C4Vn*P#)aQyISX~9*F%f*!$khc#aF7 zH$^o!54}3mB~Rxv$devxce2iN(z#CbqukLq^0nsH56`)lF)F?R@BRGX;xq33oYyE6 z2452u{;h;t$Z5L8<()_VPB`Ns7xI9Kc#w2iKepe@&^99SDN3Q?0emh+hG$KES5w~A+CTQ|hTXUKqAQ{;69iwCrdEbQd4fHVahCU1y+FD5}7Nm-^Toul2w%Yzw~>$=&v6b)%jj- zvI^T@+y|7)Jk<`eVVjxjY0C35SWks=_E(U_17YG1cpC&vG5?`m2G}RyUcJ4zW;1O5 zw-O%b&QRyy7AEhafW`8)(HV+@7VMehep#Md4tXu-x>I4J=wm}qzKwPm&VkDu3wTHJ zEDf%mg5RFvECj5lpe?j&yuB5A0NN*`kXNWMF-GEcenBgE)+u?y9s-{!5UPCZv?E}r zzfH=RXs5`Xba0L#E*UnF&xM`D^Q6!c?A)Ii$Ahu9k!v-Q!mm!l19g6&^W&PZW6$d$ z(B_`tx|2@hd6MXX99_?=jzh-Id1!wNx@S2_yln?ulGWeSZsYpAcHmp5;K=(ioNX|^ zIv(x%(N?c->J)hfp@ZXC7#Fz+Wx;*1=|V1ZJ^P$d-0vQQ2P%!h0dXfd?qx!|@Tbs) z@Y)7qOj#S}m~E%-@#{n#689(NwF96F-$(htGlJKPK7+AV=}DFsVthQG`6t48|Ln~i zgU53L%JpwxOtqIf)rq`OG${@!4k!*N4k!*N4k!*N4k!*N4k!*N4k!+^3kR^bFxuZ- zmxNzhgttwpRJY-=Ih7jF^-?Oe(Drd=rlGX8eb@ zt^*uX^1lE8a7@YnB1iK(&2Qd%IAy#P=@4Maco7aUD)M`w5pBVy)ME2{F7+w~q`K1l zzd)ZeL%R5xN`bybBByTidvj``z|8V3X#9u~4ultqtjgc%1p<}hWjMYG@HqKhTtFVm z?`|X(q?_U5n?#qtODV9%mfr$leL>pPWUcMv0qHS^cYJrrZ%B6lQZ8p$WZHBE zpeW$rX#xOfaG8y5r4h?;HESGx^&lpqZ%FSEV2}F4R?lU{} z^-O=Mf9cH0cnWxK#WRCP_xLdGt$j;pE`fWpfCZR=keMfmcz=U96EK%O24{|5Y@QYP z7oH)=vjoF$Q0955vBL!a#Xb!WpGM#+?`6;T?BL zx@Xx%ZIySE9Qb0-+}rzmW>4$uoq0Gadv636d6)2pyhSY|S$Np>k8|*iLmIKvTLSj_RGa|I9zNOPgqy7DO+;;6Z zKLf|cyUwP(_TL1MOt@_Hv?J4q#p(cE$zB z(CE)+?uy0!64jQ#-6yz9IsnU5lE^rqFX-=`{vvGXQM+BW+c)|J5A^rWem*i*g#b7S zcg3~5+`)ac=p&pmF*Nj_A+S_|2*?4B<-^8rXg51=jPG=D-(}Btt|CX!^lrw`*~ zQ!Tf@M70Spr!kU!e2^*5;R&uVN(p6Bq(^L9ysNg5p)&>~OFo1LX>zjnNk zJVATlx45JJQ!NtByvcUO(z&<6F8-V(JB<<-^a*ZU*}GxeBnVjC`I)S`HBp@2y*R?xI%8t)27UV-I~1Y>eyj6WK>^ z0bT|2q!ltnANRUWsGD0r!~GZ=_@7qDtex}Ze!Kpq)5R9pSi;Mi=^}X(w`{Qv=Fk?{ zP6GS*KHCB2mzkPeA-++Ga?aKC@+dbqBc@(#7VgEO&G9Z}!8+_C5 zt(&;xxxR&Sm2B*1DY!X(4YE)T0>Y}GGV@ZsB>*K znz9Lff#GT{M*lUI&+H!Nc^&h$EWw!SG|JLFqL*0mb6C+kbDXmMU}Xe(7q^6QhQ`O{ ztKEi8H}@07Ex#n?*SB;-4{ZMhW&gqKpS)Yqv*Dw0%hcQhyg6)pn2+#K1{CMxnFGt0 zOmE%aJM#}^`+;p=%vWOEaPWzGn&TsvicCBljM$AHj9UXiIzrGC3CRd4P8b`0^X@jYq{<97Nk z&F5dw`WQC-RXk^dzAIqcx8r#tPWlS@Ot&33FW)#5f4{P>s=Ymt>^C5fy~rZ$I6^05 zJ|}?fP_J~b-HP_Xi5PFcH%?n=CNJRnTiCXSI{SCv?^Dgd3Nh_nUU_bfx9<}Dwb;l1 zMBMg&#&L7{rTLAqKHAyOMPl2Ycn2Quw?yB{%KNz9QDAp^yYH_rw9fv?Kkzz;jG9ezSvI>B zZ1RDybj=GKKWzXf`X-09&!KL;%1d4M93|5ElJQNQ0_X7)}$#q$!K z^gU`n(Lc>+Vp|&&v<+bALt$8Z2l~_JhcrIcuOWl5lMKg>_BYK84H&v{w@gYLfG z*(2;a9@jT)^0YYhn{xnR(Z;*L(Ch;R1rILG`?I;wW8@4mmh5O?=qf9lE2 zHRFvJoD%<-;EJBveeCjx>)Ti>qCFpzUe88#hbQhh{4&b2Sm)Pb;+fw*O2D@N%rE0= z`xi3tkM(&iTyFmTrr5^>u_ovzaoM|5KG-XGTx`0W(~fe&Aq>AlCi6Pb(b;z%HkiA` zTEJuc@^a-e{q-1&-l*(f*IQx#c}qKaN4gW&6xW)&I5g3#}Xnq;8B4X`hZuPvbamm+R)Sj_8kZ(Ug#U zo=m6HThz04r!dq%H!J%$dI&A4|2YqUyM&x%6Q>{M)UJt1k7MiafTjy#qRDSRJ%ae! zWq$Q#pCuc%i_`W+{jYiJ|6ek+Q9t=xbD=!voba5dA%K?IfA7rZR;>q=I%BN2Yj!=n zxAbd=hyPvX8?`%`%k{h^+ z?mqh_zj6#a{@BpayWDIxRR5f|Ds0@nz+1g~^@~Br+E8g}^&DU>bG;YZt#SH$rax}A z%edYT@0;~BrgzBRv-7L}5BsM~tTJlVJNlBvsOyHS@p*}lP5xb6?V6o?Qv_qalhj%gtI!K3<$FB4`Y8na@bv1 z2eD5E99N)R-ka?v+C19*OZbe8jJ(P(y|9xxCtHcy&+GWVrv*D9M{3pjKMT+2{K_$^ z_}r@-hyF)}e-OARkM<0kX4q|sXP3dQ&*Xhfv|*lw&a;ey*liHH>}k>|eG7}ug*MLa z1*Wop_xV5akFv4Lt5x5qe{fEJv}5G3k3`+6H8G?u1Kl%z=;l}no*Pg%52Ieje(LOU z)Y&_=N^XtvJ!ohB#a?VRia5&t>)StN(@1`Cg`+^h(@lG(v4+@!?YV-I~%6t?XJrn3L~_P=}w{Uu4!6WW2h4TRM$(9TT? zy;eL5quCwN4!|8|8J*)4u=Vpf&d&Qitnu3M9ed4Q>cv*0h@UN++P zKkO^E{lI?t%DcK+)kpV>tDpGF{&l_O=l^rPpTml5{GL`@)h$?|i^T&Hp9W{*yHS zM_WMqKObrRkG4gjP}p)l*jum4GC|35M7f26Kr3_S8P^g3ZoUGl44+*Yms z^*a6^=J{Vv<7`RvOVavZsmGY-)}pTe*NzT$$^4WX4+mPc|3}%s^SD3DBb>+d%Xp;k zJr~&jC6U{IjOT%Rr@XUcqONJl{$FMP?)Hx|x#ed7+O?i**Q2)osO|-`)1tmB9B9?~ zAIkpS?LR5!e^j(2m4fZxAGQ6jU%&oE);!gF^c-l_`CrQZ-R(aq=YL7<``PN%FOS;x zF}^q@^HXko9GFk%efxcnqT>L@0EgnX1J(X_YyT%_?9bf$=4%gp(6WN_ zi_vMgf?SUSD|%-4QTFd-uHh7wP)YyaK2SN9aUk`aR7VGwn86Z zbDU*Bp#}GUdc8+<)qxuv_vFREpBI*YOn*&ulJ*9fdtFf<-{sqen;0V~OKC zE0Qqpk9~e4aoaxn8Ncq>6%Kj_u z^GJCCUbjjcV81s7vEiU;(9ald6|E{??;PO%GqeGoiBj)RI%-b8G`}&C&jqJE7fbse zeXYk^wbu`GeTN0L;~?o^`xD+ZP{nLD2jX526lGbHRM^;eb#N=>T*zj(#yX#8gV=Gf zG}>iY-Z0u%Lq z&6s#Bk=2INJSK*YmiV-*1J4 zqsepMWzTJoI%_I%w&ZYcZjx~%4_EeX*fts|)rX4lx0k>!ABn;KH%*LWKUg0d720kb zz*yiXU|TOF*}f8v+Y97jP`P8i?vp{>lTj`?VNsUuPSm89Y_mX6bwLTY;o$26 znpo!}_UGg?kB{fHJ%efo(mouy{pT`kg5syrwFd_<7BJkgHX2cXg6FF?BVFu~JIl1) zJA%N8>HO}$1;I_FX)g{eS)#uP=LB4>$^dWcgVE=|>Y$zV7YCIx_sGHaH_5ttGMDN1 z11qk6Ki2hszz<$3Zu@dz{}1c0L>X|KDg)}50kHi)_wAv-DyXE>>GT%k`Rt9-_Jy0v z+~Nn`M1F9es`OFp_sL~`d!5%wyy)fGQ z&!eq#Cg(!KkoH(Uvpekdc|SIbHGL<$<&E{ib;b{xD+5sX7v1tz{)z*V1N|SJelN;^ z8(L8>N1Nw#k$a*foz1$u{{nXZj33))T-5OoI^|s$9(=1`SbpM zFX-=`{XE(O4@7H|k&o-^q+!L<+2yq~2KT}Goc}_7`h*|*v&JoqWZ!Mi9d-X#t#QJ9 zcPn%3IH+&M0V@ZVFVVNeT7hFQHt=}6l>@{XV|+)4eNOl2X!dQe&8E)dn9OV6wDOtn zfLRFB=B==Bj^_{U`KfQk0k0frr*fcd_b9u((&sfE@7u#!T>rG%R>1dP+VjNx;gbPj zH5~g#{ol5C_Kc0Kel74n81tgoPlbDqbK0-){d2r$@tlfw(`UF}!p_emee*mu;JpXh zjHjS1)*$E0f%jFwe;m)bct4EmN0NlI6D})u)nAQ%!C^cHGimJs;)J&Tp~TS%_h$Xf zxw)r8vC*71w{G3J<@H7r!`gd8f$Oj?06KSXv$VG&{n&8!t z0v2G}^NI5NvTSxMqkq=iyt|toKu5+T{?PkBxW#M6A9c`1?njpSB-LgA9lS<=K2%;m zhjY!o94F0_!`kOi=lh-$u1j}LTh}RjHM!27`wloJ&oM>rw-XQF>4&g5>hwlB@nd6* z|Dn2aX$Yk{Hrp|>xHJM91Wjc83mIQs5Cii+2?$v9{f<9jG?qo4!*fh*gg9FK#~ zapW;ElsPC0Ia*=DHeXo1di9HW=C;*8)c0KG>^S(vLq~R(A=)q3M5 z3Jb$8$%4drXuF0%qgNQ5gU@EOFAb9?9Geemtlx7Pi2U{rlLo??7}CBtIW+i@u;I2d zJk%FhV-{`Q1p7Y|?TpDt^2uc$OEUja?wuV8-F0NfHAP|aB9|S=jK~5gzRSd?)^g>xVnNF~1WoOry+#p4^pWzDu2pf_7J!A)k4SvODdX zSDkXGnKZD`r^8(DJf6*N=YY>2=6g9XKAicM17AG1(>$f+xKU2~ou{~d^$D<(YX2@e z7cOs|_?64t6DCh!i#J3fJ15*Ne(XDiMF+=4>d)J*e8QM zOPx|4Ob!oz)`8~0b-g33u-A&qaz(h>sbT1O6fioW{ug`t^O~=-A02g|N@iaB4cZZ5 z$t6(z-w@~Qeb{@k4&}lBPUf=*igPltrjFwX+~3JI*st>WSA<1F6mU?+Ms^-oczRG* zUyhH0FJZuJRsSFL)JC@3!=QmMP>!{v|Ial?q0!*I{~vDMhqYf}(GUfknC5n7(f;~Z z6yMVgHD6H1lsE?%MLvKV z`Z}=?*jfg`F{~))t_M@C|0}b<>ZJu`@pFa3>Zb16ts)?T5AcLzo9^{%2w||%+mXjj zQRIVu#dQ&|B{Yzovi?81`CDh*gU*D${y(O^ZrPSl@BKV9MT!UzaExSL&T8wh(Hrnw zZS@Q7J>l#BDK^lm_W${*Mxe z{d`1Z{59&K+40=qe>a0;qqO48&xqTLX!oDi2#;pMdA=CRu`8pMg?4w+`~Bo0=gy+# zgVDD`zw3M8`8OiW>j%x~L|kd3ECXRTUxt~IR9^CWaL$#A)@^# zna7fT9*xu!=*%UN$kEm>8qa0-=RV7*@l zJjc8lUc{A_XX(IR&kBWpVT0LF?|k*j2kJTWXDSRjV27IZo+z(4G#iiQ4a=P{Xn^hC z!!o5=oZ?E)vtmP`+e`Ur@&&w}jxNvaUUBiR*?h3BWg*+^O}0TVN4uw`_Z{+nXSN|h zN1!t#p%3@MZoU%cT4leoF~`7<2OVDSE~{xPAl(Mga31eK^P6`o;1ow^&n*bQ`l46-T%w8KQP8|4Ei@H({OGjXn=lQ ziaO?EoD(#_^AMxpUtBQFzBtB>LLB>WgN1oNNf*l3uSJlB%nauo*@2b?*8a_!qu z+F=!J*u2Do<0DmJZe>PLf7~lA^HQ$jfZ~8N2hcW){LKHdo!jnrMjI-ceSl_}(dscV zXO1gh#ermSfc?m_Z3eApy{5x>m7QwzrG6A;{_LmO^Hkr81BwF%2Vi@VjltXP8n#%o zH+LeR-OC;??C+%SyHL8QIM50X;5@3`!nU<~@X!`Nz)mOEUe-$!MwnB%W5+{%D-O&z z4qz@mnmxEK{Q=H1IPn<&!l+xl!a&{cx+WiE05P1! zrd>8)Wv%E|9I$f$b^mX@+HM%}c(0fp2l*}5T}QQt%+dzH{>G2VJe8|BFkd+^nalKt zVbeZfY<6q=OBtY#9{Juw&NX7qTu0v9BKfFX#esSp;J!H6LF8wAS<43GcWphK7>4^2 z{>Pdos&}+|&TNE>3RfKHR1R<*c2u_I1{d~!a3g*i=di?5E^Th7J=u-;rot5mymEm3 zUpE_#%s;2i{A~5=mwUkxIQ$?oJS^h?D32cDyrUPKR2;>DPT;`k=;*64&i+^&_EnV! z_CkB*ANN^RrM1{>*#6wd0c^us%`zcNC*uflmabBG_@ zR9wY@cH{uo*Bn))_i@=5_DTEYmw7gJ&ABR3;g7yROV7;l%O4eAalj7;;$G)dWv4l9 z4?nodxLmJW1t;3_>i$df`Pa+5m8&?=DIDNgMCkKB6r(L!>+PF^^3iMq#B&}A$`+J2 zxAQKTp!lkEiUUdGfVkTzuie%n+t*R~yT{ zZLRI&%=)oxcKa}Co*2#?6u140{BU+Cc)Denv?y4L15t2*>uaI2vE3z#?!4&2)V7e>ul^W1_*T1&5XR@8JV_=*FKH~_mpy|s2=xhD+wBR0Y} ztnflk+XZ8s&$p6xu}&zP-8w9q6dc6?uN){82492uFx;PLvAR@0BZ$fJ`(gC7Zl9tA^jzzYX($LRZE`>k5b?>MfGKJngOa1T2U?CV2TXjl2n9}X0TwRfPu zc|$_a7(glucK*Lp?@3t#~Kvo#oS-cg}a6SM;Y+*i*JG*0E)irJv4giqN*cHlNSG zq8;RB?1R*9O)6he*1v!J9C-2cB~i zRvHrgUcj2J4~DB@3R)65Fg7&wu2$T;*Ocz(GS|18?MvA=MR$Z_0nT`7w~pts`%B!F z+c_MV9M(REwR_h%%iX!Xort(N!>e6wpW1`6_Rd7`(N9?-?q<_YZFEn1=U7K)QZhES z`n9n6RS8<3T3);3$tH5zuVMQyYiH?z|LZ)_>c>9gjr$AC1vhnmdOORn9dXCr)Ll8Y z)8=|tXh+`LaYp?=-sW-;Wj5;&thqQ&-A`nfa5BDm#|C6{D0J=5?XGW_r`FQx^cKnB zY;#zc>!|#-d*ZF%gWy)cnJ;@a#~GD63=T}@GQAwnOW6LCAUY+}bkyDzmSyc;9=qeO zJq!BAxDI>Wx^owWq0b6NeGaf*oXlw-MO}Xp>XygcWs~4BJ%{>D%tv?N*fV6*()&+h z(+`~g2A#YD;~ht-J;?QK*l+0Cku}?Zw+BL&1@QOD*ktNpr;+5%vjYR(&+C_$amR=K zUmpq2;*w3!d{8$TZG-Io{Yl!bJU!J``;IROm~q?w64L@7P?fk>@#rohomx zRqo`nyBXRb`=?PJU_FXa-TZ&Xku7e?MmNdrUvn$MVpwB?LHOe*(%KZmJp*sTmYcXCwiCb2FZ8psK(AAL^^E__`VP}c{;+eHs>m3d&>6ap$^P)pU8Y)4?1tx{mi#|PSI=aAh zV;!}2M7y88+t^~mi(&5b>WFgRtDdNMol~a3=3hYjbsf(gS-pDoi^H>w`2k7w`#Z-L z=2N+bHvYX_ugCkq=7*(*PbJFDqvn&&C|cXTteeF?#$oPX=6%)STg3c=iEaw=9CqU&*%pd^%sNmE{B4{g)|w zo4@wfa@prH55eX~d0)Hg&&OL5@a)N!%U|`m6WiyGL^rsuJeSGvT;%z3R(iZv?JYO* z9_8+IBr;YuJ-=+azP#_i*xf}F`N4fuo8Fsk#t{qSD>o|JSMB~#bs(3yg>8AP!}$*C z_)m87oj7sWXjFMX=06Bk?)A(6c~BQ>&yEdem-5Ugx*W4M>b%T)xE(mQ%Deo;xX}ry zM{wWQns-K}s|s$o9Kd?1y_M~USMQ^~`)6S^yJI~maa?O+B>O?Z`&{NQ@c;X;pFxxj zKSw=zJ)VaWArp?p<+MLy9q6yP9t5EEc%mU7lB<=Y+{K;2bR_V3UBF`xf0@a^FCkg&p1aloAe%J%)*zIMJ^ z!|Se3Dx|$QfO@P0_nP{%X)k3U>iiqVhKAnNUeZPWy{x|?#zeAM)vd^Fma(}x&4yxFS1MSO! z@p0{qXyZOSkIR7jahCKk>g>7p)nSEsa~vpSGOt5l;C!6@^89=$2Qc<=_2ls2XVdBQ z7R_-_rB)p13=UkD&2EMBo>rh7$YE{JQyot~k)y94KV7TjLza<*=zAqD;6pvFC#3v|pob zf26uArnB`?QP+A7;LL#ciaWuuk08!60rvg0kzXc*a%dIz-KzCDt=9*If#Lw?6L?NE z?{DI`I@;eCqR;&^^u7NCyZx7!egxVW8!!g(H^eW(9l>)d?EOH_$#bkyaanOdaX@iE zaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iE zaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iEaX@iE zaX@jP6FH!(UlKV`T2#R*o$9W97;kf_RG0KCLM;d6hv`yN%~wQCr509)Z=zqf;aB9l z{4k@H=w)CaqRI~mCC%^!;%kbp_{ku;n+>l4z88)6stg25W}ay?+(NHz!d%SsX0VwX zeabLJ5*cn~feBL}SPt(JNyR_u#}D~#l1ncWeS@pzaD!Zio9G)ND2G=#AX$ zrsc0B{YXGsex$k>#$Qo-QEHWN!48I(y@*mdaz(~f;iZ)Pl?jj@LTF`V%OWh3FBniJ zwyYzjMi5H3u3NskfDV2ZpKc2?Q-U1`w?OQ|X9XLuV-w_T!N_Fb0wc~sa&DnLJPl1W zvRr^*g!8}oBZG^0w{U5Z#PAAEj^P6pEEiSSZt$)?kE}M5s$R_$;|zn?5ZhC#BjgUj)2v zAb@c2S!P=sUNlM%5g)9mNpFS&41cxZC8PK%G87b5M1?O9M#^X?aQLeY2W1R5(>aFI zG)%l)!%YHf!vR>BmRtTZJS`%)r!Q9Y$pD4_Gyoz72Ly^zTnb9Wv4&3>C6H_Q93xi* zU6X#32rer^Ih=$WLE?W6{4l}?DjAq^64?v+5`L1yrF={hYVfy(n|fx@*NsF>V5X&0 z6HbQL3okVYFII<}dJIqe1^~DS*8;yLJZ@zgmjFOh26?^Kcq_)3_mO+MVW)4T+G{D#c)!}Iw zVFALKTXk@`DuUSOx^3Fcs709`bveVUv;*NK8NNuL>T(D#mq3g4QWrj|*;_8@SvwRL zBfLs`0Kd$jZs+i_20}QCflM`f0U&K42)t8^N)F-0l=v+Ka~GA2N(!keKa+sPz+_R; zA)GwJuUnr>EiBTt{Y(iCK$RTB4FrK6s*G}a15CP$uu6bVwiHtf(x!Mf2{M5&!v%-B zNoYA7Ss`G~6c*Ft6S9-(OCki64Zt7qQe6T}IUMoeUy{_6m=9tx9B+cB141>r_+|VM z&Y$>z+bv>s8Dacy2`5VslCF|QZ8&0Cq-iLEP(Qvd7RCU?vILHwTllF)uyZ)rBo*3{ zo~Rh`5iTWa3tuGiXE>RIUrjhY7;ajmd$<9>J=_puO*pwI;5&v_@Q2)N_z{%1aG;j1 zm)-~m7FG0{@)fx-y$G+BFG3*SqG|J%aN2aY)E}pCq#Tg?R}xUF^ow}{BZL47w7d%+ zQvU|{UbN^x^6jP{?HuU__zqLVe}v#am^A>0;br@f_9qB4*ek=k8P`;rbQj-Ch7toC zrthYg)6YwI(WJ#GJ;D}x2)9^NHI%fGzHWG_wtOxFNg>QbxL{d1QRRg?vxq#dcvZa# zo%IlI6hKu;#Rr6$TFid~wPEz3_`4c~6Nzr;^d$x{yvsSf$Z!(r!r(N51_TqF(<2zD z{7$C^+GL5`8HBDI{MgchyNVp_Sz=3Q3l^8x?hC2|DHp3|Z(qaG6TWDq) zZmo(7w1jsN;k5_~^aR}{hIiS)7wIdqE%B`e!8CnExL|~xgMw-!l?b=nkl`tE6`0yp zphWP7rv+36$+QR`pobA&%>e_Zk*N`0%>hBZDRwiwib4atL6jM8rLZcA`C+A?B(O7? zD#Hb{3<`{3GldMVU}#_`g;owP12B`DJT+;BXSV^pWQLmWgl7pS3NrJu z%&p-jWWzDDa}5VD{>u4PC9deKTexJnTeuWh1>x!px<&3*f~vy@%E7<9Jsp4IJvm|#4w}PY+a>+1u7$nbR)6_!Vc&rCv$_`RuzHu@OJ)3}y_JK+OR*g8h`xnA z7MWT=l#|QS$r8rbO89^+#P}?S+xRLYm&2XWufTUkuM*xZAy&I(_|@L!ue2G#CHpde yT(U2#Uw0W|^@}M#7wQq$$iJ&PmF24>B3L}^uMBs?j&Y%3Zp7I^sqePq!2bskPZD|n 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 01975635826007451d68e2477c47ba8b88a1644c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 372526 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 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 666328c51972c37c72500d119bef2b6ac4aa1db6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 372526 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

  • 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 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 f2fe15090665d68b396f0a85c98548d3e0cc5820..6e645f5563550dbeb4fe81140712e701c3feb692 100644 GIT binary patch literal 372526 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 delta 13716 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 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 a33e0b8dfb1c1f3dd8542ba129b7acc68da879cf..b49645b5fd884c1fd99799951921cbacf2102f3e 100644 GIT binary patch literal 372526 zcmeHw37jKUoqyU<2SsgfQCXT*Pb|M&ZE{=H0QAhSI4{O4y7?wC2`>6y&eGMUVF+ZpdCJu8#B9p84`(RiOjx`UsW$?UO* z`Tm+r=6OSz%$|E1@4LP;lUeibOeUK(-?tga+>tUD10%# zBgpKDdYt}B`TTY>y1sk0S{-nTm){C^@4hX6@}1#qAy=L&xZ>wI>i>kFp9ZyHw1rK>GV-UK#S6 ztYc2s)*#Pyd`G@F&gr?kkar#Ppa084UnKL`!%}s{GnohVpN%p$^V$Blrn=K|dp`Nj z{ByJwGR4P5kh-v+QshA%@zOBIf{8@eC zbtrc;cy;AWK2Ndr{F^OKybJYiVE&S>y@iQU|G9Xtf(}oioax!A{M)Vcya;rjozy;z zxL?V1b5pr5$cj+^FZtci4=~>hp1)bG2)aKD`0nXPf1S!W^{z~2*>Q-0=AVWjkKa(%`0WTgv*TWPhGq;dWirPh zU3M7~EJvOyzTroPZHM0;_|g9G?*Q1er2ohh$m%!2pBc~-GO7K)nW_ByDaWl~nYm~9 zWiyK-pNC$3hrj8?k%Rfonh)i!mwajn_1qnEh5WwOwASb1!tfzdZ=1q}kppB}$};sp zl&$Cf2t7Q}R_8e0_cL(RvXtli!j8|Mt_8!e zVazXRjdqD;L(k81E7SUYcKt4|C{{)ub*qDM#XEN2{`Avf_Y2zL0&ZQq+>gw&o@_Pq zwBo|b!=367-xi0zJU@NzJ78ZwFYOd%umQ$`b^v9PeF%P^nHhgE-;w8YGM$@#v26#; z70Bh~_&rScOHU z{EzZ^W^!_+OvCi}evgqR|3!WW-~X?f=fT-r?w`ar!iT(|@sDL*hVxVTF@8rq_aW{k z<2nB+Ynqa-?_g~&7+2CK{&hB=-%+M37K_iL4Y%8U(C`-qEZXIO^8`^o+8?&lGH=*W z*c*Wp;m?0+W@gpPK-gNUAuOz=y}dhX&=Qmpg_5 z(E1>hv593fwD%dLeK6yJ8`^wb`x4sw*Fy)NW3P{L%l-n}UC8!d>-o@^Mc>Yvp7Mft zVc%rBrVw}o#^2-njgp>CX$o4N^tP7GXTW+&b6S7>64bQ>c{#%>-`4WyCnw*^{*i0> z=tEiC7nFA-`1NthE@b$X_I``CYy)=DClk8bPzTyal>0Gz`k?RawC_RdiX%Pfml@cK zj?)%)pawEo{7%5{s?7tL6Y(2l9}B+3bhyh~J+0$$nV2*7D}1btf1h3$IjO1MTHnv;p8>Y4H6O=;s7KmQj+ySn?{~6p8G;s%qh0s}c}3gF_7(Q& zhlcQ1{PT z)-7p)w9I4ugMIsLt$bZOOUBJjPQDsEx~>*ylNI)hFWLBtXZx<(gn@Zv9Sni%KJ_}Z z>#cESU-1&q`!>*H4(X0V`YeC!H_zTV{7*saXC7-E?0?kj&tW9z|Bw6(TL0Yo;a6L0 zV0$F%2c8dE<61qpi@(wDtG8E>M=?jzs$Lntwwif5##Xobr@`>8%p>zdzAlisLk?@Q zC*%Euvz6h0!&vpHRxmIPboH&W{hptkct@>n*djl5Ii(q|zTp`dI~<4lw|Ky?rMNh9 zY^j=AM!n_y2l^;tu13J%V~pigxAmJJ`)k^~>%iwTp-(qU{H^8id3=2Q8Fk)h*KsT( z(;(kTA|J+ZoWGLaC0*I(uFzPZ7Y{Dh5&j*9PZ|C@1ewQKpure?3zYj2oaS)z2$}}>h?L&FB zJ|bWg{WgwE0QXX@9M~b52j$i0uVAa>cNub?LgoYS@5S#{`EI4JOwV}(;D@0~t$}QlT0es)G_TvS8_1r3?|9^74Ci5@^d}7?hI`RA))W1sBkMMiev{643PSp88 zy*}-9bB>|b_96aFQP(TWL)p{yx;eHs`V@5;7h~6r)-kut+ir+@kFnN`I*YPQa~!o; zix+d)jq?gJZP3t|gVKt%ve|xPKCMtFG_G4fUmDUF>HaWZ$bT?solHMHJ^g}NJ^yv9 zo*3n8H#v>s@L);?jx(fRDCxl#MsL8PXnKI8gKR39I`kVWl_Hu&0gAQC5IF|D%o^=pbBkIlIfLDv{YV`_Mj6cp(aINS7{A%+Qt`-i7 z)L%y_Ap2UhTWH@t%Qm9~NcVd0z172kTZev}-qn&NC-{@QW zZHJudHrEp;9N-oEHk!W==dA0C)-qws&ufCuFD{tJwwC4RxwNoiJB)Q}drX>PHRr?R zoKQVnaK*t`OLpz&c;N^BU3o!1?|TLo^6<+5FgNmJVdO$58nKN-U#&LYvcP+cSc}2j z1K_lbPwaYvH7$UNTat$#w>?R*rhAht-TIY!@_5azL#u#I^o>#Tx z2mJX{Wf_=Dyt`faS%xgn9*Xg-P4+?erg0x=Y#p0=%blLvoAng`zSHyChF$UiW!Yip zJNrqEv=d|LA3CLltbEm;*6Dp3bH2 zem59EPwvZUkZHHHLB7x3vFpxJ!Oyas>gIeO;BQ!$K>OA-f9908)i*JZS{Z)4T^!K% zJiF7N_)KzD+Nz+`oPJSMG1G z+diL5+5`6Av0b*OZTB7f%;v)4z`U}4YoCR5w#W-~S9^qObuw*~VR8NNdm+Oc1LLqT zTwz|q!ufmX=}CMx=iM9Z4P@z1#>Mqldf-)qSd$$`o3OPN9$c3}np5O{T*3q!pCi4e zr*p3od#J4T68$gx{vbOZ+~-=FoO}cK0o&u`yFsIg*KuA+ezS*D>sP!TI9%b5!|)Yc z>yY^{tRFys=6Jv^_V$>tuWgVWJ$IT+?-Yu?Cbj)X&3kW{N9+f~USqS|e_-9?Gfrh$ zzsY?8_H`MVhHKd<@4`k{wO>MxYI}i19M)K59(zcgKwrVwyJ_+avbF(w{!E!x)FbwF zHnf4*OSEb?nT8?ezeZc}*_xiupuJerkvKqa?*u$zok@PfUfB28^T~JWj7--UPSk7~ z_tG}TnRsA)Aj@HhdbtmYYehQ3y}7o~YOWG#ud$WA2{=7u{RSAtT9mae`OJ9;Yg+Sp zi*-GS?FGL7!=8?N-()!qQSX&aX)r%vO=EusYz=)&@&n&$>x`m2bC10}uY5x# z{vXIV8G^4zH>T09w(C0QPm!jMC-$Rxt?!UG)-sJ}jHPV-O~~_4Bpgm5<(qpWus`H0 zz@=$yBlB?2AL6l(B>y3~FHpwGaCLFj_Nea={0{K4Q%ui&Db4U>|2Oot={xqZ$U4#z zI?aW19f@nBP4E|TYtsTZ;o$xt2N*^BWyEU*hxqTL0SDS$>Zu(*e7j7-XWt_u)3U9S zX*v-a`vJ7SNq8DU+n!a}jPEwY%RYtWEKia+aGwHf3(ib5w1Y-J4gDV{Sdjh~PU+b{ z4Z4TeseC`*IM0pxzU>5u`RzuxJ=0=;cR{PT=hc49zKtI^!lXf)d?e)BYo6~GxqsiS ze6&YX?(oaLfOa|8tfLs;upbM)pWzL+sP`JnoQxazuvOX>E#a3kY4k%^qdofu=hIuQ zC$mzqu8aOCXnhuFb%VdUv{^Tqu_MBk9)(~p}_P)JSJ#l?QTQ?sHcAkIn7^}qvuf1IGGWhx_ z_EolO2krI5^=^#gj5B54d=P7Nr^U5jr(OJLv$#ITwiGfY_OLUAUHbI-g~ChQEnnsD zO$vz3LfAnh!|R5s)#LDjvtnZpDfs>aX&MSY-$m zGUO&J;Ijlu5KkbvM7)hd+h&o&UkPpcDDy^EAHAER0a?1OK7#{CT7QjCC7pAJ+4k^HBh9y%$4@mFpZwZC&aVCc)jXr*r@o+Rz~4_}Z`ot5@V3V@ z5A(LF4}AAL3lM}h)RR{A1&Qa`Gdo^E-+9K(t4_B$@E~|w(}D*7JE!YAd)4VD4tp|= z3W}>+dY0u?Ct!A-WA@a4S6$#-)e_H1dDZC^2i@J*Twib8Ct2U~8R$F9f-jtpdew zHue#n78b{r`EcG|*5eeKd(Ip7oJctUzmB%&5o-$V?2{IB&@PbW@{Bq7^l(c&Lh1qf z36I#%vCFz;2z)P=INQQSnZ_QPYq&L^1h#yR^sJ>p-$RzieL(TyZuSq%y+*Q5dno+b zS>@C6O!=^tf>^4uP}<%!}u`h5q-kN?QQUa<1KHT1S|@1V4Sux(y>+l`}7kht+L zZWyWgzHowwWA#d6bN&P475L_@`>sTvwdrgY@kf8> zKuNPsgrio_ztlid`@2=xLYk6@#(P^w-#l2S8{oM#FNNZzq+bfO>YbjpkLS*P@{ zLFYQ9_ah%p`K;ei-*HZL0v4PPGyHKk#KTXJHNV?4);#{JA)lq+0J>gj&u5-hsGn`H zq-QzS^ByuU&H?E3MN}%C)#UqErfC&Ao+U8&ya96CaLz`+fw|dMWilRV8_ylt@Uibj zqCX%vdKurfrZe<_k7sejSQPqtk)g9Vg8|>x)_GGecwOri^VrS%Qo*}vukvIZU^_~} zW)Gco$ZbDwVDH{PXTPw(^&UYzGxhT&Ucty7%eGi>0&=aIjW2Jp=a zTpab)Jh#!{!-}#(UuaIS^IQ^WbG{8mo>M%|DZSG-*tpwm`Hi_99ruQqXEvMT>u2Yj z%CvstxDYrT$n_nzeQe{b@%Cr2w-oybu&3^8)J^M|U3*&jPJi!07H}@g?lWDcaSFvg zdDD@x*KOZuBbs?@>Ytw5OZW_RXakssXWY6CUn{)k{R=Ms?zKmx zj=SjqWO_rC=k%?xCGl+ZZ<=kbArIPwgn@f+-DSjITOi9yO9*_8I_EY-~U&U#X1&nXL0KPYkV`}i4 z_^==7g}aPHd*C=9)=(b%j_?m4^K>F)`v!hp)&Q3q@6adp3#0XJot9=DMnNzM^t z4ES!eZ9&fW+!&-Cck&n3iVo1L#|aZ{h~G1je)++4T$2W`e+ZcqcW||WN5&)1x*)i- zE*GB!-Zr6G?ydo>XVB-Sy{%;(UVq}u4aNtc=XaskzeIZ$^6aQK5r!A2?^H>@C_~sE z?l0!K)w076emlfCE!;jL3;jX2JLyxzi^g7YfCGD*TeJabjOV#t_?~E|ujaZIltz3_ zm>^4!;&+0dakLlT83(@Gec1%n`^3_7&ItD5D8F;{t#GHUO26FG7HWC7`Sn&fy2Ts) z|6pxPGPL43Y;Bh3FL@5%4gPk%vyFgl4u96!%{KVm|7^Ag!1Dso6n7=%_Tzqpc5n)k z7q~b1C~Swfutg8ijs}6DQ)x->c5rpbyEch4K~d2Qyz=m?5}*%&eksogLfC~<1QF%e#gH-IB=lnf@Re$a88gFjvU0D zF}xRMu)LkPA7-$!Lnd=RuUYP}Wfa5K~|3W5n1l=P5z5@oTf7~*b9Vib}mn{t~4Q7{Z9atI~t1_$% z4l&GB1~cZn5#mA}Vv2MF43FPDdWE>37bOyN{%y^$K6-jE!Yu=Y2aqf)13r3upuYz= zh*-WP{4`{7B`{vMW`^MGf&e1~Vau6Oz|P+oe)yv^4E`-6j`-uP!TcMoRv4hHGDFmn zMZls-cmA24C9W0~9)UGo#Mjpy<;Q>#Bk0Yw2t0Yw2t z0Yw2t0Yw2t0Yw2tfd&e2-JN@5#W@#UdmHEUgRaFl6xatZ7u~nZb8+71Rq)-kf_JO& z-b~)-!k@T96W>G}(lBkONkV>d9u#Zt@C}-q!hG-*m>YHW4=>k=cwRJTBX=7ADC*~#CZ2c15BG4jzI#ZXaT945hOfq1w{J{sSh-Uau=pv1Yp>w* zIpABvKK^#r5o&qGJ(b!l_f)mIJH!vHX-)%wH~WEyH`n>;ddI1+GoUy#eB<=`k=XX+ z(f^HD?~!_(M2NF!Vvj=)`5G2y7;#@-RD5qL2W7L4C}m&V`;i2{J?enHnT~qjN0c;b zRhDSmac3O(^<)#z!uoV@&-WzGexDju9|7&V<2l!w-S*&H^l9K*sl#XUTk+Z+KU-Px zI`DiU0lqi36}Vr~?Oe1SMz441UejGm(epLHehbJO_j#hPS@R#~1%D^w*e1h=L0})Q zQOSz%h2U{Fu^-xi&W=9eIxPc6{o3vA_#5?uf0#7%H8fpW`ORQ``u6&O?lLpHmA*i{ z{2zJYI1N9~=!ezLqNjuJHhY0{IbJv<9T%%FC--*0(f-@hdH8^8MIW>QZ%Si3V3kF* z34e0(Bhd=CsN;>XU0k}{X1VaM92Q@mJ+jg}jnC*C-p4asVR39XpV`;j4%_ zbf4`9$8P9LeBbC(hCSQnPbM(0{7oy|_GjKbN&md`dCGZgoc-IEaswC-0*qh6@2hB2 zz6D&50qjQ*b}wH<=xfiVp7*A`zSnCn}3iwyY_vp`{aGZ#9 z&sdxI82w1$`AqzNg7+K3^kwF;FWL&b^_jj^yM^~AoLlDB?-u$zKm7ob)RV{{N#} z^`EfY{lz-r>Gch~arUw3&995S%5;hocT)uP0TPiMZ06knPB2X`tlYnAd0(>uEBAN8 zEyy>}IdGpXjL!zs-n8~nftPtTkZHnmnbSLi)y@oz-69 zRc5QWnT3__Pl`Wr*Hc_v@vc_*`iY1B%zE>oX$=2UKjr#KW3>a2tE<89vpCmCpO${| z2Tg@HxCF)DDZQ|lKHF8w3H0#>r*gu6;~fKG^9IeQ_}@%1*ni#$;d^|I zHI;jsOX-d;%KkU=3H;wX2>+S3xs>ksqWXW$dS+_`*3;&YdRJM~|(Z7I!22<%Za^5m5IbE7$rhKaJ_s z`j5dCWB;W8et*qIxZHi2MqWF?q}G2k8P4I>_!(6Hr?mg;MAvTlX53lc-TObh?nZCL z2kwi&+^cb)Z&2PNeJ}X^fbWGXV{4n`9*wxdg^^#k;;~me`fow*BC5>`A>R|6EAT42 zTjKh4|0m8$d?J+HhYuj0CF>Uc`hl}6_kgys_j6`u)ypu}3w(b9@;r*O82{#nrrnf= zXDPa^4Vb!Pge@NIm zyCGk{od1OG-`vb!@3DU`-k0JelRR6Eb|BT~zj;R833h&)nwaqPB^c)Xx7WEzH#oZa zJA8QVo7TPneN%YRmiynZZ{`DTX%K%?Al-)#Kew`a`FlG3zl@yZMRPfihkn{Rr*yc3 z@I8Te&-tJ!Ep#8#35IU@Mqf+t{d9JU?7$gbwBtM;cw6)H=X7r|bYBm~f9+JSI{P6G z{+drrfeak)MeA%f`!uZEud(rV+?t2`^wIBo+?K|pzn|O0dsVGv^y@SIJ9kb7R(kqC zAy2Sp=fozyVvf$bFK4bWv42wyww?-!0B=`b#N6Y5%v-`2)D zwI{Kjc+pHgziW{4>}lB#;+bUhb8l}fpMIor51@{$wN13=5A)7FgvH9rPhhMt0lRR! zUwJUn4zJ?=lOVp_F4DB;&QJNDyXX46XB*+r2Z)?T&^_!iIJ24WwmC;#+l(~Fgy92a zoa0_Q-|g>!qcU5Zh~%u46P%^$if7Jj-1)_Y$}ay*pJLo&&YyT**mXz7F^yB+R^L`n zO}*U9-rI5IJ9*FfJEu6OZ%BW0D>#D21Lo75>T>_49Ui0($}8Z$!M(gDIeV;B%&iDg zM;g-3YdZQfLm!smf3J80;qk7K0=+ePSq0KYC1ZTpgvII%x( zg(c{-AKxO7ucLyNYe|2hgw-o_qB;CY-h(yqGwIWe;|{O$buHzqHnJ+@4E?RsW3gZSzT19(z6elXc(_%1;@1 z{{-i2++Yv$9cA7bgr1O>+t6m*oSZEnPSrCDA;I`aO6OM&ae8`Ee-$N=ZweUHI;@4Lh~s*rcJ2wC)k zL(i>hXYIk*|JJ6k3Gd=LkMe-$AEJFajBDBUv|-*k=R{ioKH%OTZ5H80_R`DK&( z&iQfP)dAUAlHdG*@t5ZDk)#e`Ae}Fs$+5gQwQ@3G`WLf7h zS2bgP`(P*Dw)_S^-s9ur&#+RE{nVEEn&m9g{Qy>Y)U!7QO8NYD(AoP#@Qm|YP4}r| z?E1doWob{A^of7 z-6$K)cb-)V+E1_ZhvyvJ@4Ax^VlD}HvIZYd7{(7Kh8744&BkN42Zc(-TAKFcHrxkyu8~EG!C z-Tr4yesFe+>y4bl>=oX%g9rBuVZ1c~`fRDu$U~?7qyeuUb@K7m4xC(PhCO-0PyK$< zz#iPdyIT8I4$SlVr?oSm<3i^xajnfmu1;~O)7b|3l4I>Awt4NyW;f*}-!V1{wC6Sm z&AFE`E_wj&1A@R3Fzq7OHAqCU)-C$)jT6UkzQLR32V3F; zIV;3v!{Vfmy=?AlpTn7X@cpKMy3mqF4dtP)m&9C)IKS%4rU&PF13tAnuqU5=V(bB* z)AgP0^iA74{fJR+0rU3&^B)n0JbUP$erP*y=2_w-Xo0z$Z#eVGKfG<`UxY5@c{fc| z`}NzU8~4A!uh`eoH@Yl_zFaFiusY0nsIWLkH6Pig3|MJ$BjkbSII@%p@}iwHLSA&? zIkkCR`*-6^Is7JJ&jWse_RU9#H`j6x^@2B!IPm`uQQ{C=S>OxpBKUJT!WsO202w(N zI{M%A0myMDd|7-@@RREt^vi%V6!Qh`E2clRvmsX(ApJVfxe^^Ro0? z4u0*+eWyZ4F~)|Rd=>pL_<_qEgYu4`FAeUU!nb{dF9e)jp)Pc4zP%H20Nm%};8&z& zVvfYke?cc`)+=>FA42;Sh?Kt`{RrskZ_+X++ADk~9$aHciid5~b9OJ$JT16{^ZS$F zd@%Mla<4{O=+$d*pv@2EKducu_PqxJeeOB#JLxr=rwJeM(bZpd5qQw7_a_y!10f6Ffqmfl z#kFG0VC+@;JMD#-ALl)XhVQ^@IR}s5S+MK>jXBkKQ>J>6HVP+20Yw2t0Yw2t0Yw2t z0Yw2t0Yw2t0Yw2tfo`Dy&KAb|w|PjGUtWf%Et$-y@nLHwGghruG8ulx-{wqas9Mc} zaASOB%<^g#ul$U^Y$h{cyyG+e7-Rg8&($i5V^01r3P5qp$^S)-=6ja!y!CL&cqlVq zffeIH1ca!}_hm-1fi0Oq^L=yXd5p*mW%>UApf+OU`XC002M7ox~0wSRd=E2`63I}dujqeW% zzSQG^ena}k_c6SB#WT7dUq*fSBaHYP1M-dUnZ|dd6Y&P)h@0T9!Dz&7k>ZkNaionQ z4T7lRoy^5wnepV`ka(@Xj~ee4#xH-cB7_B>40*V#VpcDRo*BBT-0(hneAxuYKxk}P zR=x{>s{>h6nhkl~YI#S6%eM?<0q)XRb@M1I7}`8Q@XJHh@*oQwV;Z13SY@)xasmx> z@Xf?pyhEh;J_ewXo*5xx1Jxn<4ifPlB^dErG6Q@kq9VO~2WIBGNKe9B%d@^$nID+S zc-EiAds)HX@2ECANPGak`Cb{VR)(rez$7c;0hsxR_Zq$UDQlq@-!lWOrT&L^ z$q#-6S~dRg9hjFTKl#2nv#cV`jq$#`;e9ECcT-*%kC!FudpR?RA{b-*Wx+9$LH=bj zL(miXMh=dWi}J%}JmY;xzC*;uASSg6%gmdt7bpxIKAHufXw+Sy(uzN12TAdc|M`cM zC<^ZeM5u{X;oYc_52MDrNPt(C%+D;LjDX)O2<0D9Ez1{#tjPL4WP2aQd)AU3uhw@# zIZN~q<1O!FcqP>4pMcV|JNzW*WO--u>Smc*0B5SL_{LZh9oTD0#0ujqEG+Y#c$t4$ zz7NSa6TDH|`&i?E9N*ODl~$6bj4J@Gz`!H#^( zWOi)JWwcR-`d?8%Q9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~Z zQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~ZQ9w~Z zQ9w~3Mhc8q-|&q5!j9V%?%sXd(gUv=}b8T;I52hnaA~a^Y?(r zgS;$*WwFdq!1PHb-rpd60_yp%!{_LErmw)i(1##>2}bdQf5JQAd+=AL55gl@pSm}q zPXx>Z&C*>vy<}!__~XTe;d7zyfAy*3er-WL9m`%A885CM-j8*9fYj4bxI^+eyqgO? zy}u5kvgbpHi(S5r-cY1N;;9_O?x3THF zXMbUME9zd0-)9L+2T74g)x4Vv?e15hYga%|w}jQzWb$cGqOWi@;Qc=CK-#OH?s`i^ z7&=(V^!ky#p>tPuM(4f855t!iDeX|< zfGs$%R2m;lM1WN#p(s%N`%cfp{M-LPr|w9*-rM0L%rlkYuM&q)$S0i17|-W!PDKAw z)tA8CC%8*GVREbLJSdQ@W}a58tUMID|987yb>F+d;V|OlfpSNq(r{N?$L$X8qs18E zgt^JdS9g>deO~#@hLKUs<6qZ(dfqq=u)|NBh+Ep&%X|7TPqx%?{YzD!0Bah>{J*4) zSG^1m_umv3htIL=O>e!6HG`9hYg%}Tb+c2I?l;dDk|yX6{04W_e<&@y>Qy*q7FVo< zp5M@0y6)K@xPdsQfuG{e&vdoDk?a+U)~4b9a_m=31HXC=2K0*$f!=TGBi$GKiLi$G zIOaIM*lB*E{eL7{U2$$pd!Y{4%K6pBRoi!(XT4DW+G^(Mm?J;0-}K&&FYJI;!nHe{ z;;9<@2eenZ_89EIx7MsVeR-#N*6sDKsqVBKbNiRrb)jG1VF%ZcCY|6b#<*AZLfhN{ z92PM*@WD>-tefk^+}HD=`EfaMAG%4FE^k)M|vqmb$ z|29*|y)#OG9sO!cFsJ$`Y-!J&1#L(2iVG_bS32)g=WTNrq+L6-50e)@G~U;*dK)#~ zJWmjY2fdO9{yE-_zU)&<|3m6OXjfdnGVy)r<{99vQR_oLmIoV9?nj>k(ES%c_wQ1= zA5QlzbX(w>Y0_=Nd_nt4Kk927IAgyM{1$3&Oqz$CtPgAaKeotlPpr>%44;LO3p>F} z%mvKFp$~mormk(q8q40D;A7Htsxg1tZGMxz?8ICEHpvKHT+rhqrZ!J^Z~27^9zNSvOR`Q ze;&WnfZwIi?HlmBKS}&h-+p%3{JhUBtbBj!bf(Ap1EjGZUPPTn$YjEP0@x4riwFCy z=pTF=^X<1KsVnW&1z3LrUDMEJ{}%Lpu${6Z%zImyw%BL^ff_5Tc?dZKENzy8+y5^_yV^&)FeO7aR@#=k}4H!Pe zFJGcecR@b?eCXu*sASDgIYIgWPPoobX&*(~`U^jGsWiE*oHul$qt`e3@_>BaX_Zm+ z9HTwJJ!GW07f<4f6XVlDF0s8qVfz4f{7X~XE*MXr8PWV$%f3sO+LE<)y7_s>oZkUT z-(%Ewr#Xy;mHYeQO&n>UlP4u9-&_NT3J=}|hH>}tNy6VPb>TkMPY0!QwVD}#{{Kbk zf3o@yUjf&W_Cfg-ReIQf8uQaE%$Jx`QMX9f0SV%)Qxtn4dW zW6+zlm-3u+XzUfyo=ixtH^p^_C+;}>3~X7V>uU+oEYKJQ`@Js>BSUJvzk{}~L;C@w zoZzhBHxrX(T{}|N8+BO9M|gNMKlY)Z_gwb02V;V2byqd{q=*8Do7_f~t`sdE8RR%R!) z4<{w3N%-64`WN;Q{cln@rKFz6^C9;8_u@T$R^94`XY>KqNnPwzh&8p#6O!Y^&OrjF zvl7B7&^SFNm;$f+%`WV^V>jqW@54B}wG;jUthUD3Z`baAcz^ji|;!Fg{PjR{-W5zai^PBILPP>>s?Nm+e5fvH!@V zls^@A*cL1#?<>@B~OC#G3xX)lanhWluLIV(6ytYhTTtj3+;YqI*RB2}zvKMS7l3PG{t%+3 zEAM*>N-s6{pEBL&Isn#Vg7Pu0^zga!@u=n0ULN=8#sz=Sa%T(qPuby1bYAn z=DGs)mC*0{A;M#^PH_}|M@cqtyng$jdD3@=+hxQ0j`jzB`s1A=hkm5$POZ5~?Y+SJu^@PJt^~hzXq%_d zF5^6PeqXfNueZ`NP0!=iH#{Tuw#&&nsqM?G$~bAJTonFL_KiVfvEZg+UaS* z*9u2zI==_{0l32~SLHkfbp1^FA?E!a)^y$Ye96XFJUb1tu3%`kLA1?jktwlXHs=06 z^eeIRz(MKCySh3xMh^;Wkn~FboAoX&wkS>dPnkly7wf%&X<7paqC?%BR`vhWtN&^0 z|C{>Hb*cFF0-}%I&AQZ$u&epM^y+__=Km<$N!%CKjXKoLFst>y^y+__*8eEu=>HdD zZU51?D3wZqeb#rg{-ptS>^0k*mNBn_!?Z^!+W#YE6m#IQ`_Su!Idw^|ZVA=?-xT%% zQnde9$}!ftwYb~=)zcG0vOX0iLjmZ&I{%ZlJxUA1KTC%D{%e8$|0s6-PtYHzf6RM3 zr8@tc%J%=Xv`HQJuE(waaor1+Hd>|$9_9Zdefpmk{~uO4h3+rLt^aG+uIUSW<`C;Zeu72a{gn>grwt}A(j8r!wu0-6vR)M?O9ABrJSDo1_CGDYzj-eQ&c5FhR-Xgs zgMX`JrC!x0DWK*6(xLxp8T<3BE863m1M6@ba|9(_dyAAu6(&yswGSu-HXtQ_e=!&T z>m+rbbz$%Svyzv3RimVUx&t^3`ag1j0df1P_1SL&H{Qe2Qcq{6KUTmDS#fHpSbR0Z4vtlQ|0r&Q}j<$y%>icqVzvu{RhoEB?FjyJ0fY_ zM}0S9f59s|g|n*HodTr|+dm8X|A4Xq$=HBmWn=^WFuQXkz8JB;uZ=bPFm?m><}!88 zH$I+5Q8M-dWR?CWr2h*eM@7N2Fl9i0uL+~WVe?>|F%CDe9--lu5To(LET@Qng@tk_l>iD9U1qR^7-ws&*zaadK@l~ zb^-SHE$e=*=pPJ9)yy*V0d7`(fOzZx=KOCYjXNPx(ET5W)8kM+08)FeS0`xSO?4I* zSG-HvpD>oPRK%H=lP9!#h)slUY+1_bai4JaIUfe5!-+cp7BS!AG;qvvu|;2 zbW*5F>J$acJHq~;Y(O+N0QU5nPTlP_H$y*@?{`XLqpx>*<8DJ(r+!e` zfc9(vX*j#E>yFWLsC9{$e|skMa$^Gezh$nNe{*YyREln*z|7*x4=Md`PyeUakL(?m z=UA`XD{Nloo73}OOhEUm>|?Y8r^=gN3gB$ONy-LHAM26)>5%=ca2&i-ERnSK3*3)_y^Lc9Zt_r<;R?KHi)lW7P{R^vT)Al_Lk+et_~Mye$e&yN&rB^w@JPkbCeP^4Qz&(C_r)69vAijNYPvxJztd_~QPt z0mBzeFOIw*3NCVO4?62O=6BZRiD9>TF)t_g420Hk=Q@pX!~EprTixnb-xUSe7seU) z$9vcgY@&~ps5m`S$h{N#_N152+2gRM@4IgGW54iS_ViJ`qwO!d)vLZM3P=jftsj1M zZ|)ZyzJ$0*JlhR<{{?jO;h_47HhzOs-KD9Cor0DXB>il0;$2Sls&9${P829E4)29^ z!u4I>7XS{##R)X-K`GWQpS)L40Ua6fjz4F43=MQ8( zDpV8*N&$1fz<=RP%%4+l2Ns6^1e^7VcGvTrXv980d~x05rE5Oper7Kp>;8ZDnK$s< zAlm<~y|ahsC;|T=SQkANzar@LbG-iq&w2b#M89coo|mxGGfnUGrv|t`fIj01u*Dkq zd@m2>?{qvLM*ps9g4qcc<1FFG2Qh~>m$G@nO_;No<$ZkZoY`~2S$;G3_B|Sj zj_TUxHET{^F3X;q(q0=$xeogRAal3Ldb%l`naaN(G~qtz+Bqs$8tJ(Qp+l>tr*p4L z6OOBktG0)&IuknYXRNptHUfJ{y5nv|o{@nK_$+>Fp?h1C?-#Jn;mhIQ;DC0{>-dou z$ktwvz0I!j=b(43(#}rh_mp)*M`OFsi*1gqryD}*Fm&e9R`hd>hh4?@?{)gFBHDJW z^Lz{X{Fo!0E#w6}E3-}L27VF7(!z@EitC5>gD#F^&hW3X*#eY}zQ?)n!~5~lT|2!b z3Hrz(+^kLq=vKT#)$|rDW2?NSDQy?%|5BWIpuZT}7iBB>ReU1_O#fZ6>c5Z`oW;ZWfp^CxH$nZ5HBuq| zw^A{;0=T!H-w&tvR{q_HK6GsNwxR!WRa`u@mWzIX^4Au>SZ3HRvwgPGKAz|NzL1^6 z;wd{Ho?y$ zp#NvYRZo1>X}@InLwj3XcsrHLHTZl!zfJp)*Gu5sd_;5ozU?0B`&b=`FbN8m16R>-y#e%m7&$y>3N}9y&ufd5v(Eqt8 zabQ_!>uy0^7>DJHPG?i1uCE))gZ{_1_E+aC?)1j`PW?SPdjmPSIn8u7WzKiI7YU6b z_IV83os9%851VB#A8p1%TrZ7^x3l^Dj`lk6eo4RkikO|s{f`4(qLo+98|m6_W2pya zMT=bw#H-Y~MH+{tNDf<$0{A>P~Yx*#Gkh8x11Uds!~{SV2EHCV!t=F|G8a{7M=m z9Y)obnQKI`?DuI&6CZwL{qi1d&}7T{8tv-i*q8h(;tf{H-m#6(q18i(;{*vALr zum{%j#Zk~B$}&3EcUJTNO>9Iv7u)_*Z@Jbcnt(~s2iW#leenz07~}s9-gFAt574#m zr-+^jnuIHY53q}u#7!62m zD^o_&25@?1|9^bzw@z(lt_qoneE)w!W8K>P0NTBuGzdcqUjSn)>vC2-g^sSnk87zl z+C=u8^8Np0IdE0y|AO#7rahhXmx%t@y^+bDH%?o4?mLAlUjvDKlZ0qNKhUv_GvHe! zQJ-lw0{VRDmGl3!dJw z2T5I%p#OY$%yPGBR-bsCD*zEh2%n!Z_ZQLcKPduGU5lW9G1#%o;^l>QOWMc%BoNom z;-!Pp-^aM?TcG)W$5_|*Tp6CCh&XK^^k%;#P{n-4Te!a<7GB~kM-u-3ipApdfJ02@ zQF8aD-5-sV6UfYuV&S8${h!tIpPif3-Wyk4=+pgP;*{o){_;VCsP8}9%DzRCcnf~& z;tqny`i^KH%1O4F;%-Z0UOF-yIJdyF0!i|sow~4JF}Az7th&zisM%cZpRC^^e%^XP zKL7l7XpvN2`qF{E;*PHf=0b%I=J!nsc56M9bI8w96nLN=(yaGHS?h`Onf8WuCkh;( z`?u1j#0hVdWz$z|BzXIgW0NkR^`r6C*&X*uk%#or$^Lqqeb9^1?|EaCG7+>a?sI1! z5_s_3Y~Xej@^BmU=5tZ*RSt}UF$aDu@bGhYSrd-P_c(AkgLj|>t-F)yLHpS5;TLh~+-p96>1{(;T=|I+5W%Jd6e&S&lWfj*8L=2u&-YXoIe1JY*S=iDpV9u6mX^h?All3)We3d zq4N?7&W|+21<-9@`EakatV@N80*V686hPlBcAx(>eYM^1j6PI6=KxJx(&;%dXNs$@ ziUR4N0LPIv-Hf{ZwU>waDm&H~OPw2M{T!#+>s0TG0*V3#1)zJe&B5FC8oJnYHWzm< ze8iqE>i4vtyHK*ID9{NCz#rAyqt-RA^3WGQ(2ghfUV7z;BF@R~*x^v`iUR#c0j$Nx za|YMm@0Vk7?D=z|G3FoRxeLSshw1}3QC@xPR|?>spMQ&@ZS`9Q+J4tP`IrMpz!#f# zLBH}^;jJiOrvTdi-}u$tDAMs>F*^+MUF^G#>kOHt4}kNH?~`?^P*I>?DKL+FkE77( z0A+0D9wRS$V~igA*+Z^1V$a;Oyx>rAiUPe%0iKJ49>nhB%W4}?-xaNBiaXXmXw4JX zGx|NJw1P#&D+=@~1-K78F5Pk~7y5rlD|#A!SQ4>ITf6B@b}PE6ctruf6yW&RO-Ez< zu4|87GCuxnKQID@@5Kg(WgYB69#sVGnnG=*gD!rmW5DFx{&!<68asP1;SY=x7 z*KH^#UVF88exdMES+@!m1$u=7^hJa*{~ZbHlC|BwHY^>@K0q@5NMKuFZ*JgSFk#_U z`4k1xMgeiRQ9--DL%Lsu-Px-hnj|_8KpTL21JmwLs~vi(y!}G~`n}_xn~v#zAs4#s zmt0DE9)Prh&ACX{uR=wEZlwU)-q`Ne^J7!6m+wPS&<=R4@>5@wX=nrJw=xR&RT+u` zNm4-gTPdt_$45`h&u( z(klwYO@Y#Meh=vWot@GRYrXU%94Ef8FXve6OY|A{YGRK-BKK*>Nkdh3?@$1KbN9u* z-p3Nv1NZ*G*>O|W^jom8v-oPK#*L>cUs0eH1)%re@2nnJ?g_*Fh^^p_D!!y^Z^0br zlbys}>=Vl8w~GoVRgR*7Uka2;6EDPi817HB=wBzFb?xEVe11p2@JEquzMy@j)A(a7 za`*h?#5d$?>MiHG4aRzz#erP=-*E|Z3E2ukBb^NRi2`N zodW3ZCwD)m&;A7G)?BcU4dSP@puW?Sk>-{~YhRNcKr0m$_+DU$8F?iX^82KDI^7iVS*`A?;ZMzGbl z%}nLr6BoX!+}@`E{dKT?O_N+(VZnZbebU4Qz?MQgsm+#hxtFAgP70QEQ-F8iV1AEx zVJGJI!m8JPp5Z_EYiXn+=OX;TpJ*CgTbk4JU*@@tG}27Llnx4Te>={&e1`i>6O~V& z^L#a!^jt0-6l;b;(6FHWxCU!b++s|RzTD08lap_4hFX2;cM8nt`tImYU!6v|wZa73 zbXhmFV}#QRceYzORP%cn~|o%a->Z+%6fP}sH`_-34g)UHb_ zU195=f{mIkO-=074fIqnMoR&nC53FB4LNustuktb3$nVd+s?pqZNiE}il0l^)Ai

    PHc5gdhcjQZ(=etGyY=e{81^|pIY0y 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 T4LYWUgt;MSFG{_49R>bBp0p*{ literal 372526 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 ({ + 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/store/rootReducer.ts b/src/store/rootReducer.ts index 1a551aeac1..3e9ed98afd 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -42,6 +42,7 @@ 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'; @@ -142,6 +143,7 @@ export const rootReducer = combineReducers({ updateChannel, screenCaptureFallbackForced, isVideoCallDevtoolsAutoOpenEnabled, + isPresenceDisconnectionSimulated, isDownloadsWindowOpen, isLogViewerWindowOpen, isTransparentWindowEnabled, diff --git a/src/ui/__tests__/selectors.spec.ts b/src/ui/__tests__/selectors.spec.ts index 47162c8214..a58136cab4 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,48 @@ 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'); + }); }); diff --git a/src/ui/actions.ts b/src/ui/actions.ts index 5a7d77e20a..25cb781509 100644 --- a/src/ui/actions.ts +++ b/src/ui/actions.ts @@ -32,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'; @@ -75,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 = @@ -198,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; @@ -254,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']; 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/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 index f455d12a2a..d15b0e7d08 100644 --- a/src/ui/main/menuBar.main.spec.ts +++ b/src/ui/main/menuBar.main.spec.ts @@ -8,7 +8,10 @@ import { UPDATES_CHECK_FOR_UPDATES_REQUESTED, UPDATES_SIMULATION_REQUESTED, } from '../../updates/actions'; -import { MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED } from '../actions'; +import { + MENU_BAR_SET_NAVIGATION_LAYOUT_CLICKED, + SET_PRESENCE_DISCONNECTION_SIMULATED, +} from '../actions'; import { getServerContextMenuTemplate, selectAppMenuPopupTemplate, @@ -127,6 +130,7 @@ const createState = (overrides: Partial = {}): RootState => isShowWindowOnUnreadChangedEnabled: false, isDeveloperModeEnabled: true, isVideoCallDevtoolsAutoOpenEnabled: false, + isPresenceDisconnectionSimulated: false, navigationLayout: 'tabs', rootWindowState: { focused: true, @@ -152,6 +156,7 @@ jest.mock('../../store', () => ({ isShowWindowOnUnreadChangedEnabled: false, isDeveloperModeEnabled: true, isVideoCallDevtoolsAutoOpenEnabled: false, + isPresenceDisconnectionSimulated: false, navigationLayout: 'tabs', rootWindowState: { focused: true, @@ -445,6 +450,7 @@ describe('ui/main/menuBar', () => { 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 () => { @@ -467,6 +473,7 @@ describe('ui/main/menuBar', () => { 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)(); @@ -479,6 +486,25 @@ describe('ui/main/menuBar', () => { 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); }); }); @@ -506,6 +532,7 @@ describe('ui/main/menuBar', () => { 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', () => { @@ -518,6 +545,7 @@ describe('ui/main/menuBar', () => { 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'); }); }); }); diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index 7f47316573..610e9c5860 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -31,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, @@ -53,7 +54,9 @@ const on = ( getMenuItems: () => MenuItemConstructorOptions[] ): MenuItemConstructorOptions[] => (condition ? getMenuItems() : []); -const createSimulationMenuItems = (): MenuItemConstructorOptions[] => [ +const createSimulationMenuItems = ( + isPresenceDisconnectionSimulated: boolean +): MenuItemConstructorOptions[] => [ { id: 'simulateUpdate', label: t('menus.simulateUpdate'), @@ -80,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 @@ -588,6 +609,9 @@ const selectWindowDeps = createStructuredSelector({ isAddNewServersEnabled, isDeveloperModeEnabled: ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled, + isPresenceDisconnectionSimulated: ({ + isPresenceDisconnectionSimulated, + }: RootState) => isPresenceDisconnectionSimulated, }); export const createWindowMenu = createSelector( @@ -738,6 +762,9 @@ const selectHelpDeps = createStructuredSelector({ isVideoCallDevtoolsAutoOpenEnabled: ({ isVideoCallDevtoolsAutoOpenEnabled, }: RootState) => isVideoCallDevtoolsAutoOpenEnabled, + isPresenceDisconnectionSimulated: ({ + isPresenceDisconnectionSimulated, + }: RootState) => isPresenceDisconnectionSimulated, }); export const createHelpMenu = createSelector( @@ -745,6 +772,7 @@ export const createHelpMenu = createSelector( ({ isDeveloperModeEnabled, isVideoCallDevtoolsAutoOpenEnabled, + isPresenceDisconnectionSimulated, }): MenuItemConstructorOptions => ({ id: 'helpMenu', label: t('menus.helpMenu'), @@ -843,7 +871,9 @@ export const createHelpMenu = createSelector( }); }, }, - ...on(isDeveloperModeEnabled, createSimulationMenuItems), + ...on(isDeveloperModeEnabled, () => + createSimulationMenuItems(isPresenceDisconnectionSimulated) + ), { id: 'videoCallToolsSubmenu', label: t('menus.videoCallTools'), @@ -987,6 +1017,8 @@ export const selectAppMenuPopupTemplate = createSelector( createWindowMenu, createHelpMenu, ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled, + ({ isPresenceDisconnectionSimulated }: RootState) => + isPresenceDisconnectionSimulated, ], ( rocketChatMenu, @@ -995,7 +1027,8 @@ export const selectAppMenuPopupTemplate = createSelector( viewMenu, windowMenu, helpMenu, - isDeveloperModeEnabled + isDeveloperModeEnabled, + isPresenceDisconnectionSimulated ): MenuItemConstructorOptions[] => { const settingsItem: MenuItemConstructorOptions = { id: 'settings', @@ -1031,7 +1064,7 @@ export const selectAppMenuPopupTemplate = createSelector( checkForUpdatesItem, ...on(isDeveloperModeEnabled, () => [ { type: 'separator' }, - ...createSimulationMenuItems(), + ...createSimulationMenuItems(isPresenceDisconnectionSimulated), ]), ]; } @@ -1054,7 +1087,7 @@ export const selectAppMenuPopupTemplate = createSelector( checkForUpdatesItem, ...on(isDeveloperModeEnabled, () => [ { type: 'separator' }, - ...createSimulationMenuItems(), + ...createSimulationMenuItems(isPresenceDisconnectionSimulated), ]), { type: 'separator' }, createQuitMenuItem(), @@ -1072,6 +1105,7 @@ export const selectServerSwitcherMenuTemplate = createSelector( currentView, isAddNewServersEnabled, isDeveloperModeEnabled, + isPresenceDisconnectionSimulated, }): MenuItemConstructorOptions[] => { const serverItems = servers.map((server, i): MenuItemConstructorOptions => { const isActive = @@ -1146,7 +1180,7 @@ export const selectServerSwitcherMenuTemplate = createSelector( }, ...on(isDeveloperModeEnabled, () => [ { type: 'separator' } as MenuItemConstructorOptions, - ...createSimulationMenuItems(), + ...createSimulationMenuItems(isPresenceDisconnectionSimulated), ]), ]; } diff --git a/src/ui/main/trayIcon.main.spec.ts b/src/ui/main/trayIcon.main.spec.ts index fe676bd61c..e602758749 100644 --- a/src/ui/main/trayIcon.main.spec.ts +++ b/src/ui/main/trayIcon.main.spec.ts @@ -31,6 +31,9 @@ jest.mock('electron', () => ({ createFromPath: jest.fn(() => ({})), }, Tray: jest.fn(() => trayMethods), + webContents: { + fromId: jest.fn(() => undefined), + }, })); jest.mock('i18next', () => ({ @@ -70,17 +73,24 @@ jest.mock('../../store', () => { 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(); @@ -96,6 +106,7 @@ describe('ui/main/trayIcon', () => { rootWindowState: { visible: true }, hasHideOnTrayNotificationShown: false, globalBadge: 3, + activeServerPresence: { hasServers: false }, }); } return undefined; @@ -121,4 +132,164 @@ describe('ui/main/trayIcon', () => { 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..464edec00c 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,180 @@ 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, + hasServers, + } = activeServerPresence; + + if (!hasServers) { + 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) { + 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 +213,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 +243,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 +260,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 +338,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..39faf06c47 --- /dev/null +++ b/src/ui/main/trayIconMenu.main.spec.ts @@ -0,0 +1,339 @@ +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; + hasServers: 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('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('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/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/selectors.ts b/src/ui/selectors.ts index 7c134db7d4..b55dc14226 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,62 @@ 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; + hasServers: 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; + + if (typeof currentView !== 'object' || !currentView.url) { + return { hasServers }; + } + + const currentViewHref = toHref(currentView.url); + const activeServer = servers.find( + (server) => toHref(server.url) === currentViewHref + ); + + if (!activeServer) { + return { hasServers }; + } + + return { + url: activeServer.url, + title: activeServer.title, + presence: activeServer.presence, + statusText: activeServer.presenceStatusText, + connection: isPresenceDisconnectionSimulated + ? 'disconnected' + : activeServer.presenceConnection, + supported: activeServer.presenceSupported, + loggedIn: activeServer.userLoggedIn, + hasServers, + }; + } +); From 273298951368d8fca6c4351ac8be6b3ac0854b3a Mon Sep 17 00:00:00 2001 From: Moku151 <46893658+Moku151@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:13:18 +0200 Subject: [PATCH 07/17] fix: dedupe Windows notification quick replies (#3467) (#3468) --- src/notifications/main.ts | 8 ++++++ src/notifications/main/setup.main.spec.ts | 34 +++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/notifications/main.ts b/src/notifications/main.ts index aa44d079cc..d8ab627940 100644 --- a/src/notifications/main.ts +++ b/src/notifications/main.ts @@ -48,6 +48,7 @@ const resolveIcon = async ( const notifications = new Map(); const notificationTypes = new Map(); const notificationCategories = new Map(); +const repliedNotifications = new Set(); const createNotification = async ( id: string, @@ -79,6 +80,8 @@ const createNotification = async ( }); notification.addListener('show', () => { + repliedNotifications.delete(id); + dispatchSingle({ type: NOTIFICATIONS_NOTIFICATION_SHOWN, payload: { id }, @@ -126,6 +129,11 @@ const createNotification = async ( }); notification.addListener('reply', (_event, reply) => { + if (repliedNotifications.has(id)) { + return; + } + repliedNotifications.add(id); + dispatchSingle({ type: NOTIFICATIONS_NOTIFICATION_REPLIED, payload: { id, reply }, diff --git a/src/notifications/main/setup.main.spec.ts b/src/notifications/main/setup.main.spec.ts index 4862ba1ed1..9923ba2f4b 100644 --- a/src/notifications/main/setup.main.spec.ts +++ b/src/notifications/main/setup.main.spec.ts @@ -121,6 +121,11 @@ describe('notifications/main setupNotifications', () => { }); }; + 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', @@ -181,6 +186,35 @@ describe('notifications/main setupNotifications', () => { ); }); + 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', From 45c9c9bfe8de38dc4912733bca58305ad6b7dc0f Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 26 Aug 2026 16:11:00 -0300 Subject: [PATCH 08/17] fix: Windows notification quick replies lost after toast dismissal (#3464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add isNotificationQuickReplyEnabled overridden setting Override-only key (default true) so admins can disable the notification inline reply field fleet-wide via overridden-settings.json (SUP-1097). * fix: deliver Windows notification quick replies after toast dismissal Windows auto-dismisses toasts to the Action Center in ~5s; the app then dropped the Notification instance and the webview reply handler, so any reply typed afterwards was silently lost (SUP-1097). Electron 42's Notification.handleActivation delivers those activations: - pass the notification id to the toast (Tag) and keep routing metadata past 'close' (bounded map) so late replies still reach the right room - on Windows, route reply/action/click exclusively through handleActivation (instance listeners stay as fallback when the API is unavailable) to avoid double-dispatch on live toasts - keep preload event handlers alive after toast close (bounded map) - gate hasReply on the isNotificationQuickReplyEnabled setting * fix: keep instance click listener for Windows root-toast clicks Root-toast clicks carry no activation arguments on Windows (only structured reply/action activations do), so activation routing can never receive them. Attach the instance click listener unconditionally, drop the unreachable click branch from the activation handler, and slim the routing metadata to what reply/action need. * test: provide select in setup.main.spec store mock createNotification now reads isNotificationQuickReplyEnabled via select; the coverage spec from #3429 mocked the store without it. * fix: guard clipboard patch so injection survives plain-HTTP workspaces navigator.clipboard is undefined outside secure contexts, so the unguarded writeText assignment threw and aborted injected.ts start() before the Notification shim installed — silently killing all desktop notifications on http:// workspaces. Found during real-Windows runtime validation of this PR. * chore: trigger PR build artifacts for QA * fix: keep Windows reply routing alive while the Action Center card is The web client auto-closes every desktop notification ~10s after showing it, but on Windows the Action Center card stays on screen and still accepts quick replies. Treating that close as the end of the notification's life dropped the routing metadata (and left the card in place, since the instance had already been removed on the banner's own 'close'), so any reply typed after those 10s was silently lost — which is why thread replies failed while faster DM replies worked. - retain routing metadata past dismissal on the activation-routing path - keep the timed-out instance reachable so a dismissal can actually remove the Action Center card - fall back to a broadcast dispatch when metadata is genuinely gone (LRU eviction, cold start) instead of dropping the reply * refactor: log notification activation drops through the app logger console.warn output is invisible in packaged builds, so a dropped activation left no trace anywhere a user or support could look. * docs: post-mortem for Windows notification quick replies (SUP-1097) Records the investigation across Rocket.Chat.Electron (#3464) and Rocket.Chat (#41875, #41897) and adds a Windows Notifications section to AGENTS.md with the toast lifecycle facts that drove the fix. * fix: preserve notification category for clicks on retained Windows cards close() clears notificationCategories before a root-toast click on a card still in the Action Center can read it, so the click dispatched without payload.category. Carry category in NotificationRoutingMeta as a fallback once the primary map is cleared (CodeRabbit review on #3464). --- AGENTS.md | 28 ++ README.md | 23 +- ...ortem-notification-quick-reply-sup-1097.md | 297 +++++++++++++ src/app/PersistableValues.ts | 12 +- src/app/actions.ts | 1 + src/app/main/data.spec.ts | 30 ++ src/app/main/data.ts | 9 + src/app/selectors.ts | 3 + src/injected.ts | 9 +- .../parseActivationArguments.spec.ts | 39 ++ src/notifications/main.spec.ts | 390 ++++++++++++++++++ src/notifications/main.ts | 180 +++++++- src/notifications/main/setup.main.spec.ts | 1 + src/notifications/parseActivationArguments.ts | 22 + src/notifications/preload.ts | 8 +- .../isNotificationQuickReplyEnabled.ts | 32 ++ src/store/rootReducer.ts | 2 + 17 files changed, 1049 insertions(+), 37 deletions(-) create mode 100644 docs/postmortem-notification-quick-reply-sup-1097.md create mode 100644 src/notifications/__tests__/parseActivationArguments.spec.ts create mode 100644 src/notifications/main.spec.ts create mode 100644 src/notifications/parseActivationArguments.ts create mode 100644 src/notifications/reducers/isNotificationQuickReplyEnabled.ts diff --git a/AGENTS.md b/AGENTS.md index 25189b5eb7..54a9ef1dbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,34 @@ 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 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/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/src/app/PersistableValues.ts b/src/app/PersistableValues.ts index 9232a3e8ab..7e98585655 100644 --- a/src/app/PersistableValues.ts +++ b/src/app/PersistableValues.ts @@ -142,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). */ @@ -296,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/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/selectors.ts b/src/app/selectors.ts index 5283aefebe..17c6e8b102 100644 --- a/src/app/selectors.ts +++ b/src/app/selectors.ts @@ -107,6 +107,9 @@ const selectPersistableValuesB = createStructuredSelector({ isSettingsWindowOpen, secondaryWindowStates: ({ secondaryWindowStates }: RootState) => secondaryWindowStates, + isNotificationQuickReplyEnabled: ({ + isNotificationQuickReplyEnabled, + }: RootState) => isNotificationQuickReplyEnabled, }); export const selectPersistableValues = createSelector( diff --git a/src/injected.ts b/src/injected.ts index 5179f6fe65..ba3d1851ec 100644 --- a/src/injected.ts +++ b/src/injected.ts @@ -331,8 +331,13 @@ const start = async () => { }); // 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'); 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 d8ab627940..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,10 +59,30 @@ 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, { @@ -65,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, @@ -100,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); @@ -115,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: { @@ -128,31 +186,34 @@ const createNotification = async ( }); }); - notification.addListener('reply', (_event, reply) => { - if (repliedNotifications.has(id)) { - return; - } - repliedNotifications.add(id); - - 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(); @@ -234,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)) { @@ -253,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 index 9923ba2f4b..0e5b0e052b 100644 --- a/src/notifications/main/setup.main.spec.ts +++ b/src/notifications/main/setup.main.spec.ts @@ -67,6 +67,7 @@ jest.mock('electron', () => { 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); 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/store/rootReducer.ts b/src/store/rootReducer.ts index 3e9ed98afd..f7a3853c76 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'; @@ -152,6 +153,7 @@ export const rootReducer = combineReducers({ telephonyGlobalShortcutConfig, telephonyGlobalShortcutRegistrationStatus, isTelephonyEnabled, + isNotificationQuickReplyEnabled, }); export type RootState = ReturnType; From df12e0780744d3d7400d0118e62e53c9ab3dc5da Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 26 Aug 2026 18:00:50 -0300 Subject: [PATCH 09/17] fix(tray): hide presence status when the active server failed to load (#3472) * fix(tray): hide presence status when the active server failed to load The tray Status submenu (Online/Away/Busy/Offline) ignored server.failed and stayed fully clickable even after a navigation failure, so selecting a status silently no-oped against a broken workspace. Thread failed through selectActiveServerPresence and treat it like the existing unsupported-version case. * fix(tray): show Add workspace on the add-workspace screen even with servers buildPresenceMenuItems only offered "Add workspace" when the user had zero servers configured, so the tray hid the Status item entirely while the user was actively on the add-workspace screen with existing workspaces. Thread isAddingServer through selectActiveServerPresence and treat it the same as the no-servers case. * test(tray): assert the presence root item is absent on failed CodeRabbit review on #3472: the failed-server test only checked for top-level radio items, which would still pass if buildPresenceMenuItems ever returned a presence root item with an empty/disabled submenu instead of []. Assert via the existing findPresenceRootItem helper so the test actually fails if the failed guard stops suppressing presence controls. --- src/ui/__tests__/selectors.spec.ts | 34 +++++++++++++++++++++ src/ui/main/trayIcon.ts | 6 ++-- src/ui/main/trayIconMenu.main.spec.ts | 44 +++++++++++++++++++++++++++ src/ui/selectors.ts | 9 ++++-- 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/ui/__tests__/selectors.spec.ts b/src/ui/__tests__/selectors.spec.ts index a58136cab4..dcc375b8a5 100644 --- a/src/ui/__tests__/selectors.spec.ts +++ b/src/ui/__tests__/selectors.spec.ts @@ -81,4 +81,38 @@ describe('ui/selectors', () => { 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/main/trayIcon.ts b/src/ui/main/trayIcon.ts index 464edec00c..0c7d1bca5a 100644 --- a/src/ui/main/trayIcon.ts +++ b/src/ui/main/trayIcon.ts @@ -75,10 +75,12 @@ const buildPresenceMenuItems = ( connection, supported, loggedIn, + failed, hasServers, + isAddingServer, } = activeServerPresence; - if (!hasServers) { + if (!hasServers || isAddingServer) { return [ { label: t('tray.presence.addWorkspace'), @@ -106,7 +108,7 @@ const buildPresenceMenuItems = ( ]; } - if (supported === false) { + if (supported === false || failed) { return []; } diff --git a/src/ui/main/trayIconMenu.main.spec.ts b/src/ui/main/trayIconMenu.main.spec.ts index 39faf06c47..921e4b3d80 100644 --- a/src/ui/main/trayIconMenu.main.spec.ts +++ b/src/ui/main/trayIconMenu.main.spec.ts @@ -58,7 +58,9 @@ type ActiveServerPresence = { connection?: 'connected' | 'connecting' | 'disconnected'; supported?: boolean; loggedIn?: boolean; + failed?: boolean; hasServers: boolean; + isAddingServer?: boolean; }; const baseState = ( @@ -299,6 +301,24 @@ describe('ui/main/trayIcon buildMenuTemplate', () => { 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({ @@ -321,6 +341,30 @@ describe('ui/main/trayIcon buildMenuTemplate', () => { 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({ diff --git a/src/ui/selectors.ts b/src/ui/selectors.ts index b55dc14226..1258cd88f1 100644 --- a/src/ui/selectors.ts +++ b/src/ui/selectors.ts @@ -52,7 +52,9 @@ export type ActiveServerPresence = { connection?: 'connected' | 'connecting' | 'disconnected'; supported?: boolean; loggedIn?: boolean; + failed?: boolean; hasServers: boolean; + isAddingServer: boolean; }; const toHref = (url: string): string => { @@ -74,9 +76,10 @@ export const selectActiveServerPresence = createSelector( isPresenceDisconnectionSimulated ): ActiveServerPresence => { const hasServers = servers.length > 0; + const isAddingServer = currentView === 'add-new-server'; if (typeof currentView !== 'object' || !currentView.url) { - return { hasServers }; + return { hasServers, isAddingServer }; } const currentViewHref = toHref(currentView.url); @@ -85,7 +88,7 @@ export const selectActiveServerPresence = createSelector( ); if (!activeServer) { - return { hasServers }; + return { hasServers, isAddingServer }; } return { @@ -98,7 +101,9 @@ export const selectActiveServerPresence = createSelector( : activeServer.presenceConnection, supported: activeServer.presenceSupported, loggedIn: activeServer.userLoggedIn, + failed: activeServer.failed, hasServers, + isAddingServer, }; } ); From c691a89e719db4eb5391b37ecc19f135f43c873d Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Wed, 26 Aug 2026 19:34:14 -0300 Subject: [PATCH 10/17] chore: bump version to 4.17.0-alpha.2 (#3473) --- electron-builder.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/electron-builder.json b/electron-builder.json index adff6da359..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": "26081", + "bundleVersion": "26082", "helperBundleId": "chat.rocket.electron.helper", "type": "distribution", "artifactName": "rocketchat-${version}-${os}.${ext}", diff --git a/package.json b/package.json index 4d2dad9543..072c613562 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.17.0-alpha.1", + "version": "4.17.0-alpha.2", "author": "Rocket.Chat Support ", "copyright": "© 2016-2026, Rocket.Chat", "homepage": "https://rocket.chat", From e8abf7f5f8d23b128dfc32ed2371d49d96de5475 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Thu, 27 Aug 2026 21:04:46 -0300 Subject: [PATCH 11/17] fix(updates): make Check for updates work on store builds (Mac App Store, Microsoft Store, Snap, Flathub) (#3460) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(updates): make Check for updates work on Mac App Store builds Mac App Store builds cannot use electron-updater (store policy requires updates to ship through the App Store), so the Check for updates action silently did nothing there. Route user-initiated checks on MAS builds through the unauthenticated iTunes Lookup API instead: compare the published store version against the running one and offer an Open App Store deep link when a newer version is available. All other distributions keep the existing electron-updater flow unchanged. * fix(updates): address review — dedicated App Store action and URL allowlist Opening the App Store reused UPDATES_DOWNLOAD_REQUESTED, which flips updateDownloadStatus to 'downloading' with no terminal action on the MAS path, leaving the update pill stuck at 'Updating 0%'. Route the hand-off through a dedicated UPDATES_OPEN_APP_STORE_REQUESTED action that closes the panel without touching download state. Also restrict store URLs from the lookup response to https on Apple store hosts before shell.openExternal, build a real Error for failed lookups, and exercise the lookup timeout path in the specs. * fix(updates): extend store-aware update checks to Microsoft Store, Snap, and Flathub Generalize the Mac App Store work into per-store adapters behind a single updateStore field ('mas' | 'windows' | 'snap' | 'flatpak'). Snap and Flathub get real version checks against their public catalog APIs with store-flavored open-page actions; the Microsoft Store has no unauthenticated version endpoint, so its check opens the product page directly and resets the transient checking state. Linux installs with no update path (deb/rpm/tar.gz) no longer show the dead Check for updates menu item. The dev simulation override becomes ROCKETCHAT_SIMULATE_STORE=. * fix(updates): address review minors — spec placement, menu gate, error payload Move the store-update specs under src/updates/main/ so the main-process Jest project actually discovers them (the renderer project was picking them up — testMatch keys on the main/ directory, not the file suffix). Gate the Check for updates menu items on isUpdatingEnabled as well, so admin-disabled builds don't expose a click that nothing settles. Route the store lookup failure through dispatchUpdateError so the custom error name survives the structured clone to renderers, and strengthen the progress-reducer regression test with a nonzero seed. * test(settings): cover store-update bypass in CheckForUpdates Mirrors UpdateLabel.spec.tsx's isUpdatingAllowed/isStoreUpdate coverage for the settings-window equivalent, ported from the deleted AboutDialog. --- src/i18n/en.i18n.json | 6 + src/store/rootReducer.ts | 2 + .../features/CheckForUpdates.spec.tsx | 33 ++ .../SettingsView/features/CheckForUpdates.tsx | 8 +- src/ui/components/TopBar/UpdateLabel.spec.tsx | 41 ++ src/ui/components/TopBar/UpdateLabel.tsx | 31 +- src/ui/main/menuBar.main.spec.ts | 110 ++++- src/ui/main/menuBar.ts | 48 +- src/updates/actions.ts | 11 + src/updates/common.ts | 9 + src/updates/main.spec.ts | 109 +++++ src/updates/main.ts | 130 +++++ .../main/detectUpdateStore.main.spec.ts | 241 ++++++++++ src/updates/main/storeUpdates.main.spec.ts | 455 ++++++++++++++++++ src/updates/reducers.ts | 26 +- .../reducers/__tests__/updates.spec.ts | 50 ++ src/updates/storeUpdates.ts | 370 ++++++++++++++ 17 files changed, 1661 insertions(+), 19 deletions(-) create mode 100644 src/updates/main/detectUpdateStore.main.spec.ts create mode 100644 src/updates/main/storeUpdates.main.spec.ts create mode 100644 src/updates/storeUpdates.ts diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 8ac11f0bc8..6f24aed827 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -89,6 +89,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": { diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index f7a3853c76..fbfc35d624 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -78,6 +78,7 @@ import { updateDownloadStatus, updateError, updateChannel, + updateStore, } from '../updates/reducers'; export const rootReducer = combineReducers({ @@ -142,6 +143,7 @@ export const rootReducer = combineReducers({ isDetailedEventsLoggingEnabled, isVerboseOutlookLoggingEnabled, updateChannel, + updateStore, screenCaptureFallbackForced, isVideoCallDevtoolsAutoOpenEnabled, isPresenceDisconnectionSimulated, diff --git a/src/ui/components/SettingsView/features/CheckForUpdates.spec.tsx b/src/ui/components/SettingsView/features/CheckForUpdates.spec.tsx index 7a98475dce..5cab1c5e12 100644 --- a/src/ui/components/SettingsView/features/CheckForUpdates.spec.tsx +++ b/src/ui/components/SettingsView/features/CheckForUpdates.spec.tsx @@ -122,4 +122,37 @@ describe('CheckForUpdates', () => { ).not.toBeInTheDocument(); expect(invokeMock).not.toHaveBeenCalled(); }); + + it('renders nothing when updates are not allowed in this build', () => { + renderWithStore(, { + preloadedState: { ...preloadedState, isUpdatingAllowed: false }, + }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders nothing when updates are disabled', () => { + renderWithStore(, { + preloadedState: { ...preloadedState, isUpdatingEnabled: false }, + }); + + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it.each(['mas', 'windows', 'snap', 'flatpak'] as const)( + 'still renders the check button for a %s build despite isUpdatingAllowed being false', + (store) => { + renderWithStore(, { + preloadedState: { + ...preloadedState, + isUpdatingAllowed: false, + updateStore: store, + }, + }); + + expect( + screen.getByRole('button', { name: 'dialog.about.checkUpdates' }) + ).toBeInTheDocument(); + } + ); }); diff --git a/src/ui/components/SettingsView/features/CheckForUpdates.tsx b/src/ui/components/SettingsView/features/CheckForUpdates.tsx index 1032ac6c45..5ee965ba66 100644 --- a/src/ui/components/SettingsView/features/CheckForUpdates.tsx +++ b/src/ui/components/SettingsView/features/CheckForUpdates.tsx @@ -55,6 +55,8 @@ export const CheckForUpdates = (props: CheckForUpdatesProps) => { ({ newUpdateVersion }: RootState) => newUpdateVersion ); const updateError = useSelector(({ updateError }: RootState) => updateError); + const updateStore = useSelector(({ updateStore }: RootState) => updateStore); + const isStoreUpdate = updateStore !== null; const [resultMessage, setResultMessage] = useState(null); @@ -132,7 +134,11 @@ export const CheckForUpdates = (props: CheckForUpdatesProps) => { const fieldId = useId(); - if (!isUpdatingAllowed || !isUpdatingEnabled) return null; + // Store builds cannot use electron-updater (isUpdatingAllowed is false for + // mas/windows there on purpose), but they still get a user-initiated check + // via the store's own lookup/page. Store builds have their own check + // listener despite isUpdatingAllowed being false, so they stay exempt. + if (!isStoreUpdate && (!isUpdatingAllowed || !isUpdatingEnabled)) return null; return ( { }); }); + 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/main/menuBar.main.spec.ts b/src/ui/main/menuBar.main.spec.ts index d15b0e7d08..8cacc908fd 100644 --- a/src/ui/main/menuBar.main.spec.ts +++ b/src/ui/main/menuBar.main.spec.ts @@ -429,10 +429,15 @@ describe('ui/main/menuBar', () => { expect(serverB?.checked).toBe(false); }); - it('always includes checkForUpdates and dispatches on click', async () => { + 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 }); + const state = createState({ + isDeveloperModeEnabled: false, + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); const template = selectServerSwitcherMenuTemplate(state); const checkForUpdates = findMenu(template, 'checkForUpdates'); @@ -443,6 +448,45 @@ describe('ui/main/menuBar', () => { }); }); + 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); @@ -464,7 +508,12 @@ describe('ui/main/menuBar', () => { focus: jest.fn(), }); - const state = createState({ isDeveloperModeEnabled: true }); + const state = createState({ + isDeveloperModeEnabled: true, + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); const template = selectServerSwitcherMenuTemplate(state); const ids = template.map((item) => item.id); @@ -536,7 +585,12 @@ describe('ui/main/menuBar', () => { }); it('lists simulate items right after checkForUpdates when developer mode is on', () => { - const state = createState({ isDeveloperModeEnabled: true }); + const state = createState({ + isDeveloperModeEnabled: true, + isUpdatingAllowed: true, + isUpdatingEnabled: true, + updateStore: null, + }); const template = selectAppMenuPopupTemplate(state); const ids = template.map((item) => item.id); @@ -547,6 +601,54 @@ describe('ui/main/menuBar', () => { 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'); + }); }); }); }); diff --git a/src/ui/main/menuBar.ts b/src/ui/main/menuBar.ts index 610e9c5860..9e23c9f78a 100644 --- a/src/ui/main/menuBar.ts +++ b/src/ui/main/menuBar.ts @@ -609,6 +609,9 @@ const selectWindowDeps = createStructuredSelector({ isAddNewServersEnabled, isDeveloperModeEnabled: ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled, + isUpdatingAllowed: ({ isUpdatingAllowed }: RootState) => isUpdatingAllowed, + isUpdatingEnabled: ({ isUpdatingEnabled }: RootState) => isUpdatingEnabled, + updateStore: ({ updateStore }: RootState) => updateStore, isPresenceDisconnectionSimulated: ({ isPresenceDisconnectionSimulated, }: RootState) => isPresenceDisconnectionSimulated, @@ -1017,6 +1020,9 @@ export const selectAppMenuPopupTemplate = createSelector( createWindowMenu, createHelpMenu, ({ isDeveloperModeEnabled }: RootState) => isDeveloperModeEnabled, + ({ isUpdatingAllowed }: RootState) => isUpdatingAllowed, + ({ isUpdatingEnabled }: RootState) => isUpdatingEnabled, + ({ updateStore }: RootState) => updateStore, ({ isPresenceDisconnectionSimulated }: RootState) => isPresenceDisconnectionSimulated, ], @@ -1028,8 +1034,19 @@ export const selectAppMenuPopupTemplate = createSelector( windowMenu, helpMenu, 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'), @@ -1061,7 +1078,7 @@ export const selectAppMenuPopupTemplate = createSelector( return [ settingsItem, downloadsItem, - checkForUpdatesItem, + ...on(canCheckForUpdates, () => [checkForUpdatesItem]), ...on(isDeveloperModeEnabled, () => [ { type: 'separator' }, ...createSimulationMenuItems(isPresenceDisconnectionSimulated), @@ -1084,7 +1101,7 @@ export const selectAppMenuPopupTemplate = createSelector( { type: 'separator' }, settingsItem, downloadsItem, - checkForUpdatesItem, + ...on(canCheckForUpdates, () => [checkForUpdatesItem]), ...on(isDeveloperModeEnabled, () => [ { type: 'separator' }, ...createSimulationMenuItems(isPresenceDisconnectionSimulated), @@ -1105,8 +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; @@ -1171,13 +1199,15 @@ 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(isPresenceDisconnectionSimulated), 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..b823a0022c 100644 --- a/src/updates/main.ts +++ b/src/updates/main.ts @@ -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, @@ -303,6 +313,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 +406,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 +450,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 +525,7 @@ export const setupUpdates = async (): Promise => { isInternalVideoChatWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, updateChannel, + updateStore, } = await loadConfiguration(); dispatch({ @@ -422,6 +542,7 @@ export const setupUpdates = async (): Promise => { isInternalVideoChatWindowEnabled, isVideoCallScreenCaptureFallbackEnabled, updateChannel, + updateStore, }, }); @@ -429,6 +550,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/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; +}; From 8c6bd9eb551725ef0dbbd83f6dbc1b0833895348 Mon Sep 17 00:00:00 2001 From: Felipe Scuciatto Date: Fri, 28 Aug 2026 15:01:39 -0300 Subject: [PATCH 12/17] fix: keep macOS native fullscreen when pressing ESC (#3433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: keep macOS native fullscreen when pressing ESC On macOS, Electron hands every key event the web content did not consume back to the native window (`WebContents::PlatformHandleKeyboardEvent` calls `[ns_event.window redispatchKeyEvent:]`). AppKit answers ESC with `cancelOperation:`, which leaves native fullscreen. So pressing ESC while a video is in HTML5 fullscreen — or with nothing at all to dismiss — also dropped the app out of fullscreen. Chrome never returns the raw NSEvent to AppKit and therefore stays fullscreen. The previous guard only stopped forwarding ESC through `sendInputEvent`, which produces a synthetic event that can never reach AppKit, so the redispatch was left untouched. Swallow the ESC key down in `before-input-event`, which runs before the renderer and thus before the redispatch, and replay it with `sendInputEvent`: the replay still reaches the page but carries no NSEvent. While the guest is in HTML5 fullscreen the key is consumed and the fullscreen exit is driven explicitly, so ESC leaves only the video fullscreen, like Chrome. The guard is applied to both the server views and the root window contents, and is inert outside macOS. Also stop forwarding the ESC key up to the root window: only key down is consumed there, and the key up leaked an unguarded ESC once the guest had left HTML5 fullscreen. * test: cover the macOS Escape fullscreen guard Add regression coverage for the Escape handling that keeps the window in macOS native fullscreen. Automated coverage, in `src/ui/main/rootWindow.spec.ts`, asserts that the `before-input-event` listener is installed on the root window contents, that an Escape key down in fullscreen is cancelled and replayed, that the replay passes through, and that the handler is inert when the window is not fullscreen or the platform is not macOS. QA coverage, in `qa/macos-fullscreen-escape/`, covers the half no Jest test can reach: the bug depends on how AppKit reacts to a real key event from the operating system, and any test generated key event is synthetic and never gets there, which is what let the earlier regression pass CI. The pack covers video HTML5 fullscreen for both an attachment and an embedded cross-origin player, plain Escape with nothing to dismiss, the Escape/menu-shortcut/fullscreen-toggle regressions, and a Windows and Linux unchanged-behavior check. Also restrict the HTML5 fullscreen Escape branch in the server view to macOS. It was running on every platform, which changed Windows and Linux behavior for a bug that only exists on macOS; those platforms keep leaving the fullscreen exit to Chromium and keep the original no-forward guard. * fix: harden the Escape fullscreen guard replay bookkeeping - swallow auto-repeated ESC keydowns instead of replaying them, so a held ESC can never race an in-flight replay and leak to AppKit (also dedupes the per-repeat exitFullscreen executeJavaScript calls) - consume the replay credit before the fullscreen gate, so a replay landing after fullscreen ended clears the credit instead of leaving it armed for the next real press - collapse the pending-replay counter + expiry pair into a single expiry timestamp (the counter could never exceed 1) - extract isWindowInAnyFullscreen so serverView and rootWindow share one fullscreen predicate; restore the typed createInput fixture - bail the guest-to-root ESC forward when the root window or its webContents is destroyed - rewrite the serverView Escape branch comment that described the opposite of the code; keep the guard debug log in packaged builds - cover auto-repeat swallow, non-fullscreen pass-through and stale-credit clearing with new specs * ci: trigger pull request artifact build --------- Co-authored-by: Jean Brito --- qa/README.md | 19 +- qa/macos-fullscreen-escape/README.md | 63 +++++ qa/macos-fullscreen-escape/exports/README.md | 15 ++ .../exports/qase-import.csv | 142 ++++++++++ .../flows/01-video-fullscreen-escape.md | 68 +++++ .../flows/02-plain-escape-keeps-fullscreen.md | 66 +++++ .../03-escape-and-shortcut-regressions.md | 70 +++++ .../04-windows-linux-escape-unchanged.md | 62 +++++ qa/macos-fullscreen-escape/results/README.md | 10 + .../main/escapeFullscreenGuard.main.spec.ts | 159 +++++++++++ src/ui/main/escapeFullscreenGuard.ts | 119 ++++++++ src/ui/main/rootWindow.spec.ts | 139 ++++++++++ src/ui/main/rootWindow.ts | 15 ++ src/ui/main/serverView/index.spec.ts | 254 +++++++++++++++++- src/ui/main/serverView/index.ts | 61 ++++- 15 files changed, 1244 insertions(+), 18 deletions(-) create mode 100644 qa/macos-fullscreen-escape/README.md create mode 100644 qa/macos-fullscreen-escape/exports/README.md create mode 100644 qa/macos-fullscreen-escape/exports/qase-import.csv create mode 100644 qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md create mode 100644 qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md create mode 100644 qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md create mode 100644 qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md create mode 100644 qa/macos-fullscreen-escape/results/README.md create mode 100644 src/ui/main/escapeFullscreenGuard.main.spec.ts create mode 100644 src/ui/main/escapeFullscreenGuard.ts diff --git a/qa/README.md b/qa/README.md index 0e79a75d25..c4be391bcb 100644 --- a/qa/README.md +++ b/qa/README.md @@ -9,13 +9,13 @@ lowercase, and specific, for example `qa/telephony-deeplink/`. ## Pack Structure -| Path | Required | Purpose | -| --- | --- | --- | -| `README.md` | Yes | Entry point, prerequisites, smoke order, result format | -| `flows/` | Yes | One Markdown file per scenario | -| `test-links.html` | When useful | Static browser page for protocol/deep-link/manual click targets | -| `scripts/` | Optional | Small helper scripts for repeatable environment checks | -| `results/` | Optional | Local evidence notes; do not commit run-specific artifacts by default | +| Path | Required | Purpose | +| ----------------- | ----------- | --------------------------------------------------------------------- | +| `README.md` | Yes | Entry point, prerequisites, smoke order, result format | +| `flows/` | Yes | One Markdown file per scenario | +| `test-links.html` | When useful | Static browser page for protocol/deep-link/manual click targets | +| `scripts/` | Optional | Small helper scripts for repeatable environment checks | +| `results/` | Optional | Local evidence notes; do not commit run-specific artifacts by default | ## Flow Files @@ -167,3 +167,8 @@ unless a release owner explicitly asks for them. - `qa/telephony-deeplink/` covers telephony `tel:` / `callto:` links, settings, diagnostics, workspace selection, default handlers, and installer policy. +- `qa/supported-versions/` covers supported-version exception matching and the + unsupported-version block. +- `qa/macos-fullscreen-escape/` covers the macOS native fullscreen behavior of the + Escape key, including video HTML5 fullscreen, and the Windows/Linux regression + check. diff --git a/qa/macos-fullscreen-escape/README.md b/qa/macos-fullscreen-escape/README.md new file mode 100644 index 0000000000..4fc4427004 --- /dev/null +++ b/qa/macos-fullscreen-escape/README.md @@ -0,0 +1,63 @@ +# macOS Fullscreen Escape QA Pack + +This folder contains manual and agent-readable QA flows for the macOS native +fullscreen behavior of the Escape key. It covers the case where the app window is +in native fullscreen and the Escape key must not drop the window out of +fullscreen, whether or not a video is playing in HTML5 fullscreen. + +This behavior cannot be proven by the Jest suite. The bug lives in how macOS +AppKit reacts to a real key event coming from the operating system, and any test +generated key event is synthetic and never reaches AppKit. Automated tests only +protect the desktop-side wiring, so these flows are the coverage for the OS half +and must be run on real macOS hardware. + +The flows are intentionally written for testers without implementation context. + +## Quick Start + +From the repo root: + +```sh +node qa/scripts/validate-flows.mjs qa/macos-fullscreen-escape +node qa/scripts/export-qase-csv.mjs qa/macos-fullscreen-escape +``` + +## Smoke Order + +1. Run `flows/01-video-fullscreen-escape.md`. +2. Run `flows/02-plain-escape-keeps-fullscreen.md`. +3. Run `flows/03-escape-and-shortcut-regressions.md`. +4. Run `flows/04-windows-linux-escape-unchanged.md` on Windows and Linux. + +## Flow Result Format + +```text +Flow ID: +Platform: +Build: +Review range: +Coverage: Full requested range | Partial surface review +Result: Pass | Fail | Blocked +Finding status: confirmed | suspected | blocked | none +Evidence: +Notes: +``` + +## 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 Truth + +When updating this pack, derive expected behavior from: + +- `src/ui/main/escapeFullscreenGuard.ts` +- `src/ui/main/escapeFullscreenGuard.main.spec.ts` +- `src/ui/main/serverView/index.ts` (`before-input-event` handling for guest views) +- `src/ui/main/rootWindow.ts` (`before-input-event` handling for the app chrome) +- `src/ui/main/menuBar.ts` (macOS `Full screen` item, `Control+Command+F`) +- `src/ui/components/TabBar/styles.tsx` (`TrafficLightSpacer` collapses in fullscreen) diff --git a/qa/macos-fullscreen-escape/exports/README.md b/qa/macos-fullscreen-escape/exports/README.md new file mode 100644 index 0000000000..b082904d8a --- /dev/null +++ b/qa/macos-fullscreen-escape/exports/README.md @@ -0,0 +1,15 @@ +# Qase Exports + +This directory is for generated Qase import files. + +Run: + +```sh +node qa/scripts/export-qase-csv.mjs qa/macos-fullscreen-escape +``` + +The script writes `qase-import.csv`. Import it in Qase with source type +`Qase.io`. + +Do not hand-edit generated CSV files. Edit the Markdown flows, validate them, +and export again. diff --git a/qa/macos-fullscreen-escape/exports/qase-import.csv b/qa/macos-fullscreen-escape/exports/qase-import.csv new file mode 100644 index 0000000000..aeedcf04fd --- /dev/null +++ b/qa/macos-fullscreen-escape/exports/qase-import.csv @@ -0,0 +1,142 @@ +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","Window and fullscreen","1","","","","","","","","","","","","","" +"","1","Window and fullscreen","","Escape leaves only the video fullscreen while the app window stays fullscreen","Source flow: FSESC-QA-001 + +Pressing Escape while a video plays in HTML5 fullscreen returns the video to its inline size and the app window remains in macOS native fullscreen.","installed_branch_build +workspace_logged_in","- Screenshot taken right after the Escape press showing the inline video and a + window that still covers the whole screen. +- Note of the `View` -> `Full screen` checkmark state before and after Escape. +- macOS version and build identifier.","major","high","actual","manual","classic","FSESC-QA-001,source:repo-qa,platform:macos,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on macOS and wait until a workspace channel list is visible on the left and message content fills the rest of the window."" +2. ""Put the window into macOS native fullscreen: click the green circular button at the top-left corner of the window, the third of the three colored dots."" +3. ""Confirm the app itself knows it is fullscreen: move the pointer to the very top of the screen so the macOS menu bar appears, click `View` in that menu bar, and look at the `Full screen` item."" +4. ""In the message box at the bottom of the channel, attach and send a short video file, then click the play button in the middle of the video player that appears in the message list."" +5. ""Hover the playing video to reveal its control bar, then click the fullscreen button at the right end of that bar, the icon drawn as four outward-pointing corner brackets."" +6. ""Press the `Escape` key once."" +7. ""Move the pointer to the top of the screen, click `View` in the macOS menu bar and confirm `Full screen` still shows a checkmark."" +8. ""Repeat steps 5 to 7 with an embedded player instead of an attachment: send a message containing a YouTube video link, wait for the preview player to render under the message, click its play button, then click the fullscreen button at the right end of the player's own control bar."" +9. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it; any workspace where you can post a file. +Agent: Launch the build and confirm the workspace view rendered."" +2. ""No input data. +Agent: Enter fullscreen through the window control and confirm the window covers the screen."" +3. ""Menu path: macOS menu bar -> `View` -> `Full screen` (keyboard equivalent `Control+Command+F`). +Agent: Read the menu state or the app's fullscreen state and confirm it reports fullscreen."" +4. ""Any small `.mp4` file, for example a 5 to 10 second clip. +Agent: Post the video and start playback."" +5. ""No input data. +Agent: Enter the video's HTML5 fullscreen and confirm only the video is visible."" +6. ""Key: `Escape`. +Agent: Send one Escape key press and then compare the window bounds against the screen bounds."" +7. ""Menu path: macOS menu bar -> `View` -> `Full screen`. +Agent: Re-read the fullscreen state and confirm it is unchanged."" +8. ""Any public YouTube URL, for example a short clip link pasted as plain text. +Agent: Repeat the fullscreen and Escape sequence on the embedded player and re-check the window bounds."" +9. ""Include macOS version, build, and whether both the attachment and the embedded player were covered. +Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The workspace is loaded and messages are readable."" +2. ""The window fills the whole screen, the macOS menu bar hides until you move the pointer to the top edge, and the row of colored dots disappears."" +3. ""The `Full screen` item shows a checkmark. Close the menu with a click elsewhere."" +4. ""The video plays inline inside the message, with a control bar along the bottom edge of the player."" +5. ""The video expands to cover the entire screen; the channel list and message list are no longer visible."" +6. ""The video returns to its inline size inside the message list, and the app window is still in macOS fullscreen: it still covers the whole screen, the colored window dots are still hidden, and the macOS menu bar still auto-hides. This is the pass condition of the flow."" +7. ""The checkmark is still present, confirming the window never left fullscreen."" +8. ""Escape returns the embedded player to its inline preview size and the app window is still in macOS fullscreen. This covers a video inside a cross-origin frame."" +9. ""The result states clearly which video sources were exercised.""" +"","1","Window and fullscreen","","Escape with nothing to dismiss keeps the app window in macOS fullscreen","Source flow: FSESC-QA-002 + +Pressing Escape in the workspace or in the desktop app chrome while the window is in macOS native fullscreen never takes the window out of fullscreen.","installed_branch_build +workspace_logged_in","- Screenshot after the repeated Escape presses showing a window that still covers + the whole screen. +- Note of how many Escape presses and whether the held-key repeat case was run. +- macOS version and build identifier.","major","high","actual","manual","classic","FSESC-QA-002,source:repo-qa,platform:macos,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on macOS, then click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen."" +2. ""Click once on an empty area of the message list, for example the blank space to the right of a message timestamp, so keyboard focus is in the workspace content and no search field, modal, or side panel is open."" +3. ""Press the `Escape` key once."" +4. ""Press the `Escape` key five more times, once per second."" +5. ""Press and hold the `Escape` key for about three seconds so macOS generates key repeats, then release it."" +6. ""Move focus to the desktop app chrome instead of the workspace: move the pointer to the very top of the screen so the macOS menu bar appears, click `Window` in that menu bar, then click `App settings`."" +7. ""Click once on an empty area of the App settings screen, away from any input field, then press the `Escape` key once."" +8. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. +Agent: Launch the build and enter fullscreen."" +2. ""No input data. +Agent: Focus the workspace content and confirm no dismissable UI is open."" +3. ""Key: `Escape`. +Agent: Send one Escape press and compare window bounds against screen bounds."" +4. ""Key: `Escape`, six presses total including step 3. +Agent: Send repeated Escape presses with a short delay and re-check the window bounds after each."" +5. ""Key: `Escape` held down. +Agent: Send an auto-repeating Escape and re-check the window bounds."" +6. ""Menu path: macOS menu bar -> `Window` -> `App settings`, keyboard equivalent `Command+,`. +Agent: Navigate to the desktop App settings screen."" +7. ""Key: `Escape`. +Agent: Send one Escape press with the App settings screen focused and re-check the window bounds."" +8. ""Include macOS version, build, and whether both the workspace and the App settings screen were covered. +Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The window fills the whole screen and the three colored dots are hidden."" +2. ""No dialog, dropdown, search field, or contextual panel is visible on screen."" +3. ""Nothing happens visually and the window is still fullscreen: it still covers the whole screen, the colored window dots stay hidden, and the macOS menu bar still auto-hides."" +4. ""The window is still fullscreen after every press. Repeated presses matter because the fix has to re-arm between presses."" +5. ""The window is still fullscreen."" +6. ""The desktop App settings screen replaces the workspace content, showing tabs such as `General` near the top."" +7. ""The window is still fullscreen and the App settings screen is still shown."" +8. ""The result states which focus locations were exercised.""" +"","1","Window and fullscreen","","Escape, menu shortcuts and fullscreen toggles still work on macOS","Source flow: FSESC-QA-003 + +Escape still dismisses workspace UI, menu keyboard shortcuts still fire from the workspace, and both fullscreen toggles still enter and leave fullscreen.","installed_branch_build +workspace_logged_in","- Note or screenshot for each check: search closes on Escape while windowed and + while fullscreen, `Command+R` reloads, `Control+Command+F` toggles both ways, + the `View` -> `Full screen` item toggles both ways, and the windowed video + fullscreen cycle returns to the previous window size. +- macOS version and build identifier.","major","high","actual","manual","classic","FSESC-QA-003,source:repo-qa,platform:macos,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on macOS in a normal, non-fullscreen window, and wait for a workspace channel list to appear on the left."" +2. ""Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once."" +3. ""Click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen, then repeat the search and `Escape` sequence from step 2."" +4. ""With the window still fullscreen and focus in the workspace message list, press `Command+R`."" +5. ""With the window still fullscreen, press `Control+Command+F`."" +6. ""Press `Control+Command+F` again, then move the pointer to the very top of the screen so the macOS menu bar appears, click `View`, and check the `Full screen` item."" +7. ""With the window in a normal, non-fullscreen state, post or open a short video in the message list, play it, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets. Press `Escape` once."" +8. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. +Agent: Launch the build and confirm the window is not fullscreen."" +2. ""Search text: any short string such as `gen`. +Agent: Open workspace search, type, send Escape, and confirm the search UI closed."" +3. ""Search text: any short string such as `gen`. +Agent: Enter fullscreen, repeat the search and Escape check, and re-check the window bounds."" +4. ""Shortcut: `Command+R`, matching the macOS menu bar item `View` -> `Reload`. +Agent: Send the shortcut and confirm the workspace reloaded."" +5. ""Shortcut: `Control+Command+F`, matching the macOS menu bar item `View` -> `Full screen`. +Agent: Send the shortcut and confirm the window is no longer fullscreen."" +6. ""Menu path: macOS menu bar -> `View` -> `Full screen`. +Agent: Toggle fullscreen through both the shortcut and the menu item and confirm each direction works."" +7. ""Any small `.mp4` file, for example a 5 to 10 second clip. +Agent: Run the windowed video fullscreen cycle and confirm the window returned to its previous bounds."" +8. ""Include macOS version, build, and which of the checks passed. +Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The workspace is loaded and the window is windowed, with the three colored dots visible at the top-left."" +2. ""The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app while windowed."" +3. ""The search UI closes on Escape and the window is still fullscreen. Escape is delivered to the workspace and does not leave fullscreen."" +4. ""The workspace view reloads, showing a loading state and then the channel list again. Menu keyboard shortcuts still reach the macOS menu from the workspace."" +5. ""The window leaves fullscreen and returns to a normal window, with the three colored dots visible again at the top-left."" +6. ""The window is fullscreen again and the `Full screen` item shows a checkmark. Click the item to leave fullscreen and confirm the window returns to a normal window."" +7. ""The video fills the screen while fullscreen, and after Escape both the video and the window return to the previous normal window size. The windowed case must not regress."" +8. ""The result names each regression check separately.""" +"","1","Window and fullscreen","","Escape and video fullscreen behave exactly as before on Windows and Linux","Source flow: FSESC-QA-004 + +On Windows and Linux, Escape and video fullscreen behave the same as before the macOS fix, with no swallowed keys and no changed window state.","installed_branch_build +workspace_logged_in","- Note or screenshot showing workspace search closing on Escape. +- Note of the window bounds before and after the video fullscreen cycle. +- OS name and version plus build identifier.","normal","medium","actual","manual","classic","FSESC-QA-004,source:repo-qa,platform:windows,platform:linux,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on Windows or Linux and wait for a workspace channel list to appear on the left."" +2. ""Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once."" +3. ""Post or open a short video in the message list, click its play button, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets."" +4. ""Press `Escape` once."" +5. ""Put the window into fullscreen using the platform control: on Windows press `F11` if the build exposes it, otherwise maximize the window; on Linux use the window manager fullscreen action for the window. Then click an empty area of the message list and press `Escape` once."" +6. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`, naming the platform covered.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. Record which OS this run covers. +Agent: Launch the build and confirm the workspace view rendered."" +2. ""Search text: any short string such as `gen`. +Agent: Open workspace search, type, send Escape, and confirm the search UI closed."" +3. ""Any small `.mp4` file, for example a 5 to 10 second clip. +Agent: Enter the video's fullscreen and confirm only the video is visible."" +4. ""Key: `Escape`. +Agent: Send one Escape press and compare the window bounds with the pre-fullscreen bounds."" +5. ""Note which control was used to reach fullscreen or maximized state. +Agent: Reach the platform fullscreen or maximized state, send Escape, and confirm the window state did not change."" +6. ""Include OS and version, build, and which checks passed. +Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The workspace is loaded."" +2. ""The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app."" +3. ""The video fills the whole screen."" +4. ""The video returns to its inline size inside the message list and the window returns to the size it had before the video went fullscreen."" +5. ""The window state is unchanged by the Escape press; Escape neither leaves fullscreen nor restores the window."" +6. ""The result names the platform explicitly, because this flow must be run once per platform.""" diff --git a/qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md b/qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md new file mode 100644 index 0000000000..2b3f0163a2 --- /dev/null +++ b/qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md @@ -0,0 +1,68 @@ +--- +id: FSESC-QA-001 +title: Escape leaves only the video fullscreen while the app window stays fullscreen +platforms: [macos] +priority: smoke +qase: + suite: Window and fullscreen + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build, workspace_logged_in] +test_links: [] +expected_result: Pressing Escape while a video plays in HTML5 fullscreen returns the video to its inline size and the app window remains in macOS native fullscreen. +--- + +# Escape Leaves Only The Video Fullscreen + +## Review Basis + +- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` + (`3bdd1e747`). +- Changed surface: Electron main process keyboard handling for server webviews + and the root window (`src/ui/main/escapeFullscreenGuard.ts`, + `src/ui/main/serverView/index.ts`, `src/ui/main/rootWindow.ts`). +- User-visible risk: A customer watching a video in a fullscreen app window + presses Escape to leave the video and the whole app drops out of macOS + fullscreen, losing their window arrangement and Space. +- Hypothesis: With the app window in macOS native fullscreen and a video in HTML5 + fullscreen, one Escape press returns the video to its inline size and the app + window is still fullscreen, matching Google Chrome. +- Smallest useful proof: OS-level repro on macOS hardware. The Jest suite covers + the desktop wiring only + (`src/ui/main/escapeFullscreenGuard.main.spec.ts`, + `src/ui/main/serverView/index.spec.ts`), because a test generated key event is + synthetic and never reaches macOS AppKit, which is what caused the original + regression. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Launch the Rocket.Chat Desktop build under test on macOS and wait until a workspace channel list is visible on the left and message content fills the rest of the window. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it; any workspace where you can post a file. | The workspace is loaded and messages are readable. | Launch the build and confirm the workspace view rendered. | +| 2 | Put the window into macOS native fullscreen: click the green circular button at the top-left corner of the window, the third of the three colored dots. | No input data. | The window fills the whole screen, the macOS menu bar hides until you move the pointer to the top edge, and the row of colored dots disappears. | Enter fullscreen through the window control and confirm the window covers the screen. | +| 3 | Confirm the app itself knows it is fullscreen: move the pointer to the very top of the screen so the macOS menu bar appears, click `View` in that menu bar, and look at the `Full screen` item. | Menu path: macOS menu bar -> `View` -> `Full screen` (keyboard equivalent `Control+Command+F`). | The `Full screen` item shows a checkmark. Close the menu with a click elsewhere. | Read the menu state or the app's fullscreen state and confirm it reports fullscreen. | +| 4 | In the message box at the bottom of the channel, attach and send a short video file, then click the play button in the middle of the video player that appears in the message list. | Any small `.mp4` file, for example a 5 to 10 second clip. | The video plays inline inside the message, with a control bar along the bottom edge of the player. | Post the video and start playback. | +| 5 | Hover the playing video to reveal its control bar, then click the fullscreen button at the right end of that bar, the icon drawn as four outward-pointing corner brackets. | No input data. | The video expands to cover the entire screen; the channel list and message list are no longer visible. | Enter the video's HTML5 fullscreen and confirm only the video is visible. | +| 6 | Press the `Escape` key once. | Key: `Escape`. | The video returns to its inline size inside the message list, and the app window is still in macOS fullscreen: it still covers the whole screen, the colored window dots are still hidden, and the macOS menu bar still auto-hides. This is the pass condition of the flow. | Send one Escape key press and then compare the window bounds against the screen bounds. | +| 7 | Move the pointer to the top of the screen, click `View` in the macOS menu bar and confirm `Full screen` still shows a checkmark. | Menu path: macOS menu bar -> `View` -> `Full screen`. | The checkmark is still present, confirming the window never left fullscreen. | Re-read the fullscreen state and confirm it is unchanged. | +| 8 | Repeat steps 5 to 7 with an embedded player instead of an attachment: send a message containing a YouTube video link, wait for the preview player to render under the message, click its play button, then click the fullscreen button at the right end of the player's own control bar. | Any public YouTube URL, for example a short clip link pasted as plain text. | Escape returns the embedded player to its inline preview size and the app window is still in macOS fullscreen. This covers a video inside a cross-origin frame. | Repeat the fullscreen and Escape sequence on the embedded player and re-check the window bounds. | +| 9 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`. | Include macOS version, build, and whether both the attachment and the embedded player were covered. | The result states clearly which video sources were exercised. | Write a concise result note without committing machine-specific logs unless requested. | + +## Evidence + +- Screenshot taken right after the Escape press showing the inline video and a + window that still covers the whole screen. +- Note of the `View` -> `Full screen` checkmark state before and after Escape. +- macOS version and build identifier. + +## Failure Signals + +- The app window leaves macOS fullscreen when Escape is pressed. +- The video stays in fullscreen after Escape, or needs a second Escape press. +- The `View` -> `Full screen` checkmark clears after Escape. +- The window leaves fullscreen for the embedded player but not for the attachment, + or the reverse. +- The window visibly starts leaving fullscreen and then animates back in. diff --git a/qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md b/qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md new file mode 100644 index 0000000000..7ce466f0ae --- /dev/null +++ b/qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md @@ -0,0 +1,66 @@ +--- +id: FSESC-QA-002 +title: Escape with nothing to dismiss keeps the app window in macOS fullscreen +platforms: [macos] +priority: release +qase: + suite: Window and fullscreen + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build, workspace_logged_in] +test_links: [] +expected_result: Pressing Escape in the workspace or in the desktop app chrome while the window is in macOS native fullscreen never takes the window out of fullscreen. +--- + +# Plain Escape Keeps The Window Fullscreen + +## Review Basis + +- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` + (`3bdd1e747`). +- Changed surface: Escape handling for both the server webview + (`src/ui/main/serverView/index.ts`) and the desktop app chrome + (`src/ui/main/rootWindow.ts`), sharing + `src/ui/main/escapeFullscreenGuard.ts`. +- User-visible risk: A customer in fullscreen presses Escape out of habit, with no + modal or search open, and the app leaves fullscreen. This is the general form of + the bug; the video case is only its most visible symptom. +- Hypothesis: With the app window in macOS native fullscreen, an Escape press that + has nothing to dismiss leaves the window fullscreen, both when focus is in the + workspace content and when focus is in the desktop App settings screen. +- Smallest useful proof: OS-level repro on macOS hardware. The desktop wiring is + covered by `src/ui/main/rootWindow.spec.ts` + (`rootWindow Escape fullscreen guard`) and + `src/ui/main/escapeFullscreenGuard.main.spec.ts`, but a synthetic key event + cannot reproduce the macOS AppKit behavior under test. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Launch the Rocket.Chat Desktop build under test on macOS, then click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. | The window fills the whole screen and the three colored dots are hidden. | Launch the build and enter fullscreen. | +| 2 | Click once on an empty area of the message list, for example the blank space to the right of a message timestamp, so keyboard focus is in the workspace content and no search field, modal, or side panel is open. | No input data. | No dialog, dropdown, search field, or contextual panel is visible on screen. | Focus the workspace content and confirm no dismissable UI is open. | +| 3 | Press the `Escape` key once. | Key: `Escape`. | Nothing happens visually and the window is still fullscreen: it still covers the whole screen, the colored window dots stay hidden, and the macOS menu bar still auto-hides. | Send one Escape press and compare window bounds against screen bounds. | +| 4 | Press the `Escape` key five more times, once per second. | Key: `Escape`, six presses total including step 3. | The window is still fullscreen after every press. Repeated presses matter because the fix has to re-arm between presses. | Send repeated Escape presses with a short delay and re-check the window bounds after each. | +| 5 | Press and hold the `Escape` key for about three seconds so macOS generates key repeats, then release it. | Key: `Escape` held down. | The window is still fullscreen. | Send an auto-repeating Escape and re-check the window bounds. | +| 6 | Move focus to the desktop app chrome instead of the workspace: move the pointer to the very top of the screen so the macOS menu bar appears, click `Window` in that menu bar, then click `App settings`. | Menu path: macOS menu bar -> `Window` -> `App settings`, keyboard equivalent `Command+,`. | The desktop App settings screen replaces the workspace content, showing tabs such as `General` near the top. | Navigate to the desktop App settings screen. | +| 7 | Click once on an empty area of the App settings screen, away from any input field, then press the `Escape` key once. | Key: `Escape`. | The window is still fullscreen and the App settings screen is still shown. | Send one Escape press with the App settings screen focused and re-check the window bounds. | +| 8 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`. | Include macOS version, build, and whether both the workspace and the App settings screen were covered. | The result states which focus locations were exercised. | Write a concise result note without committing machine-specific logs unless requested. | + +## Evidence + +- Screenshot after the repeated Escape presses showing a window that still covers + the whole screen. +- Note of how many Escape presses and whether the held-key repeat case was run. +- macOS version and build identifier. + +## Failure Signals + +- The window leaves macOS fullscreen on any Escape press. +- The first Escape press is safe but a later press, or a held key, leaves + fullscreen. That points at the guard failing to re-arm. +- Escape leaves fullscreen only when focus is in the desktop App settings screen, + or only when focus is in the workspace content. diff --git a/qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md b/qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md new file mode 100644 index 0000000000..a12378f1b7 --- /dev/null +++ b/qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md @@ -0,0 +1,70 @@ +--- +id: FSESC-QA-003 +title: Escape, menu shortcuts and fullscreen toggles still work on macOS +platforms: [macos] +priority: release +qase: + suite: Window and fullscreen + priority: high + severity: major + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build, workspace_logged_in] +test_links: [] +expected_result: Escape still dismisses workspace UI, menu keyboard shortcuts still fire from the workspace, and both fullscreen toggles still enter and leave fullscreen. +--- + +# Escape And Shortcut Regressions On macOS + +## Review Basis + +- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` + (`3bdd1e747`). +- Changed surface: `before-input-event` interception on the server webview and the + root window contents. The fix cancels the original Escape key event and replays + a synthetic one, and it stops forwarding the Escape key up to the desktop + window. +- User-visible risk: The interception could swallow Escape entirely, so workspace + search and modals would stop closing; it could break macOS menu keyboard + shortcuts typed while the workspace has focus; or it could leave the window + unable to enter or leave fullscreen through the normal controls. +- Hypothesis: After the fix, Escape still reaches the workspace web app, menu + shortcuts still fire while the workspace has focus, and both `Control+Command+F` + and the `View` -> `Full screen` menu item still toggle fullscreen in both + directions. +- Smallest useful proof: Local UI repro on macOS. Automated coverage exists for the + key forwarding contract in `src/ui/main/serverView/index.spec.ts` and + `src/ui/main/rootWindow.spec.ts`, but keyboard delivery to the web app and to the + macOS menu bar can only be observed at runtime. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Launch the Rocket.Chat Desktop build under test on macOS in a normal, non-fullscreen window, and wait for a workspace channel list to appear on the left. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. | The workspace is loaded and the window is windowed, with the three colored dots visible at the top-left. | Launch the build and confirm the window is not fullscreen. | +| 2 | Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once. | Search text: any short string such as `gen`. | The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app while windowed. | Open workspace search, type, send Escape, and confirm the search UI closed. | +| 3 | Click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen, then repeat the search and `Escape` sequence from step 2. | Search text: any short string such as `gen`. | The search UI closes on Escape and the window is still fullscreen. Escape is delivered to the workspace and does not leave fullscreen. | Enter fullscreen, repeat the search and Escape check, and re-check the window bounds. | +| 4 | With the window still fullscreen and focus in the workspace message list, press `Command+R`. | Shortcut: `Command+R`, matching the macOS menu bar item `View` -> `Reload`. | The workspace view reloads, showing a loading state and then the channel list again. Menu keyboard shortcuts still reach the macOS menu from the workspace. | Send the shortcut and confirm the workspace reloaded. | +| 5 | With the window still fullscreen, press `Control+Command+F`. | Shortcut: `Control+Command+F`, matching the macOS menu bar item `View` -> `Full screen`. | The window leaves fullscreen and returns to a normal window, with the three colored dots visible again at the top-left. | Send the shortcut and confirm the window is no longer fullscreen. | +| 6 | Press `Control+Command+F` again, then move the pointer to the very top of the screen so the macOS menu bar appears, click `View`, and check the `Full screen` item. | Menu path: macOS menu bar -> `View` -> `Full screen`. | The window is fullscreen again and the `Full screen` item shows a checkmark. Click the item to leave fullscreen and confirm the window returns to a normal window. | Toggle fullscreen through both the shortcut and the menu item and confirm each direction works. | +| 7 | With the window in a normal, non-fullscreen state, post or open a short video in the message list, play it, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets. Press `Escape` once. | Any small `.mp4` file, for example a 5 to 10 second clip. | The video fills the screen while fullscreen, and after Escape both the video and the window return to the previous normal window size. The windowed case must not regress. | Run the windowed video fullscreen cycle and confirm the window returned to its previous bounds. | +| 8 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`. | Include macOS version, build, and which of the checks passed. | The result names each regression check separately. | Write a concise result note without committing machine-specific logs unless requested. | + +## Evidence + +- Note or screenshot for each check: search closes on Escape while windowed and + while fullscreen, `Command+R` reloads, `Control+Command+F` toggles both ways, + the `View` -> `Full screen` item toggles both ways, and the windowed video + fullscreen cycle returns to the previous window size. +- macOS version and build identifier. + +## Failure Signals + +- Escape no longer closes workspace search or workspace modals. +- `Command+R` or other menu shortcuts stop working while the workspace has focus. +- `Control+Command+F` or the `View` -> `Full screen` item no longer enters or + leaves fullscreen. +- The `Full screen` checkmark disagrees with the actual window state. +- After a windowed video fullscreen cycle the window does not return to its + previous size, or stays fullscreen. diff --git a/qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md b/qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md new file mode 100644 index 0000000000..aaa36ce215 --- /dev/null +++ b/qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md @@ -0,0 +1,62 @@ +--- +id: FSESC-QA-004 +title: Escape and video fullscreen behave exactly as before on Windows and Linux +platforms: [windows, linux] +priority: release +qase: + suite: Window and fullscreen + priority: medium + severity: normal + status: actual + automation: manual + qase_id: null +requires: [installed_branch_build, workspace_logged_in] +test_links: [] +expected_result: On Windows and Linux, Escape and video fullscreen behave the same as before the macOS fix, with no swallowed keys and no changed window state. +--- + +# Windows And Linux Escape Unchanged + +## Review Basis + +- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` + (`3bdd1e747`). +- Changed surface: `before-input-event` handling shared by all platforms in + `src/ui/main/serverView/index.ts` and `src/ui/main/rootWindow.ts`. Every new code + path is behind `process.platform === 'darwin'`, but the Escape key up is no + longer forwarded to the desktop window on any platform. +- User-visible risk: A macOS-only fix silently changing Windows or Linux behavior, + either by swallowing Escape, by leaving a video stuck in fullscreen, or by + changing how the window responds to fullscreen keys. +- Hypothesis: On Windows and Linux, Escape still reaches the workspace web app, + video fullscreen still exits on Escape, and the window fullscreen state is not + affected by Escape at all. +- Smallest useful proof: Local UI repro per platform. The platform guards are + covered by `src/ui/main/escapeFullscreenGuard.main.spec.ts` and the + `other platforms` cases in `src/ui/main/serverView/index.spec.ts`, but the + desktop build should still be smoke-checked on each platform. + +## Steps + +| Step | Action | Test data | Expected result | Agent action | +| --- | --- | --- | --- | --- | +| 1 | Launch the Rocket.Chat Desktop build under test on Windows or Linux and wait for a workspace channel list to appear on the left. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. Record which OS this run covers. | The workspace is loaded. | Launch the build and confirm the workspace view rendered. | +| 2 | Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once. | Search text: any short string such as `gen`. | The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app. | Open workspace search, type, send Escape, and confirm the search UI closed. | +| 3 | Post or open a short video in the message list, click its play button, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets. | Any small `.mp4` file, for example a 5 to 10 second clip. | The video fills the whole screen. | Enter the video's fullscreen and confirm only the video is visible. | +| 4 | Press `Escape` once. | Key: `Escape`. | The video returns to its inline size inside the message list and the window returns to the size it had before the video went fullscreen. | Send one Escape press and compare the window bounds with the pre-fullscreen bounds. | +| 5 | Put the window into fullscreen using the platform control: on Windows press `F11` if the build exposes it, otherwise maximize the window; on Linux use the window manager fullscreen action for the window. Then click an empty area of the message list and press `Escape` once. | Note which control was used to reach fullscreen or maximized state. | The window state is unchanged by the Escape press; Escape neither leaves fullscreen nor restores the window. | Reach the platform fullscreen or maximized state, send Escape, and confirm the window state did not change. | +| 6 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`, naming the platform covered. | Include OS and version, build, and which checks passed. | The result names the platform explicitly, because this flow must be run once per platform. | Write a concise result note without committing machine-specific logs unless requested. | + +## Evidence + +- Note or screenshot showing workspace search closing on Escape. +- Note of the window bounds before and after the video fullscreen cycle. +- OS name and version plus build identifier. + +## Failure Signals + +- Escape stops closing workspace search or workspace modals. +- The video stays in fullscreen after Escape, or needs a second press. +- The window does not return to its pre-fullscreen size after the video exits + fullscreen. +- The window state changes on an Escape press. diff --git a/qa/macos-fullscreen-escape/results/README.md b/qa/macos-fullscreen-escape/results/README.md new file mode 100644 index 0000000000..bc9f7c7cd7 --- /dev/null +++ b/qa/macos-fullscreen-escape/results/README.md @@ -0,0 +1,10 @@ +# QA Results + +Store local run notes, screenshots, logs, or diagnostics here while testing. Do +not commit run-specific evidence unless a release owner asks for it. + +Recommended filename format: + +```text +YYYY-MM-DD-platform-flow-id-result.md +``` diff --git a/src/ui/main/escapeFullscreenGuard.main.spec.ts b/src/ui/main/escapeFullscreenGuard.main.spec.ts new file mode 100644 index 0000000000..b7f909de1a --- /dev/null +++ b/src/ui/main/escapeFullscreenGuard.main.spec.ts @@ -0,0 +1,159 @@ +import type { Input, WebContents } from 'electron'; + +import { createEscapeFullscreenGuard } from './escapeFullscreenGuard'; + +const originalPlatform = process.platform; + +const setPlatform = (platform: NodeJS.Platform): void => { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +}; + +const createInput = (overrides: Partial = {}): Input => + ({ + type: 'keyDown', + key: 'Escape', + code: 'Escape', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: false, + alt: false, + meta: false, + location: 0, + modifiers: [], + ...overrides, + }) as Input; + +describe('createEscapeFullscreenGuard', () => { + let target: Pick; + let currentTime: number; + + const createGuard = (isWindowFullscreen = true) => + createEscapeFullscreenGuard( + target, + () => isWindowFullscreen, + () => currentTime + ); + + beforeEach(() => { + setPlatform('darwin'); + currentTime = 1_000; + target = { sendInputEvent: jest.fn() }; + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + it('swallows a native Escape key down and replays it as a synthetic event', () => { + const guard = createGuard(); + + expect(guard.handleInput(createInput())).toBe(true); + expect(target.sendInputEvent).toHaveBeenCalledTimes(1); + expect(target.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: [], + }); + }); + + it('forwards the active modifiers with the replayed event', () => { + const guard = createGuard(); + + guard.handleInput(createInput({ shift: true, control: true })); + + expect(target.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: ['shift', 'control'], + }); + }); + + it('swallows auto-repeated Escapes without replaying them', () => { + const guard = createGuard(); + + expect(guard.handleInput(createInput())).toBe(true); + expect(guard.handleInput(createInput({ isAutoRepeat: true }))).toBe(true); + expect(guard.handleInput(createInput({ isAutoRepeat: true }))).toBe(true); + expect(target.sendInputEvent).toHaveBeenCalledTimes(1); + + expect(guard.handleInput(createInput())).toBe(false); + expect(target.sendInputEvent).toHaveBeenCalledTimes(1); + }); + + it('lets auto-repeated Escapes through when the window is not in fullscreen', () => { + const guard = createGuard(false); + + expect(guard.handleInput(createInput({ isAutoRepeat: true }))).toBe(false); + expect(target.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('clears the replay credit when the replay lands after fullscreen ended', () => { + let fullscreen = true; + const guard = createEscapeFullscreenGuard( + target, + () => fullscreen, + () => currentTime + ); + + expect(guard.handleInput(createInput())).toBe(true); + + fullscreen = false; + expect(guard.handleInput(createInput())).toBe(false); + + fullscreen = true; + expect(guard.handleInput(createInput())).toBe(true); + expect(target.sendInputEvent).toHaveBeenCalledTimes(2); + }); + + it('lets the replayed event through without replaying it again', () => { + const guard = createGuard(); + + expect(guard.handleInput(createInput())).toBe(true); + expect(guard.handleInput(createInput())).toBe(false); + expect(target.sendInputEvent).toHaveBeenCalledTimes(1); + }); + + it('guards the next Escape again after the replay window expires', () => { + const guard = createGuard(); + + expect(guard.handleInput(createInput())).toBe(true); + + currentTime += 1_000; + + expect(guard.handleInput(createInput())).toBe(true); + expect(target.sendInputEvent).toHaveBeenCalledTimes(2); + }); + + it('does nothing when the window is not in fullscreen', () => { + const guard = createGuard(false); + + expect(guard.handleInput(createInput())).toBe(false); + expect(target.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('ignores key up events', () => { + const guard = createGuard(); + + expect(guard.handleInput(createInput({ type: 'keyUp' }))).toBe(false); + expect(target.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('ignores keys other than Escape', () => { + const guard = createGuard(); + + expect(guard.handleInput(createInput({ key: 'Meta' }))).toBe(false); + expect(target.sendInputEvent).not.toHaveBeenCalled(); + }); + + it.each(['win32', 'linux'])('is inert on %s', (platform) => { + setPlatform(platform); + const guard = createGuard(); + + expect(guard.handleInput(createInput())).toBe(false); + expect(target.sendInputEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/main/escapeFullscreenGuard.ts b/src/ui/main/escapeFullscreenGuard.ts new file mode 100644 index 0000000000..2cb5615624 --- /dev/null +++ b/src/ui/main/escapeFullscreenGuard.ts @@ -0,0 +1,119 @@ +import type { Input, KeyboardInputEvent, WebContents } from 'electron'; + +/** + * On macOS, Electron hands every key event the web content did not consume back + * to the native window + * (`WebContents::PlatformHandleKeyboardEvent` in `electron_api_web_contents_mac.mm` + * calls `[ns_event.window redispatchKeyEvent:]`). AppKit answers ESC with + * `cancelOperation:`, which on a window in native fullscreen leaves fullscreen. + * That is why pressing ESC while a video is in HTML5 fullscreen — or with nothing + * at all to dismiss — also drops the app out of fullscreen, while Chrome, which + * never returns the raw `NSEvent` to AppKit, stays fullscreen. + * + * The only interception point is `before-input-event`, which runs before the + * renderer sees the event. Preventing it there stops the redispatch, so the event + * is replayed through `sendInputEvent`: a synthetic event carries no `NSEvent` and + * therefore can never reach AppKit, while the page still receives its ESC. + * + * Auto-repeated ESC key downs are swallowed without a replay: a replay of a + * repeat could race the previous replay's round trip through the renderer, and + * the raced native repeat would reach AppKit unguarded. + * + * Windows and Linux redispatch to registered window accelerators only, and no ESC + * accelerator exists, so the guard is inert there. + */ + +const REPLAY_TIMEOUT_MS = 250; + +type KeyboardModifier = NonNullable[number]; + +const toKeyboardModifiers = (input: Input): KeyboardModifier[] => { + const modifiers: KeyboardModifier[] = []; + + if (input.shift) { + modifiers.push('shift'); + } + + if (input.control) { + modifiers.push('control'); + } + + if (input.alt) { + modifiers.push('alt'); + } + + if (input.meta) { + modifiers.push('meta'); + } + + return modifiers; +}; + +type EscapeFullscreenGuard = { + /** + * Returns `true` when the caller must call `event.preventDefault()` on the + * `before-input-event` it is handling. + */ + handleInput: (input: Input) => boolean; +}; + +export const createEscapeFullscreenGuard = ( + target: Pick, + isWindowFullscreen: () => boolean, + now: () => number = Date.now +): EscapeFullscreenGuard => { + let replayExpiresAt = 0; + + const isReplay = (): boolean => { + if (replayExpiresAt === 0 || now() > replayExpiresAt) { + replayExpiresAt = 0; + return false; + } + + replayExpiresAt = 0; + return true; + }; + + return { + handleInput: (input) => { + if (process.platform !== 'darwin') { + return false; + } + + if (input.type !== 'keyDown' || input.key !== 'Escape') { + return false; + } + + // Auto-repeats never come back as replays (only the initial key down is + // replayed), so they must be swallowed before the replay credit is + // consulted — otherwise a repeat racing the in-flight replay would + // consume its credit and pass through to AppKit as a raw native event. + if (input.isAutoRepeat) { + return isWindowFullscreen(); + } + + // Consume the replay credit before the fullscreen gate: a replay that + // lands after the window already left fullscreen must not leave a stale + // credit behind for the next real ESC to be mistaken for a replay. + if (isReplay()) { + return false; + } + + if (!isWindowFullscreen()) { + return false; + } + + replayExpiresAt = now() + REPLAY_TIMEOUT_MS; + + console.debug('Replaying Escape to keep the window in fullscreen'); + + target.sendInputEvent({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: toKeyboardModifiers(input), + }); + + return true; + }, + }; +}; diff --git a/src/ui/main/rootWindow.spec.ts b/src/ui/main/rootWindow.spec.ts index c3d71c3904..5f7d8e3899 100644 --- a/src/ui/main/rootWindow.spec.ts +++ b/src/ui/main/rootWindow.spec.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-var-requires */ +import type { Input } from 'electron'; import { app, screen } from 'electron'; jest.mock('electron', () => ({ @@ -54,8 +55,13 @@ describe('rootWindow close event handler', () => { minimize: jest.fn(), blur: jest.fn(), isFullScreen: jest.fn(() => false), + isSimpleFullScreen: jest.fn(() => false), isDestroyed: jest.fn(() => false), setFullScreen: jest.fn(), + webContents: { + addListener: jest.fn(), + sendInputEvent: jest.fn(), + }, once: jest.fn(), flashFrame: jest.fn(), setIcon: jest.fn(), @@ -476,6 +482,139 @@ describe('rootWindow close event handler', () => { }); }); +describe('rootWindow Escape fullscreen guard', () => { + const originalPlatform = process.platform; + + let mockWindow: any; + + const setPlatform = (platform: NodeJS.Platform): void => { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + }; + + const createInput = (overrides: Partial = {}): Input => + ({ + type: 'keyDown', + key: 'Escape', + code: 'Escape', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: false, + alt: false, + meta: false, + location: 0, + modifiers: [], + ...overrides, + }) as Input; + + const setupAndGetHandler = async (): Promise => { + const { setupRootWindow } = require('./rootWindow'); + require('./rootWindow').getRootWindow = jest + .fn() + .mockResolvedValue(mockWindow); + + setupRootWindow(); + await Promise.resolve(); + + const call = mockWindow.webContents.addListener.mock.calls.find( + ([event]: any[]) => event === 'before-input-event' + ); + + expect(call).toBeDefined(); + + return call[1]; + }; + + beforeEach(() => { + jest.clearAllMocks(); + setPlatform('darwin'); + + (require('../../store').select as jest.Mock).mockImplementation( + (selector: any) => selector({ isTrayIconEnabled: false }) + ); + + mockWindow = { + addListener: jest.fn(), + removeAllListeners: jest.fn(), + close: jest.fn(), + hide: jest.fn(), + minimize: jest.fn(), + blur: jest.fn(), + isFullScreen: jest.fn(() => true), + isSimpleFullScreen: jest.fn(() => false), + isDestroyed: jest.fn(() => false), + setFullScreen: jest.fn(), + once: jest.fn(), + flashFrame: jest.fn(), + setIcon: jest.fn(), + setTitle: jest.fn(), + setOverlayIcon: jest.fn(), + autoHideMenuBar: false, + setMenuBarVisibility: jest.fn(), + webContents: { + addListener: jest.fn(), + sendInputEvent: jest.fn(), + }, + }; + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + it('swallows Escape so it cannot reach the native window while in fullscreen', async () => { + const handler = await setupAndGetHandler(); + const event = { preventDefault: jest.fn() }; + + handler(event, createInput()); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: [], + }); + expect(mockWindow.setFullScreen).not.toHaveBeenCalled(); + }); + + it('lets the replayed Escape reach the renderer', async () => { + const handler = await setupAndGetHandler(); + handler({ preventDefault: jest.fn() }, createInput()); + mockWindow.webContents.sendInputEvent.mockClear(); + + const event = { preventDefault: jest.fn() }; + handler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('leaves Escape untouched when the window is not in fullscreen', async () => { + mockWindow.isFullScreen.mockReturnValue(false); + const handler = await setupAndGetHandler(); + const event = { preventDefault: jest.fn() }; + + handler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('leaves Escape untouched on other platforms', async () => { + setPlatform('win32'); + const handler = await setupAndGetHandler(); + const event = { preventDefault: jest.fn() }; + + handler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); +}); + describe('isInsideSomeScreen', () => { const { isInsideSomeScreen } = require('./rootWindow'); diff --git a/src/ui/main/rootWindow.ts b/src/ui/main/rootWindow.ts index 54acf2c1a2..1e94a78023 100644 --- a/src/ui/main/rootWindow.ts +++ b/src/ui/main/rootWindow.ts @@ -34,6 +34,7 @@ import { import type { WindowState } from '../common'; import { selectGlobalBadge, selectGlobalBadgeCount } from '../selectors'; import { debounce } from './debounce'; +import { createEscapeFullscreenGuard } from './escapeFullscreenGuard'; import { getTrayIconPath } from './icons'; const webPreferences: WebPreferences = { @@ -159,6 +160,9 @@ export const isInsideSomeScreen = ({ y + height > bounds.y ); +export const isWindowInAnyFullscreen = (window: BrowserWindow): boolean => + window.isFullScreen() || window.isSimpleFullScreen(); + export const applyRootWindowState = (browserWindow: BrowserWindow): void => { const rootWindowState = select(selectRootWindowState); const isTrayIconEnabled = select( @@ -409,6 +413,17 @@ export const setupRootWindow = (): void => { rootWindow.flashFrame(false); }); + const escapeFullscreenGuard = createEscapeFullscreenGuard( + rootWindow.webContents, + () => isWindowInAnyFullscreen(rootWindow) + ); + + rootWindow.webContents.addListener('before-input-event', (event, input) => { + if (escapeFullscreenGuard.handleInput(input)) { + event.preventDefault(); + } + }); + rootWindow.addListener('close', async (event) => { try { if (rootWindow.isDestroyed()) { diff --git a/src/ui/main/serverView/index.spec.ts b/src/ui/main/serverView/index.spec.ts index 2af4487cd2..bcb8aca894 100644 --- a/src/ui/main/serverView/index.spec.ts +++ b/src/ui/main/serverView/index.spec.ts @@ -1,9 +1,10 @@ -import type { Event, WebContents } from 'electron'; +import type { Event, Input, WebContents } from 'electron'; import { isProtocolAllowed } from '../../../navigation/main'; import { listen } from '../../../store'; import { openExternal } from '../../../utils/browserLauncher'; -import { WEBVIEW_READY } from '../../actions'; +import { WEBVIEW_ATTACHED, WEBVIEW_READY } from '../../actions'; +import { getRootWindow } from '../rootWindow'; import { attachGuestWebContentsEvents } from './index'; jest.mock('electron', () => ({ @@ -53,6 +54,12 @@ jest.mock('../rootWindow', () => ({ webContents: { addListener: jest.fn() }, }) ), + isWindowInAnyFullscreen: jest.fn( + (window: { + isFullScreen: () => boolean; + isSimpleFullScreen: () => boolean; + }) => window.isFullScreen() || window.isSimpleFullScreen() + ), })); jest.mock('./popupMenu', () => ({ @@ -154,3 +161,246 @@ describe('serverView attachGuestWebContentsEvents will-navigate guard', () => { expect(mockOpenExternal).not.toHaveBeenCalled(); }); }); + +describe('serverView before-input-event fullscreen handling', () => { + const mockListen = listen as unknown as jest.Mock; + const mockGetRootWindow = getRootWindow as jest.MockedFunction< + typeof getRootWindow + >; + const originalPlatform = process.platform; + + let beforeInputEventHandler: (event: Event, input: Input) => void; + let guestListeners: Map void>; + let guestWebContents: { + sendInputEvent: jest.Mock; + executeJavaScript: jest.Mock; + }; + let rootWindow: { + isFullScreen: jest.Mock; + isSimpleFullScreen: jest.Mock; + isDestroyed: jest.Mock; + webContents: { + sendInputEvent: jest.Mock; + addListener: jest.Mock; + isDestroyed: jest.Mock; + }; + }; + + const setPlatform = (platform: NodeJS.Platform): void => { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); + }; + + const createInput = (overrides: Partial = {}): Input => + ({ + type: 'keyDown', + key: 'Escape', + code: 'Escape', + isAutoRepeat: false, + isComposing: false, + shift: false, + control: false, + alt: false, + meta: false, + location: 0, + modifiers: [], + ...overrides, + }) as Input; + + const createEvent = (): Event => + ({ preventDefault: jest.fn() }) as unknown as Event; + + const attachGuest = async (): Promise => { + await attachGuestWebContentsEvents(); + + const webviewAttachedCall = mockListen.mock.calls.find( + ([actionType]) => actionType === WEBVIEW_ATTACHED + ); + const webviewAttachedCallback = webviewAttachedCall?.[1] as ( + action: unknown + ) => void; + + ( + jest.requireMock('electron').webContents.fromId as jest.Mock + ).mockReturnValue(guestWebContents); + + webviewAttachedCallback({ + payload: { webContentsId: 1, url: 'https://open.rocket.chat' }, + }); + + beforeInputEventHandler = guestListeners.get('before-input-event') as ( + event: Event, + input: Input + ) => void; + }; + + beforeEach(async () => { + jest.clearAllMocks(); + setPlatform('darwin'); + + guestListeners = new Map(); + rootWindow = { + isFullScreen: jest.fn(() => true), + isSimpleFullScreen: jest.fn(() => false), + isDestroyed: jest.fn(() => false), + webContents: { + sendInputEvent: jest.fn(), + addListener: jest.fn(), + isDestroyed: jest.fn(() => false), + }, + }; + mockGetRootWindow.mockResolvedValue(rootWindow as any); + + guestWebContents = { + sendInputEvent: jest.fn(), + executeJavaScript: jest.fn(() => Promise.resolve()), + }; + + Object.assign(guestWebContents, { + addListener: jest.fn((event: string, handler: any) => { + guestListeners.set(event, handler); + }), + on: jest.fn(), + setWindowOpenHandler: jest.fn(), + session: { on: jest.fn(), setPermissionRequestHandler: jest.fn() }, + }); + + await attachGuest(); + }); + + afterEach(() => { + setPlatform(originalPlatform); + }); + + const enterHtmlFullscreen = (): void => { + guestListeners.get('enter-html-full-screen')?.(); + }; + + it('exits only the HTML5 fullscreen when Escape is pressed while a video is fullscreen', () => { + enterHtmlFullscreen(); + + const event = createEvent(); + beforeInputEventHandler(event, createInput()); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(guestWebContents.executeJavaScript).toHaveBeenCalledWith( + expect.stringContaining('exitFullscreen') + ); + expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('swallows auto-repeated Escapes in HTML5 fullscreen without re-running the exit', () => { + enterHtmlFullscreen(); + + beforeInputEventHandler(createEvent(), createInput()); + const event = createEvent(); + beforeInputEventHandler(event, createInput({ isAutoRepeat: true })); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(guestWebContents.executeJavaScript).toHaveBeenCalledTimes(1); + expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('replays Escape into the guest instead of letting it reach the native window', () => { + const event = createEvent(); + beforeInputEventHandler(event, createInput()); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(guestWebContents.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: [], + }); + expect(guestWebContents.executeJavaScript).not.toHaveBeenCalled(); + }); + + it('forwards the replayed Escape to the root window', () => { + beforeInputEventHandler(createEvent(), createInput()); + guestWebContents.sendInputEvent.mockClear(); + + const event = createEvent(); + beforeInputEventHandler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); + expect(rootWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: [], + }); + }); + + it('forwards Escape untouched when the window is not in fullscreen', () => { + rootWindow.isFullScreen.mockReturnValue(false); + + const event = createEvent(); + beforeInputEventHandler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); + expect(rootWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: [], + }); + }); + + it('does not forward the Escape key up to the root window', () => { + const event = createEvent(); + beforeInputEventHandler(event, createInput({ type: 'keyUp' })); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('keeps forwarding both key down and key up of the shortcut key', () => { + beforeInputEventHandler(createEvent(), createInput({ key: 'Meta' })); + beforeInputEventHandler( + createEvent(), + createInput({ key: 'Meta', type: 'keyUp' }) + ); + + expect(rootWindow.webContents.sendInputEvent).toHaveBeenNthCalledWith(1, { + type: 'keyDown', + keyCode: 'Meta', + modifiers: [], + }); + expect(rootWindow.webContents.sendInputEvent).toHaveBeenNthCalledWith(2, { + type: 'keyUp', + keyCode: 'Meta', + modifiers: [], + }); + }); + + it('leaves HTML5 fullscreen to Chromium on other platforms', async () => { + setPlatform('linux'); + await attachGuest(); + enterHtmlFullscreen(); + + const event = createEvent(); + beforeInputEventHandler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(guestWebContents.executeJavaScript).not.toHaveBeenCalled(); + expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); + expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); + }); + + it('leaves Escape alone on other platforms', async () => { + setPlatform('linux'); + await attachGuest(); + + const event = createEvent(); + beforeInputEventHandler(event, createInput()); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); + expect(rootWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ + type: 'keyDown', + keyCode: 'Escape', + modifiers: [], + }); + }); +}); diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index b8660b6650..c6f412f885 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -43,8 +43,9 @@ import { SIDE_BAR_SERVER_REMOVE, WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR, } from '../../actions'; +import { createEscapeFullscreenGuard } from '../escapeFullscreenGuard'; import { handleMediaPermissionRequest } from '../mediaPermissions'; -import { getRootWindow } from '../rootWindow'; +import { getRootWindow, isWindowInAnyFullscreen } from '../rootWindow'; import { isMarkdownViewerDownloadUrl } from './isMarkdownViewerDownloadUrl'; import { createPopupMenuForServerView } from './popupMenu'; @@ -354,10 +355,25 @@ const initializeServerWebContentsAfterAttach = ( isGuestInHtmlFullscreen = false; }); - const handleBeforeInputEvent = ( - _event: Event, - { type, key }: Input - ): void => { + const escapeFullscreenGuard = createEscapeFullscreenGuard( + guestWebContents, + () => isWindowInAnyFullscreen(rootWindow) + ); + + const exitGuestHtmlFullscreen = (): void => { + guestWebContents + .executeJavaScript('document.exitFullscreen && document.exitFullscreen()') + .catch((error) => { + console.error( + 'Failed to exit HTML5 fullscreen on the server view:', + error + ); + }); + }; + + const handleBeforeInputEvent = (event: Event, input: Input): void => { + const { type, key } = input; + if (type !== 'keyUp' && type !== 'keyDown') { return; } @@ -368,10 +384,37 @@ const initializeServerWebContentsAfterAttach = ( return; } - // On macOS, forwarding ESC to the root window while the guest is in - // HTML5 fullscreen (e.g. a video player) causes the native window to - // also exit fullscreen. This does not occur on Windows/Linux. - if (key === 'Escape' && isGuestInHtmlFullscreen) { + if (key === 'Escape') { + // The root window only reacts to ESC on key down; forwarding the key up as + // well would leak an unguarded ESC once the guest left HTML5 fullscreen. + if (type !== 'keyDown') { + return; + } + + // While the guest is in HTML5 fullscreen (e.g. a video player) ESC is + // never forwarded to the root window. On macOS it must additionally only + // leave the video fullscreen, like Chrome does: swallow the native key + // event so it cannot reach the native window and exit its fullscreen too, + // and drive the fullscreen exit explicitly. Calling it on the guest's + // main frame also covers a fullscreen element inside a nested frame. + // Windows and Linux leave the fullscreen exit itself to Chromium. + if (isGuestInHtmlFullscreen) { + if (process.platform === 'darwin') { + event.preventDefault(); + if (!input.isAutoRepeat) { + exitGuestHtmlFullscreen(); + } + } + return; + } + + if (escapeFullscreenGuard.handleInput(input)) { + event.preventDefault(); + return; + } + } + + if (rootWindow.isDestroyed() || rootWindow.webContents.isDestroyed()) { return; } From 9bd599917404c16113b39d8c9c619631e490fdd4 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 28 Aug 2026 15:01:43 -0300 Subject: [PATCH 13/17] feat(logViewer): correctness, performance, and UX improvements (#3446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto current origin/dev after #3444. Keeps the #3444 shell (sidebar/timeline/toolbar) and ports the additive hardening from this PR: - Tail reads stop at the last newline (trimBufferToLastNewline) so mid-poll writes cannot split lines/UTF-8 characters - fs.promises everywhere in log-viewer handlers (no existsSync/statSync blocking the main event loop) - Precompute searchText/rawLower at parse time for filter matching - Auto-scroll timestamp guard so programmatic scrolls do not cancel themselves; resume pill when paused with new entries pending - Reveal log file in folder (authorized IPC → shell.showItemInFolder) - Save as plain .log alongside zip; i18n for native file dialogs - isAtLeastLevel helper retained for callers; UI keeps #3444 facet multi-select level filters --- src/i18n/en.i18n.json | 14 ++ src/ipc/channels.ts | 4 + src/logViewerWindow/LogViewerToolbar.tsx | 9 ++ .../__tests__/LogEntry.spec.tsx | 2 + .../__tests__/LogTimeline.spec.tsx | 2 + src/logViewerWindow/__tests__/ipc.spec.ts | 115 ++++++++++++++ .../__tests__/parseLogs.spec.ts | 24 +++ .../__tests__/textHighlight.spec.ts | 83 ++++++++++ .../__tests__/timeline.spec.ts | 2 + src/logViewerWindow/__tests__/types.spec.ts | 29 +++- src/logViewerWindow/constants.ts | 3 + src/logViewerWindow/ipc.ts | 145 +++++++++++++++--- src/logViewerWindow/logFormatters.ts | 22 ++- src/logViewerWindow/logViewerWindow.tsx | 119 +++++++++++--- src/logViewerWindow/main/ipc.main.spec.ts | 2 + src/logViewerWindow/parseLogs.ts | 22 ++- src/logViewerWindow/textHighlight.ts | 49 ++++++ src/logViewerWindow/types.ts | 16 ++ 18 files changed, 616 insertions(+), 46 deletions(-) create mode 100644 src/logViewerWindow/__tests__/ipc.spec.ts create mode 100644 src/logViewerWindow/__tests__/textHighlight.spec.ts create mode 100644 src/logViewerWindow/textHighlight.ts diff --git a/src/i18n/en.i18n.json b/src/i18n/en.i18n.json index 6f24aed827..b50b420a5d 100644 --- a/src/i18n/en.i18n.json +++ b/src/i18n/en.i18n.json @@ -79,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" @@ -113,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", @@ -726,6 +737,7 @@ "entries_one": "{{count}} entry", "entries_other": "{{count}} entries", "entriesOfTotal": "{{count}} of {{total}} entries", + "matches": "{{count}} matches", "noEntries": "No entries" }, "buttons": { @@ -738,6 +750,7 @@ "copied": "Copied", "save": "Save", "saved": "Saved", + "showInFolder": "Show in Folder", "retry": "Retry", "dismissError": "Dismiss error", "clear": "Clear all", @@ -792,6 +805,7 @@ }, "messages": { "noLogsFound": "No logs found", + "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" diff --git a/src/ipc/channels.ts b/src/ipc/channels.ts index 2dbe442df0..52477f6b49 100644 --- a/src/ipc/channels.ts +++ b/src/ipc/channels.ts @@ -123,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: { diff --git a/src/logViewerWindow/LogViewerToolbar.tsx b/src/logViewerWindow/LogViewerToolbar.tsx index 3970943245..ebc2f84eac 100644 --- a/src/logViewerWindow/LogViewerToolbar.tsx +++ b/src/logViewerWindow/LogViewerToolbar.tsx @@ -13,6 +13,7 @@ export type LogViewerToolbarProps = { isStreaming: boolean; onOpenLogFile: () => void; onOpenDefaultLog: () => void; + onRevealLogFile: () => void; onRefresh: () => void; onToggleStreaming: () => void; onCopy: () => void; @@ -28,6 +29,7 @@ export const LogViewerToolbar = ({ isStreaming, onOpenLogFile, onOpenDefaultLog, + onRevealLogFile, onRefresh, onToggleStreaming, onCopy, @@ -48,6 +50,13 @@ export const LogViewerToolbar = ({ aria-label={t('logViewer.buttons.openLogFile')} onClick={onOpenLogFile} /> + {!isDefaultLog && ( = {}): LogEntryType => ({ contextTags: ['main', 'open.rocket.chat'], context: 'main open.rocket.chat', raw: 'raw line', + searchText: 'hello world main open.rocket.chat', + rawLower: 'raw line', ...overrides, }); diff --git a/src/logViewerWindow/__tests__/LogTimeline.spec.tsx b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx index 31fb3312df..7badc20e29 100644 --- a/src/logViewerWindow/__tests__/LogTimeline.spec.tsx +++ b/src/logViewerWindow/__tests__/LogTimeline.spec.tsx @@ -24,6 +24,8 @@ const makeEntry = (offsetMs: number): LogEntryType => ({ context: '', message: 'message', raw: 'raw', + searchText: 'message ', + rawLower: 'raw', }); const entries: LogEntryType[] = Array.from({ length: 20 }, (_unused, index) => 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__/parseLogs.spec.ts b/src/logViewerWindow/__tests__/parseLogs.spec.ts index 89aa9c34a8..ddb1e9bc6c 100644 --- a/src/logViewerWindow/__tests__/parseLogs.spec.ts +++ b/src/logViewerWindow/__tests__/parseLogs.spec.ts @@ -86,6 +86,30 @@ describe('parseLogLines', () => { 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', () => { 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 index 7453a82635..0aaf08e14b 100644 --- a/src/logViewerWindow/__tests__/timeline.spec.ts +++ b/src/logViewerWindow/__tests__/timeline.spec.ts @@ -14,6 +14,8 @@ const entry = (timestamp: string, level: LogLevel = 'info'): LogEntryType => ({ context: 'main', message: 'message', raw: `[${timestamp}] [${level}] [main] message`, + searchText: 'message main', + rawLower: `[${timestamp}] [${level}] [main] message`.toLowerCase(), }); describe('buildTimeline', () => { 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/constants.ts b/src/logViewerWindow/constants.ts index 85fbe5fb03..d2d6e71eee 100644 --- a/src/logViewerWindow/constants.ts +++ b/src/logViewerWindow/constants.ts @@ -33,3 +33,6 @@ 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 a93cee517a..bb158e4349 100644 --- a/src/logViewerWindow/ipc.ts +++ b/src/logViewerWindow/ipc.ts @@ -1,10 +1,9 @@ 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'; @@ -35,8 +34,19 @@ const t = i18next.t.bind(i18next); const isMac = process.platform === 'darwin'; -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); +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(); @@ -80,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 } => { @@ -112,6 +133,21 @@ const getLastNEntries = ( }; }; +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; @@ -287,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'], }); @@ -345,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 { @@ -366,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 { @@ -419,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(), @@ -463,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 @@ -478,7 +518,7 @@ export const startLogViewerWindowHandler = (): void => { return { success: true, logs: '', - newSize: stats.size, + newSize: fromByte, lastModifiedTime: stats.mtime.getTime(), }; } @@ -494,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) { @@ -509,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; @@ -546,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: ['*'] }, ], }); @@ -558,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/logFormatters.ts b/src/logViewerWindow/logFormatters.ts index 945e50e743..e02f94fa58 100644 --- a/src/logViewerWindow/logFormatters.ts +++ b/src/logViewerWindow/logFormatters.ts @@ -13,6 +13,13 @@ export const formatFileSize = (bytes: number): string => { 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 []; @@ -38,18 +45,29 @@ export const parseLogLines = (logText: string): LogEntryType[] => { entries.push(currentEntry); } + const parsedMessage = message.trim(); + const context = contextTags.join(' '); + currentEntry = { id: `log-${entries.length}`, timestamp, level: parseLogLevel(level), contextTags, - context: contextTags.join(' '), - message: message.trim(), + 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; } }); diff --git a/src/logViewerWindow/logViewerWindow.tsx b/src/logViewerWindow/logViewerWindow.tsx index 7481296a0c..ed30e4c5cf 100644 --- a/src/logViewerWindow/logViewerWindow.tsx +++ b/src/logViewerWindow/logViewerWindow.tsx @@ -2,6 +2,7 @@ import { Box, Button, Callout, + Icon, IconButton, States, StatesAction, @@ -46,6 +47,7 @@ import { LOG_LEVELS } from './appearance'; import { TRANSPARENCY_CHANNEL, AUTO_REFRESH_INTERVAL_MS, + AUTO_SCROLL_GUARD_MS, PAGE_SIZE, SCROLL_DELAY_MS, SEARCH_DEBOUNCE_MS, @@ -134,8 +136,11 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const lastModifiedTimeRef = useRef(undefined); const lastKnownSizeRef = useRef(0); const isAutoScrollingRef = useRef(false); + 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() ); @@ -349,11 +354,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { const matchesSearch = useCallback( (entry: LogEntryType): boolean => { if (!debouncedSearchFilter) return true; - const needle = debouncedSearchFilter.toLowerCase(); - return ( - entry.message.toLowerCase().includes(needle) || - entry.context.toLowerCase().includes(needle) - ); + return entry.searchText.includes(debouncedSearchFilter.toLowerCase()); }, [debouncedSearchFilter] ); @@ -691,6 +692,10 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { return [...newEntries, ...prev]; }); + if (isSuspendedRef.current) { + setPendingNewEntryCount((prev) => prev + newEntries.length); + } + setFileInfo((prev) => prev ? { @@ -728,6 +733,13 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { } }, [autoScroll]); + useEffect(() => { + isSuspendedRef.current = autoScroll && userHasScrolled; + if (!isSuspendedRef.current) { + setPendingNewEntryCount(0); + } + }, [autoScroll, userHasScrolled]); + useEffect(() => { if ( autoScroll && @@ -737,6 +749,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { ) { const timeoutId = setTimeout(() => { isAutoScrollingRef.current = true; + lastAutoScrollAtRef.current = Date.now(); if (virtuosoRef.current && autoScroll && !userHasScrolled) { virtuosoRef.current.scrollToIndex({ index: 0, @@ -753,11 +766,23 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { 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); @@ -843,6 +868,24 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { }); }, []); + 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]); @@ -965,6 +1008,7 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { isStreaming={isStreaming} onOpenLogFile={handleOpenLogFile} onOpenDefaultLog={handleOpenDefaultLog} + onRevealLogFile={handleRevealLogFile} onRefresh={handleRefresh} onToggleStreaming={handleToggleStreaming} onCopy={handleCopyLogs} @@ -1111,20 +1155,57 @@ function LogViewerWindow({ paletteTheme }: LogViewerWindowProps) { )} {!loadError && visibleLogs.length > 0 && ( - entry?.id ?? `day-${index}`} - itemContent={renderLogEntry} - overscan={VIRTUOSO_OVERSCAN} - style={{ height: '100%', width: '100%' }} - onScroll={handleScroll} - endReached={handleEndReached} - /> + + {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} + /> + )} diff --git a/src/logViewerWindow/main/ipc.main.spec.ts b/src/logViewerWindow/main/ipc.main.spec.ts index 4e90ea34f4..cbf74afba0 100644 --- a/src/logViewerWindow/main/ipc.main.spec.ts +++ b/src/logViewerWindow/main/ipc.main.spec.ts @@ -26,8 +26,10 @@ jest.mock('fs', () => { 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'), })), }, diff --git a/src/logViewerWindow/parseLogs.ts b/src/logViewerWindow/parseLogs.ts index 2e0b637e44..3d838fe729 100644 --- a/src/logViewerWindow/parseLogs.ts +++ b/src/logViewerWindow/parseLogs.ts @@ -4,6 +4,13 @@ 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 @@ -46,18 +53,29 @@ export const parseLogLines = ( entries.push(currentEntry); } + const parsedMessage = message.trim(); + const context = contextTags.join(' '); + currentEntry = { id: `${idPrefix}-${entries.length}`, timestamp, level: parseLogLevel(level), contextTags, - context: contextTags.join(' '), - message: message.trim(), + 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; } }); 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/types.ts b/src/logViewerWindow/types.ts index 3698e970ca..a778667f1d 100644 --- a/src/logViewerWindow/types.ts +++ b/src/logViewerWindow/types.ts @@ -16,6 +16,10 @@ export interface ILogEntryType { 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 { @@ -84,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]; From 437f0f3e7c61610784c0ff2a5041de9900509cb9 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 28 Aug 2026 15:01:47 -0300 Subject: [PATCH 14/17] fix(updates): install macOS updates through electron-updater (#955) (#3431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(updates): install macOS updates through electron-updater (#955) The darwin branch of the `update-downloaded` handler destroyed every window and then drove Electron's native `autoUpdater` directly: nativeUpdater.checkForUpdates(); nativeUpdater.on('update-downloaded', nativeUpdateDownloadedCallback); electron-updater's `MacUpdater` already owns that same native `autoUpdater` singleton. It serves the downloaded zip from a local authenticated proxy server, calls `setFeedURL` against it, and installs via its own `quitAndInstall()` — the only path that consults `squirrelDownloadedUpdate` and reaches `handleUpdateDownloaded()`. Calling `checkForUpdates()` on the singleton bypassed that bookkeeping, so Squirrel never installed: the app quit with no windows left and offered the same update on the next launch. The listener was also registered after the call meant to trigger it, and the `try/catch` wrapped only the `setImmediate` scheduling rather than its body, so a throw from the later tick escaped and never dispatched `UPDATES_ERROR_THROWN` — leaving the failure silent. Drop the darwin special case and call `autoUpdater.quitAndInstall(true, true)` on every platform, restoring the behaviour that predated 972f1415d, and move the `try/catch` inside the callback so install failures are reported. Not verified against a live update feed: that needs a packaged, signed macOS build. `tsc --noEmit`, `eslint`, and the full test suite pass. * fix(updates): restore quit handler and normalize errors on failed install Addresses two review findings on the install path: - `app.removeAllListeners('window-all-closed')` detaches the app's quit handler process-wide. The removal is needed so the pending quit isn't cancelled, but when the install throws we never quit, leaving those listeners detached for the rest of the session — closing every window would then no longer reach the handler in src/app/main/app.ts. Capture the listeners first and reattach them in the error path. - `error instanceof Error && dispatch(...)` silently dropped non-Error thrown values, so a thrown string or object produced no error action at all. Since this block exists specifically to surface install failures that used to go unreported, normalize any thrown value into a serializable {message, stack, name} payload before dispatching. --- src/updates/main.ts | 45 ++++++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/updates/main.ts b/src/updates/main.ts index b823a0022c..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'; @@ -217,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), + }); } }); }; From 5b075594f37e02b351fe9ac523832bf6e03b9e19 Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Fri, 28 Aug 2026 16:07:49 -0300 Subject: [PATCH 15/17] chore: bump version to 4.17.0-alpha.3 (#3474) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 072c613562..b1ee51d098 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.17.0-alpha.2", + "version": "4.17.0-alpha.3", "author": "Rocket.Chat Support ", "copyright": "© 2016-2026, Rocket.Chat", "homepage": "https://rocket.chat", From 5f77aef68b63db8518ef806d38ca83f663475a9e Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 31 Aug 2026 16:22:02 -0300 Subject: [PATCH 16/17] Revert "fix: keep macOS native fullscreen when pressing ESC (#3433)" (#3480) This reverts commit 8c6bd9eb551725ef0dbbd83f6dbc1b0833895348. --- qa/README.md | 19 +- qa/macos-fullscreen-escape/README.md | 63 ----- qa/macos-fullscreen-escape/exports/README.md | 15 -- .../exports/qase-import.csv | 142 ---------- .../flows/01-video-fullscreen-escape.md | 68 ----- .../flows/02-plain-escape-keeps-fullscreen.md | 66 ----- .../03-escape-and-shortcut-regressions.md | 70 ----- .../04-windows-linux-escape-unchanged.md | 62 ----- qa/macos-fullscreen-escape/results/README.md | 10 - .../main/escapeFullscreenGuard.main.spec.ts | 159 ----------- src/ui/main/escapeFullscreenGuard.ts | 119 -------- src/ui/main/rootWindow.spec.ts | 139 ---------- src/ui/main/rootWindow.ts | 15 -- src/ui/main/serverView/index.spec.ts | 254 +----------------- src/ui/main/serverView/index.ts | 61 +---- 15 files changed, 18 insertions(+), 1244 deletions(-) delete mode 100644 qa/macos-fullscreen-escape/README.md delete mode 100644 qa/macos-fullscreen-escape/exports/README.md delete mode 100644 qa/macos-fullscreen-escape/exports/qase-import.csv delete mode 100644 qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md delete mode 100644 qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md delete mode 100644 qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md delete mode 100644 qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md delete mode 100644 qa/macos-fullscreen-escape/results/README.md delete mode 100644 src/ui/main/escapeFullscreenGuard.main.spec.ts delete mode 100644 src/ui/main/escapeFullscreenGuard.ts diff --git a/qa/README.md b/qa/README.md index c4be391bcb..0e79a75d25 100644 --- a/qa/README.md +++ b/qa/README.md @@ -9,13 +9,13 @@ lowercase, and specific, for example `qa/telephony-deeplink/`. ## Pack Structure -| Path | Required | Purpose | -| ----------------- | ----------- | --------------------------------------------------------------------- | -| `README.md` | Yes | Entry point, prerequisites, smoke order, result format | -| `flows/` | Yes | One Markdown file per scenario | -| `test-links.html` | When useful | Static browser page for protocol/deep-link/manual click targets | -| `scripts/` | Optional | Small helper scripts for repeatable environment checks | -| `results/` | Optional | Local evidence notes; do not commit run-specific artifacts by default | +| Path | Required | Purpose | +| --- | --- | --- | +| `README.md` | Yes | Entry point, prerequisites, smoke order, result format | +| `flows/` | Yes | One Markdown file per scenario | +| `test-links.html` | When useful | Static browser page for protocol/deep-link/manual click targets | +| `scripts/` | Optional | Small helper scripts for repeatable environment checks | +| `results/` | Optional | Local evidence notes; do not commit run-specific artifacts by default | ## Flow Files @@ -167,8 +167,3 @@ unless a release owner explicitly asks for them. - `qa/telephony-deeplink/` covers telephony `tel:` / `callto:` links, settings, diagnostics, workspace selection, default handlers, and installer policy. -- `qa/supported-versions/` covers supported-version exception matching and the - unsupported-version block. -- `qa/macos-fullscreen-escape/` covers the macOS native fullscreen behavior of the - Escape key, including video HTML5 fullscreen, and the Windows/Linux regression - check. diff --git a/qa/macos-fullscreen-escape/README.md b/qa/macos-fullscreen-escape/README.md deleted file mode 100644 index 4fc4427004..0000000000 --- a/qa/macos-fullscreen-escape/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# macOS Fullscreen Escape QA Pack - -This folder contains manual and agent-readable QA flows for the macOS native -fullscreen behavior of the Escape key. It covers the case where the app window is -in native fullscreen and the Escape key must not drop the window out of -fullscreen, whether or not a video is playing in HTML5 fullscreen. - -This behavior cannot be proven by the Jest suite. The bug lives in how macOS -AppKit reacts to a real key event coming from the operating system, and any test -generated key event is synthetic and never reaches AppKit. Automated tests only -protect the desktop-side wiring, so these flows are the coverage for the OS half -and must be run on real macOS hardware. - -The flows are intentionally written for testers without implementation context. - -## Quick Start - -From the repo root: - -```sh -node qa/scripts/validate-flows.mjs qa/macos-fullscreen-escape -node qa/scripts/export-qase-csv.mjs qa/macos-fullscreen-escape -``` - -## Smoke Order - -1. Run `flows/01-video-fullscreen-escape.md`. -2. Run `flows/02-plain-escape-keeps-fullscreen.md`. -3. Run `flows/03-escape-and-shortcut-regressions.md`. -4. Run `flows/04-windows-linux-escape-unchanged.md` on Windows and Linux. - -## Flow Result Format - -```text -Flow ID: -Platform: -Build: -Review range: -Coverage: Full requested range | Partial surface review -Result: Pass | Fail | Blocked -Finding status: confirmed | suspected | blocked | none -Evidence: -Notes: -``` - -## 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 Truth - -When updating this pack, derive expected behavior from: - -- `src/ui/main/escapeFullscreenGuard.ts` -- `src/ui/main/escapeFullscreenGuard.main.spec.ts` -- `src/ui/main/serverView/index.ts` (`before-input-event` handling for guest views) -- `src/ui/main/rootWindow.ts` (`before-input-event` handling for the app chrome) -- `src/ui/main/menuBar.ts` (macOS `Full screen` item, `Control+Command+F`) -- `src/ui/components/TabBar/styles.tsx` (`TrafficLightSpacer` collapses in fullscreen) diff --git a/qa/macos-fullscreen-escape/exports/README.md b/qa/macos-fullscreen-escape/exports/README.md deleted file mode 100644 index b082904d8a..0000000000 --- a/qa/macos-fullscreen-escape/exports/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Qase Exports - -This directory is for generated Qase import files. - -Run: - -```sh -node qa/scripts/export-qase-csv.mjs qa/macos-fullscreen-escape -``` - -The script writes `qase-import.csv`. Import it in Qase with source type -`Qase.io`. - -Do not hand-edit generated CSV files. Edit the Markdown flows, validate them, -and export again. diff --git a/qa/macos-fullscreen-escape/exports/qase-import.csv b/qa/macos-fullscreen-escape/exports/qase-import.csv deleted file mode 100644 index aeedcf04fd..0000000000 --- a/qa/macos-fullscreen-escape/exports/qase-import.csv +++ /dev/null @@ -1,142 +0,0 @@ -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","Window and fullscreen","1","","","","","","","","","","","","","" -"","1","Window and fullscreen","","Escape leaves only the video fullscreen while the app window stays fullscreen","Source flow: FSESC-QA-001 - -Pressing Escape while a video plays in HTML5 fullscreen returns the video to its inline size and the app window remains in macOS native fullscreen.","installed_branch_build -workspace_logged_in","- Screenshot taken right after the Escape press showing the inline video and a - window that still covers the whole screen. -- Note of the `View` -> `Full screen` checkmark state before and after Escape. -- macOS version and build identifier.","major","high","actual","manual","classic","FSESC-QA-001,source:repo-qa,platform:macos,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on macOS and wait until a workspace channel list is visible on the left and message content fills the rest of the window."" -2. ""Put the window into macOS native fullscreen: click the green circular button at the top-left corner of the window, the third of the three colored dots."" -3. ""Confirm the app itself knows it is fullscreen: move the pointer to the very top of the screen so the macOS menu bar appears, click `View` in that menu bar, and look at the `Full screen` item."" -4. ""In the message box at the bottom of the channel, attach and send a short video file, then click the play button in the middle of the video player that appears in the message list."" -5. ""Hover the playing video to reveal its control bar, then click the fullscreen button at the right end of that bar, the icon drawn as four outward-pointing corner brackets."" -6. ""Press the `Escape` key once."" -7. ""Move the pointer to the top of the screen, click `View` in the macOS menu bar and confirm `Full screen` still shows a checkmark."" -8. ""Repeat steps 5 to 7 with an embedded player instead of an attachment: send a message containing a YouTube video link, wait for the preview player to render under the message, click its play button, then click the fullscreen button at the right end of the player's own control bar."" -9. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it; any workspace where you can post a file. -Agent: Launch the build and confirm the workspace view rendered."" -2. ""No input data. -Agent: Enter fullscreen through the window control and confirm the window covers the screen."" -3. ""Menu path: macOS menu bar -> `View` -> `Full screen` (keyboard equivalent `Control+Command+F`). -Agent: Read the menu state or the app's fullscreen state and confirm it reports fullscreen."" -4. ""Any small `.mp4` file, for example a 5 to 10 second clip. -Agent: Post the video and start playback."" -5. ""No input data. -Agent: Enter the video's HTML5 fullscreen and confirm only the video is visible."" -6. ""Key: `Escape`. -Agent: Send one Escape key press and then compare the window bounds against the screen bounds."" -7. ""Menu path: macOS menu bar -> `View` -> `Full screen`. -Agent: Re-read the fullscreen state and confirm it is unchanged."" -8. ""Any public YouTube URL, for example a short clip link pasted as plain text. -Agent: Repeat the fullscreen and Escape sequence on the embedded player and re-check the window bounds."" -9. ""Include macOS version, build, and whether both the attachment and the embedded player were covered. -Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The workspace is loaded and messages are readable."" -2. ""The window fills the whole screen, the macOS menu bar hides until you move the pointer to the top edge, and the row of colored dots disappears."" -3. ""The `Full screen` item shows a checkmark. Close the menu with a click elsewhere."" -4. ""The video plays inline inside the message, with a control bar along the bottom edge of the player."" -5. ""The video expands to cover the entire screen; the channel list and message list are no longer visible."" -6. ""The video returns to its inline size inside the message list, and the app window is still in macOS fullscreen: it still covers the whole screen, the colored window dots are still hidden, and the macOS menu bar still auto-hides. This is the pass condition of the flow."" -7. ""The checkmark is still present, confirming the window never left fullscreen."" -8. ""Escape returns the embedded player to its inline preview size and the app window is still in macOS fullscreen. This covers a video inside a cross-origin frame."" -9. ""The result states clearly which video sources were exercised.""" -"","1","Window and fullscreen","","Escape with nothing to dismiss keeps the app window in macOS fullscreen","Source flow: FSESC-QA-002 - -Pressing Escape in the workspace or in the desktop app chrome while the window is in macOS native fullscreen never takes the window out of fullscreen.","installed_branch_build -workspace_logged_in","- Screenshot after the repeated Escape presses showing a window that still covers - the whole screen. -- Note of how many Escape presses and whether the held-key repeat case was run. -- macOS version and build identifier.","major","high","actual","manual","classic","FSESC-QA-002,source:repo-qa,platform:macos,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on macOS, then click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen."" -2. ""Click once on an empty area of the message list, for example the blank space to the right of a message timestamp, so keyboard focus is in the workspace content and no search field, modal, or side panel is open."" -3. ""Press the `Escape` key once."" -4. ""Press the `Escape` key five more times, once per second."" -5. ""Press and hold the `Escape` key for about three seconds so macOS generates key repeats, then release it."" -6. ""Move focus to the desktop app chrome instead of the workspace: move the pointer to the very top of the screen so the macOS menu bar appears, click `Window` in that menu bar, then click `App settings`."" -7. ""Click once on an empty area of the App settings screen, away from any input field, then press the `Escape` key once."" -8. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. -Agent: Launch the build and enter fullscreen."" -2. ""No input data. -Agent: Focus the workspace content and confirm no dismissable UI is open."" -3. ""Key: `Escape`. -Agent: Send one Escape press and compare window bounds against screen bounds."" -4. ""Key: `Escape`, six presses total including step 3. -Agent: Send repeated Escape presses with a short delay and re-check the window bounds after each."" -5. ""Key: `Escape` held down. -Agent: Send an auto-repeating Escape and re-check the window bounds."" -6. ""Menu path: macOS menu bar -> `Window` -> `App settings`, keyboard equivalent `Command+,`. -Agent: Navigate to the desktop App settings screen."" -7. ""Key: `Escape`. -Agent: Send one Escape press with the App settings screen focused and re-check the window bounds."" -8. ""Include macOS version, build, and whether both the workspace and the App settings screen were covered. -Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The window fills the whole screen and the three colored dots are hidden."" -2. ""No dialog, dropdown, search field, or contextual panel is visible on screen."" -3. ""Nothing happens visually and the window is still fullscreen: it still covers the whole screen, the colored window dots stay hidden, and the macOS menu bar still auto-hides."" -4. ""The window is still fullscreen after every press. Repeated presses matter because the fix has to re-arm between presses."" -5. ""The window is still fullscreen."" -6. ""The desktop App settings screen replaces the workspace content, showing tabs such as `General` near the top."" -7. ""The window is still fullscreen and the App settings screen is still shown."" -8. ""The result states which focus locations were exercised.""" -"","1","Window and fullscreen","","Escape, menu shortcuts and fullscreen toggles still work on macOS","Source flow: FSESC-QA-003 - -Escape still dismisses workspace UI, menu keyboard shortcuts still fire from the workspace, and both fullscreen toggles still enter and leave fullscreen.","installed_branch_build -workspace_logged_in","- Note or screenshot for each check: search closes on Escape while windowed and - while fullscreen, `Command+R` reloads, `Control+Command+F` toggles both ways, - the `View` -> `Full screen` item toggles both ways, and the windowed video - fullscreen cycle returns to the previous window size. -- macOS version and build identifier.","major","high","actual","manual","classic","FSESC-QA-003,source:repo-qa,platform:macos,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on macOS in a normal, non-fullscreen window, and wait for a workspace channel list to appear on the left."" -2. ""Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once."" -3. ""Click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen, then repeat the search and `Escape` sequence from step 2."" -4. ""With the window still fullscreen and focus in the workspace message list, press `Command+R`."" -5. ""With the window still fullscreen, press `Control+Command+F`."" -6. ""Press `Control+Command+F` again, then move the pointer to the very top of the screen so the macOS menu bar appears, click `View`, and check the `Full screen` item."" -7. ""With the window in a normal, non-fullscreen state, post or open a short video in the message list, play it, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets. Press `Escape` once."" -8. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. -Agent: Launch the build and confirm the window is not fullscreen."" -2. ""Search text: any short string such as `gen`. -Agent: Open workspace search, type, send Escape, and confirm the search UI closed."" -3. ""Search text: any short string such as `gen`. -Agent: Enter fullscreen, repeat the search and Escape check, and re-check the window bounds."" -4. ""Shortcut: `Command+R`, matching the macOS menu bar item `View` -> `Reload`. -Agent: Send the shortcut and confirm the workspace reloaded."" -5. ""Shortcut: `Control+Command+F`, matching the macOS menu bar item `View` -> `Full screen`. -Agent: Send the shortcut and confirm the window is no longer fullscreen."" -6. ""Menu path: macOS menu bar -> `View` -> `Full screen`. -Agent: Toggle fullscreen through both the shortcut and the menu item and confirm each direction works."" -7. ""Any small `.mp4` file, for example a 5 to 10 second clip. -Agent: Run the windowed video fullscreen cycle and confirm the window returned to its previous bounds."" -8. ""Include macOS version, build, and which of the checks passed. -Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The workspace is loaded and the window is windowed, with the three colored dots visible at the top-left."" -2. ""The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app while windowed."" -3. ""The search UI closes on Escape and the window is still fullscreen. Escape is delivered to the workspace and does not leave fullscreen."" -4. ""The workspace view reloads, showing a loading state and then the channel list again. Menu keyboard shortcuts still reach the macOS menu from the workspace."" -5. ""The window leaves fullscreen and returns to a normal window, with the three colored dots visible again at the top-left."" -6. ""The window is fullscreen again and the `Full screen` item shows a checkmark. Click the item to leave fullscreen and confirm the window returns to a normal window."" -7. ""The video fills the screen while fullscreen, and after Escape both the video and the window return to the previous normal window size. The windowed case must not regress."" -8. ""The result names each regression check separately.""" -"","1","Window and fullscreen","","Escape and video fullscreen behave exactly as before on Windows and Linux","Source flow: FSESC-QA-004 - -On Windows and Linux, Escape and video fullscreen behave the same as before the macOS fix, with no swallowed keys and no changed window state.","installed_branch_build -workspace_logged_in","- Note or screenshot showing workspace search closing on Escape. -- Note of the window bounds before and after the video fullscreen cycle. -- OS name and version plus build identifier.","normal","medium","actual","manual","classic","FSESC-QA-004,source:repo-qa,platform:windows,platform:linux,requires:installed_branch_build,requires:workspace_logged_in","1. ""Launch the Rocket.Chat Desktop build under test on Windows or Linux and wait for a workspace channel list to appear on the left."" -2. ""Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once."" -3. ""Post or open a short video in the message list, click its play button, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets."" -4. ""Press `Escape` once."" -5. ""Put the window into fullscreen using the platform control: on Windows press `F11` if the build exposes it, otherwise maximize the window; on Linux use the window manager fullscreen action for the window. Then click an empty area of the message list and press `Escape` once."" -6. ""Record the result using the format in `qa/macos-fullscreen-escape/README.md`, naming the platform covered.""","1. ""Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. Record which OS this run covers. -Agent: Launch the build and confirm the workspace view rendered."" -2. ""Search text: any short string such as `gen`. -Agent: Open workspace search, type, send Escape, and confirm the search UI closed."" -3. ""Any small `.mp4` file, for example a 5 to 10 second clip. -Agent: Enter the video's fullscreen and confirm only the video is visible."" -4. ""Key: `Escape`. -Agent: Send one Escape press and compare the window bounds with the pre-fullscreen bounds."" -5. ""Note which control was used to reach fullscreen or maximized state. -Agent: Reach the platform fullscreen or maximized state, send Escape, and confirm the window state did not change."" -6. ""Include OS and version, build, and which checks passed. -Agent: Write a concise result note without committing machine-specific logs unless requested.""","1. ""The workspace is loaded."" -2. ""The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app."" -3. ""The video fills the whole screen."" -4. ""The video returns to its inline size inside the message list and the window returns to the size it had before the video went fullscreen."" -5. ""The window state is unchanged by the Escape press; Escape neither leaves fullscreen nor restores the window."" -6. ""The result names the platform explicitly, because this flow must be run once per platform.""" diff --git a/qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md b/qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md deleted file mode 100644 index 2b3f0163a2..0000000000 --- a/qa/macos-fullscreen-escape/flows/01-video-fullscreen-escape.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -id: FSESC-QA-001 -title: Escape leaves only the video fullscreen while the app window stays fullscreen -platforms: [macos] -priority: smoke -qase: - suite: Window and fullscreen - priority: high - severity: major - status: actual - automation: manual - qase_id: null -requires: [installed_branch_build, workspace_logged_in] -test_links: [] -expected_result: Pressing Escape while a video plays in HTML5 fullscreen returns the video to its inline size and the app window remains in macOS native fullscreen. ---- - -# Escape Leaves Only The Video Fullscreen - -## Review Basis - -- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` - (`3bdd1e747`). -- Changed surface: Electron main process keyboard handling for server webviews - and the root window (`src/ui/main/escapeFullscreenGuard.ts`, - `src/ui/main/serverView/index.ts`, `src/ui/main/rootWindow.ts`). -- User-visible risk: A customer watching a video in a fullscreen app window - presses Escape to leave the video and the whole app drops out of macOS - fullscreen, losing their window arrangement and Space. -- Hypothesis: With the app window in macOS native fullscreen and a video in HTML5 - fullscreen, one Escape press returns the video to its inline size and the app - window is still fullscreen, matching Google Chrome. -- Smallest useful proof: OS-level repro on macOS hardware. The Jest suite covers - the desktop wiring only - (`src/ui/main/escapeFullscreenGuard.main.spec.ts`, - `src/ui/main/serverView/index.spec.ts`), because a test generated key event is - synthetic and never reaches macOS AppKit, which is what caused the original - regression. - -## Steps - -| Step | Action | Test data | Expected result | Agent action | -| --- | --- | --- | --- | --- | -| 1 | Launch the Rocket.Chat Desktop build under test on macOS and wait until a workspace channel list is visible on the left and message content fills the rest of the window. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it; any workspace where you can post a file. | The workspace is loaded and messages are readable. | Launch the build and confirm the workspace view rendered. | -| 2 | Put the window into macOS native fullscreen: click the green circular button at the top-left corner of the window, the third of the three colored dots. | No input data. | The window fills the whole screen, the macOS menu bar hides until you move the pointer to the top edge, and the row of colored dots disappears. | Enter fullscreen through the window control and confirm the window covers the screen. | -| 3 | Confirm the app itself knows it is fullscreen: move the pointer to the very top of the screen so the macOS menu bar appears, click `View` in that menu bar, and look at the `Full screen` item. | Menu path: macOS menu bar -> `View` -> `Full screen` (keyboard equivalent `Control+Command+F`). | The `Full screen` item shows a checkmark. Close the menu with a click elsewhere. | Read the menu state or the app's fullscreen state and confirm it reports fullscreen. | -| 4 | In the message box at the bottom of the channel, attach and send a short video file, then click the play button in the middle of the video player that appears in the message list. | Any small `.mp4` file, for example a 5 to 10 second clip. | The video plays inline inside the message, with a control bar along the bottom edge of the player. | Post the video and start playback. | -| 5 | Hover the playing video to reveal its control bar, then click the fullscreen button at the right end of that bar, the icon drawn as four outward-pointing corner brackets. | No input data. | The video expands to cover the entire screen; the channel list and message list are no longer visible. | Enter the video's HTML5 fullscreen and confirm only the video is visible. | -| 6 | Press the `Escape` key once. | Key: `Escape`. | The video returns to its inline size inside the message list, and the app window is still in macOS fullscreen: it still covers the whole screen, the colored window dots are still hidden, and the macOS menu bar still auto-hides. This is the pass condition of the flow. | Send one Escape key press and then compare the window bounds against the screen bounds. | -| 7 | Move the pointer to the top of the screen, click `View` in the macOS menu bar and confirm `Full screen` still shows a checkmark. | Menu path: macOS menu bar -> `View` -> `Full screen`. | The checkmark is still present, confirming the window never left fullscreen. | Re-read the fullscreen state and confirm it is unchanged. | -| 8 | Repeat steps 5 to 7 with an embedded player instead of an attachment: send a message containing a YouTube video link, wait for the preview player to render under the message, click its play button, then click the fullscreen button at the right end of the player's own control bar. | Any public YouTube URL, for example a short clip link pasted as plain text. | Escape returns the embedded player to its inline preview size and the app window is still in macOS fullscreen. This covers a video inside a cross-origin frame. | Repeat the fullscreen and Escape sequence on the embedded player and re-check the window bounds. | -| 9 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`. | Include macOS version, build, and whether both the attachment and the embedded player were covered. | The result states clearly which video sources were exercised. | Write a concise result note without committing machine-specific logs unless requested. | - -## Evidence - -- Screenshot taken right after the Escape press showing the inline video and a - window that still covers the whole screen. -- Note of the `View` -> `Full screen` checkmark state before and after Escape. -- macOS version and build identifier. - -## Failure Signals - -- The app window leaves macOS fullscreen when Escape is pressed. -- The video stays in fullscreen after Escape, or needs a second Escape press. -- The `View` -> `Full screen` checkmark clears after Escape. -- The window leaves fullscreen for the embedded player but not for the attachment, - or the reverse. -- The window visibly starts leaving fullscreen and then animates back in. diff --git a/qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md b/qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md deleted file mode 100644 index 7ce466f0ae..0000000000 --- a/qa/macos-fullscreen-escape/flows/02-plain-escape-keeps-fullscreen.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -id: FSESC-QA-002 -title: Escape with nothing to dismiss keeps the app window in macOS fullscreen -platforms: [macos] -priority: release -qase: - suite: Window and fullscreen - priority: high - severity: major - status: actual - automation: manual - qase_id: null -requires: [installed_branch_build, workspace_logged_in] -test_links: [] -expected_result: Pressing Escape in the workspace or in the desktop app chrome while the window is in macOS native fullscreen never takes the window out of fullscreen. ---- - -# Plain Escape Keeps The Window Fullscreen - -## Review Basis - -- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` - (`3bdd1e747`). -- Changed surface: Escape handling for both the server webview - (`src/ui/main/serverView/index.ts`) and the desktop app chrome - (`src/ui/main/rootWindow.ts`), sharing - `src/ui/main/escapeFullscreenGuard.ts`. -- User-visible risk: A customer in fullscreen presses Escape out of habit, with no - modal or search open, and the app leaves fullscreen. This is the general form of - the bug; the video case is only its most visible symptom. -- Hypothesis: With the app window in macOS native fullscreen, an Escape press that - has nothing to dismiss leaves the window fullscreen, both when focus is in the - workspace content and when focus is in the desktop App settings screen. -- Smallest useful proof: OS-level repro on macOS hardware. The desktop wiring is - covered by `src/ui/main/rootWindow.spec.ts` - (`rootWindow Escape fullscreen guard`) and - `src/ui/main/escapeFullscreenGuard.main.spec.ts`, but a synthetic key event - cannot reproduce the macOS AppKit behavior under test. - -## Steps - -| Step | Action | Test data | Expected result | Agent action | -| --- | --- | --- | --- | --- | -| 1 | Launch the Rocket.Chat Desktop build under test on macOS, then click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. | The window fills the whole screen and the three colored dots are hidden. | Launch the build and enter fullscreen. | -| 2 | Click once on an empty area of the message list, for example the blank space to the right of a message timestamp, so keyboard focus is in the workspace content and no search field, modal, or side panel is open. | No input data. | No dialog, dropdown, search field, or contextual panel is visible on screen. | Focus the workspace content and confirm no dismissable UI is open. | -| 3 | Press the `Escape` key once. | Key: `Escape`. | Nothing happens visually and the window is still fullscreen: it still covers the whole screen, the colored window dots stay hidden, and the macOS menu bar still auto-hides. | Send one Escape press and compare window bounds against screen bounds. | -| 4 | Press the `Escape` key five more times, once per second. | Key: `Escape`, six presses total including step 3. | The window is still fullscreen after every press. Repeated presses matter because the fix has to re-arm between presses. | Send repeated Escape presses with a short delay and re-check the window bounds after each. | -| 5 | Press and hold the `Escape` key for about three seconds so macOS generates key repeats, then release it. | Key: `Escape` held down. | The window is still fullscreen. | Send an auto-repeating Escape and re-check the window bounds. | -| 6 | Move focus to the desktop app chrome instead of the workspace: move the pointer to the very top of the screen so the macOS menu bar appears, click `Window` in that menu bar, then click `App settings`. | Menu path: macOS menu bar -> `Window` -> `App settings`, keyboard equivalent `Command+,`. | The desktop App settings screen replaces the workspace content, showing tabs such as `General` near the top. | Navigate to the desktop App settings screen. | -| 7 | Click once on an empty area of the App settings screen, away from any input field, then press the `Escape` key once. | Key: `Escape`. | The window is still fullscreen and the App settings screen is still shown. | Send one Escape press with the App settings screen focused and re-check the window bounds. | -| 8 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`. | Include macOS version, build, and whether both the workspace and the App settings screen were covered. | The result states which focus locations were exercised. | Write a concise result note without committing machine-specific logs unless requested. | - -## Evidence - -- Screenshot after the repeated Escape presses showing a window that still covers - the whole screen. -- Note of how many Escape presses and whether the held-key repeat case was run. -- macOS version and build identifier. - -## Failure Signals - -- The window leaves macOS fullscreen on any Escape press. -- The first Escape press is safe but a later press, or a held key, leaves - fullscreen. That points at the guard failing to re-arm. -- Escape leaves fullscreen only when focus is in the desktop App settings screen, - or only when focus is in the workspace content. diff --git a/qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md b/qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md deleted file mode 100644 index a12378f1b7..0000000000 --- a/qa/macos-fullscreen-escape/flows/03-escape-and-shortcut-regressions.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -id: FSESC-QA-003 -title: Escape, menu shortcuts and fullscreen toggles still work on macOS -platforms: [macos] -priority: release -qase: - suite: Window and fullscreen - priority: high - severity: major - status: actual - automation: manual - qase_id: null -requires: [installed_branch_build, workspace_logged_in] -test_links: [] -expected_result: Escape still dismisses workspace UI, menu keyboard shortcuts still fire from the workspace, and both fullscreen toggles still enter and leave fullscreen. ---- - -# Escape And Shortcut Regressions On macOS - -## Review Basis - -- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` - (`3bdd1e747`). -- Changed surface: `before-input-event` interception on the server webview and the - root window contents. The fix cancels the original Escape key event and replays - a synthetic one, and it stops forwarding the Escape key up to the desktop - window. -- User-visible risk: The interception could swallow Escape entirely, so workspace - search and modals would stop closing; it could break macOS menu keyboard - shortcuts typed while the workspace has focus; or it could leave the window - unable to enter or leave fullscreen through the normal controls. -- Hypothesis: After the fix, Escape still reaches the workspace web app, menu - shortcuts still fire while the workspace has focus, and both `Control+Command+F` - and the `View` -> `Full screen` menu item still toggle fullscreen in both - directions. -- Smallest useful proof: Local UI repro on macOS. Automated coverage exists for the - key forwarding contract in `src/ui/main/serverView/index.spec.ts` and - `src/ui/main/rootWindow.spec.ts`, but keyboard delivery to the web app and to the - macOS menu bar can only be observed at runtime. - -## Steps - -| Step | Action | Test data | Expected result | Agent action | -| --- | --- | --- | --- | --- | -| 1 | Launch the Rocket.Chat Desktop build under test on macOS in a normal, non-fullscreen window, and wait for a workspace channel list to appear on the left. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. | The workspace is loaded and the window is windowed, with the three colored dots visible at the top-left. | Launch the build and confirm the window is not fullscreen. | -| 2 | Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once. | Search text: any short string such as `gen`. | The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app while windowed. | Open workspace search, type, send Escape, and confirm the search UI closed. | -| 3 | Click the green circular button at the top-left corner of the window, the third of the three colored dots, to enter macOS native fullscreen, then repeat the search and `Escape` sequence from step 2. | Search text: any short string such as `gen`. | The search UI closes on Escape and the window is still fullscreen. Escape is delivered to the workspace and does not leave fullscreen. | Enter fullscreen, repeat the search and Escape check, and re-check the window bounds. | -| 4 | With the window still fullscreen and focus in the workspace message list, press `Command+R`. | Shortcut: `Command+R`, matching the macOS menu bar item `View` -> `Reload`. | The workspace view reloads, showing a loading state and then the channel list again. Menu keyboard shortcuts still reach the macOS menu from the workspace. | Send the shortcut and confirm the workspace reloaded. | -| 5 | With the window still fullscreen, press `Control+Command+F`. | Shortcut: `Control+Command+F`, matching the macOS menu bar item `View` -> `Full screen`. | The window leaves fullscreen and returns to a normal window, with the three colored dots visible again at the top-left. | Send the shortcut and confirm the window is no longer fullscreen. | -| 6 | Press `Control+Command+F` again, then move the pointer to the very top of the screen so the macOS menu bar appears, click `View`, and check the `Full screen` item. | Menu path: macOS menu bar -> `View` -> `Full screen`. | The window is fullscreen again and the `Full screen` item shows a checkmark. Click the item to leave fullscreen and confirm the window returns to a normal window. | Toggle fullscreen through both the shortcut and the menu item and confirm each direction works. | -| 7 | With the window in a normal, non-fullscreen state, post or open a short video in the message list, play it, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets. Press `Escape` once. | Any small `.mp4` file, for example a 5 to 10 second clip. | The video fills the screen while fullscreen, and after Escape both the video and the window return to the previous normal window size. The windowed case must not regress. | Run the windowed video fullscreen cycle and confirm the window returned to its previous bounds. | -| 8 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`. | Include macOS version, build, and which of the checks passed. | The result names each regression check separately. | Write a concise result note without committing machine-specific logs unless requested. | - -## Evidence - -- Note or screenshot for each check: search closes on Escape while windowed and - while fullscreen, `Command+R` reloads, `Control+Command+F` toggles both ways, - the `View` -> `Full screen` item toggles both ways, and the windowed video - fullscreen cycle returns to the previous window size. -- macOS version and build identifier. - -## Failure Signals - -- Escape no longer closes workspace search or workspace modals. -- `Command+R` or other menu shortcuts stop working while the workspace has focus. -- `Control+Command+F` or the `View` -> `Full screen` item no longer enters or - leaves fullscreen. -- The `Full screen` checkmark disagrees with the actual window state. -- After a windowed video fullscreen cycle the window does not return to its - previous size, or stays fullscreen. diff --git a/qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md b/qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md deleted file mode 100644 index aaa36ce215..0000000000 --- a/qa/macos-fullscreen-escape/flows/04-windows-linux-escape-unchanged.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -id: FSESC-QA-004 -title: Escape and video fullscreen behave exactly as before on Windows and Linux -platforms: [windows, linux] -priority: release -qase: - suite: Window and fullscreen - priority: medium - severity: normal - status: actual - automation: manual - qase_id: null -requires: [installed_branch_build, workspace_logged_in] -test_links: [] -expected_result: On Windows and Linux, Escape and video fullscreen behave the same as before the macOS fix, with no swallowed keys and no changed window state. ---- - -# Windows And Linux Escape Unchanged - -## Review Basis - -- Comparison range: `master` (`7aa3dae6d`) to `fix/macos-escape-exits-fullscreen` - (`3bdd1e747`). -- Changed surface: `before-input-event` handling shared by all platforms in - `src/ui/main/serverView/index.ts` and `src/ui/main/rootWindow.ts`. Every new code - path is behind `process.platform === 'darwin'`, but the Escape key up is no - longer forwarded to the desktop window on any platform. -- User-visible risk: A macOS-only fix silently changing Windows or Linux behavior, - either by swallowing Escape, by leaving a video stuck in fullscreen, or by - changing how the window responds to fullscreen keys. -- Hypothesis: On Windows and Linux, Escape still reaches the workspace web app, - video fullscreen still exits on Escape, and the window fullscreen state is not - affected by Escape at all. -- Smallest useful proof: Local UI repro per platform. The platform guards are - covered by `src/ui/main/escapeFullscreenGuard.main.spec.ts` and the - `other platforms` cases in `src/ui/main/serverView/index.spec.ts`, but the - desktop build should still be smoke-checked on each platform. - -## Steps - -| Step | Action | Test data | Expected result | Agent action | -| --- | --- | --- | --- | --- | -| 1 | Launch the Rocket.Chat Desktop build under test on Windows or Linux and wait for a workspace channel list to appear on the left. | Build from branch `fix/macos-escape-exits-fullscreen` or the release candidate containing it. Record which OS this run covers. | The workspace is loaded. | Launch the build and confirm the workspace view rendered. | -| 2 | Click the magnifier search icon in the top row of the workspace channel list on the left, type three or four letters of a channel name, then press `Escape` once. | Search text: any short string such as `gen`. | The search field and its result list close and the normal channel list returns, proving Escape still reaches the workspace web app. | Open workspace search, type, send Escape, and confirm the search UI closed. | -| 3 | Post or open a short video in the message list, click its play button, then click the fullscreen button at the right end of the video control bar, the icon drawn as four outward-pointing corner brackets. | Any small `.mp4` file, for example a 5 to 10 second clip. | The video fills the whole screen. | Enter the video's fullscreen and confirm only the video is visible. | -| 4 | Press `Escape` once. | Key: `Escape`. | The video returns to its inline size inside the message list and the window returns to the size it had before the video went fullscreen. | Send one Escape press and compare the window bounds with the pre-fullscreen bounds. | -| 5 | Put the window into fullscreen using the platform control: on Windows press `F11` if the build exposes it, otherwise maximize the window; on Linux use the window manager fullscreen action for the window. Then click an empty area of the message list and press `Escape` once. | Note which control was used to reach fullscreen or maximized state. | The window state is unchanged by the Escape press; Escape neither leaves fullscreen nor restores the window. | Reach the platform fullscreen or maximized state, send Escape, and confirm the window state did not change. | -| 6 | Record the result using the format in `qa/macos-fullscreen-escape/README.md`, naming the platform covered. | Include OS and version, build, and which checks passed. | The result names the platform explicitly, because this flow must be run once per platform. | Write a concise result note without committing machine-specific logs unless requested. | - -## Evidence - -- Note or screenshot showing workspace search closing on Escape. -- Note of the window bounds before and after the video fullscreen cycle. -- OS name and version plus build identifier. - -## Failure Signals - -- Escape stops closing workspace search or workspace modals. -- The video stays in fullscreen after Escape, or needs a second press. -- The window does not return to its pre-fullscreen size after the video exits - fullscreen. -- The window state changes on an Escape press. diff --git a/qa/macos-fullscreen-escape/results/README.md b/qa/macos-fullscreen-escape/results/README.md deleted file mode 100644 index bc9f7c7cd7..0000000000 --- a/qa/macos-fullscreen-escape/results/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# QA Results - -Store local run notes, screenshots, logs, or diagnostics here while testing. Do -not commit run-specific evidence unless a release owner asks for it. - -Recommended filename format: - -```text -YYYY-MM-DD-platform-flow-id-result.md -``` diff --git a/src/ui/main/escapeFullscreenGuard.main.spec.ts b/src/ui/main/escapeFullscreenGuard.main.spec.ts deleted file mode 100644 index b7f909de1a..0000000000 --- a/src/ui/main/escapeFullscreenGuard.main.spec.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { Input, WebContents } from 'electron'; - -import { createEscapeFullscreenGuard } from './escapeFullscreenGuard'; - -const originalPlatform = process.platform; - -const setPlatform = (platform: NodeJS.Platform): void => { - Object.defineProperty(process, 'platform', { - value: platform, - configurable: true, - }); -}; - -const createInput = (overrides: Partial = {}): Input => - ({ - type: 'keyDown', - key: 'Escape', - code: 'Escape', - isAutoRepeat: false, - isComposing: false, - shift: false, - control: false, - alt: false, - meta: false, - location: 0, - modifiers: [], - ...overrides, - }) as Input; - -describe('createEscapeFullscreenGuard', () => { - let target: Pick; - let currentTime: number; - - const createGuard = (isWindowFullscreen = true) => - createEscapeFullscreenGuard( - target, - () => isWindowFullscreen, - () => currentTime - ); - - beforeEach(() => { - setPlatform('darwin'); - currentTime = 1_000; - target = { sendInputEvent: jest.fn() }; - }); - - afterEach(() => { - setPlatform(originalPlatform); - }); - - it('swallows a native Escape key down and replays it as a synthetic event', () => { - const guard = createGuard(); - - expect(guard.handleInput(createInput())).toBe(true); - expect(target.sendInputEvent).toHaveBeenCalledTimes(1); - expect(target.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: [], - }); - }); - - it('forwards the active modifiers with the replayed event', () => { - const guard = createGuard(); - - guard.handleInput(createInput({ shift: true, control: true })); - - expect(target.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: ['shift', 'control'], - }); - }); - - it('swallows auto-repeated Escapes without replaying them', () => { - const guard = createGuard(); - - expect(guard.handleInput(createInput())).toBe(true); - expect(guard.handleInput(createInput({ isAutoRepeat: true }))).toBe(true); - expect(guard.handleInput(createInput({ isAutoRepeat: true }))).toBe(true); - expect(target.sendInputEvent).toHaveBeenCalledTimes(1); - - expect(guard.handleInput(createInput())).toBe(false); - expect(target.sendInputEvent).toHaveBeenCalledTimes(1); - }); - - it('lets auto-repeated Escapes through when the window is not in fullscreen', () => { - const guard = createGuard(false); - - expect(guard.handleInput(createInput({ isAutoRepeat: true }))).toBe(false); - expect(target.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('clears the replay credit when the replay lands after fullscreen ended', () => { - let fullscreen = true; - const guard = createEscapeFullscreenGuard( - target, - () => fullscreen, - () => currentTime - ); - - expect(guard.handleInput(createInput())).toBe(true); - - fullscreen = false; - expect(guard.handleInput(createInput())).toBe(false); - - fullscreen = true; - expect(guard.handleInput(createInput())).toBe(true); - expect(target.sendInputEvent).toHaveBeenCalledTimes(2); - }); - - it('lets the replayed event through without replaying it again', () => { - const guard = createGuard(); - - expect(guard.handleInput(createInput())).toBe(true); - expect(guard.handleInput(createInput())).toBe(false); - expect(target.sendInputEvent).toHaveBeenCalledTimes(1); - }); - - it('guards the next Escape again after the replay window expires', () => { - const guard = createGuard(); - - expect(guard.handleInput(createInput())).toBe(true); - - currentTime += 1_000; - - expect(guard.handleInput(createInput())).toBe(true); - expect(target.sendInputEvent).toHaveBeenCalledTimes(2); - }); - - it('does nothing when the window is not in fullscreen', () => { - const guard = createGuard(false); - - expect(guard.handleInput(createInput())).toBe(false); - expect(target.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('ignores key up events', () => { - const guard = createGuard(); - - expect(guard.handleInput(createInput({ type: 'keyUp' }))).toBe(false); - expect(target.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('ignores keys other than Escape', () => { - const guard = createGuard(); - - expect(guard.handleInput(createInput({ key: 'Meta' }))).toBe(false); - expect(target.sendInputEvent).not.toHaveBeenCalled(); - }); - - it.each(['win32', 'linux'])('is inert on %s', (platform) => { - setPlatform(platform); - const guard = createGuard(); - - expect(guard.handleInput(createInput())).toBe(false); - expect(target.sendInputEvent).not.toHaveBeenCalled(); - }); -}); diff --git a/src/ui/main/escapeFullscreenGuard.ts b/src/ui/main/escapeFullscreenGuard.ts deleted file mode 100644 index 2cb5615624..0000000000 --- a/src/ui/main/escapeFullscreenGuard.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { Input, KeyboardInputEvent, WebContents } from 'electron'; - -/** - * On macOS, Electron hands every key event the web content did not consume back - * to the native window - * (`WebContents::PlatformHandleKeyboardEvent` in `electron_api_web_contents_mac.mm` - * calls `[ns_event.window redispatchKeyEvent:]`). AppKit answers ESC with - * `cancelOperation:`, which on a window in native fullscreen leaves fullscreen. - * That is why pressing ESC while a video is in HTML5 fullscreen — or with nothing - * at all to dismiss — also drops the app out of fullscreen, while Chrome, which - * never returns the raw `NSEvent` to AppKit, stays fullscreen. - * - * The only interception point is `before-input-event`, which runs before the - * renderer sees the event. Preventing it there stops the redispatch, so the event - * is replayed through `sendInputEvent`: a synthetic event carries no `NSEvent` and - * therefore can never reach AppKit, while the page still receives its ESC. - * - * Auto-repeated ESC key downs are swallowed without a replay: a replay of a - * repeat could race the previous replay's round trip through the renderer, and - * the raced native repeat would reach AppKit unguarded. - * - * Windows and Linux redispatch to registered window accelerators only, and no ESC - * accelerator exists, so the guard is inert there. - */ - -const REPLAY_TIMEOUT_MS = 250; - -type KeyboardModifier = NonNullable[number]; - -const toKeyboardModifiers = (input: Input): KeyboardModifier[] => { - const modifiers: KeyboardModifier[] = []; - - if (input.shift) { - modifiers.push('shift'); - } - - if (input.control) { - modifiers.push('control'); - } - - if (input.alt) { - modifiers.push('alt'); - } - - if (input.meta) { - modifiers.push('meta'); - } - - return modifiers; -}; - -type EscapeFullscreenGuard = { - /** - * Returns `true` when the caller must call `event.preventDefault()` on the - * `before-input-event` it is handling. - */ - handleInput: (input: Input) => boolean; -}; - -export const createEscapeFullscreenGuard = ( - target: Pick, - isWindowFullscreen: () => boolean, - now: () => number = Date.now -): EscapeFullscreenGuard => { - let replayExpiresAt = 0; - - const isReplay = (): boolean => { - if (replayExpiresAt === 0 || now() > replayExpiresAt) { - replayExpiresAt = 0; - return false; - } - - replayExpiresAt = 0; - return true; - }; - - return { - handleInput: (input) => { - if (process.platform !== 'darwin') { - return false; - } - - if (input.type !== 'keyDown' || input.key !== 'Escape') { - return false; - } - - // Auto-repeats never come back as replays (only the initial key down is - // replayed), so they must be swallowed before the replay credit is - // consulted — otherwise a repeat racing the in-flight replay would - // consume its credit and pass through to AppKit as a raw native event. - if (input.isAutoRepeat) { - return isWindowFullscreen(); - } - - // Consume the replay credit before the fullscreen gate: a replay that - // lands after the window already left fullscreen must not leave a stale - // credit behind for the next real ESC to be mistaken for a replay. - if (isReplay()) { - return false; - } - - if (!isWindowFullscreen()) { - return false; - } - - replayExpiresAt = now() + REPLAY_TIMEOUT_MS; - - console.debug('Replaying Escape to keep the window in fullscreen'); - - target.sendInputEvent({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: toKeyboardModifiers(input), - }); - - return true; - }, - }; -}; diff --git a/src/ui/main/rootWindow.spec.ts b/src/ui/main/rootWindow.spec.ts index 5f7d8e3899..c3d71c3904 100644 --- a/src/ui/main/rootWindow.spec.ts +++ b/src/ui/main/rootWindow.spec.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import type { Input } from 'electron'; import { app, screen } from 'electron'; jest.mock('electron', () => ({ @@ -55,13 +54,8 @@ describe('rootWindow close event handler', () => { minimize: jest.fn(), blur: jest.fn(), isFullScreen: jest.fn(() => false), - isSimpleFullScreen: jest.fn(() => false), isDestroyed: jest.fn(() => false), setFullScreen: jest.fn(), - webContents: { - addListener: jest.fn(), - sendInputEvent: jest.fn(), - }, once: jest.fn(), flashFrame: jest.fn(), setIcon: jest.fn(), @@ -482,139 +476,6 @@ describe('rootWindow close event handler', () => { }); }); -describe('rootWindow Escape fullscreen guard', () => { - const originalPlatform = process.platform; - - let mockWindow: any; - - const setPlatform = (platform: NodeJS.Platform): void => { - Object.defineProperty(process, 'platform', { - value: platform, - configurable: true, - }); - }; - - const createInput = (overrides: Partial = {}): Input => - ({ - type: 'keyDown', - key: 'Escape', - code: 'Escape', - isAutoRepeat: false, - isComposing: false, - shift: false, - control: false, - alt: false, - meta: false, - location: 0, - modifiers: [], - ...overrides, - }) as Input; - - const setupAndGetHandler = async (): Promise => { - const { setupRootWindow } = require('./rootWindow'); - require('./rootWindow').getRootWindow = jest - .fn() - .mockResolvedValue(mockWindow); - - setupRootWindow(); - await Promise.resolve(); - - const call = mockWindow.webContents.addListener.mock.calls.find( - ([event]: any[]) => event === 'before-input-event' - ); - - expect(call).toBeDefined(); - - return call[1]; - }; - - beforeEach(() => { - jest.clearAllMocks(); - setPlatform('darwin'); - - (require('../../store').select as jest.Mock).mockImplementation( - (selector: any) => selector({ isTrayIconEnabled: false }) - ); - - mockWindow = { - addListener: jest.fn(), - removeAllListeners: jest.fn(), - close: jest.fn(), - hide: jest.fn(), - minimize: jest.fn(), - blur: jest.fn(), - isFullScreen: jest.fn(() => true), - isSimpleFullScreen: jest.fn(() => false), - isDestroyed: jest.fn(() => false), - setFullScreen: jest.fn(), - once: jest.fn(), - flashFrame: jest.fn(), - setIcon: jest.fn(), - setTitle: jest.fn(), - setOverlayIcon: jest.fn(), - autoHideMenuBar: false, - setMenuBarVisibility: jest.fn(), - webContents: { - addListener: jest.fn(), - sendInputEvent: jest.fn(), - }, - }; - }); - - afterEach(() => { - setPlatform(originalPlatform); - }); - - it('swallows Escape so it cannot reach the native window while in fullscreen', async () => { - const handler = await setupAndGetHandler(); - const event = { preventDefault: jest.fn() }; - - handler(event, createInput()); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(mockWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: [], - }); - expect(mockWindow.setFullScreen).not.toHaveBeenCalled(); - }); - - it('lets the replayed Escape reach the renderer', async () => { - const handler = await setupAndGetHandler(); - handler({ preventDefault: jest.fn() }, createInput()); - mockWindow.webContents.sendInputEvent.mockClear(); - - const event = { preventDefault: jest.fn() }; - handler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(mockWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('leaves Escape untouched when the window is not in fullscreen', async () => { - mockWindow.isFullScreen.mockReturnValue(false); - const handler = await setupAndGetHandler(); - const event = { preventDefault: jest.fn() }; - - handler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(mockWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('leaves Escape untouched on other platforms', async () => { - setPlatform('win32'); - const handler = await setupAndGetHandler(); - const event = { preventDefault: jest.fn() }; - - handler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(mockWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); -}); - describe('isInsideSomeScreen', () => { const { isInsideSomeScreen } = require('./rootWindow'); diff --git a/src/ui/main/rootWindow.ts b/src/ui/main/rootWindow.ts index 1e94a78023..54acf2c1a2 100644 --- a/src/ui/main/rootWindow.ts +++ b/src/ui/main/rootWindow.ts @@ -34,7 +34,6 @@ import { import type { WindowState } from '../common'; import { selectGlobalBadge, selectGlobalBadgeCount } from '../selectors'; import { debounce } from './debounce'; -import { createEscapeFullscreenGuard } from './escapeFullscreenGuard'; import { getTrayIconPath } from './icons'; const webPreferences: WebPreferences = { @@ -160,9 +159,6 @@ export const isInsideSomeScreen = ({ y + height > bounds.y ); -export const isWindowInAnyFullscreen = (window: BrowserWindow): boolean => - window.isFullScreen() || window.isSimpleFullScreen(); - export const applyRootWindowState = (browserWindow: BrowserWindow): void => { const rootWindowState = select(selectRootWindowState); const isTrayIconEnabled = select( @@ -413,17 +409,6 @@ export const setupRootWindow = (): void => { rootWindow.flashFrame(false); }); - const escapeFullscreenGuard = createEscapeFullscreenGuard( - rootWindow.webContents, - () => isWindowInAnyFullscreen(rootWindow) - ); - - rootWindow.webContents.addListener('before-input-event', (event, input) => { - if (escapeFullscreenGuard.handleInput(input)) { - event.preventDefault(); - } - }); - rootWindow.addListener('close', async (event) => { try { if (rootWindow.isDestroyed()) { diff --git a/src/ui/main/serverView/index.spec.ts b/src/ui/main/serverView/index.spec.ts index bcb8aca894..2af4487cd2 100644 --- a/src/ui/main/serverView/index.spec.ts +++ b/src/ui/main/serverView/index.spec.ts @@ -1,10 +1,9 @@ -import type { Event, Input, WebContents } from 'electron'; +import type { Event, WebContents } from 'electron'; import { isProtocolAllowed } from '../../../navigation/main'; import { listen } from '../../../store'; import { openExternal } from '../../../utils/browserLauncher'; -import { WEBVIEW_ATTACHED, WEBVIEW_READY } from '../../actions'; -import { getRootWindow } from '../rootWindow'; +import { WEBVIEW_READY } from '../../actions'; import { attachGuestWebContentsEvents } from './index'; jest.mock('electron', () => ({ @@ -54,12 +53,6 @@ jest.mock('../rootWindow', () => ({ webContents: { addListener: jest.fn() }, }) ), - isWindowInAnyFullscreen: jest.fn( - (window: { - isFullScreen: () => boolean; - isSimpleFullScreen: () => boolean; - }) => window.isFullScreen() || window.isSimpleFullScreen() - ), })); jest.mock('./popupMenu', () => ({ @@ -161,246 +154,3 @@ describe('serverView attachGuestWebContentsEvents will-navigate guard', () => { expect(mockOpenExternal).not.toHaveBeenCalled(); }); }); - -describe('serverView before-input-event fullscreen handling', () => { - const mockListen = listen as unknown as jest.Mock; - const mockGetRootWindow = getRootWindow as jest.MockedFunction< - typeof getRootWindow - >; - const originalPlatform = process.platform; - - let beforeInputEventHandler: (event: Event, input: Input) => void; - let guestListeners: Map void>; - let guestWebContents: { - sendInputEvent: jest.Mock; - executeJavaScript: jest.Mock; - }; - let rootWindow: { - isFullScreen: jest.Mock; - isSimpleFullScreen: jest.Mock; - isDestroyed: jest.Mock; - webContents: { - sendInputEvent: jest.Mock; - addListener: jest.Mock; - isDestroyed: jest.Mock; - }; - }; - - const setPlatform = (platform: NodeJS.Platform): void => { - Object.defineProperty(process, 'platform', { - value: platform, - configurable: true, - }); - }; - - const createInput = (overrides: Partial = {}): Input => - ({ - type: 'keyDown', - key: 'Escape', - code: 'Escape', - isAutoRepeat: false, - isComposing: false, - shift: false, - control: false, - alt: false, - meta: false, - location: 0, - modifiers: [], - ...overrides, - }) as Input; - - const createEvent = (): Event => - ({ preventDefault: jest.fn() }) as unknown as Event; - - const attachGuest = async (): Promise => { - await attachGuestWebContentsEvents(); - - const webviewAttachedCall = mockListen.mock.calls.find( - ([actionType]) => actionType === WEBVIEW_ATTACHED - ); - const webviewAttachedCallback = webviewAttachedCall?.[1] as ( - action: unknown - ) => void; - - ( - jest.requireMock('electron').webContents.fromId as jest.Mock - ).mockReturnValue(guestWebContents); - - webviewAttachedCallback({ - payload: { webContentsId: 1, url: 'https://open.rocket.chat' }, - }); - - beforeInputEventHandler = guestListeners.get('before-input-event') as ( - event: Event, - input: Input - ) => void; - }; - - beforeEach(async () => { - jest.clearAllMocks(); - setPlatform('darwin'); - - guestListeners = new Map(); - rootWindow = { - isFullScreen: jest.fn(() => true), - isSimpleFullScreen: jest.fn(() => false), - isDestroyed: jest.fn(() => false), - webContents: { - sendInputEvent: jest.fn(), - addListener: jest.fn(), - isDestroyed: jest.fn(() => false), - }, - }; - mockGetRootWindow.mockResolvedValue(rootWindow as any); - - guestWebContents = { - sendInputEvent: jest.fn(), - executeJavaScript: jest.fn(() => Promise.resolve()), - }; - - Object.assign(guestWebContents, { - addListener: jest.fn((event: string, handler: any) => { - guestListeners.set(event, handler); - }), - on: jest.fn(), - setWindowOpenHandler: jest.fn(), - session: { on: jest.fn(), setPermissionRequestHandler: jest.fn() }, - }); - - await attachGuest(); - }); - - afterEach(() => { - setPlatform(originalPlatform); - }); - - const enterHtmlFullscreen = (): void => { - guestListeners.get('enter-html-full-screen')?.(); - }; - - it('exits only the HTML5 fullscreen when Escape is pressed while a video is fullscreen', () => { - enterHtmlFullscreen(); - - const event = createEvent(); - beforeInputEventHandler(event, createInput()); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(guestWebContents.executeJavaScript).toHaveBeenCalledWith( - expect.stringContaining('exitFullscreen') - ); - expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('swallows auto-repeated Escapes in HTML5 fullscreen without re-running the exit', () => { - enterHtmlFullscreen(); - - beforeInputEventHandler(createEvent(), createInput()); - const event = createEvent(); - beforeInputEventHandler(event, createInput({ isAutoRepeat: true })); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(guestWebContents.executeJavaScript).toHaveBeenCalledTimes(1); - expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('replays Escape into the guest instead of letting it reach the native window', () => { - const event = createEvent(); - beforeInputEventHandler(event, createInput()); - - expect(event.preventDefault).toHaveBeenCalled(); - expect(guestWebContents.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: [], - }); - expect(guestWebContents.executeJavaScript).not.toHaveBeenCalled(); - }); - - it('forwards the replayed Escape to the root window', () => { - beforeInputEventHandler(createEvent(), createInput()); - guestWebContents.sendInputEvent.mockClear(); - - const event = createEvent(); - beforeInputEventHandler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); - expect(rootWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: [], - }); - }); - - it('forwards Escape untouched when the window is not in fullscreen', () => { - rootWindow.isFullScreen.mockReturnValue(false); - - const event = createEvent(); - beforeInputEventHandler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); - expect(rootWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: [], - }); - }); - - it('does not forward the Escape key up to the root window', () => { - const event = createEvent(); - beforeInputEventHandler(event, createInput({ type: 'keyUp' })); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('keeps forwarding both key down and key up of the shortcut key', () => { - beforeInputEventHandler(createEvent(), createInput({ key: 'Meta' })); - beforeInputEventHandler( - createEvent(), - createInput({ key: 'Meta', type: 'keyUp' }) - ); - - expect(rootWindow.webContents.sendInputEvent).toHaveBeenNthCalledWith(1, { - type: 'keyDown', - keyCode: 'Meta', - modifiers: [], - }); - expect(rootWindow.webContents.sendInputEvent).toHaveBeenNthCalledWith(2, { - type: 'keyUp', - keyCode: 'Meta', - modifiers: [], - }); - }); - - it('leaves HTML5 fullscreen to Chromium on other platforms', async () => { - setPlatform('linux'); - await attachGuest(); - enterHtmlFullscreen(); - - const event = createEvent(); - beforeInputEventHandler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(guestWebContents.executeJavaScript).not.toHaveBeenCalled(); - expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); - expect(rootWindow.webContents.sendInputEvent).not.toHaveBeenCalled(); - }); - - it('leaves Escape alone on other platforms', async () => { - setPlatform('linux'); - await attachGuest(); - - const event = createEvent(); - beforeInputEventHandler(event, createInput()); - - expect(event.preventDefault).not.toHaveBeenCalled(); - expect(guestWebContents.sendInputEvent).not.toHaveBeenCalled(); - expect(rootWindow.webContents.sendInputEvent).toHaveBeenCalledWith({ - type: 'keyDown', - keyCode: 'Escape', - modifiers: [], - }); - }); -}); diff --git a/src/ui/main/serverView/index.ts b/src/ui/main/serverView/index.ts index c6f412f885..b8660b6650 100644 --- a/src/ui/main/serverView/index.ts +++ b/src/ui/main/serverView/index.ts @@ -43,9 +43,8 @@ import { SIDE_BAR_SERVER_REMOVE, WEBVIEW_FORCE_RELOAD_WITH_CACHE_CLEAR, } from '../../actions'; -import { createEscapeFullscreenGuard } from '../escapeFullscreenGuard'; import { handleMediaPermissionRequest } from '../mediaPermissions'; -import { getRootWindow, isWindowInAnyFullscreen } from '../rootWindow'; +import { getRootWindow } from '../rootWindow'; import { isMarkdownViewerDownloadUrl } from './isMarkdownViewerDownloadUrl'; import { createPopupMenuForServerView } from './popupMenu'; @@ -355,25 +354,10 @@ const initializeServerWebContentsAfterAttach = ( isGuestInHtmlFullscreen = false; }); - const escapeFullscreenGuard = createEscapeFullscreenGuard( - guestWebContents, - () => isWindowInAnyFullscreen(rootWindow) - ); - - const exitGuestHtmlFullscreen = (): void => { - guestWebContents - .executeJavaScript('document.exitFullscreen && document.exitFullscreen()') - .catch((error) => { - console.error( - 'Failed to exit HTML5 fullscreen on the server view:', - error - ); - }); - }; - - const handleBeforeInputEvent = (event: Event, input: Input): void => { - const { type, key } = input; - + const handleBeforeInputEvent = ( + _event: Event, + { type, key }: Input + ): void => { if (type !== 'keyUp' && type !== 'keyDown') { return; } @@ -384,37 +368,10 @@ const initializeServerWebContentsAfterAttach = ( return; } - if (key === 'Escape') { - // The root window only reacts to ESC on key down; forwarding the key up as - // well would leak an unguarded ESC once the guest left HTML5 fullscreen. - if (type !== 'keyDown') { - return; - } - - // While the guest is in HTML5 fullscreen (e.g. a video player) ESC is - // never forwarded to the root window. On macOS it must additionally only - // leave the video fullscreen, like Chrome does: swallow the native key - // event so it cannot reach the native window and exit its fullscreen too, - // and drive the fullscreen exit explicitly. Calling it on the guest's - // main frame also covers a fullscreen element inside a nested frame. - // Windows and Linux leave the fullscreen exit itself to Chromium. - if (isGuestInHtmlFullscreen) { - if (process.platform === 'darwin') { - event.preventDefault(); - if (!input.isAutoRepeat) { - exitGuestHtmlFullscreen(); - } - } - return; - } - - if (escapeFullscreenGuard.handleInput(input)) { - event.preventDefault(); - return; - } - } - - if (rootWindow.isDestroyed() || rootWindow.webContents.isDestroyed()) { + // On macOS, forwarding ESC to the root window while the guest is in + // HTML5 fullscreen (e.g. a video player) causes the native window to + // also exit fullscreen. This does not occur on Windows/Linux. + if (key === 'Escape' && isGuestInHtmlFullscreen) { return; } From 729869a62172012d96700e4228c4ecff4fcb8aaf Mon Sep 17 00:00:00 2001 From: Jean Brito Date: Mon, 31 Aug 2026 16:36:15 -0300 Subject: [PATCH 17/17] chore: bump version to 4.17.0 (#3482) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b1ee51d098..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.17.0-alpha.3", + "version": "4.17.0", "author": "Rocket.Chat Support ", "copyright": "© 2016-2026, Rocket.Chat", "homepage": "https://rocket.chat",