Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 2b39843d70 fix(dashboard): return focus after the Security tab's dialogs close
Closing Blocked people, or the 2FA setup or disable dialog opened from
the switch, left keyboard focus on <body>, so the next Tab started at
the top of the page. The 2FA switch also disables itself while its
dialog is up, which drops focus even before then. Focus now goes back
to the button or switch that opened the dialog once it has gone.
2026-10-23 20:34:25 -07:00
Nariman Jelveh 44a2741ce0 fix(gui): move keyboard focus into the Manage sessions dialog
The sessions dialog is modal but never took focus: opening it from the
keyboard left focus on the button behind the backdrop, and Tab walked
the page underneath instead of the session list. It now focuses its
close button on open, keeps Tab inside, and hands focus back to the
control that opened it when it closes.
2026-10-23 20:33:32 -07:00
Nariman Jelveh d5fdcaa4e4 fix(dashboard): size the touch menu and bottom sheets to the visible height
The touch context menu, the app feedback dialog and the profile-photo
crop sheet capped their height in vh. On phones 100vh is the viewport
with the browser bars hidden, so while they show these could be taller
than the screen. The touch menu, which ContextMenu.js keeps inside the
visible height, then ran its last rows off the bottom on a landscape
phone, and the bottom sheets could push their header off the top. They
now cap in dvh, as style.css already does.
2026-10-23 20:32:17 -07:00
Nariman Jelveh 4d357c065e fix(dashboard): keep a landscape phone's notch clear of the dashboard
A phone held sideways and wider than 768px (an iPhone Pro Max, a large
Android) gets the desktop layout, and the page asks for the full screen
with viewport-fit=cover. Nothing in that layout read the side safe-area
insets, so with the notch on the left the sidebar's icons sat under it
(x=24 against a 47px inset), and with it on the right the content's
edge and the toasts did.

The sidebar (open or collapsed) now adds the left inset, and the content
area, including Apps and Files, and the toasts add the right one. The
insets are 0 everywhere else, so other layouts don't change.
2026-10-23 20:31:36 -07:00
Nariman Jelveh 286ee5237c fix(dashboard): give small dashboard controls 44px touch targets
Several controls a phone or tablet user taps were under the 44px
minimum touch size, with nothing enlarging them on a coarse pointer:
the Files toolbar buttons (32px), the touch menu's rows (36px tall),
the drawer's close button (32px), the notifications close (30px), the
Properties close (32px), the feedback dialog's ✕ (30px), the add-app
Back arrow (26px), the Apps modal buttons (34px tall) and the share
dialog's Copy link (38px tall).

Following the earlier touch fixes, the icon buttons keep their look
and get a 44x44 hit area, and the text buttons and menu rows grow to
44px. Mouse and trackpad layouts are unchanged.
2026-10-23 20:29:37 -07:00
Nariman Jelveh d178436ecd fix(dashboard): keep the Files preview and drag ghost above the window
The Space-bar image preview (z-index 9999) and the drag ghost (10000)
are appended to <body>, beside the dashboard window. On a touch-only
tablet with a keyboard every window is pinned to 9999999, so the preview
opened invisibly behind it from the first press; on desktop the
window's z-index climbs with each click and overtakes both in a long
session. Both now sit in the band the sidebar tooltip and the toasts
already use.
2026-10-23 20:27:05 -07:00
Nariman Jelveh 7beffe39f9 fix(dashboard): show the drop outline and dwell fill on grid folders
Dragging files over a folder in grid view only tinted it like a
selection: the grid's own border, hover and selected rules out-ranked
the shared drop-target and spring-load rules, and the hover rule's
background shorthand wiped the dwell fill. The folder then opened after
700ms with no sign it was about to. Grid and compact-grid folders now
get the list's dashed outline and filling dwell, with the padding
trimmed to match so the tile doesn't shift.
2026-10-23 20:25:31 -07:00
Nariman Jelveh c463bf954e fix(dashboard): give the app feedback dialog's Send button AA contrast
The feedback dialog's primary button (Send, and Close once sent) put
white text on a light-blue gradient, 2.66:1 at the top and 3.42:1 at the
bottom. It now uses the dashboard's primary button colors, which meet
4.5:1 in both themes, like the dialogs around it.
2026-10-23 20:24:11 -07:00
Nariman Jelveh 9f6c2d612b fix(dashboard): give danger buttons and error text AA contrast
White text on the red fills fell short of 4.5:1: the Apps uninstall
confirm (#e53935, 4.23:1), the delete-account button (#f00808, 4.43:1)
and the Files drag "Cancel" zone (#ef4444, 3.76:1). So did light-theme
danger text on its pink tint (#dc2626 on #fef2f2, 4.41:1), used for
form, share, crop and notification errors and the past-due badge.

The fills are now #d32f2f, #d90707 and #dc2626 (4.8:1 to 5.3:1), each
with a darker hover, and danger text is #b91c1c (5.9:1 on the tint).
2026-10-23 20:22:21 -07:00
Nariman Jelveh aa9ef5b6df fix(dashboard): give success text AA contrast in the light theme
Success text (the 2FA "enabled" line, Home's active-plan badge, the
share dialog's confirmations) used #03933a: 4.01:1 on white and 3.80:1
on its green tint, under the 4.5:1 minimum. It is now #15803d (5.02:1
and 4.75:1); the success check icons that share the token darken with
it. Dark mode already passed.
2026-10-23 20:21:56 -07:00
Nariman Jelveh 3556b6342f fix(dashboard): give links AA contrast in the light theme
Link-colored text (Home's "View more" and "Upgrade", the notification
panel's "Mark all read" and "Retry", Properties' website links, the
token dialog's sessions link) used #5271ff, 4.07:1 on white and 3.97:1
on the content background, under the 4.5:1 AA minimum for text that
size. The light theme's link is now #3d5bd9 (5.7:1), with a darker
hover. Dark mode already passed.
2026-10-23 20:21:41 -07:00
Nariman Jelveh 1c5ef1f2c1 fix(dashboard): keep keyboard focus on the grid after Ungroup
Ungrouping a folder from its menu re-rendered the grid without the
folder's tile, so a keyboard user who had it focused was left on
<body> and the next Tab started from the top of the page. Focus now
moves to the folder's first app, which takes the folder's place.
2026-10-23 20:21:12 -07:00
Nariman Jelveh fa25c02ba9 feat(dashboard): open an app tile's menu with the Menu key or Shift+F10
An app or folder tile's menu (Open in New Tab, Copy Link, Uninstall,
Remove from Folder; a folder's Rename and Ungroup) opened only on a
right-click or long-press, and browsers don't all turn the keyboard's
menu keys into a contextmenu event (macOS has none). The Menu key and
Shift+F10 on a focused tile now open its menu at the tile, where arrow
keys and Enter work.
2026-10-23 20:21:12 -07:00
Nariman Jelveh 0451aece0c fix(dashboard): include an open folder's page dots in its Tab cycle
The open folder keeps Tab inside it, but its list of stops left out the
page dots, which are buttons. Tab never reached them, and once one had
focus (clicking it focuses it) the next Tab walked out of the modal
folder to the page behind. The dots are now part of the cycle.
2026-10-23 20:18:58 -07:00
Nariman Jelveh 1ccdf392ad feat(dashboard): open the first Apps search result on Enter
Typing in the Apps search and pressing Enter did nothing; reaching a
result took ArrowDown and then Enter. Enter in a non-empty search now
opens the first match (or a folder that matches), as launchers do. It
does nothing while an IME composition is open, or when nothing matches.
2026-10-23 20:17:49 -07:00
Nariman Jelveh 8e5222b36a fix(dashboard): don't turn Apps pages on a trackpad pinch
A pinch on a trackpad reaches the page as wheel events with ctrlKey
set, and the Apps grid (and an open folder) read them as a wheel flick
and turned the page while the user zoomed. Those events are now left
alone.

The handlers also called preventDefault() from a listener the app makes
passive, which does nothing and logged a console warning on every wheel
tick; that call is gone, as the scroller has no vertical scroll to stop.
2026-10-23 20:17:04 -07:00
Nariman Jelveh 5a90c91e4e fix(dashboard): keep paging while a dragged app is held at the edge
Holding a dragged tile still at the Apps grid's edge (or an open
folder's) turned one page and stopped: the next dwell only started on
another pointer move, so a mouse held steady stayed on the second page
of three. Once a flip settles, the dwell starts again if the pointer is
still in the edge zone.
2026-10-23 20:16:12 -07:00
Nariman Jelveh de7bfc976c fix(dashboard): stop the Apps pager's dots jumping back mid-flip
A page arrow, a dot, or the wheel starts a smooth scroll and marks the
target page at once, but the scroll handler then read the pages passed
over on the way as the current one. The active dot went forward, back
to the old page, and forward again, and a second click on the arrow
added to the stale page: two quick clicks showed 0 → 1 → 0 → 2 → 1 → 2.

While such a scroll is on its way, the pager (and the open folder's)
now ignores the positions in between, until it arrives or a second has
passed. The user's own scrolling still moves the dots.
2026-10-23 20:15:12 -07:00
Nariman Jelveh 9f3818e5a7 fix(dashboard): don't hide the Apps grid on every search keystroke
Search re-rendered the grid behind the first load's fade-in, which waits
for page one's icons. Each keystroke made fresh <img> elements, so an
icon that was slow or failing (an unreachable icon host) blanked the
whole grid until it settled, about a second per keystroke with a 1s
icon and up to 1.5s if it never answers. Search, its clear button and
entering reorder mode now render the results at once.
2026-10-23 20:13:01 -07:00
Nariman Jelveh 9f3369406a perf(dashboard): launch the add-app modal's apps without reloading the grid
"Browse the App Center" and "Build with AI" go through the same intro
as a deep link, which first awaited loadApps(). With the grid already
loaded that started a fresh fetch of every app list and the saved
layout, and the launch waited on it: with an 800ms connection, ~1.35s
before anything opened instead of ~0.1s. The intro now uses the grid
already loaded, and only joins or starts a load when there is none.
2026-10-23 20:11:47 -07:00
Nariman Jelveh 40447f6c0e fix(dashboard): announce a failed Properties load, on both tabs
When Properties couldn't read the item, the General list showed the
error but stayed aria-busy, so screen readers kept it unannounced, and
the Versions tab stayed blank. The list is no longer busy once the
error shows, and Versions shows the same error.
2026-10-23 20:09:55 -07:00
Nariman Jelveh c87066e416 fix(dashboard): say "the other item" when one item is missing a grant
The share dialog's "Add to all" link told screen readers "Give bob
access to the other 1 items" when the person was missing from a single
item of the selection. That case now has its own singular string.
2026-10-23 20:09:23 -07:00
Nariman Jelveh 2637e2a310 fix(dashboard): keep Tab inside the touch context menu
The touch menu sheet is modal, but Tab and Shift+Tab left it for the
page behind the backdrop while it stayed open; closing it then no
longer returned focus, since focus was elsewhere. Tab and Shift+Tab now
move between its items like the arrow keys, wrapping at the ends.
2026-10-23 20:08:52 -07:00
Nariman Jelveh d7ca3e81aa fix(dashboard): don't treat a long tail after a dot as a file extension
Files middle-truncates long names and keeps everything after the last
dot whole as the extension. For "Report v1.2 final draft for the board
meeting" that kept ".2 final draft for the board meeting", so the tile
read "….2 final draft for the board meeting" and lost the start of the
name. Only a short tail without spaces now counts as an extension; this
one shortens to "Report v1.…d meeting".
2026-10-23 20:08:10 -07:00
Nariman Jelveh 8b89b9ce54 fix(dashboard): count folders, not items, in "via N folders"
A person who reaches a multi-item selection through more than one shared
ancestor gets a "via N folders" note in the share dialog, and N was the
number of items reached that way: five files under two shared folders
read "via 5 folders". It is now the number of distinct folders.
2026-10-23 20:07:11 -07:00
Nariman Jelveh fcc2a9706e fix(dashboard): warn that removing a team removes everyone in it
Removing a team from a single item's share dialog asked "Remove Design's
access to this item?", the wording used for one person. The desktop's
share window adds "Everyone in the team loses it."; the dashboard now
uses that confirmation for teams too.
2026-10-23 20:06:46 -07:00
Nariman Jelveh e95ef045d9 fix(gui): ignore clicks on a context menu's dividers
A divider is an <li> like the items, so a click on one (they're 11px
tall, easy to hit between two items) ran the item handler with no
action behind it and threw "Cannot read properties of undefined". The
item handler now skips dividers; the menu stays open, as a native
menu's separator does.
2026-10-23 20:06:34 -07:00
Nariman Jelveh 570012fa68 fix(gui): unbind each context menu's mousemove handler with the menu
The submenu-aim code bound a mousemove handler on the document for every
menu and submenu and never removed it. Each one kept its closed menu and
items alive and ran on every mouse move for the rest of the session; ten
right-clicks in Files left five such handlers behind. The handler now
comes off when its menu is removed. Moving diagonally into a submenu
still keeps it open.
2026-10-23 20:05:58 -07:00
Nariman Jelveh de62470062 fix(dashboard): mark rows shared after sharing more than 50 at once
The share dialog updates the Files rows' shared markers from the share
listings it reloads after a change, and doesn't reload them for more
than 50 items. Sharing such a selection, with a person or by link,
reported "Shared with bob on 55 items" while no row showed the marker
until the folder was reloaded. Those grants now mark their rows
directly.
2026-10-23 20:04:55 -07:00
Nariman Jelveh 4f1788be31 fix(dashboard): update Files' shared markers after the share dialog closes
The share dialog marks the Files rows it covers as shared or not from
the listings it reloads after each change, and skipped that whole reload
once it was closed. Closing it while a share or removal was still saving
left the row without its shared marker (or with a stale one) until the
folder was reloaded. The markers are now updated either way; only the
dialog's own list is skipped once it's gone.
2026-10-23 20:03:04 -07:00
Nariman Jelveh df55a4300e fix(dashboard): only offer share controls on grants the user can change
The backend lets an item's owner change or withdraw any share of it,
and anyone else only the grants they issued (or their own access). The
share dialog offered the level select and Remove on every row. Someone
the owner let share who removed a person the owner had added got
"Removed bob" while nothing changed, and lowering their level added a
second grant that changed nothing either.

The access list now records who issued each row's grants. A user who
doesn't own the selection gets the controls only on rows whose grants
they issued all of, plus Remove on their own row to leave; other rows
show their level as a tag.
2026-10-23 20:00:22 -07:00
Nariman Jelveh b3b6e1bcbc fix(dashboard): show a person's strongest grant in the share list
When someone held two grants on one item (the owner gave them view
access, and someone the owner let share gave them edit), the share
dialog showed whichever grant the listing returned first, so their row
could read "Can view" while they could edit. A second grant on the same
item now counts toward the row with the stronger of the two levels.
2026-10-23 19:58:49 -07:00
Nariman Jelveh 38d7fc45d7 fix(dashboard): tell assistive tech the Files Select toggle's state
The Select button only toggled a class, so a screen reader announced a
plain button with no state; it now carries aria-pressed. Sort and View
open menus and now say so with aria-haspopup.
2026-10-23 19:57:23 -07:00
Nariman Jelveh e72d98a8b9 perf(dashboard): don't rewrite unchanged Files names while resizing
Every width change (a window resize, collapsing the sidebar) re-fits the
item names, at most once a frame, and wrote each name's text back even
when it came out the same. Each write invalidated layout for the whole
list. With 300 rows that took ~6.4ms a frame in list view and ~4.9ms in
grid; skipping the unchanged writes brings it to ~0.7ms and ~3.2ms.
2026-10-23 19:56:47 -07:00
Nariman Jelveh c8b65d10d1 fix(dashboard): keep the Files marquee when an item arrives mid-drag
Adding a row clears the empty-folder notice by removing every non-row
child of the listing, and the selection marquee is one. A file added
from elsewhere (an upload, another tab, an app) while the user was
drawing a selection made the rectangle vanish under the pointer.
2026-10-23 19:54:53 -07:00
Nariman Jelveh 3277d59b7b fix(dashboard): don't drop a folder open during a Files refresh
A refresh of the folder on screen (after an upload or paste, Refresh, a
sort, or returning to the browser tab) set the same "already rendering"
flag that a navigation sets, so double-clicking a folder, a sidebar
folder, a crumb or Back during that round-trip did nothing, with no
spinner to explain it.

Opening another folder now takes over from a refresh in flight; the
refresh notices it was superseded and drops its result. A navigation
during another navigation is still ignored, as before.
2026-10-23 19:54:06 -07:00
Nariman Jelveh 7db01715d4 fix(dashboard): keep the Files selection across a sort or view change
Sorting by a column, switching between list and grid, or toggling Show
hidden lists the folder again, and the rebuild dropped the selection and
the shift anchor: select three files, sort by Size, and nothing was
selected. Only the refresh on returning to the browser tab carried them
across. That logic is now refreshKeepingSelection, and those three use
it too.
2026-10-23 19:50:24 -07:00
Nariman Jelveh 8a064e2298 fix(dashboard): reach Home's Shared and Trash rows from the keyboard
At 480px wide or less (a phone, or a desktop browser zoomed in) the
folder sidebar is hidden and Home lists Shared and Trash as rows instead.
Those rows only had a click handler, so a keyboard user couldn't get to
either. They are now focusable links that open on Enter or Space, with
the sidebar's focus ring.
2026-10-23 19:49:33 -07:00
Nariman Jelveh d240d362c3 fix(dashboard): keep keyboard focus on the Files breadcrumbs
Every listing rebuilds the breadcrumbs, which took a keyboard-focused
crumb with it: pressing Enter on one, or any refresh of the folder while
a crumb had focus, left focus on <body>, and the next Tab started over
from the top of the page. Focus now moves to the same folder's crumb,
or to the one for the folder just opened.
2026-10-23 19:48:29 -07:00
Nariman Jelveh e47735505b fix(dashboard): show a shortened Files name in full on hover
Grid tiles and a resized Name column shorten long names in the middle
("Quarterl…draft v2.xlsx"), and nothing showed the rest short of opening
Rename or Properties. A shortened name now carries the full one as its
tooltip.
2026-10-23 19:47:03 -07:00
Nariman Jelveh 79acc853d2 fix(dashboard): name folders in the Back/Forward history menus as the crumbs do
Holding Back or Forward listed each folder by its path's basename: `/`
was a blank entry, the Shared view read "shared", Trash stayed in
English, and a trashed folder showed its uid. The menus now use the same
names the breadcrumbs show.
2026-10-23 19:46:17 -07:00
Nariman Jelveh 27070e883d fix(dashboard): let Shift+Arrow shrink a Files selection
Shift+Arrow only ever added the next row, so after Shift+Down twice,
Shift+Up left all three rows selected instead of going back to two. A
run of Shift+Arrows now selects the range from the row it started on to
the cursor, on top of whatever was selected before the run, so moving
back shrinks it. A click or a plain arrow starts a new run.
2026-10-23 19:45:05 -07:00
Nariman Jelveh cd2afa5bdf feat(dashboard): open a Files row's menu with the Menu key or Shift+F10
Share, Download, Open With and Properties were reachable only by
pointer: the ⋯ button isn't focusable, and the keyboard's menu keys
fired a contextmenu on <body>, which the listing ignores. Both keys now
open the selection's menu at its row's ⋯ button, where arrow keys and
Enter already work.
2026-10-23 19:43:44 -07:00
Nariman Jelveh b298e948e2 fix(gui): show the failed-upload title without entities
The upload progress window writes its error title with innerText, but
was handed the HTML-encoded i18n string, so French showed "Échec de
l&apos;importation des fichiers". The Files tab's two upload paths and
the desktop's now pass the plain string.
2026-10-23 19:42:30 -07:00
Nariman Jelveh 01d2a146b2 fix(gui): show the "can't rename in Trash" alert without entities
Pressing F2 on a trashed item (on the desktop or in the dashboard's
Files tab) showed "This item can&apos;t be renamed because it&apos;s in
the trash": the message was encoded by i18n and then again by UIAlert.
2026-10-23 19:40:53 -07:00
Nariman Jelveh 9137ae93f6 fix(dashboard): clear the drop highlight when Escape cancels a drag
Escape ends a Files drag with a synthetic mouseup, which jQuery UI
treats as a release outside every target: it fires no `out`, so the
folder row under the pointer kept its dashed drop outline and tint (and
a sidebar folder or breadcrumb its highlight) after the drag was gone.
The Escape branch now takes those off.
2026-10-23 19:40:09 -07:00
Nariman Jelveh 0b7648fc1c fix(dashboard): don't paste or upload into the folder being left
Opening a folder clears the listing at once but only moves currentPath
when the new listing arrives. Ctrl+V, a file dropped from the computer
onto the pane, or Paste from the background menu during that wait went
into the folder the user had just left, and since the refresh then
showed the new one, the items seemed to vanish.

Those three now refuse while another folder is loading, as New Folder
already did.
2026-10-23 19:38:44 -07:00
Nariman Jelveh 611b7b0acc fix(dashboard): don't offer Cut on items the user can't move
A file's menu offered Cut on anything that wasn't immutable, and Shared
rows aren't marked immutable. Cutting a file someone shared with you, or
anything inside a read-only shared folder, then pasting it, ended in a
"Forbidden" alert and an emptied clipboard. Cut now needs the same
permission as Delete in that menu, as Ctrl+X and the selection's Cut
already do.
2026-10-23 19:37:12 -07:00
Nariman Jelveh f0b2defe4c fix(dashboard): tapping into a Files name field edits the name
On a phone or tablet, tapping the name field of a new folder or a rename
(which iOS needs before it shows the keyboard) reached the row's tap
handler, which opened the folder or launched the file and threw the edit
away. A long-press in the field opened the touch sheet instead of
selecting text, and on desktop a right-click in it showed the file menu
instead of the browser's Cut/Copy/Paste.

The row's tap and menu handlers now leave the name field alone.
2026-10-23 19:36:16 -07:00
Nariman Jelveh c667053afe fix(dashboard): keep keyboard focus inside the open phone drawer
Below 768px the sidebar is a modal drawer over a scrim, but opening it
left focus on the menu button, and Tab past its last entry walked into
the dimmed content behind the scrim. Opening it now focuses the current
page's entry, and Tab and Shift+Tab cycle within it while it is open.
2026-10-23 19:34:17 -07:00
Nariman Jelveh 36a614d74a fix(dashboard): don't show storage as 100% while there is room left
Home and Usage rounded the storage share to a whole percent, so an
account at 99.5% or more read "100%" while uploads still fit. The usage
meter already holds at 99% until the allowance is spent; that rule moves
into meterPercent and the storage meters use it too.
2026-10-23 19:33:18 -07:00
Nariman Jelveh 9631b45d77 fix(dashboard): save only the profile fields that were edited
Edit profile sent both the display name and the bio on every save, taken
from this session's copy of the profile. That copy comes from the cached
user and can be stale (the profile changed on another device) or empty
(the profile read failed), so changing only the bio wrote the old display
name back, and a save from an empty prefill erased the bio.

The dialog now sends just the fields whose value changed from what it
opened with, and closes without a request when nothing did.
2026-10-23 19:29:43 -07:00
Nariman Jelveh ce5e2d77a3 fix(dashboard): enable the token and 2FA cards once the email is confirmed
The dashboard had no handler for the `user.email_confirmed` socket event,
and Account and Security pick the token button's state and whether to
offer 2FA when they render. A user who confirmed their email in another
tab, or in this one through the confirmation prompt, kept a disabled
"Create token" and no 2FA card until a reload.

The dashboard now refreshes the user on that event and renders both tabs
again, as it already does after a temporary account is saved.
2026-10-23 19:28:03 -07:00
Nariman Jelveh 08ce88d5ec fix(dashboard): label credit amounts with a key meant for them
Home's allowance and the Usage tab's total and Credits column used the
`credits` key, which the translations render in its acknowledgments sense:
"5,000 Mitwirkende" (contributors) in German, "Авторы" in Russian,
"特别鸣谢" in Chinese. They now use `usage_credits`, which falls back to
English until it is translated, as `monthly_credits` already does.
2026-10-23 19:25:34 -07:00
Nariman Jelveh 2e6f9a5542 fix(dashboard): render in the user's saved language
The dashboard never read the account's preferences: only the desktop did,
on boot. Everyone saw it in English whatever language they had chosen,
until the Files tab's Show hidden toggle switched a running session over,
to the browser's language when none was saved, since that was the
fallback the toggle wrote back.

The desktop's preference loading moves into load_user_preferences, which
the dashboard now runs in the background. The first paint uses the
language this browser last loaded, so nothing waits on the read; a saved
language that differs reloads into it, and a changed Show hidden setting
refreshes the open folder. The tab names are read when the sidebar is
drawn rather than when the modules load. A failed read keeps the cached
preferences instead of resetting them to English.
2026-10-23 19:24:57 -07:00
30 changed files with 839 additions and 213 deletions

No files matched your search

@@ -323,14 +323,17 @@ export default class ContextMenuModal {
};
document.addEventListener('keydown', this.escapeKeyHandler);
// Arrow keys move between the enabled items, as in any menu.
// Arrow keys move between the enabled items, as in any menu. So does
// Tab: the sheet is modal, and focus mustn't reach the page behind.
this.arrowKeyHandler = (e) => {
const items = [...this.modal.querySelectorAll('.context-menu-item:not(:disabled)')];
if ( items.length === 0 ) return;
const current = items.indexOf(document.activeElement);
const forward = e.key === 'ArrowDown' || (e.key === 'Tab' && ! e.shiftKey);
const back = e.key === 'ArrowUp' || (e.key === 'Tab' && e.shiftKey);
let next;
if ( e.key === 'ArrowDown' ) next = (current + 1) % items.length;
else if ( e.key === 'ArrowUp' ) next = current <= 0 ? items.length - 1 : current - 1;
if ( forward ) next = (current + 1) % items.length;
else if ( back ) next = current <= 0 ? items.length - 1 : current - 1;
else if ( e.key === 'Home' ) next = 0;
else if ( e.key === 'End' ) next = items.length - 1;
else return;
@@ -66,7 +66,7 @@ describe('ContextMenuModal accessibility', () => {
{ label: 'Locked', disabled: true, action: () => {} },
{ label: 'New', items: [{ label: 'Folder', action: () => {} }] },
];
const press = (el, key) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
const press = (el, key, init = {}) => el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }));
it('is a menu of menuitems and separators', () => {
setTouchViewport(390);
@@ -109,4 +109,19 @@ describe('ContextMenuModal accessibility', () => {
press(document.activeElement, 'ArrowUp');
expect(label()).toBe('New');
});
it('keeps Tab and Shift+Tab inside the menu', () => {
setTouchViewport(390);
const menu = new ContextMenuModal();
menu.show(items, { left: 0, top: 0, width: 10, height: 10 });
const dialog = document.querySelector('.context-menu-modal-dialog');
const label = () => document.activeElement.querySelector('.context-menu-item-label')?.textContent;
press(dialog, 'ArrowDown');
press(document.activeElement, 'Tab');
expect(label()).toBe('New');
expect(press(document.activeElement, 'Tab')).toBe(false);
expect(label()).toBe('Open');
press(document.activeElement, 'Tab', { shiftKey: true });
expect(label()).toBe('New');
});
});
+3 -1
View File
@@ -31,7 +31,9 @@ import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
const TabAccount = {
id: 'account',
label: i18n('account'),
get label () {
return i18n('account');
},
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
html () {
+98 -26
View File
@@ -821,6 +821,35 @@ function showAddAppModal ({ $el_window }) {
/** An app id minted from a uuid (`app-<uuid>`), as website shortcuts are named. */
const GENERATED_APP_ID = /^app-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/**
* Scrolls a pager to a page. A smooth scroll passes over the pages between,
* which the pager's scroll handler must not read back as the current page
* (see settledPage) until it arrives, or within a second if the user takes
* over.
*/
function scrollPagerTo (scroller, page, smooth) {
const left = page * scroller.clientWidth;
clearTimeout(scroller._targetPageTimer);
scroller._targetPage = smooth && Math.abs(scroller.scrollLeft - left) > 1 ? page : null;
if ( scroller._targetPage !== null ) {
scroller._targetPageTimer = setTimeout(() => {
scroller._targetPage = null;
}, 1000);
}
scroller.scrollTo({ left, behavior: smooth ? 'smooth' : 'auto' });
}
/** The page a pager's scroll position shows, or null while scrollPagerTo is still on its way. */
function settledPage (scroller, pageCount) {
const idx = Math.round(Math.abs(scroller.scrollLeft) / (scroller.clientWidth || 1));
const page = Math.max(0, Math.min(pageCount - 1, idx));
if ( scroller._targetPage !== null && scroller._targetPage !== undefined ) {
if ( page !== scroller._targetPage ) return null;
scroller._targetPage = null;
}
return page;
}
/** Longest the grid waits on page one's icons before showing anyway. */
const ICON_REVEAL_DEADLINE_MS = 1500;
@@ -862,7 +891,9 @@ function revealWhenLoaded ($container) {
const TabApps = {
id: 'apps',
label: i18n('apps'),
get label () {
return i18n('apps');
},
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg>',
_apps: null,
@@ -991,16 +1022,18 @@ const TabApps = {
self.updateRunningDots($el_window);
});
// Results show at once: the first load's fade waits on page one's
// icons, and a slow one would blank the grid on every keystroke.
$el_window.on('input', '.myapps-search', function () {
self.updateSearchIcons($el_window);
self.renderApps($el_window);
self.renderApps($el_window, { instant: true });
});
// Clear search on cross click
$el_window.on('click', '.myapps-icon-clear', function () {
$el_window.find('.myapps-search').val('').focus();
self.updateSearchIcons($el_window);
self.renderApps($el_window);
self.renderApps($el_window, { instant: true });
});
// Handle app tile clicks. External apps carry a target link (their
@@ -1253,11 +1286,14 @@ const TabApps = {
// Mouse wheel / two-finger vertical swipe flips one page per gesture.
// Horizontal trackpad panning is left to the native scroller, whose
// snap points already handle it.
// jQuery wheel listeners are passive here (see initgui), and the
// scroller can't scroll vertically anyway, so nothing is prevented.
// A trackpad pinch arrives as ctrlKey wheel events: that's a zoom.
$el_window.on('wheel', '.myapps-pager-scroller', function (e) {
const oe = e.originalEvent;
if ( oe.ctrlKey ) return;
if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return;
if ( Math.abs(oe.deltaY) < 4 ) return;
e.preventDefault();
// Inertial scrolling keeps emitting events after the flip; treat
// everything within 150ms of the last event as the same gesture.
clearTimeout(self._wheelTimer);
@@ -1279,6 +1315,20 @@ const TabApps = {
// take over from there. While a folder is open the same arrows walk
// ITS tiles: the grid behind is inert, so focus must not be there.
$(document).off('keydown.myapps-keyboard').on('keydown.myapps-keyboard', function (e) {
// The Menu key or Shift+F10 on a focused tile opens its menu
// (Open, Uninstall, a folder's Ungroup) at the tile; browsers
// don't all send a contextmenu event for them.
if ( e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') ) {
const tile = document.activeElement;
if ( ! tile?.classList?.contains('myapps-tile') || ! $el_window[0].contains(tile) ) return;
e.preventDefault();
const r = tile.getBoundingClientRect();
$(tile).trigger($.Event('contextmenu', {
clientX: r.left + r.width / 2,
clientY: r.top + r.height / 2,
}));
return;
}
if ( ! ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Enter', ' '].includes(e.key) ) return;
if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return;
if ( ! $el_window.is(':visible') ) return;
@@ -1307,6 +1357,15 @@ const TabApps = {
// button behavior keep working.
if ( ae && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.tagName === 'SELECT' || ae.isContentEditable) ) {
const emptySearch = $(ae).hasClass('myapps-search') && ae.value === '';
// Enter in a search opens the first match, as in any launcher.
if ( e.key === 'Enter' && $(ae).hasClass('myapps-search') && ! emptySearch && ! e.isComposing ) {
const first = pageTiles.find(el => ! el.classList.contains('myapps-add-tile'));
if ( first ) {
e.preventDefault();
$(first).trigger('click');
}
return;
}
if ( e.key !== 'ArrowDown' && ! (emptySearch && e.key.startsWith('Arrow')) ) return;
} else if ( ! e.key.startsWith('Arrow') ) {
return;
@@ -1506,10 +1565,8 @@ const TabApps = {
ticking = true;
requestAnimationFrame(() => {
ticking = false;
const pageW = scroller.clientWidth || 1;
const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW);
const page = Math.max(0, Math.min(self._pageCount - 1, idx));
if ( page !== self._page ) {
const page = settledPage(scroller, self._pageCount);
if ( page !== null && page !== self._page ) {
self._page = page;
self.updatePagerUI($el_window);
}
@@ -1563,10 +1620,7 @@ const TabApps = {
const scroller = $el_window.find('.myapps-pager-scroller')[0];
if ( ! scroller || this._pageCount === 0 ) return;
const page = Math.max(0, Math.min(this._pageCount - 1, index));
scroller.scrollTo({
left: page * scroller.clientWidth,
behavior: (smooth && !this._reduceMotion()) ? 'smooth' : 'auto',
});
scrollPagerTo(scroller, page, smooth && ! this._reduceMotion());
// Programmatic scrolls don't reliably emit 'scroll' events (so the
// scroll-driven sync above can miss them); track the page eagerly.
this._page = page;
@@ -1618,7 +1672,7 @@ const TabApps = {
if ( on && String($search.val() || '') !== '' ) {
$search.val('');
this.updateSearchIcons($el_window);
this.renderApps($el_window);
this.renderApps($el_window, { instant: true });
}
$search.prop('disabled', on);
@@ -1793,11 +1847,12 @@ const TabApps = {
// Mouse wheel / two-finger vertical swipe flips one folder page per
// gesture, mirroring the grid's scroller (which see for the debounce).
// As the grid's: passive, and a pinch is a zoom.
$overlay.on('wheel', '.myapps-group-panel-grid', function (e) {
const oe = e.originalEvent;
if ( oe.ctrlKey ) return;
if ( Math.abs(oe.deltaX) > Math.abs(oe.deltaY) ) return;
if ( Math.abs(oe.deltaY) < 4 ) return;
e.preventDefault();
clearTimeout(self._groupWheelTimer);
self._groupWheelTimer = setTimeout(() => {
self._groupWheelActive = false;
@@ -1817,10 +1872,8 @@ const TabApps = {
ticking = true;
requestAnimationFrame(() => {
ticking = false;
const pageW = scroller.clientWidth || 1;
const idx = Math.round(Math.abs(scroller.scrollLeft) / pageW);
const page = Math.max(0, Math.min((self._groupPageCount || 1) - 1, idx));
if ( page !== self._groupPage ) {
const page = settledPage(scroller, self._groupPageCount || 1);
if ( page !== null && page !== self._groupPage ) {
self._groupPage = page;
self._updateGroupPagerUI($el_window);
}
@@ -1860,7 +1913,7 @@ const TabApps = {
// The folder is modal: Tab cycles inside it rather than walking
// off into the inert grid behind.
if ( e.key !== 'Tab' ) return;
const focusables = $overlay.find('.myapps-group-name, .myapps-tile').toArray()
const focusables = $overlay.find('.myapps-group-name, .myapps-tile, .myapps-group-dot').toArray()
.filter(el => el.offsetParent !== null);
if ( focusables.length === 0 ) return;
const first = focusables[0];
@@ -2037,10 +2090,7 @@ const TabApps = {
const scroller = $el_window.find('.myapps-group-panel-grid')[0];
if ( ! scroller || ! this._groupPageCount ) return;
const page = Math.max(0, Math.min(this._groupPageCount - 1, index));
scroller.scrollTo({
left: page * scroller.clientWidth,
behavior: (smooth && ! this._reduceMotion()) ? 'smooth' : 'auto',
});
scrollPagerTo(scroller, page, smooth && ! this._reduceMotion());
// Programmatic scrolls don't reliably emit 'scroll' events; track the
// page eagerly (same as goToPage).
this._groupPage = page;
@@ -2164,11 +2214,19 @@ const TabApps = {
// Dissolve a folder: its apps stay exactly where the folder was, side by
// side, so nothing is lost or moved somewhere the user has to find.
_ungroup ($el_window, groupId) {
if ( ! findGroupById(this._groups, groupId) ) return;
const group = findGroupById(this._groups, groupId);
if ( ! group ) return;
if ( this._openGroupId === groupId ) this._closeGroup($el_window);
this._groups = removeGroup(this._groups, groupId);
this.saveGroups();
this.renderApps($el_window, { preservePage: true, instant: true });
// The folder's tile, focused or not, is gone; a keyboard user lands
// on its first app instead of back at the top of the page.
if ( ! document.activeElement || document.activeElement === document.body ) {
$el_window.find('.myapps-page .myapps-tile').toArray()
.find(el => el.dataset.appName === group.apps[0])
?.focus({ preventScroll: true });
}
},
// Take one app out of a folder. It lands on the grid immediately after
@@ -2508,6 +2566,9 @@ const TabApps = {
if ( this._drag !== d ) return;
d.flipping = false;
this._updatePlaceholder(d.lastClientX, d.lastClientY);
// Still held at the edge: keep paging, as only a move would
// otherwise start the next dwell.
this._maybeEdgeFlip(d.lastClientX);
}, this._reduceMotion() ? 60 : DRAG_FLIP_SETTLE_MS);
}, DRAG_EDGE_DWELL_MS);
},
@@ -2552,6 +2613,7 @@ const TabApps = {
if ( this._drag !== d ) return;
d.flipping = false;
this._updatePlaceholder(d.lastClientX, d.lastClientY);
this._maybeGroupEdgeFlip(d.lastClientX);
}, this._reduceMotion() ? 60 : DRAG_FLIP_SETTLE_MS);
}, DRAG_EDGE_DWELL_MS);
},
@@ -3536,6 +3598,16 @@ const TabApps = {
// the tile mid-intro is swallowed instead of spawning a second instance
// (same guard as the click handler). The caller MUST call
// settleDeepLinkLaunch once its launch attempt settles.
/**
* The grid's apps: the load in flight, or the one already done. A launch
* from the grid (the add-app modal's options) must not wait on a fresh
* fetch of every app list first.
*/
_appsLoaded ($el_window) {
if ( Array.isArray(this._apps) && ! this._loadPromise ) return Promise.resolve();
return this.loadApps($el_window);
},
async beginDeepLinkLaunch (appName, $el_window, appInfoPromise = null) {
this._launchingApps.add(appName);
// No animations, no intro: the morph and the flourish would both
@@ -3545,7 +3617,7 @@ const TabApps = {
// hold the launch, and a failure just means the tile waits for the
// next refresh, as it always did).
if ( ! window.animate_window_opening || this._reduceMotion() ) {
Promise.resolve(this.loadApps($el_window))
Promise.resolve(this._appsLoaded($el_window))
.then(() => this._spliceDeepLinkApp($el_window, appName, appInfoPromise))
.catch(() => {});
return null;
@@ -3588,7 +3660,7 @@ const TabApps = {
// lets input cut this wait short.)
try {
await Promise.race([
this.loadApps($el_window),
this._appsLoaded($el_window),
sleep(Math.max(0, deadline - Date.now())),
]);
} catch ( _e ) { /* a failed load leaves _apps unset; handled below */ }
+193 -60
View File
@@ -98,7 +98,9 @@ export function failedUploadsMessage (err) {
*/
const TabFiles = {
id: 'files',
label: i18n('files'),
get label () {
return i18n('files');
},
icon: icons.files,
/**
@@ -134,9 +136,9 @@ const TabFiles = {
</div>
<div class="path-breadcrumbs"></div>
<div class="path-actions">
<button class="path-action-btn select-mode-btn" title="${i18n('select')}">${icons.select}</button>
<button class="path-action-btn sort-btn" title="${i18n('sort_by')}">${icons.sort}</button>
<button class="path-action-btn view-toggle-btn" title="${i18n('toggle_view')}">${icons.grid}</button>
<button class="path-action-btn select-mode-btn" title="${i18n('select')}" aria-pressed="false">${icons.select}</button>
<button class="path-action-btn sort-btn" title="${i18n('sort_by')}" aria-haspopup="menu">${icons.sort}</button>
<button class="path-action-btn view-toggle-btn" title="${i18n('toggle_view')}" aria-haspopup="menu">${icons.grid}</button>
<button class="path-action-btn new-folder-btn" title="${i18n('new_folder')}">${icons.newFolder}</button>
<button class="path-action-btn upload-btn" title="${i18n('upload')}">${icons.upload}</button>
</div>
@@ -289,7 +291,7 @@ const TabFiles = {
// If the directory was empty, drop the "No files in this directory."
// placeholder before inserting the first real row — otherwise it
// stays and overlaps the new item.
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row)').remove();
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
await _this.renderItem(file);
@@ -311,6 +313,7 @@ const TabFiles = {
};
this.renderingDirectory = false;
this.refreshInFlight = false;
// Count, not flag: creates are started from a button that answers
// instantly now, so two can easily overlap and the first one finishing
// must not uncover the second.
@@ -682,39 +685,53 @@ const TabFiles = {
// So would an open menu: its actions are bound to the old rows.
if ( $('.context-menu, .context-menu-modal-backdrop').length > 0 ) return;
// The rebuild also wipes selection, the keyboard/shift anchor,
// and scroll position — state the user expects to survive a quick
// trip to another browser tab. Carry it across by uid.
const $files = $filesTab.find('.files');
const scrollTop = $files.scrollTop();
const selectedUids = new Set($filesTab.find('.row.selected').map(function () {
return $(this).attr('data-uid');
}).get());
const anchorUid = window.latest_selected_item
? $(window.latest_selected_item).attr('data-uid')
: null;
await this.renderDirectory(this.currentPath, { skipNavHistory: true, skipUrlUpdate: true });
if ( selectedUids.size > 0 ) {
$filesTab.find('.row').each(function () {
if ( selectedUids.has($(this).attr('data-uid')) ) {
this.classList.add('selected');
}
});
this.updateFooterStats();
}
if ( anchorUid ) {
const anchorRow = $filesTab.find(`.row[data-uid="${anchorUid}"]`)[0];
if ( anchorRow ) {
window.latest_selected_item = anchorRow;
window.active_element = anchorRow;
}
}
$files.scrollTop(scrollTop);
await this.refreshKeepingSelection({ skipNavHistory: true, skipUrlUpdate: true });
});
},
/**
* Lists the folder on screen again. The rebuild wipes the selection, the
* keyboard/shift anchor and the scroll position, which a re-sort or a
* quick trip to another browser tab must not lose, so they're carried
* across by uid.
*
* @param {Object} [options] - Passed on to renderDirectory
* @returns {Promise<void>}
*/
async refreshKeepingSelection (options = {}) {
const $filesTab = this.$el_window.find('.files-tab');
const $files = $filesTab.find('.files');
const scrollTop = $files.scrollTop();
const selectedUids = new Set($filesTab.find('.row.selected').map(function () {
return $(this).attr('data-uid');
}).get());
const anchorUid = window.latest_selected_item
? $(window.latest_selected_item).attr('data-uid')
: null;
const folder = this.currentPath;
await this.renderDirectory(folder, options);
// The user opened another folder meanwhile.
if ( this.currentPath !== folder ) return;
if ( selectedUids.size > 0 ) {
$filesTab.find('.row').each(function () {
if ( selectedUids.has($(this).attr('data-uid')) ) {
this.classList.add('selected');
}
});
this.updateFooterStats();
}
if ( anchorUid ) {
const anchorRow = $filesTab.find(`.row[data-uid="${anchorUid}"]`)[0];
if ( anchorRow ) {
window.latest_selected_item = anchorRow;
window.active_element = anchorRow;
}
}
$files.scrollTop(scrollTop);
},
/**
* Called when the Files tab becomes active.
* Updates the URL hash to reflect the current file path.
@@ -817,6 +834,20 @@ const TabFiles = {
return false;
}
// Menu key / Shift+F10 - the selection's menu, anchored to the
// row's ⋯ button: the keyboard's only way to Share, Download or
// open Properties.
if ( e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') ) {
const row = $selectedRows.toArray().find(r => r === window.latest_selected_item) ?? $selectedRows[0];
if ( row ) {
e.preventDefault();
e.stopPropagation();
row.scrollIntoView({ block: 'nearest' });
row.querySelector('.item-more')?.click();
}
return false;
}
// Enter - Open selected items
if ( e.which === 13 && !$(focused_el).hasClass('item-name-editor') ) {
if ( $selectedRows.length > 0 ) {
@@ -859,6 +890,13 @@ const TabFiles = {
// Force jQuery UI to end the drag
$(document).trigger('mouseup');
// Ended this way, the drag never leaves the target it was
// over, so nothing undoes that target's highlight.
$('.files-tab .row.ui-droppable-hover:not(.dragging-source)').removeClass('selected');
$('.files-tab .ui-droppable-hover').removeClass('ui-droppable-hover');
$('.path-breadcrumbs .drop-target').removeClass('drop-target');
_this.updateSidebarSelection();
// Cleanup
$('.drag-cancel-zone').remove();
$('.item-selected-clone').remove();
@@ -991,7 +1029,7 @@ const TabFiles = {
if ( window.clipboard.length > 0 && _this.currentPath ) {
e.preventDefault();
e.stopPropagation();
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
if ( ! _this.acceptsNewItemsHere() ) {
return false;
}
_this.pasteClipboardInto(_this.currentPath);
@@ -1058,9 +1096,32 @@ const TabFiles = {
if ( ! e.shiftKey ) {
// Normal navigation - clear selection
$allRows.removeClass('selected');
$next.addClass('selected');
} else {
// A run of Shift+Arrows selects from where it started
// to the cursor, on top of what was selected before
// it, so stepping back shrinks the range again.
// Any other change to the selection starts a new run.
const run = _this.shiftArrowRun;
const continuing = run?.cursor === $current.get(0)
&& $allRows.index(run.anchor) !== -1
&& run.after.length === $selectedRows.length
&& run.after.every(row => row.classList.contains('selected'));
const { anchor, before } = continuing
? run
: { anchor: $current.get(0), before: $selectedRows.toArray() };
const anchorIndex = $allRows.index(anchor);
$allRows.removeClass('selected');
$(before).addClass('selected');
$allRows.slice(Math.min(anchorIndex, nextIndex), Math.max(anchorIndex, nextIndex) + 1)
.addClass('selected');
_this.shiftArrowRun = {
anchor,
before,
cursor: $next.get(0),
after: $allRows.filter('.selected').toArray(),
};
}
$next.addClass('selected');
window.active_element = $next.get(0);
window.latest_selected_item = $next.get(0);
$next.get(0).scrollIntoView({ block: 'nearest' });
@@ -1295,7 +1356,7 @@ const TabFiles = {
const history_item = window.dashboard_nav_history[index];
items.push({
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
html: `<span>${html_encode(_this.historyLabel(history_item))}</span>`,
val: index,
onClick: function (e) {
_this.goToHistoryPosition(e.value);
@@ -1332,7 +1393,7 @@ const TabFiles = {
const history_item = window.dashboard_nav_history[index];
items.push({
html: `<span>${history_item === window.home_path ? i18n('home') : html_encode(path.basename(history_item))}</span>`,
html: `<span>${html_encode(_this.historyLabel(history_item))}</span>`,
val: index,
onClick: function (e) {
_this.goToHistoryPosition(e.value);
@@ -1487,7 +1548,7 @@ const TabFiles = {
error: async function (err) {
const failedMessage = failedUploadsMessage(err);
if ( failedMessage ) UIAlert(failedMessage);
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
// remove from active_uploads
delete window.active_uploads[opid];
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
@@ -2030,15 +2091,20 @@ const TabFiles = {
names.forEach((el, i) => {
const fullName = el.closest('.row')?.getAttribute('data-name');
if ( ! fullName ) return;
let text;
if ( ! isList ) {
el.textContent = truncateFilenameToWidth(fullName, widths[i], font);
text = truncateFilenameToWidth(fullName, widths[i], font);
} else if ( nameWidth ) {
// 8px padding on each side
el.textContent = truncateFilenameToWidth(fullName, nameWidth - 16, font);
text = truncateFilenameToWidth(fullName, nameWidth - 16, font);
} else {
// Auto-width column: the full name, clipped by CSS
el.textContent = fullName;
text = fullName;
}
if ( el.textContent !== text ) el.textContent = text;
// A shortened name shows in full on hover.
const title = text === fullName ? '' : fullName;
if ( el.title !== title ) el.title = title;
});
},
@@ -2079,6 +2145,16 @@ const TabFiles = {
&& folderPath !== window.shared_path;
},
/**
* Whether the folder on screen takes new items. Not while another folder
* loads: until its listing arrives, currentPath is the one being left.
*
* @returns {boolean}
*/
acceptsNewItemsHere () {
return ! this.loadingFolder && this.acceptsNewItems(this.currentPath);
},
/**
* Updates header action buttons based on current folder context.
*
@@ -2387,7 +2463,7 @@ const TabFiles = {
// Drop the "No files in this directory." placeholder before inserting
// the first row, otherwise it stays and overlaps the new item.
this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row)').remove();
this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
await this.renderItem(placeholder);
const $row = this.$el_window.find(`.files-tab .files .item[data-uid='${placeholder.uid}']`);
@@ -2485,7 +2561,7 @@ const TabFiles = {
}
this.updateSortIndicators();
this.renderDirectory(this.currentPath);
this.refreshKeepingSelection();
// Persisting is best-effort and must not hold up the re-sort: awaited,
// the two writes added two round-trips before anything moved.
@@ -2524,8 +2600,11 @@ const TabFiles = {
* @returns {Promise<void>}
*/
async renderDirectory (target, options = {}) {
if ( this.renderingDirectory ) return;
if ( this.renderBlocked(target) ) return;
this.renderingDirectory = true;
// A newer render supersedes this one; past each await, a superseded
// render stops without touching the listing or the flags.
const seq = this.renderSeq = (this.renderSeq ?? 0) + 1;
const _this = this;
// The preview shows a row of the listing being replaced.
if ( this.previewOpen && target !== this.currentPath ) this.closeImagePreview();
@@ -2537,6 +2616,7 @@ const TabFiles = {
// read as flicker. Navigating to a different directory still clears
// immediately so the user gets instant feedback.
const isSameDirRefresh = typeof target === 'string' && target === this.currentPath;
this.refreshInFlight = isSameDirRefresh;
const $files = this.$el_window.find('.files-tab .files');
const prevScrollTop = isSameDirRefresh ? $files.scrollTop() : 0;
@@ -2558,6 +2638,7 @@ const TabFiles = {
if ( ! isSameDirRefresh ) {
clearListing();
this.showSpinner();
this.loadingFolder = true;
}
// Determine whether target is a path or uid
@@ -2583,8 +2664,10 @@ const TabFiles = {
readFailed = true;
directoryContents = [];
}
if ( seq !== this.renderSeq ) return;
if ( ! directoryContents ) {
this.hideSpinner();
this.loadingFolder = false;
this.renderingDirectory = false;
return;
}
@@ -2605,6 +2688,7 @@ const TabFiles = {
});
this.currentPath = path || target;
}
this.loadingFolder = false;
// Update browser URL to reflect current file path (only when Files tab is active)
if ( !options.skipUrlUpdate && window.is_dashboard_mode && this.isDashboardFilesActive() ) {
@@ -2627,7 +2711,17 @@ const TabFiles = {
const isTrashFolder = this.currentPath === window.trash_path;
this.updateActionButtons(isTrashFolder);
// Rebuilding the crumbs drops a focused one; keyboard focus stays on
// the same folder's crumb, else on the folder now open.
const focusedCrumb = document.activeElement?.matches('.path-breadcrumbs .dirname:focus-visible')
? document.activeElement.getAttribute('data-path')
: undefined;
$('.path-breadcrumbs').html(this.renderPath(this.currentPath, window.user.username));
if ( focusedCrumb !== undefined ) {
const $crumbs = $('.path-breadcrumbs .dirname');
const $same = $crumbs.filter((_, el) => el.getAttribute('data-path') === focusedCrumb);
($same[0] ?? $crumbs.last()[0])?.focus({ preventScroll: true });
}
this.revealCurrentCrumb();
$('.path-breadcrumbs .dirname').each(function () {
const dirnameElement = this;
@@ -2792,6 +2886,7 @@ const TabFiles = {
// resolve every icon first and append in sorted order. allSettled so
// one failure can't reject the batch and leave renderingDirectory stuck.
const iconResults = await Promise.allSettled(sortedContents.map(file => item_icon(file)));
if ( seq !== this.renderSeq ) return;
for ( let i = 0; i < sortedContents.length; i++ ) {
const iconResult = iconResults[i].status === 'fulfilled'
? iconResults[i].value
@@ -2870,6 +2965,9 @@ const TabFiles = {
row.setAttribute('data-path', place.path);
row.setAttribute('data-name', place.label);
row.setAttribute('data-is_dir', '1');
// The sidebar's Shared and Trash are keyboard-reachable; so are these.
row.setAttribute('tabindex', '0');
row.setAttribute('role', 'link');
row.innerHTML = `
<div class="item-checkbox"><span class="checkbox-icon"></span></div>
<div class="item-icon"><img src="${html_encode(place.icon)}"/></div>
@@ -3235,6 +3333,8 @@ const TabFiles = {
};
el_item.onclick = (e) => {
// A tap in the name field places the caret; it doesn't open the item.
if ( e.target.classList.contains('item-name-editor') ) return;
if ( e.target.classList.contains('item-more') ) {
if ( isPending() ) return;
this.handleMoreClick(el_item, file, e.target, lastPointerType === 'touch');
@@ -3428,6 +3528,12 @@ const TabFiles = {
// Right-click context menu handler (desktop) and taphold (touch devices)
$(el_item).on('contextmenu taphold', async (e) => {
// The name field keeps the browser's own menu and long-press
// text selection; the listing's menu mustn't take it either.
if ( e.target.classList.contains('item-name-editor') ) {
e.stopPropagation();
return;
}
// A taphold only counts when it came from an actual touch — the
// plugin (helpers.js) also fires it for a 1s mouse-button hold,
// which must stay inert, touchscreen or not.
@@ -3512,7 +3618,7 @@ const TabFiles = {
cursorAt: { left: -14, top: -12 },
cursor: 'grabbing',
revert: 'invalid',
zIndex: 10000,
zIndex: 999999999,
scroll: false,
distance: 5,
revertDuration: 100,
@@ -4138,7 +4244,7 @@ const TabFiles = {
// Refresh content to update icons for the new view mode
if ( this.currentPath ) {
this.renderDirectory(this.currentPath);
this.refreshKeepingSelection();
}
},
@@ -4183,10 +4289,10 @@ const TabFiles = {
if ( this.selectModeActive ) {
$filesTab.addClass('select-mode-active');
$selectBtn.addClass('active');
$selectBtn.addClass('active').attr('aria-pressed', 'true');
} else {
$filesTab.removeClass('select-mode-active');
$selectBtn.removeClass('active');
$selectBtn.removeClass('active').attr('aria-pressed', 'false');
// Clear all selections when exiting select mode
this.$el_window.find('.files .row.selected').removeClass('selected');
this.updateFooterStats();
@@ -4204,7 +4310,7 @@ const TabFiles = {
const $filesTab = this.$el_window.find('.files-tab');
const $selectBtn = this.$el_window.find('.select-mode-btn');
$filesTab.removeClass('select-mode-active');
$selectBtn.removeClass('active');
$selectBtn.removeClass('active').attr('aria-pressed', 'false');
// Clear all selections
this.$el_window.find('.files .row.selected').removeClass('selected');
this.updateFooterStats();
@@ -4326,11 +4432,23 @@ const TabFiles = {
* @param {string} newPath - The path to add to history
* @returns {void}
*/
/**
* Whether a render of `target` would be dropped: one is already loading,
* and it isn't a refresh of the folder on screen, which gives way to
* opening another folder.
*
* @param {string} target
* @returns {boolean}
*/
renderBlocked (target) {
return this.renderingDirectory && ! (this.refreshInFlight && target !== this.currentPath);
},
pushNavHistory (newPath) {
// renderDirectory drops a navigation while another is loading, so
// history must not move either, or Back/Forward would point at
// folders that were never shown.
if ( this.renderingDirectory ) return;
if ( this.renderBlocked(newPath) ) return;
// If history is empty, initialize with this path
if ( window.dashboard_nav_history.length === 0 ) {
window.dashboard_nav_history = [newPath];
@@ -4353,7 +4471,7 @@ const TabFiles = {
* @returns {void}
*/
goToHistoryPosition (position) {
if ( this.renderingDirectory ) return;
if ( this.renderBlocked(window.dashboard_nav_history[position]) ) return;
window.dashboard_nav_history_current_position = position;
this.updateNavButtonStates();
this.renderDirectory(window.dashboard_nav_history[position]);
@@ -4661,7 +4779,7 @@ const TabFiles = {
// The Shared view is a query, not a directory — nothing can be
// created, pasted or uploaded "into" it.
const isSharedView = targetPath === window.shared_path;
const canCreate = this.acceptsNewItems(targetPath);
const canCreate = folderPath ? this.acceptsNewItems(targetPath) : this.acceptsNewItemsHere();
const items = [];
// New submenu (folder, text document, etc.) - not available in Trash
@@ -4710,7 +4828,7 @@ const TabFiles = {
const result = await uploadPromise;
if ( targetPath === _this.currentPath ) {
// Remove empty-directory placeholder if present
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row)').remove();
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
// Add the new file incrementally
await _this.renderItem(result);
const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${result.uid}']`);
@@ -4815,7 +4933,7 @@ const TabFiles = {
window.mutate_user_preferences({
show_hidden_files: ! showHiddenFiles(),
});
_this.renderDirectory(_this.currentPath);
_this.refreshKeepingSelection();
},
});
@@ -5092,7 +5210,7 @@ const TabFiles = {
}
// No drop zone where nothing can be uploaded
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
if ( ! _this.acceptsNewItemsHere() ) {
return;
}
@@ -5124,7 +5242,7 @@ const TabFiles = {
return;
}
if ( ! _this.acceptsNewItems(_this.currentPath) ) {
if ( ! _this.acceptsNewItemsHere() ) {
return;
}
@@ -5218,7 +5336,7 @@ const TabFiles = {
error: async function (err) {
const failedMessage = failedUploadsMessage(err);
if ( failedMessage ) UIAlert(failedMessage);
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
delete window.active_uploads[opid];
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
},
@@ -5342,6 +5460,21 @@ const TabFiles = {
rows[0].scrollIntoView({ block: 'nearest' });
},
/**
* A folder's name in the Back/Forward history menus, as the breadcrumbs
* show it.
*
* @param {string} folderPath
* @returns {string} Plain text
*/
historyLabel (folderPath) {
if ( folderPath === window.home_path ) return i18n('home', [], false);
if ( folderPath === '/' ) return window.root_dirname;
if ( folderPath === window.shared_path ) return i18n('shared', [], false);
if ( folderPath === window.trash_path ) return i18n('trash', [], false);
return this.trashedNames.get(folderPath) ?? path.basename(folderPath);
},
/**
* Renders the breadcrumb path navigation HTML.
*
+30
View File
@@ -235,3 +235,33 @@ describe('TabFiles opening a folder that fails to list', () => {
expect(TabFiles.renderingDirectory).toBe(false);
});
});
describe('TabFiles opening a folder during a refresh', () => {
beforeEach(() => {
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
});
it('opens it, and the refresh gives way', async () => {
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
let finishRefresh;
window.puter = { fs: { readdir: vi.fn(({ path }) => (path === '/alice/Documents'
? new Promise(resolve => {
finishRefresh = resolve;
})
: Promise.resolve([]))) } };
const refresh = TabFiles.renderDirectory('/alice/Documents');
await TabFiles.renderDirectory('/alice/Pictures');
expect(TabFiles.currentPath).toBe('/alice/Pictures');
finishRefresh([]);
await refresh;
expect(TabFiles.currentPath).toBe('/alice/Pictures');
expect(TabFiles.renderingDirectory).toBe(false);
});
});
+8 -7
View File
@@ -19,7 +19,7 @@
import UIWindowSaveAccount from '../UIWindowSaveAccount.js';
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
import { appIconAttrs } from '../../helpers/appIcon.js';
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
@@ -197,7 +197,9 @@ function trialUsageNote(fraction, planName) {
const TabHome = {
id: 'home',
label: i18n('home'),
get label () {
return i18n('home');
},
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>',
html() {
@@ -626,10 +628,9 @@ const TabHome = {
try {
const res = await getStorageSpace();
// Guard capacity 0 — 0/0 would render literally as "NaN%".
let usage_percentage = res.capacity
? ((res.used / res.capacity) * 100).toFixed(0)
: '0';
usage_percentage = usage_percentage > 100 ? 100 : usage_percentage;
const usage_percentage = res.capacity
? Math.min(100, meterPercent((res.used / res.capacity) * 100))
: 0;
let general_used = res.used;
if (res.host_used) {
@@ -672,7 +673,7 @@ const TabHome = {
.find('.bento-resources-capacity')
.text(
inCredits
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'credits' : 'monthly_credits')}`
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'usage_credits' : 'monthly_credits')}`
: amount(budget.capacity),
);
$el_window
+15 -1
View File
@@ -26,7 +26,9 @@ import { openChangePasswordDialog } from './UIAccountDialogs.js';
const TabSecurity = {
id: 'security',
label: i18n('security'),
get label () {
return i18n('security');
},
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
html () {
@@ -115,6 +117,14 @@ const TabSecurity = {
},
init ($el_window) {
// These dialogs don't hand focus back when they close; a keyboard
// user would be left on <body>, at the top of the page.
const returnFocus = (el) => {
if ( ! document.activeElement || document.activeElement === document.body ) {
el?.focus({ preventScroll: true });
}
};
$el_window.find('.dashboard-section-security .change-password').on('click', function () {
openChangePasswordDialog({ $container: $el_window, returnFocusTo: this });
});
@@ -134,8 +144,10 @@ const TabSecurity = {
});
$el_window.find('.dashboard-section-security .manage-blocked-senders').on('click', function (e) {
const opener = this;
UIWindowBlockedSenders({
window_options: {
on_close: () => setTimeout(() => returnFocus(opener)),
parent_uuid: $el_window.attr('data-element_uuid'),
backdrop: true,
close_on_backdrop_click: true,
@@ -168,6 +180,8 @@ const TabSecurity = {
$toggle.prop('disabled', false);
$label.removeClass('dashboard-switch-pending');
// After the dialog has gone, which can trail its answer.
setTimeout(() => returnFocus(this), 250);
if ( ! succeeded ) {
$toggle.prop('checked', ! enabling);
+7 -6
View File
@@ -19,7 +19,7 @@
import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js';
import { isOrgSeat } from './orgSeat.js';
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
// Whether the server reported credits — decides whether the usage surfaces
@@ -38,7 +38,9 @@ const USAGE_TABLE_INITIAL_ROWS = 10;
const TabUsage = {
id: 'usage',
label: i18n('usage'),
get label () {
return i18n('usage');
},
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-speedometer2" viewBox="0 0 16 16"> <path d="M8 4a.5.5 0 0 1 .5.5V6a.5.5 0 0 1-1 0V4.5A.5.5 0 0 1 8 4M3.732 5.732a.5.5 0 0 1 .707 0l.915.914a.5.5 0 1 1-.708.708l-.914-.915a.5.5 0 0 1 0-.707M2 10a.5.5 0 0 1 .5-.5h1.586a.5.5 0 0 1 0 1H2.5A.5.5 0 0 1 2 10m9.5 0a.5.5 0 0 1 .5-.5h1.5a.5.5 0 0 1 0 1H12a.5.5 0 0 1-.5-.5m.754-4.246a.39.39 0 0 0-.527-.02L7.547 9.31a.91.91 0 1 0 1.302 1.258l3.434-4.297a.39.39 0 0 0-.029-.518z"/> <path fill-rule="evenodd" d="M0 10a8 8 0 1 1 15.547 2.661c-.442 1.253-1.845 1.602-2.932 1.25C11.309 13.488 9.475 13 8 13c-1.474 0-3.31.488-4.615.911-1.087.352-2.49.003-2.932-1.25A8 8 0 0 1 0 10m8-7a7 7 0 0 0-6.603 9.329c.203.575.923.876 1.68.63C4.397 12.533 6.358 12 8 12s3.604.532 4.923.96c.757.245 1.477-.056 1.68-.631A7 7 0 0 0 8 3"/> </svg>',
html: () => {
return `
@@ -271,7 +273,7 @@ function renderUsageTable () {
<tr>
${sortableTh('resource', i18n('resource'))}
<th>${i18n('units')}</th>
${sortableTh('cost', i18n(usageShowsCredits ? 'credits' : 'cost'))}
${sortableTh('cost', i18n(usageShowsCredits ? 'usage_credits' : 'cost'))}
</tr>
</thead>`;
@@ -320,7 +322,7 @@ async function update_usage_details ($el_window) {
$('#total-usage').html(amount(budget.used));
$('#total-capacity').html(inCredits
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'credits' : 'monthly_credits')}`
? `${amount(budget.capacity)} ${i18n(addonLeft === null ? 'usage_credits' : 'monthly_credits')}`
: amount(budget.capacity));
$('.usage-progbar-percent').html(`${budget.percent }%`);
$('.usage-progbar').css({
@@ -379,8 +381,7 @@ async function update_usage_details ($el_window) {
const spacePromise = getStorageSpace().then(res => {
// Guard capacity 0 — otherwise 0/0 renders literally as "NaN%".
let usage_percentage = res.capacity ? (res.used / res.capacity * 100).toFixed(0) : '0';
usage_percentage = usage_percentage > 100 ? 100 : usage_percentage;
const usage_percentage = res.capacity ? Math.min(100, meterPercent(res.used / res.capacity * 100)) : 0;
let general_used = res.used;
+20 -7
View File
@@ -66,6 +66,10 @@ const revalidating = (dialog) => () => dialog.setStatus(t('revalidate_sign_in_po
* @param {AccountDialogOptions & { onSave?: (profile: { displayName?: string|null, bio?: string|null }) => void }} [opts]
*/
export function openEditProfileDialog ({ onSave, ...opts } = {}) {
const initial = {
displayName: window.user?.profile?.displayName ?? '',
bio: window.user?.profile?.bio ?? '',
};
return UIDashboardFormDialog({
...opts,
title: t('edit_profile'),
@@ -74,24 +78,33 @@ export function openEditProfileDialog ({ onSave, ...opts } = {}) {
{
name: 'displayName',
label: i18n('display_name'),
value: window.user?.profile?.displayName ?? '',
value: initial.displayName,
attrs: { maxlength: DISPLAY_NAME_MAX_LENGTH, autocomplete: 'off' },
},
{
name: 'bio',
type: 'textarea',
label: i18n('bio'),
value: window.user?.profile?.bio ?? '',
value: initial.bio,
attrs: { maxlength: BIO_MAX_LENGTH, rows: 4 },
},
],
async onSubmit (dialog) {
const values = dialog.values();
// An empty field clears it.
const patch = {
displayName: values.displayName.trim() || null,
bio: values.bio.trim() || null,
};
// Only what was edited: the prefill comes from this session's copy
// of the profile, which can be stale, and sending an untouched
// field would write that copy over a newer one. An empty field
// clears it.
const patch = {};
for ( const key of ['displayName', 'bio'] ) {
if ( values[key].trim() !== initial[key].trim() ) {
patch[key] = values[key].trim() || null;
}
}
if ( ! Object.keys(patch).length ) {
dialog.close();
return;
}
// update_profile resolves with no profile when the request failed.
const profile = await window.update_profile(window.user.username, patch);
if ( ! profile ) {
@@ -82,13 +82,33 @@ describe('edit profile', () => {
expect(isOpen()).toBe(false);
});
it('sends only the fields that were edited', async () => {
window.update_profile = vi.fn(async (_username, patch) => patch);
dialogs.openEditProfileDialog();
await submitWith({ bio: 'New bio' });
expect(window.update_profile).toHaveBeenCalledWith('alice', { bio: 'New bio' });
expect(isOpen()).toBe(false);
});
it('closes without saving when nothing changed', async () => {
window.update_profile = vi.fn();
dialogs.openEditProfileDialog();
await submitWith({ displayName: ' Alice ' });
expect(window.update_profile).not.toHaveBeenCalled();
expect(isOpen()).toBe(false);
});
it('stays open with an error when the save fails', async () => {
window.update_profile = vi.fn(async () => undefined);
const onSave = vi.fn();
dialogs.openEditProfileDialog({ onSave });
await settle();
await submitWith();
await submitWith({ bio: 'Bye' });
expect(status()).toBe('profile_save_failed');
expect(onSave).not.toHaveBeenCalled();
+41 -5
View File
@@ -228,16 +228,21 @@ async function UIDashboard (options) {
}
}
// Saving a temporary account promotes this session's user in place: the
// save prompt goes, and the tabs that leave out cards for a temporary
// user (password, email) render again.
window.addEventListener('account-saved', () => {
$el_window.find('.bento-save-account-warning').remove();
// Account and Security decide some cards from the account's state when
// they render (password and email for a temporary user, the token
// button and 2FA for an unconfirmed email).
const rerender_account_tabs = () => {
for ( const tab of tabs ) {
if ( tab.id !== 'account' && tab.id !== 'security' ) continue;
$el_window.find(`.dashboard-section-${tab.id}`).html(tab.html());
tab.init?.($el_window);
}
};
// Saving a temporary account promotes this session's user in place.
window.addEventListener('account-saved', () => {
$el_window.find('.bento-save-account-warning').remove();
rerender_account_tabs();
});
// Dispatch 'dashboard-ready' event for extensions
@@ -285,6 +290,13 @@ async function UIDashboard (options) {
console.log('Dashboard Socket: Reconnection Failed');
});
// Confirming the email, here or in another tab, enables the token button
// and the 2FA card.
window.socket.on('user.email_confirmed', async () => {
await window.refresh_user_data(window.auth_token);
rerender_account_tabs();
});
// Upload/download progress tracking
window.socket.on('upload.progress', (msg) => {
if ( window.progress_tracker[msg.operation_id] ) {
@@ -594,16 +606,40 @@ async function UIDashboard (options) {
const $sidebar = $el_window.find('.dashboard-sidebar');
const $scrim = $el_window.find('.dashboard-sidebar-scrim');
const $sidebar_toggle = $el_window.find('.dashboard-sidebar-toggle');
const drawerFocusables = () => $sidebar.find('a[href], button').filter(':visible:not(:disabled)');
const setMobileSidebar = (open) => {
// Focus left inside a closing drawer would sit on hidden controls
// (and fall to <body>); the toggle that reopens it takes it instead.
const focusInside = ! open && $sidebar.hasClass('open') && $sidebar[0].contains(document.activeElement);
const opening = open && ! $sidebar.hasClass('open');
$sidebar.toggleClass('open', open);
$scrim.toggleClass('open', open);
$sidebar_toggle.toggleClass('open', open).attr('aria-expanded', open ? 'true' : 'false');
if ( focusInside ) $sidebar_toggle[0].focus({ preventScroll: true });
// The drawer is modal: focus starts on the current page's entry.
if ( opening ) {
const current = $sidebar.find('.dashboard-sidebar-item[aria-current="page"]')[0];
(current ?? drawerFocusables()[0])?.focus({ preventScroll: true });
}
};
// Tab cycles inside the open drawer instead of reaching the content
// behind the scrim.
$sidebar.on('keydown', function (e) {
if ( e.key !== 'Tab' || ! $sidebar.hasClass('open') ) return;
const $focusables = drawerFocusables();
if ( ! $focusables.length ) return;
const first = $focusables[0];
const last = $focusables[$focusables.length - 1];
if ( e.shiftKey && document.activeElement === first ) {
e.preventDefault();
last.focus();
} else if ( ! e.shiftKey && document.activeElement === last ) {
e.preventDefault();
first.focus();
}
});
$el_window.on('click', '.dashboard-sidebar-toggle', function () {
setMobileSidebar(!$sidebar.hasClass('open'));
});
@@ -287,7 +287,10 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
},
error: () => {
if ( closed ) return;
$list.html(`<div class="item-props-empty">${i18n('error_unknown_cause')}</div>`);
// Still marked busy, the list's error would go unannounced.
const failed = `<div class="item-props-empty">${i18n('error_unknown_cause')}</div>`;
$list.attr('aria-busy', 'false').html(failed);
$versions.html(failed);
},
})
// The error callback renders the failure; the promise rejects with it too.
+31 -12
View File
@@ -315,7 +315,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
if ( group.inheritedFrom ) {
notes.push(i18n('share_inherited_via', { folder: path.basename(group.inheritedFrom) }));
} else if ( group.inheritedPaths.length ) {
notes.push(i18n('share_inherited_via_count', { count: group.inheritedPaths.length }));
notes.push(i18n('share_inherited_via_count', { count: group.inheritedFolderCount }));
}
return notes;
};
@@ -329,9 +329,15 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// Only direct grants live on the items themselves; an inherited one
// belongs to the ancestor folder and has to be changed there.
// Every action addresses a person by name; a withheld invite has none.
const can_change = group.directPaths.length > 0 && ! group.anonymous;
const can_revoke = ! group.anonymous
&& (can_change || group.pendingPaths.length > 0);
// Someone the owner let share can only change or withdraw the grants
// they issued, and leave their own; on anyone else's the request
// would do nothing.
const may_act = ! group.anonymous
&& (allow_manage || group.issuers.every((issuer) => issuer === window.user.username));
const is_self = ! group.teamUid && ! group.pending && group.name === window.user.username;
const can_change = group.directPaths.length > 0 && may_act;
const can_revoke = (may_act && (can_change || group.pendingPaths.length > 0))
|| (is_self && group.directPaths.length > 0);
// Extending someone needs a mode to extend; a person whose grants
// disagree levels them with the select first.
const missing = missingPathsFor(target_paths, group).length;
@@ -346,7 +352,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
if ( notes.length ) via += `<span>${notes.join(' · ')}</span>`;
if ( can_add_to_all ) {
via += `<button type="button" class="share-modal-row-link" data-key="${key}"
aria-label="${i18n('share_add_to_all_for', { recipient: group.name, count: missing })}"
aria-label="${missing === 1
? i18n('share_add_to_all_for_one', { recipient: group.name })
: i18n('share_add_to_all_for', { recipient: group.name, count: missing })}"
>${i18n('share_add_to_all')}</button>`;
}
via += '</span>';
@@ -365,7 +373,9 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
// reader user unable to tell whose grant a control changes.
row += `<select class="share-modal-row-mode" data-key="${key}" aria-label="${i18n('share_access_level_for', { recipient: group.name })}">${options_for(group.mode, { allow_manage })}</select>`;
} else {
const fixed_mode = group.pending ? group.pendingMode : group.inheritedMode;
let fixed_mode = group.inheritedMode;
if ( group.pending ) fixed_mode = group.pendingMode;
else if ( group.directPaths.length ) fixed_mode = group.mode;
row += `<span class="share-modal-row-tag">${fixed_mode ? mode_label(fixed_mode) : i18n('share_access_mixed')}</span>`;
}
@@ -426,7 +436,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
const settled = await Promise.allSettled(
target_paths.map((target) => puter.fs.getShares(target)),
);
if ( closed || seq !== refresh_seq ) return;
if ( seq !== refresh_seq ) return;
/** @type {Map<string, Object[]>} */
const by_path = new Map();
@@ -439,15 +449,18 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
}
});
// Each listing is authoritative for its own item. The Files rows
// still need it when the dialog was closed while a change was saving.
for ( const [target, shares] of by_path ) {
mark_item_shared(target, has_direct_share(shares));
}
if ( closed ) return;
if ( by_path.size === 0 ) {
$list.attr('aria-busy', 'false').empty();
show_error(error_html(failure));
return;
}
// Each listing is authoritative for its own item.
for ( const [target, shares] of by_path ) {
mark_item_shared(target, has_direct_share(shares));
}
render(aggregateShares(target_paths, by_path));
link_state = linkShareState(target_paths, by_path);
render_link_access();
@@ -568,6 +581,11 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
() => puter.fs.share({ paths: run, recipient, mode }),
) ?? []));
}
// A selection too large to list again (see refresh) learns which rows
// are now shared from the grants themselves.
if ( total > MAX_ITEMS_PER_REQUEST ) {
for ( const share of created ) mark_item_shared(share.path, true);
}
return created;
};
@@ -801,9 +819,10 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
confirm_text = i18n('share_confirm_remove_items', { recipient: group.name, count: affected });
} else {
// "this item" only reads right when the dialog is about one item.
// A team's removal reaches all its members; the desktop says so too.
confirm_text = is_multi
? i18n('share_confirm_remove_plain', { recipient: group.name })
: i18n('share_confirm_remove', { recipient: group.name });
: i18n(group.teamUid ? 'share_confirm_remove_team' : 'share_confirm_remove', { recipient: group.name });
}
// One confirmation at a time: opening a second one restores the first
+6 -1
View File
@@ -18,6 +18,8 @@
*/
const ELLIPSIS = '…';
// Longer than any extension in common use (".markdown", ".numbers").
const MAX_EXTENSION_LENGTH = 10;
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
@@ -53,8 +55,11 @@ const largestFitting = (max, fits) => {
export function middleTruncate (filename, maxWidth, measure) {
if ( measure(filename) <= maxWidth ) return filename;
// Only a short, unbroken tail is an extension: in "Report v1.2 final
// draft" the text after the dot is part of the name.
const lastDot = filename.lastIndexOf('.');
const hasExtension = lastDot > 0 && lastDot < filename.length - 1;
const tail = filename.slice(lastDot + 1);
const hasExtension = lastDot > 0 && tail.length > 0 && tail.length <= MAX_EXTENSION_LENGTH && ! /\s/.test(tail);
const extension = hasExtension ? filename.slice(lastDot) : '';
const baseName = hasExtension ? filename.slice(0, lastDot) : filename;
@@ -44,6 +44,11 @@ describe('middleTruncate', () => {
expect(middleTruncate('abcdefghij.markdown', 10, measure)).toBe('….markdown');
});
it('treats a long or spaced tail after the last dot as part of the name', () => {
expect(middleTruncate('Report v1.2 final draft for board', 15, measure)).toBe('Report …r board');
expect(middleTruncate('backup.20240101-full-archive', 15, measure)).toBe('backup.…archive');
});
it('never cuts an emoji in half', () => {
const name = `x${'😀'.repeat(30)}.txt`;
for ( let width = 6; width <= 60; width++ ) {
+22 -2
View File
@@ -46,6 +46,10 @@ import { team_label } from '../../helpers/shareTeams.js';
* @property {string|null} inheritedMode - Mode of the inherited grants, null when they disagree
* @property {string|null} inheritedFrom - The one ancestor every inherited grant
* comes through, null when there is more than one
* @property {number} inheritedFolderCount - How many ancestors the inherited
* grants come through
* @property {string[]} issuers - Who issued the direct grants and invitations.
* Only the item's owner or a grant's issuer can change or withdraw it.
* @property {number} accessCount - Items the person can reach, by any of the three
*/
@@ -61,6 +65,12 @@ const uniform = (values) => {
return values.every((value) => (value ?? null) === first) ? first : null;
};
// Weakest to strongest.
const MODE_ORDER = ['see', 'list', 'read', 'write', 'manage'];
/** The stronger of two modes. */
const stronger = (a, b) => (MODE_ORDER.indexOf(b) > MODE_ORDER.indexOf(a) ? b : a);
/**
* Which of the three buckets a share row belongs to. Pending wins over
* inherited: an invitation carries no username to hang an inherited row on.
@@ -131,7 +141,8 @@ export const aggregateShares = (paths, sharesByPath) => {
for ( const item_path of paths ) {
// The same person can hold more than one grant on an item (different
// issuers), which must not count as reaching it twice.
// issuers), which must not count as reaching it twice. They hold the
// stronger of the two.
const counted = new Set();
for ( const share of sharesByPath.get(item_path) ?? [] ) {
@@ -140,7 +151,12 @@ export const aggregateShares = (paths, sharesByPath) => {
if ( ! identity ) continue;
const { key, name, teamUid, anonymous } = identity;
if ( counted.has(`${key}|${bucket}`) ) continue;
if ( counted.has(`${key}|${bucket}`) ) {
if ( bucket !== 'inherited' ) groups.get(key)._issuers.add(share.issuer);
const modes = groups.get(key)[`_${bucket}Modes`];
modes[modes.length - 1] = stronger(modes[modes.length - 1], share.mode);
continue;
}
counted.add(`${key}|${bucket}`);
if ( ! groups.has(key) ) {
@@ -157,9 +173,11 @@ export const aggregateShares = (paths, sharesByPath) => {
_pendingModes: [],
_inheritedModes: [],
_inheritedFroms: [],
_issuers: new Set(),
});
}
const group = groups.get(key);
if ( bucket !== 'inherited' ) group._issuers.add(share.issuer);
if ( bucket === 'pending' ) {
group.pendingPaths.push(item_path);
@@ -194,6 +212,8 @@ export const aggregateShares = (paths, sharesByPath) => {
pendingMode: uniform(group._pendingModes),
inheritedMode: uniform(group._inheritedModes),
inheritedFrom: uniform(group._inheritedFroms),
inheritedFolderCount: new Set(group._inheritedFroms).size,
issuers: [...group._issuers],
accessCount: reached.size,
};
});
@@ -88,6 +88,17 @@ describe('aggregateShares', () => {
expect(groups[0].inheritedMode).toBe('read');
});
it('counts the folders inherited grants come through, not the items', () => {
const groups = aggregateShares(['/me/D/a', '/me/D/b', '/me/D/c', '/me/P/d'], new Map([
['/me/D/a', [grant('ann', 'read', { inheritedFrom: '/me/D' })]],
['/me/D/b', [grant('ann', 'read', { inheritedFrom: '/me/D' })]],
['/me/D/c', [grant('ann', 'read', { inheritedFrom: '/me/D' })]],
['/me/P/d', [grant('ann', 'read', { inheritedFrom: '/me/P' })]],
]));
expect(groups[0].inheritedFolderCount).toBe(2);
});
it('keys an invitation by its email, apart from any username', () => {
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
['/me/a', [grant(null, 'read', { pending: true, recipientEmail: '[email protected]' })]],
@@ -137,6 +148,33 @@ describe('aggregateShares', () => {
expect(groups[0].accessCount).toBe(1);
});
it('shows the stronger grant when two issuers grant the same person', () => {
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
['/me/a', [
grant('ann', 'read', { issuer: 'me' }),
grant('ann', 'write', { issuer: 'bob' }),
]],
['/me/b', [
grant('ann', 'write', { issuer: 'bob' }),
grant('ann', 'read', { issuer: 'me' }),
]],
]));
expect(groups[0].mode).toBe('write');
});
it('lists who issued the grants and invitations, but not inherited ones', () => {
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
['/me/a', [
grant('ann', 'read', { issuer: 'me' }),
grant('ann', 'write', { issuer: 'bob' }),
]],
['/me/b', [grant('ann', 'read', { issuer: 'carl', inheritedFrom: '/me' })]],
]));
expect(groups[0].issuers).toEqual(['me', 'bob']);
});
it('lists people in the order the items first mention them', () => {
const groups = aggregateShares(['/me/a', '/me/b'], new Map([
['/me/a', [grant('zed', 'read')]],
+10 -2
View File
@@ -19,6 +19,15 @@
const nonNegative = (value) => Number.isFinite(value) ? Math.max(0, value) : 0;
/**
* A meter's share as a whole percent. 100% reads as spent, so a meter with
* anything left shows 99% at most.
*
* @param {number} share - The share, 0-100 (or past 100 when overspent).
* @returns {number}
*/
export const meterPercent = (share) => (share < 100 ? Math.min(99, Math.round(share)) : Math.round(share));
/**
* @typedef {Object} UsageBudget
* @property {number} used - Allowance-charged spend, in the server's units.
@@ -55,8 +64,7 @@ export const usageBudget = (usage, allowanceInfo) => {
return {
used,
capacity,
// 100% reads as spent: an allowance with anything left shows 99%.
percent: share < 100 ? Math.min(99, Math.round(share)) : Math.round(share),
percent: meterPercent(share),
barPercent: Math.max(0, Math.min(100, share)),
};
};
+19 -1
View File
@@ -18,7 +18,7 @@
*/
import { describe, expect, it } from 'vitest';
import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
import { addonCreditsRemaining, meterPercent, monthlyUsageOrThrow, usageBudget } from './usageBudget.js';
const info = (allowance, addons = {}) => ({
monthUsageAllowance: allowance,
@@ -161,3 +161,21 @@ describe('monthlyUsageOrThrow', () => {
expect(() => monthlyUsageOrThrow(undefined)).toThrow();
});
});
describe('meterPercent', () => {
it('holds at 99% until the meter is full', () => {
expect(meterPercent(99.5)).toBe(99);
expect(meterPercent(99.99)).toBe(99);
expect(meterPercent(100)).toBe(100);
});
it('rounds anything below that', () => {
expect(meterPercent(0)).toBe(0);
expect(meterPercent(42.5)).toBe(43);
expect(meterPercent(98.4)).toBe(98);
});
it('reports an overspent meter as it is', () => {
expect(meterPercent(130.2)).toBe(130);
});
});
+5 -2
View File
@@ -355,7 +355,10 @@
.mouseleave(mouseleaveRow)
.click(clickRow);
$(document).mousemove(mousemoveDocument);
// Comes off with the menu: left bound, it would keep the removed menu
// and its items alive and run on every mouse move for the session.
$(document).on('mousemove', mousemoveDocument);
$menu.on('remove', () => $(document).off('mousemove', mousemoveDocument));
};
})(jQuery);
@@ -638,7 +641,7 @@ function UIContextMenu (options) {
// An item is clicked. Bound on the menu, not delegated from document, so
// the handler (and the items its closure holds) goes when the menu does.
$(contextMenu).on('click', 'li:not(.context-menu-item-disabled)', function (e) {
$(contextMenu).on('click', 'li:not(.context-menu-item-disabled):not(.context-menu-divider)', function (e) {
// A double-click's second click lands while the menu fades out; the
// item has run already (opening a dialog twice, say).
if ( $(contextMenu).hasClass('context-menu-closing') ) {
+1 -31
View File
@@ -738,37 +738,7 @@ async function UIDesktop (options) {
window.history.pushState(null, document.title, window.location.pathname);
}
//show_hidden_files
let show_hidden_files = false;
try {
show_hidden_files = JSON.parse(await puter.kv.get('user_preferences.show_hidden_files'));
} catch (e) {
console.error('Error loading show_hidden_files', e);
}
// language
let language = 'en';
try {
language = await puter.kv.get('user_preferences.language');
} catch (e) {
console.error('Error loading language', e);
}
// update local user preferences
const user_preferences = {
show_hidden_files: show_hidden_files,
language: language,
};
// update default apps
{
const entries = await puter.kv.list('user_preferences.default_apps.*', true).catch(() => []);
for ( const entry of entries ) {
user_preferences[entry.key.substring(17)] = entry.value;
}
window.update_user_preferences(user_preferences);
}
await window.load_user_preferences();
// Append to <body>
$('body').append(h);
+1 -1
View File
@@ -1859,7 +1859,7 @@ window.activate_item_name_editor = async function (el_item) {
}
// files in trash cannot be renamed, user should be notified with an Alert.
else if ( path.dirname($(el_item).attr('data-path')) === window.trash_path ) {
UIAlert(i18n('items_in_trash_cannot_be_renamed'));
UIAlert(i18n('items_in_trash_cannot_be_renamed', [], false));
return;
}
// Someone else's item is renameable only with write on it (files, not
+23
View File
@@ -131,9 +131,12 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
w_body.className = 'session-manager-list';
modal.appendChild(w_body);
// The modal takes keyboard focus while it's up, and gives it back after.
const el_previous_focus = document.activeElement;
document.body.appendChild(backdrop);
// Next frame so the open transition runs.
requestAnimationFrame(() => backdrop.classList.add('open'));
el_close.focus({ preventScroll: true });
// Refresh handles — assigned once the list wiring below is in place,
// referenced by close(). Declared here so close() can see them.
@@ -150,6 +153,9 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
backdrop.classList.remove('open');
// Remove after the fade-out; guard against a missing transitionend.
setTimeout(() => backdrop.remove(), 200);
if ( modal.contains(document.activeElement) || document.activeElement === document.body ) {
el_previous_focus?.focus?.({ preventScroll: true });
}
};
// Escape: cancel an open confirm sheet if there is one, otherwise close
@@ -165,6 +171,23 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
};
document.addEventListener('keydown', onKeydown);
// Tab cycles inside the modal rather than reaching the page behind it.
modal.addEventListener('keydown', (e) => {
if ( e.key !== 'Tab' ) return;
const focusables = [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
.filter(el => ! el.disabled && el.offsetParent !== null);
if ( ! focusables.length ) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if ( e.shiftKey && document.activeElement === first ) {
e.preventDefault();
last.focus();
} else if ( ! e.shiftKey && document.activeElement === last ) {
e.preventDefault();
first.focus();
}
});
el_close.addEventListener('click', close);
backdrop.addEventListener('mousedown', (e) => {
if ( e.target !== backdrop ) return;
+162 -35
View File
@@ -63,8 +63,9 @@
--dashboard-avatar-background: #ddd;
--dashboard-input-background: rgb(245, 247, 249);
--dashboard-link: #5271ff;
--dashboard-link-hover: #3d5bd9;
/* Link text: 4.5:1 or better on the cards and the content background. */
--dashboard-link: #3d5bd9;
--dashboard-link-hover: #2f4bc0;
/* Primary button fills, under white text: 4.5:1 or better in both themes. */
--dashboard-button-primary: #4a67f5;
--dashboard-button-primary-hover: #3d5bd9;
@@ -85,9 +86,9 @@
--dashboard-success-background: #e6ffed;
--dashboard-success-border: #08bf4e;
--dashboard-success-text: #03933a;
--dashboard-success-text: #15803d;
--dashboard-danger-text: #dc2626;
--dashboard-danger-text: #b91c1c;
--dashboard-danger-background: #fef2f2;
--dashboard-danger-border: #fecaca;
--dashboard-error-text: #dc2626;
@@ -277,11 +278,12 @@ body {
}
.dashboard-sidebar {
width: 250px;
min-width: 200px;
/* The safe-area insets clear a landscape phone's notch; elsewhere they're 0. */
width: calc(250px + env(safe-area-inset-left, 0px));
min-width: calc(200px + env(safe-area-inset-left, 0px));
background: var(--dashboard-sidebar-background);
border-right: 1px solid var(--dashboard-border);
padding: 16px 12px;
padding: 16px 12px 16px calc(12px + env(safe-area-inset-left, 0px));
display: flex;
flex-direction: column;
box-sizing: border-box;
@@ -386,9 +388,9 @@ body {
drawer as an unlabeled icon strip. */
@media (min-width: 769px) {
.dashboard-sidebar.collapsed {
width: 50px;
min-width: 50px;
padding: 16px 6px;
width: calc(50px + env(safe-area-inset-left, 0px));
min-width: calc(50px + env(safe-area-inset-left, 0px));
padding: 16px 6px 16px calc(6px + env(safe-area-inset-left, 0px));
}
.dashboard-sidebar.collapsed .dashboard-sidebar-header {
@@ -503,6 +505,23 @@ body {
}
}
@media (pointer: coarse) {
/* Keeps its 32px look and hit-tests at 44x44. */
.dashboard-sidebar-close {
position: relative;
}
.dashboard-sidebar-close::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
.dashboard-sidebar-nav {
flex: 1;
min-height: 0;
@@ -637,7 +656,7 @@ body {
.dashboard-content {
flex: 1;
padding: 24px 32px;
padding: 24px calc(32px + env(safe-area-inset-right, 0px)) 24px 32px;
overflow-y: auto;
overflow-x: hidden;
background: var(--dashboard-content-background);
@@ -696,7 +715,7 @@ body {
/* Dashboard Apps */
.dashboard-content.apps {
padding: 0;
padding: 0 env(safe-area-inset-right, 0px) 0 0;
}
.dashboard-section-apps {
@@ -1594,6 +1613,11 @@ input.myapps-group-name:focus {
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
@media (pointer: coarse) {
.myapps-modal-btn {
min-height: 44px;
}
}
.myapps-modal-cancel {
background: var(--dashboard-card-background);
color: var(--dashboard-text-primary);
@@ -1604,9 +1628,9 @@ input.myapps-group-name:focus {
}
}
.myapps-modal-confirm {
background: #e53935;
background: #d32f2f;
color: #fff;
border-color: #e53935;
border-color: #d32f2f;
}
@media (hover: hover) {
.myapps-modal-confirm:hover {
@@ -1669,7 +1693,9 @@ input.myapps-group-name:focus {
.app-feedback-modal {
width: min(440px, 100%);
max-height: calc(100vh - 40px);
/* dvh: on phones 100vh is the viewport with the browser bars hidden,
taller than what's visible while they show. */
max-height: calc(100dvh - 40px);
overflow-y: auto;
background: var(--afb-bg);
color: var(--afb-fg);
@@ -1696,6 +1722,7 @@ input.myapps-group-name:focus {
font-weight: 600;
}
.app-feedback-x {
position: relative;
display: flex;
align-items: center;
justify-content: center;
@@ -1710,6 +1737,18 @@ input.myapps-group-name:focus {
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
@media (pointer: coarse) {
/* Keeps its 30px look and hit-tests at 44x44. */
.app-feedback-x::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
.app-feedback-x svg {
width: 16px;
height: 16px;
@@ -1828,22 +1867,21 @@ input.myapps-group-name:focus {
}
}
.app-feedback-btn-primary {
border-color: #088ef0;
background: linear-gradient(#34a5f8, #088ef0);
border-color: var(--dashboard-button-primary);
background: var(--dashboard-button-primary);
color: #fff;
}
@media (hover: hover) {
/* Must out-rank .app-feedback-btn:hover:not(:disabled) above, which
would otherwise repaint the primary button with the light hover bg. */
.app-feedback-btn.app-feedback-btn-primary:hover:not(:disabled) {
filter: brightness(1.05);
background: linear-gradient(#34a5f8, #088ef0);
border-color: var(--dashboard-button-primary-hover);
background: var(--dashboard-button-primary-hover);
}
}
.app-feedback-btn-primary:disabled {
opacity: 0.5;
cursor: default;
filter: none;
}
/* Cancel/X are disabled while a submit is in flight. */
.app-feedback-btn:disabled,
@@ -1885,7 +1923,7 @@ input.myapps-group-name:focus {
}
.app-feedback-modal {
width: 100%;
max-height: 92vh;
max-height: 92dvh;
border-radius: 14px 14px 0 0;
transform: translateY(16px);
/* Flush with the bottom edge: keep the actions above the home
@@ -2000,6 +2038,7 @@ input.myapps-group-name:focus {
margin: 0;
}
.myapps-add-back {
position: relative;
flex: none;
display: flex;
align-items: center;
@@ -2016,6 +2055,18 @@ input.myapps-group-name:focus {
color: var(--dashboard-text-secondary);
cursor: pointer;
}
@media (pointer: coarse) {
/* Keeps its 26px look and hit-tests at 44x44. */
.myapps-add-back::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
.myapps-add-back svg {
width: 17px;
height: 17px;
@@ -2178,6 +2229,7 @@ p.myapps-add-error {
color: var(--dashboard-text-muted);
}
.item-props-close {
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
@@ -2192,6 +2244,18 @@ p.myapps-add-error {
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
@media (pointer: coarse) {
/* Keeps its 32px look and hit-tests at 44x44. */
.item-props-close::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
@media (hover: hover) {
.item-props-close:hover {
background: var(--dashboard-hover);
@@ -3109,7 +3173,8 @@ select.share-modal-row-mode:focus {
padding-top: 9px;
padding-bottom: 9px;
}
.share-modal-submit {
.share-modal-submit,
.share-modal-copy-link {
height: 44px;
}
.share-modal-btn-quiet,
@@ -3542,7 +3607,7 @@ select.share-modal-row-mode:focus {
.profile-crop-modal {
width: 100%;
max-width: none;
max-height: 92vh;
max-height: 92dvh;
border-radius: 16px 16px 0 0;
border-bottom: none;
transform: translateY(24px);
@@ -3848,7 +3913,7 @@ body.myapps-reordering .myapps-tile {
so a vh-sized list overhung the clipped content box and its last rows could
never be scrolled into view. */
.dashboard-content.files {
padding: 0 0 0 10px;
padding: 0 env(safe-area-inset-right, 0px) 0 10px;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -3998,6 +4063,21 @@ body.myapps-reordering .myapps-tile {
padding: 8px;
margin: -4px;
}
/* Keeps its 32px look and hit-tests at 44x44. */
.dashboard-section-files .header .path-action-btn {
position: relative;
}
.dashboard-section-files .header .path-action-btn::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
@media (hover: hover) {
@@ -4068,8 +4148,10 @@ body.myapps-reordering .myapps-tile {
}
}
/* Keyboard focus on the folder sidebar, crumbs, and nav arrows */
/* Keyboard focus on the folder sidebar, crumbs, nav arrows, and Home's
Shared/Trash rows */
.dashboard-section-files .directories li:focus-visible,
.dashboard-section-files .files-tab .files .row.place-row:focus-visible,
.dashboard-section-files .header .path .dirname:focus-visible,
.dashboard-section-files .header .path-btn:focus-visible {
outline: 2px solid var(--select-ring);
@@ -4350,7 +4432,8 @@ body.myapps-reordering .myapps-tile {
.files-drag-ghost {
pointer-events: none;
z-index: 10000;
/* On body, above the dashboard window (see the sidebar tooltip). */
z-index: 999999999;
opacity: 0.95;
animation: files-drag-ghost-in 120ms ease-out;
}
@@ -4481,7 +4564,7 @@ body.myapps-reordering .myapps-tile {
position: absolute;
bottom: 32px;
right: 32px;
background: #ef4444;
background: #dc2626;
color: white;
padding: 16px 32px;
border-radius: 6px;
@@ -4495,7 +4578,7 @@ body.myapps-reordering .myapps-tile {
}
.drag-cancel-zone.drag-cancel-hover {
background: #dc2626;
background: #b91c1c;
transform: scale(1.05);
}
@@ -4837,6 +4920,22 @@ body.myapps-reordering .myapps-tile {
border-color: var(--select-ring);
}
/* Drop target and spring-load dwell, as in the list: the grid rules above
out-rank the shared ones. A pixel of padding pays for the wider border,
so the tile doesn't shift. */
.dashboard-section-files .files-tab .files.files-grid-view .row.folder.ui-droppable-hover,
.dashboard-section-files .files-tab .files.files-grid-view .row.folder.dwell-opening {
padding: 11px;
background-color: rgba(59, 130, 246, 0.1);
border: 2px dashed var(--select-color);
}
.dashboard-section-files .files-tab .files.files-grid-view .row.folder.dwell-opening {
background-image: linear-gradient(90deg, rgba(59, 130, 246, 0.15) 100%, transparent 100%);
background-size: 0% 100%;
background-repeat: no-repeat;
}
.dashboard-section-files .files-tab .files.files-grid-view .row .item-icon {
width: 150px;
height: 150px;
@@ -4940,6 +5039,11 @@ body.myapps-reordering .myapps-tile {
gap: 6px;
}
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row.folder.ui-droppable-hover,
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row.folder.dwell-opening {
padding: 7px;
}
.dashboard-section-files .files-tab .files.files-grid-view.files-grid-view-sm .row .item-icon {
width: 80px;
height: 80px;
@@ -5380,7 +5484,8 @@ body.myapps-reordering .myapps-tile {
/* Image preview popover */
.image-preview-popover {
position: fixed;
z-index: 9999;
/* On body, above the dashboard window (see the sidebar tooltip). */
z-index: 999999999;
background: var(--dashboard-background);
border: 1px solid var(--dashboard-border);
border-radius: 8px;
@@ -6990,7 +7095,9 @@ body.myapps-reordering .myapps-tile {
transform: scale(0.9);
opacity: 0;
transition: transform 200ms ease-in-out, opacity 200ms ease-in-out;
max-height: calc(100vh - 40px);
/* dvh: ContextMenu.js places the menu within the visible height, which
on phones is less than 100vh while the browser bars show. */
max-height: calc(100dvh - 40px);
display: flex;
flex-direction: column;
}
@@ -7058,6 +7165,12 @@ body.myapps-reordering .myapps-tile {
font-family: inherit;
}
@media (pointer: coarse) {
.context-menu-modal-dialog .context-menu-item {
min-height: 44px;
}
}
.context-menu-modal-dialog .context-menu-item:last-child {
border-bottom: none;
}
@@ -7837,6 +7950,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
}
.dashboard-notifications-close {
position: relative;
width: 30px;
height: 30px;
padding: 0;
@@ -7844,6 +7958,19 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
color: var(--dashboard-text-muted);
}
@media (pointer: coarse) {
/* Keeps its 30px look and hit-tests at 44x44. */
.dashboard-notifications-close::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
.dashboard-notifications-close svg {
width: 16px;
height: 16px;
@@ -8306,7 +8433,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
while one is open must still be seen. Under context menus. */
body.dashboard-mode .notification-container {
top: 12px;
right: 12px;
right: calc(12px + env(safe-area-inset-right, 0px));
z-index: 999999999;
}
@@ -8619,8 +8746,8 @@ body.dashboard-mode .notifications-close-all {
}
.dashboard-dialog-btn-danger {
border-color: #f00808;
background: #f00808;
border-color: #d90707;
background: #d90707;
color: #fff;
}
@@ -8635,8 +8762,8 @@ body.dashboard-mode .notifications-close-all {
}
.dashboard-dialog-btn-danger:hover {
background: #d90707;
border-color: #d90707;
background: #b80606;
border-color: #b80606;
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ try {
if ( window.user_preferences === null ) {
window.user_preferences = {
show_hidden_files: false,
language: navigator.language.split('-')[0] || navigator.userLanguage || 'en',
language: null,
};
}
+36 -5
View File
@@ -711,13 +711,18 @@ window.mutate_user_preferences = function (user_preferences_delta) {
window.update_user_preferences({ ...window.user_preferences, ...user_preferences_delta });
};
/**
* The language to render in for a saved `language` preference. The stored
* value can be anything, and i18n/Intl throw on some bad locales.
*/
window.resolve_language = (language) => (window.listSupportedLanguages().some(lang => lang.code === language)
? language
: 'en');
window.update_user_preferences = function (user_preferences) {
window.user_preferences = user_preferences;
localStorage.setItem('user_preferences', JSON.stringify(user_preferences));
// The stored value can be anything, and i18n/Intl throw on some bad locales.
const language = window.listSupportedLanguages().some(lang => lang.code === user_preferences.language)
? user_preferences.language
: 'en';
const language = window.resolve_language(user_preferences.language);
window.locale = language;
// Broadcast locale change to apps
@@ -727,6 +732,32 @@ window.update_user_preferences = function (user_preferences) {
}, { sendToNewAppInstances: true });
};
/**
* Read the account's saved preferences and apply them. A read that fails
* keeps what this browser last loaded.
*/
window.load_user_preferences = async function () {
const user_preferences = {
show_hidden_files: window.user_preferences.show_hidden_files,
language: window.user_preferences.language,
};
try {
user_preferences.show_hidden_files = JSON.parse(await puter.kv.get('user_preferences.show_hidden_files'));
} catch (e) {
console.error('Error loading show_hidden_files', e);
}
try {
user_preferences.language = await puter.kv.get('user_preferences.language');
} catch (e) {
console.error('Error loading language', e);
}
const entries = await puter.kv.list('user_preferences.default_apps.*', true).catch(() => []);
for ( const entry of entries ) {
user_preferences[entry.key.substring(17)] = entry.value;
}
window.update_user_preferences(user_preferences);
};
window.sendWindowWillCloseMsg = function (iframe_element) {
return new Promise(function (resolve) {
const msg_id = window.uuidv4();
@@ -2397,7 +2428,7 @@ window.upload_items = async function (items, dest_path) {
},
// error
error: async function (err) {
upload_progress_window.show_error(i18n('error_uploading_files'), err.message);
upload_progress_window.show_error(i18n('error_uploading_files', [], false), err.message);
// remove from active_uploads
delete window.active_uploads[opid];
},
@@ -281,7 +281,7 @@ const generate_file_context_menu = async function (options) {
// Cut
// -------------------------------------------
// Trashed items leave the Trash through Restore; moved any other way, they'd keep their uid as their name.
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed ) {
if ( $(el_item).attr('data-immutable') === '0' && !is_trashed && may_restructure ) {
menu_items.push({
html: i18n('cut'),
onClick: function () {
+2
View File
@@ -540,6 +540,7 @@ const en = {
share_add_to_all: 'Add to all',
share_add_to_all_for:
'Give {{recipient}} access to the other {{count}} items',
share_add_to_all_for_one: 'Give {{recipient}} access to the other item',
share_inherited_via_count: 'via {{count}} folders',
share_no_one_items: 'None of these items are shared yet.',
share_failed_items: 'Could not share these items.',
@@ -716,6 +717,7 @@ const en = {
uptime: 'Uptime',
used_of: '{{used}} used of {{available}}',
usage_subtitle: 'Puter resources and AI usage',
usage_credits: 'Credits',
usage_details: 'Usage Details',
usage_details_unavailable: 'Usage details are unavailable right now.',
usage_show_more: 'Show {{count}} more',
+14
View File
@@ -245,6 +245,20 @@ const postAuthActions = async (action) => {
// Dashboard mode
// -------------------------------------------------------------------------------------
else if ( window.is_dashboard_mode ) {
// Paint in the language this browser last loaded, so the dashboard
// doesn't wait on the saved preferences; a different saved language
// reloads into it.
window.locale = window.resolve_language(window.user_preferences.language);
const painted_language = window.locale;
const painted_hidden = !! window.user_preferences.show_hidden_files;
window.load_user_preferences().then(() => {
if ( window.locale !== painted_language ) {
location.reload();
} else if ( !! window.user_preferences.show_hidden_files !== painted_hidden
&& window.dashboard_object?.currentPath ) {
window.dashboard_object.renderDirectory(window.dashboard_object.currentPath);
}
});
const el_dashboard_promise = UIDashboard();
// Direct landing on /app/<name>: open the app in the dashboard the
// same way a tile launch does. The dashboard's route is slotted