Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 6b276a4823 fix(gui): stop disabling 2FA when the provider sign-in doesn't happen
For an account that signs in through a linked provider, turning 2FA off
opens the provider's sign-in popup. When the popup was closed or
blocked, the dialog showed why for a moment and then sent the request
anyway, whose refusal ("OIDC revalidation required") replaced the
reason. It now stops at the popup's error.
2026-10-24 14:44:26 -07:00
Nariman Jelveh 906ea0f7ee fix(gui): don't leave the 2FA dialogs stuck when a request can't be sent
Turning 2FA on or off from the dashboard's Security tab while offline,
or on a dropped connection, left the dialog busy for good: enabling kept
its spinner up with the code boxes disabled, and disabling kept its
button and password field disabled, with nothing said either way. A
request that gets no answer is now reported like a refused one, and the
dialog can be tried again.
2026-10-24 14:43:58 -07:00
Nariman Jelveh 54c0d48d69 fix(gui): show the 2FA setup's confirmations without entities
The 2FA setup window, which the dashboard's Security tab opens, encoded
already-encoded translations for its labels, so in French the two
checkboxes before enabling read "J'ai enregistré mes codes…" and
"Je suis prêt à activer l'A2F". The labels, and the window's other
translated text set the same way, are now encoded once.
2026-10-24 14:42:51 -07:00
Nariman Jelveh a3bf0bd150 fix(dashboard): undo a drag out of an Apps folder when the drag is cancelled
Dragging an app past the edge of an open folder takes it out, closes the
folder and carries the drag on over the grid, and the removal was saved
right then. Pressing Escape, or the window losing focus, cancels the
drag but left the app out; out of a two-app folder that dissolved the
folder for good, its name with it (dragging the app back made a new
"Folder"). A cancelled drag now puts the folder and the order back.
2026-10-24 14:42:21 -07:00
Nariman Jelveh 2f3b9ec581 fix(dashboard): re-read the Apps layout on returning to its browser tab
The Apps tab read its order and folders only when it was opened, and
saves write its whole copy of them. With the dashboard open in two
browser tabs, a folder made in one was wiped as soon as the other,
showing Apps since before, changed anything (a reorder, a new folder, a
rename), as was the order arranged in the first. The tab now reads the
layout again when its browser tab comes back into view.
2026-10-24 14:41:06 -07:00
Nariman Jelveh 55c0225c67 fix(dashboard): leave IME keystrokes to the IME in the share recipient field
With a Japanese or Chinese input method on, even a Latin username is
composed, and the share dialog's recipient field took the keys that
drive it: the arrows moved through its suggestions instead of the IME's
candidates, and Enter to confirm the text picked a suggestion instead.
Keys during composition are now left to the IME.
2026-10-24 14:40:10 -07:00
Nariman Jelveh 01b0014c87 fix(dashboard): leave IME keystrokes to the IME in Apps
Typing Japanese or Chinese in the Apps search or a folder's name, the
keys that drive the input method reached the tab's own handlers:
ArrowDown to pick a candidate moved focus to the first tile and cut the
conversion short, Enter to confirm one named the folder half-written or
launched the first match, and Escape put the old name back. The search
box's Enter meant to skip composition, but read isComposing off jQuery's
event, which never carries it. Keys during composition are now left to
the IME.
2026-10-24 14:39:46 -07:00
Nariman Jelveh 2590bfcd32 fix(dashboard): refresh Home's recently used apps on returning to the tab
Recent apps open in a new browser tab, and coming back to the dashboard
refreshed the usage cards but not "Recently used", whose comment said
it followed the same triggers: the app just opened only moved up the
list after switching away from Home and back. Returning to the tab now
re-reads the list too.
2026-10-24 14:39:01 -07:00
Nariman Jelveh 345f25d337 fix(dashboard): measure Home's storage meter by the amount it shows
On a self-hosted install the storage card names the disk's use (Puter's
files and everything else on it) but filled its meter, and gave its
percentage, from Puter's files alone, so it could read "93 GB used ·
0% of 466 GB". The meter and percentage now come from the same amount
the card shows.
2026-10-24 14:38:37 -07:00
Nariman Jelveh 329e02347e fix(dashboard): prefill the username when saving a session from Home
Home's "Save session" button opened Save Account with an empty username,
so a temporary user had to make one up, while the same action in the
user menu fills in the account's current name and starts at the email
field. Both now open it the same way.
2026-10-24 14:38:03 -07:00
Nariman Jelveh 09d0c5d640 fix(dashboard): link Home's card headers to their tabs
The headers of Home's cards (Recently used, Usage, Your Plan, Your
Storage, Resources) linked to "#", so opening one in a new browser tab
(middle-click, the browser's menu) showed Apps rather than the tab it
names, and Cmd/Ctrl+click switched tabs in place. They now link to the
tab they open, and a modified click is left to the browser, as on the
sidebar.
2026-10-24 14:37:39 -07:00
Nariman Jelveh b0c06f23c6 fix(dashboard): fill Home's storage and usage meters without waiting on the plan
Home read the plan first and only then asked for storage and the month's
usage, so every visit paid for one more round-trip before either meter
filled in. The plan request had no timeout either: one that never
answered kept both meters at "--" and, as later loads wait for the one
in flight, held every later refresh of the cards for the rest of the
session. The three now load side by side, and the plan read gives up
after 15 seconds.
2026-10-24 14:37:05 -07:00
Nariman Jelveh a67eea4ab2 fix(dashboard): close the drawer and Files' dialogs on browser Back and Forward
Browser Back or Forward switched tabs but left whatever was open on
screen: on a phone, Android's Back changed the tab under the open menu
drawer and left it open; in Files, the Share or Properties dialog
stayed up over Home, still acting on Files rows; an open context menu
stayed too. These now close with the tab, as with a sidebar click.
2026-10-24 14:36:23 -07:00
Nariman Jelveh bf8d9ea349 fix(dashboard): don't let a #files/<path> link pull Files back to its folder
Files doesn't keep the folder on screen in the URL, but a link like
/dashboard#files/alice/Documents stayed in the address bar once opened.
After the user moved on to Pictures, went to Home and pressed Back, the
browser returned to that link and Files jumped back to Documents; a
reload did the same, and Files' own Back/Forward lost track, as the jump
wasn't recorded in it. The link's path is now consumed once opened, as
a share link's is, and a folder opened from such a link is a step in
Files' history.
2026-10-24 14:35:41 -07:00
Nariman Jelveh 35aeb5189b fix(dashboard): let go of the phone drawer when the window widens
With the sidebar open as a drawer below 768px, widening the window past
it (an iPad turned to landscape) drew the sidebar as the desktop one but
left it marked open: Tab stayed trapped in it, so the content couldn't
be reached by keyboard, and the Apps grid ignored the arrow keys, until
an Escape or a click in the content. The drawer now closes when the
window widens, leaving focus where it is.
2026-10-24 14:34:32 -07:00
Nariman Jelveh e550ac1207 fix(dashboard): drop "Save session" when the account was saved in another tab
A temporary account saved in one browser tab stayed temporary-looking
in its other dashboard tabs: Home kept its "Save session" button, which
opened Save Account for an account already saved, and Account and
Security kept their temporary-account layout. Coming back to such a tab
re-reads the account; that now removes the button and redraws the two
tabs when the account is no longer temporary.
2026-10-24 14:34:00 -07:00
Nariman Jelveh 027280eec5 fix(dashboard): don't say "No recent apps yet" before Home's list has loaded
Home's "Recently used" card was drawn from the list as it stood when the
page opened, which is empty until the request answers: every visit
first read "No recent apps yet / Apps you use will appear here" and
then swapped in the apps, and if the request failed the card kept
saying there were none. It now stays blank until the list arrives, and
a first load that fails says the apps couldn't be loaded; a list
already shown stays when a later refresh fails.
2026-10-24 14:33:33 -07:00
Nariman Jelveh 0a5cd46373 fix(dashboard): open a recent app once on a double-click
Double-clicking an app under Home's "Recently used" opened it in two new
browser tabs, one per click. The second click of a double-click is now
ignored, as on the Apps tab's tiles.
2026-10-24 14:32:35 -07:00
Nariman Jelveh 344e7bf406 fix(dashboard): apply the hidden-files setting to a renamed Files row
With Show hidden off, renaming notes.txt to .notes.txt left the row in
the listing until the folder was next loaded, and with it on the row
wasn't dimmed as other dot-files are; renaming .env to env left it
dimmed. A rename made in another tab did the same. A renamed row now
follows the setting as listing the folder would.
2026-10-24 14:32:02 -07:00
Nariman Jelveh 95d846d13b fix(dashboard): move through Files with Home, End, Page Up and Page Down
The Files listing took the arrow keys but not Home, End, Page Up or Page
Down, and as the listing itself never has focus the browser didn't
scroll it on them either: in a folder of hundreds of items the keyboard
could only step through them one row at a time. Home and End now select
the first and last item, Page Up and Page Down move a screenful, and
Shift extends the selection as with the arrows. With a menu open they
are left to it, as the arrows are.
2026-10-24 14:30:37 -07:00
Nariman Jelveh 3c07591878 fix(dashboard): don't offer New Folder, Upload or Paste in a view-only share
Inside a folder shared with the user to view only, the Files tab still
showed New Folder and Upload, offered New and Paste in its menu, and
took files dropped on the listing or on a folder in it; each then
failed: an upload window ending in an error, or a new folder row with
its name field open, then taken back with a Forbidden alert. Those are
now left out there, as in Trash. A share whose level isn't known yet
stays as before, the server deciding.
2026-10-24 14:30:01 -07:00
Nariman Jelveh f8eaf3264c fix(dashboard): download one selected file as itself from the phone selection bar
In select mode, the floating bar's Download zipped whatever was
selected, so a single photo arrived as "photo.jpg.zip" where its own
menu's Download gives "photo.jpg". One selected file now downloads as
itself; folders and several items are still zipped.
2026-10-24 14:28:36 -07:00
Nariman Jelveh af05f0deb9 fix(dashboard): close the Files image preview when its file goes away
Deleting the previewed image (the Delete key, the menu, the phone
selection bar), moving it, or having it removed from another tab took
its row out of the listing but left the preview up, still showing the
image. The preview now closes with its row; a refresh of the folder,
which redraws the row, keeps it.
2026-10-24 14:28:07 -07:00
Nariman Jelveh 563b6b0c80 fix(dashboard): close the Files image preview when an app opens over it
The image preview sits above every window, and only a double-click
closed it on the way to an app. Opening the image with Enter, or with
Open or Open With from its menu, started the app underneath the
preview, which went on covering it while keyboard focus moved into the
app, so Space and Escape no longer reached the preview. It now closes
whenever an app window comes on screen.
2026-10-24 14:27:18 -07:00
Nariman Jelveh 0eea4cfb17 fix(gui): let Escape on a context menu close only the menu
Escape removed any open context menu and then went on to the Files tab's
own Escape handling, which, finding no menu left, cleared the selection
and closed the image preview. Select three files, open their menu,
press Escape: the menu closed and the selection was gone. When Escape
closes a menu, nothing else now acts on it; a second Escape clears the
selection as before.
2026-10-24 14:26:23 -07:00
Nariman Jelveh d748100ea9 fix(gui): leave Ctrl/Cmd+Z to the text field being typed in
Ctrl/Cmd+Z ran the file Undo wherever focus was, and cancelled the
field's own text undo. Typing a new name (F2) or anything in another
field and pressing it undid the last file action instead; when that was
an upload, the uploaded file was deleted for good. Undo now leaves text
fields alone.
2026-10-24 14:25:35 -07:00
Nariman Jelveh b63175b6d5 fix(dashboard): show 2FA as it is when its dialog closes without success
The Security tab's 2FA toggle assumed nothing had changed whenever its
dialog closed without success. With 2FA turned on in another tab, the
setup here refused ("2FA is already enabled."), the toggle went back to
off, and every retry got the same refusal, with Disable never offered;
a code accepted just as the user closed the setup dialog left the same
state. The tab now re-reads the account and shows its real 2FA state.
2026-10-24 14:24:57 -07:00
Nariman Jelveh c260e25f30 fix(dashboard): show the server's message when a session can't be revoked
Manage sessions put a refused request's raw response body in its alert,
so revoking a session already signed out from another tab read
{"message":"Session not found","code":"not_found"}, and the stale row
stayed listed; a failed rename and "Revoke all other sessions" did the
same. The alerts now show the message itself, and a session found gone
reloads the list.
2026-10-24 14:24:24 -07:00
Nariman Jelveh 6c35c2b895 fix(dashboard): let Escape cancel a session rename without closing Manage sessions
In Manage sessions (Security), Escape in a session's rename field
cancelled the rename and then closed the whole modal, and Escape in the
search box closed it too, search and all. Escape now cancels the rename
or empties the search, and only an Escape with neither closes the
modal.
2026-10-24 14:23:47 -07:00
Nariman Jelveh 5b8a93365c fix(dashboard): ask for the new password after a provider-only account sets one
An account that signs in only through a linked provider can set a
password with Change password, after which the server treats it as a
password account. This session's copy still said provider-only, so
Change username, Change email and Delete account went on hiding the
password field and sending the user through the provider's popup until
a reload. The copy now changes with the password.
2026-10-24 14:23:02 -07:00
Nariman Jelveh ffaed41ade fix(dashboard): don't send an account change to the value it already has
Change username with the account's own name got "This username is
already taken." and used up one of the hour's password attempts. Change
email with the current address was accepted: it mailed a confirmation
and a change notice to the same inbox, and following the link unlinked
any sign-in provider. Change password to the current password signed
out every other session and mailed a "password changed" notice. Each
dialog now says the value is already the account's and sends nothing.
2026-10-24 14:22:45 -07:00
Nariman Jelveh 94f4ed9eba fix(dashboard): say "Incorrect password" when the account password is wrong
The account dialogs showed the server's message as is. A wrong current
password in Change password read "Password mismatch", in English in
every language, beside a dialog that says "New Password and Confirm New
Password do not match" when those two differ, so users corrected the
wrong fields. A wrong code read "Incorrect code." and too many attempts
"Too many requests.", also always in English. These now show the
translated messages the GUI already has, and a wrong password puts the
status on the password field, focused for retyping.
2026-10-24 14:22:16 -07:00
Nariman Jelveh fd150cb475 fix(dashboard): drop into a drag-opened folder while it is still loading
When a dragged file springs a folder open, the drop anywhere in the
listing moves it into the folder now shown. Until that folder's listing
arrived, though, the Files tab still counted the folder the drag left as
the one on screen, so a drop in that time was taken as a move to where
the file already was and did nothing. It now goes to the folder being
opened.
2026-10-24 14:11:31 -07:00
Nariman Jelveh 4ca1fa5b63 fix(dashboard): return from a drag-opened folder while it is still loading
Hovering a dragged file over a folder springs it open. Cancelling the
drag (Escape, the Cancel zone, or a drop outside the listing) is meant
to go back where the drag started, but while the sprung folder was still
loading the return was dropped like any navigation during another, and
the user was left in the folder they had backed out of. The return now
takes over from that load.
2026-10-24 14:10:18 -07:00
Nariman Jelveh 251ceca2c9 fix(dashboard): give the Apps page dots a 24px touch target
The Apps pager's dots, and a folder's, are 17px buttons side by side,
under the 24px a target needs when it has neighbors, so on a phone or
tablet a tap meant for one page often opened the next. On a touch
screen each dot is now 24px around the same 7px dot; mouse layouts are
unchanged.
2026-10-24 14:08:56 -07:00
Nariman Jelveh 49d2b010e2 fix(dashboard): bring text in the selection blue up to AA contrast
The selection blue is 3.7:1 against white, and a few places set text in
it or white text on it, under the 4.5:1 that text needs: the active
folder in the Files sidebar, the share dialog's "Add to all" link, the
add-app dialog's Send button, the notification count badge, the drag
count badge, and a breadcrumb whose menu is open. They now use the same
hue darkened to 45% (5.3:1; a lighter one for text in dark mode), from
two new tokens, --select-text and --select-fill.
2026-10-24 14:08:28 -07:00
Nariman Jelveh 22febda3fa fix(dashboard): make the keyboard focus ring visible on sidebar, tiles and lists
The sidebar items, the Files folders, crumbs and arrows, the app tiles,
Home's cards and recent apps, the notification rows and the Usage sort
buttons show keyboard focus with a 2px outline and nothing else, drawn
in --select-ring: the selection color at 40% (50% dark), about 1.6:1
against the surfaces it sits on. Keyboard users could hardly tell where
focus was; a focus indicator needs 3:1. Those outlines now use the solid
selection color (3.7:1 on white), as the switch and pager dots already
do. Field halos, which come with a colored border, are unchanged.
2026-10-24 14:07:06 -07:00
Nariman Jelveh 6d8359de5c fix(dashboard): outline the off state of the 2FA switch
Off, the switch was a light grey track (#e0e0e0) with a white thumb,
1.3:1 against a white card and 1.2:1 against the amber card the 2FA
toggle sits on while 2FA is off, so the control could barely be seen
where it matters most. WCAG asks 3:1 for a control's boundary. The off
track now carries a 1px slate outline (4.3:1 on the amber card); the
on state is unchanged.
2026-10-24 14:06:29 -07:00
Nariman Jelveh 5d83d73a91 fix(dashboard): keep Share and Properties inside the visible screen
Both dialogs capped their height at 85vh (88vh as phone sheets), and vh
is the viewport with the browser's bars hidden. With the bars showing,
on a phone in landscape say, the card plus the overlay's padding was
taller than the screen, and the overlay centers without scrolling: the
title and the buttons were cut off with no way to reach them. Each card
is now also held within its overlay, as the dashboard's dialogs are,
and its body scrolls instead.
2026-10-24 14:05:44 -07:00
Nariman Jelveh dada49ffbb fix(dashboard): label collapsed sidebar items for keyboard users too
With the sidebar collapsed, each item shows only its icon, and its name
appeared in a tooltip on mouse hover alone. Tabbing through the sidebar
showed a row of icons with no names. The tooltip now also shows while
an item has keyboard focus.
2026-10-24 14:04:16 -07:00
Nariman Jelveh 75ed97e1d8 fix(dashboard): give the Delete account button an AA-contrast red
The Account tab's Delete account button used the desktop's danger red
(#f00808), which puts its white label at 4.4:1, under the 4.5:1 that
WCAG AA asks of text. It now uses the dashboard dialogs' danger red
(#d90707, 5.3:1) and their darker hover, so the two match.
2026-10-24 14:03:21 -07:00
Nariman Jelveh 3f4ac45975 fix(dashboard): skip disabled Back, Forward and Up when tabbing in Files
The Files header's arrows stayed in the Tab order while disabled, drawn
at 10% opacity, which takes their focus ring with them. Tabbing into
Files, the first two stops (Back and Forward, disabled on arrival)
looked like nothing had focus. A disabled arrow now leaves the Tab
order, as a disabled button does, and returns once it can be used.
2026-10-24 14:02:44 -07:00
Nariman Jelveh 1877e040a0 fix(dashboard): show the website-link badge on dark Files tiles
In grid view a file inside a published folder carries a link badge: a
black glyph on a disc of the page's background color. In dark mode that
is black on near-black and can't be seen. The badge now gets the white
disc the shortcut and worker badges already have.
2026-10-24 14:02:00 -07:00
Nariman Jelveh 1d8a7cb206 fix(dashboard): show the Files breadcrumb separators in dark mode
The chevrons between breadcrumbs are a black stroke at 20% opacity,
which vanishes on the dark header: a nested folder read "Home Documents
Work" with nothing between the names. In dark mode they are now
inverted, like the toolbar's icons.
2026-10-24 14:00:06 -07:00
Nariman Jelveh c89c191ab6 fix(dashboard): style the Usage table's Show less button in dark mode
The button's dark variant was written for an `html.dark-mode` class that
nothing sets, so in dark mode it kept its light look: grey text at 3.4:1
on the dark card, a light border, and a light chip on hover. Its rules
move under prefers-color-scheme, as the table's fade overlay already
did.
2026-10-24 13:59:49 -07:00
Nariman Jelveh 562e3ab9ac fix(dashboard): write Files and Properties dates in the dashboard's language
The Files tab's Modified column and Properties' Modified, Created and
version times came from timeago, which has no language but English
registered, so a German dashboard read "3 hours ago" beside "Geändert".
They now use the same localized formatter as the notification panel
("vor 3 Stunden"), which also gives a date past a week rather than
"3 weeks ago". Its formatters are now made once per language, since a
listing formats one date per row.
2026-10-24 13:59:13 -07:00
Nariman Jelveh 9af451a5d0 refactor(gui): move the localized time formatting to i18n/formatTime.js
formatRelativeTime and formatAbsoluteTime lived in the notification
center's module; Files and Properties are about to use them too.
2026-10-24 13:57:43 -07:00
Nariman Jelveh 853beb0b1b fix(dashboard): don't pull focus back after a share change saves
When a change in the share dialog finished saving (a level change, Add
to all, a removal, a share), focus was put back on that row's control or
the add field, wherever the user had gone meanwhile. Someone who changed
a person's level and then started typing a name had the next keystrokes
land on that person's level select, where a matching letter picked
another level and granted it. Focus now goes back only when the save
took it away; focus the user moved elsewhere stays there.
2026-10-24 13:57:06 -07:00
Nariman Jelveh acb306a82d fix(dashboard): say the access list couldn't load, not that sharing failed
When the share dialog couldn't read who has access (a network error,
which carries no message of its own), it said "Could not share this
item." though nothing had been shared or attempted. It now says "Could
not load who has access."
2026-10-24 13:55:49 -07:00
Nariman Jelveh 1537846e08 fix(dashboard): close the share dialog after leaving a share
Someone the owner let share could remove their own row in the share
dialog to leave. The removal went through, but the dialog then reloaded
its access list with the access just given up: the server refused it,
the success line gave way to "Subject does not exist" over an empty
list, and the item stayed in the Shared view until a reload. Leaving
now closes the dialog and refreshes the folder on screen, which drops
the item, as "Remove from Shared" does.
2026-10-24 13:55:08 -07:00
Nariman Jelveh b99b5c1bad fix(dashboard): show an email change confirmed in another tab
Confirming a change of email from its link sets the new address and
marks it confirmed, but sends no socket event, unlike confirming a code.
Back in the dashboard the user was re-read on returning to the browser
tab, yet nothing redrew: Account kept the old address under the
username, Create token stayed disabled and Security had no 2FA card
until a reload. Account and Security now redraw after that re-read when
the address or its confirmation changed.
2026-10-24 13:52:49 -07:00
Nariman Jelveh 92a5eae92d fix(dashboard): announce that a new API token is shown only once
After a token was created, focus moved to Done and a screen reader read
"Done, button" and nothing else: the done state arrives already filled
in a status region, which isn't announced, and Done is described by the
message only for plain-text results. The token dialog's warning ("Copy
this token now — it won't be shown again…") now describes Done, so it
is read out when focus lands there.
2026-10-24 13:51:40 -07:00
Nariman Jelveh a90cbd29b3 fix(dashboard): say why Create token is off where everyone can read it
When the account's email isn't confirmed, Create token is disabled and
the reason ("Verify your email address to create an API token.") lived
only in a hover tooltip: a disabled button isn't reachable by Tab, a
phone has no hover, and screen readers don't read a plain span's title,
so those users met a dead button with no explanation. The reason now
replaces the card's description while the button is off. The tooltip,
which encoded the already-encoded text again, now uses it as is.
2026-10-24 13:50:47 -07:00
Nariman Jelveh 93d1a60f33 fix(dashboard): write credit and dollar amounts in the dashboard's language
Credits were always formatted with English separators and dollar amounts
with "$" and English separators, so in German Home read "5,000
monatliche Credits", which a German reader takes as five, and Usage
showed "2.5" and "$1,234.50". Both now use the dashboard's language
("5.000", "2,5", "1.234,50 $"); English is unchanged.
2026-10-24 13:49:52 -07:00
Nariman Jelveh a24980c44e fix(dashboard): write Home's trial dates in the dashboard's language
The free-trial note on Home formats its dates with the browser's
language, not the one chosen in Puter, so with Puter in French and the
browser in English it read "…le Mar 5", and the other way round "First
charge on 5 mars". The dates now follow the dashboard's language. The
notification panel's mapping of Puter's language codes to Intl tags
(Brazilian Portuguese, Ukrainian, Traditional Chinese) moves to
i18n/intlLocale.js so both use it.
2026-10-24 13:48:44 -07:00
Nariman Jelveh a6f0d7c091 fix(dashboard): show placeholders in the Usage headers until they load
The Storage and Resources headers started out as " ·  used of " with the
numbers left blank, and stayed that way when reading the storage space
or the month's usage failed. They now start at "--% · -- used of --",
as Home's cards start at "--", and keep that if the read fails.
2026-10-24 13:47:41 -07:00
Nariman Jelveh 0bb656b582 fix(dashboard): don't let a slow picture removal undo a newer picture
Saving a profile picture is ordered so an older save can't put its
picture back over a newer one, but Remove picture stood outside that
order. Clicking Remove on a slow connection and then saving a new photo
before the removal answered left the default avatar on screen, and
Remove hidden, while the account had the new photo. A removal now
yields to a picture saved after it.
2026-10-24 13:46:32 -07:00
Nariman Jelveh 05b455fa09 fix(dashboard): don't offer Change email to a team seat
A team seat's Account tab offered Change email, and after the user
filled in the new address and their password the server refused it:
"Your team set this address. Ask a team admin to change it." The Email
card now shows that note in place of the button, as the Username card
already does for seats.
2026-10-24 13:44:29 -07:00
Nariman Jelveh d95cfd102c fix(dashboard): don't offer Change username to a temporary account
A temporary account's Account tab offered Change username, whose dialog
asks for an account password the account doesn't have: left empty it
said "All fields are required", and anything typed got a 403 from the
server, which refuses temporary accounts. The button is now left out
until the account is saved, as the Password card already is; saving it
re-renders the tab with the button.
2026-10-24 13:43:56 -07:00
Nariman Jelveh 10372d5824 fix(dashboard): keep keyboard focus on an Apps tile across a re-render
The grid is rebuilt wholesale whenever its data or size changes: the
refresh that follows opening the tab, a resize or zoom that changes how
many columns fit, an uninstall that fails. A keyboard user who had
arrowed onto a tile lost focus to <body>, so Enter did nothing and the
next arrow started over from the first tile. Focus now moves to the
same app's or folder's new tile.
2026-10-24 13:43:18 -07:00
Nariman Jelveh 1f0a199b34 fix(dashboard): tell screen readers when an Apps search finds nothing
Typing in the Apps search re-renders the grid silently, and the "No apps
match your search" line is drawn into it fresh each time, which screen
readers don't announce. A search that matched nothing gave no feedback
at all. A persistent status region now says so.
2026-10-24 13:42:36 -07:00
Nariman Jelveh de6b753a0b fix(dashboard): make the Apps search's clear control a button
The "×" that clears the Apps search was a bare SVG with a click handler:
Tab couldn't reach it, screen readers had nothing to announce, and on a
phone it was a 16px target inside the field. It is now a labelled button
after the field in the Tab order, with a focus ring, and on touch it
hit-tests at 44x44 while keeping its look.
2026-10-24 13:41:33 -07:00
Nariman Jelveh 96d10bfe3f fix(dashboard): open an app from the add-app dialog at once during a search
"Browse the App Center" and "Build with AI" play an intro from the
app's tile, and wait up to 3s for that tile to appear. With a search
that leaves the app out (the case the "+" tile stays for: a search that
found nothing), the tile never appears, so the app opened only after the
full 3s. The intro now gives up as soon as the search is what hides the
tile, and the app opens straight away.
2026-10-24 13:39:34 -07:00
Nariman Jelveh 12cf5b3a31 fix(dashboard): don't blank the Apps grid when it re-flows on resize
When a resize changed how many columns or rows fit (resizing the window,
collapsing the sidebar, zooming, an Android keyboard), the grid was
rebuilt behind the first load's fade, which waits on page one's icons.
With an icon host that was slow or unreachable the grid went blank for
up to 1.5s each time. A grid already on screen now re-flows at once;
one still loading keeps its fade.
2026-10-24 13:37:26 -07:00
Nariman Jelveh 60b9fc0366 fix(dashboard): act once on a held Enter or Space in Apps
Holding Enter on an Apps folder opened it, moved focus to its first app,
and the key's repeats then launched that app; held on the "+" tile, the
repeats picked "Browse the App Center" in the dialog it opened, and held
in the search box they launched the first match again and again. Key
repeats now do nothing on a tile, in the search box or on the add-app
dialog's options.
2026-10-24 13:36:26 -07:00
Nariman Jelveh 4c47fb048f fix(dashboard): keep arrow keys in the open phone drawer on Apps
With the drawer open over the Apps tab, an arrow key moved focus out of
it to the first app tile behind the scrim, and the next Tab then walked
the page behind it. The open drawer now keeps the arrow keys, as the
notifications panel and dialogs already do.
2026-10-24 13:34:19 -07:00
Nariman Jelveh 1913483f1c fix(dashboard): treat Ctrl+click on a Mac as a right-click in Files
On a Mac, Ctrl+click opens the context menu, but the Files rows read it
as a Ctrl-toggle first: Ctrl+clicking one of three selected files
deselected it and the menu then acted on that file alone, and on an
unselected file it was added to the selection. On a Mac it now leaves
the selection alone, as a right-click does; Cmd+click still toggles.
2026-10-24 13:33:33 -07:00
Nariman Jelveh b100f2f329 fix(dashboard): open one touch menu per long-press
A long-press on Android fires both contextmenu and taphold. The Files
rows already ignore the second, but a breadcrumb, a sidebar folder,
Home's Shared and Trash rows and the listing's empty space each opened a
second sheet over the first; picking an item or tapping the backdrop
then left the other one up. The touch menu now doesn't open while
another is on screen.
2026-10-24 13:28:09 -07:00
Nariman Jelveh 64e1a93a1a fix(dashboard): step through Files names on a repeated letter
Type-to-select meant to move past the selected row to the next name with
the same start, but returned early whenever the selected row matched, so
the stepping never ran: with readme, report-a and report-b, pressing "r"
again stayed on readme, and "rr" matched nothing. A repeated letter now
steps through the names it starts, wrapping around, as file managers do;
a longer prefix still stays on a selected row it matches.
2026-10-24 13:26:41 -07:00
Nariman Jelveh ef2547e520 fix(dashboard): don't drop New Folder during a Files refresh
New Folder did nothing while the folder on screen was refreshing (after
an upload or paste, or on returning to the browser tab). A refresh keeps
the rows up with no spinner, so the click just looked ignored. It now
takes over the refresh and makes the folder once the listing is in;
during a navigation to another folder it is still dropped, as before.
2026-10-24 13:25:53 -07:00
Nariman Jelveh cddcc6d39f fix(dashboard): let a focused folder or crumb take the Menu key
The Files tab answered the Menu key and Shift+F10 with the selection's
menu wherever focus was. With a file selected and a sidebar folder, a
breadcrumb or Home's Shared row focused, the key opened the file's menu;
with nothing selected it cancelled the browser's contextmenu, so the
focused element opened none. A focused control now gets the browser's
contextmenu event, and its own menu, as with a right-click.
2026-10-24 13:25:06 -07:00
Nariman Jelveh 6ec1c0aaf1 fix(dashboard): don't add a Files row to a folder opened meanwhile
A file added from elsewhere (an upload, another tab, an app) is checked
against the folder on screen and then drawn once its icon resolves. A web
link's or app's icon is read from the file, and opening another folder
during that read put the row into the new folder's listing, where it
stayed and opened the file from the old one. The checks now run again
once the icon is in.
2026-10-24 13:22:11 -07:00
Nariman Jelveh 97d25bb054 fix(gui): read the Trash's state fresh when updating its icon
refresh_trash_state stats the Trash to pick the empty or full icon, and
its comment calls for a strong read, but it asked for an eventual one.
Once a listing of Home had cached the Trash entry (without `is_empty`),
the stat came back without it: permanently deleting the last items from
Trash in Files left the full-Trash icon up, and other tabs were told the
same. The stat is now strong, as intended.
2026-10-24 13:21:15 -07:00
Nariman Jelveh 86304b1fbc fix(dashboard): show delete and log-out confirmations without entities
The Files tab's "delete these items permanently?" confirmation and the
dashboard's "close your apps and log out?" one passed HTML-encoded i18n
strings to UIAlert, which encodes its message and button labels again.
In Igbo both questions read "Ị ji n&#039;aka …". These alerts, and the
Files tab's name-conflict and folder-depth ones, now pass plain strings.
2026-10-24 13:19:01 -07:00
Nariman Jelveh 6df270a133 fix(dashboard): title a trashed folder's Properties with its name
Properties from the breadcrumb of a folder opened inside Trash was
titled with the folder's uid, the last segment of its trashed path,
while the crumb itself showed the original name. It now uses that name.
2026-10-24 13:17:53 -07:00
Nariman Jelveh 08c5ed405e fix(dashboard): give Files footer sizes in the rows' format
The footer used the desktop's byte formatter while the rows use their
own, so a 5-byte file read "5 B" in its row and "5.00 Bytes" below it,
1.5 KB read "1.50 KB", and a folder of folders read "0 Byte". The footer
now formats sizes as the rows do.
2026-10-24 13:16:25 -07:00
Nariman Jelveh e2545d1b9c fix(dashboard): show the phone selection bar when Select starts with a selection
Uploaded or pasted items arrive selected. Tapping Select on a phone then
turned select mode on with them selected but no floating action bar,
until a row was toggled. Entering select mode now brings the bar up for
the selection it starts with.
2026-10-24 13:16:06 -07:00
Nariman Jelveh 6acf64f31e fix(dashboard): keep the phone selection bar after cancelling a delete
In select mode on a phone, tapping Delete permanently and then Cancel
left every item selected but hid the floating action bar, the only way
to act on the selection, until the selection next changed. The same
happened when nothing in the selection could be moved to Trash. The bar
now goes away only once something is actually deleted or trashed.
2026-10-24 13:15:51 -07:00
Nariman Jelveh f7884986f4 fix(dashboard): don't drop a Files refresh asked for during another
A refresh of the folder on screen was ignored while another refresh of
it was loading. Drop two files: when the second finished during the
first's refresh, its own refresh was dropped, the earlier listing (read
before the file existed) was drawn, and the file's item.added was
ignored meanwhile too, so it stayed missing until a manual refresh.
Paste, Refresh and returning to the browser tab could lose items the
same way. A refresh now takes over from one in flight, as opening
another folder already did; a navigation in progress still holds.
2026-10-24 13:14:37 -07:00
Nariman Jelveh 62743d0a2e fix(dashboard): don't drop a Files selection onto one of its own folders
With folders A and B selected, dragging A onto B moved A into B and then
stopped on "Cannot move a directory into itself" for B. Hovering B also
sprang it open mid-drag. A folder row that is part of the drag is no
longer a drop target, so the drag reverts as it does over any other
non-target. A drop elsewhere (a sidebar folder, say) also leaves out the
target folder itself and any folder that holds it.
2026-10-24 13:13:26 -07:00
Nariman Jelveh 132a60d53e fix(dashboard): let Undo take back a Files cut and paste
Pasting cut items in the Files tab moves them itself rather than through
the desktop's move_items, and recorded nothing for Undo. Undo after a
paste reversed the action before it instead, which deletes an upload for
good. The paste now records the move as move_items does, so Undo puts
the items back where they were cut from.
2026-10-24 13:10:25 -07:00
Nariman Jelveh e8c3e38027 fix(dashboard): let Undo take back a new Files folder or file
The Files tab creates folders and New › files itself rather than through
the desktop's helpers, and never recorded them for Undo. Undo then
reversed whatever came before: upload notes.txt, create a folder, press
Ctrl+Z, and notes.txt was deleted for good (undoing an upload deletes it
outright, not to Trash) while the new folder stayed. Both now record
themselves, so Undo removes what was just created, as on the desktop.
2026-10-24 13:09:06 -07:00
Nariman Jelveh 79e1b3377e fix(dashboard): keep every unread notification in the panel
The socket replays up to 200 unread notifications on connect, but the
panel's listing held only the 30 newest, and the first refresh after that
dropped the rest: 45 unread read "45" until the panel was opened, then
"30", and "Mark all as read" left the other 15 unread on the server for
the next visit. The panel now keeps unread notifications past the newest
30, up to the 200 a listing returns.
2026-10-24 13:06:11 -07:00
Nariman Jelveh ea51080f06 fix(dashboard): don't let deploy results crowd notifications out of the panel
The panel listed the 30 newest notification rows and then dropped the
ones it never shows, a worker's deploy results among them. A developer
with 30 deploys since their last share saw "No notifications" and no
badge while the share sat behind them. The listing now asks for up to
200 rows and keeps the newest 30 the panel shows.
2026-10-24 13:04:50 -07:00
Nariman Jelveh 5a0c2db90f fix(dashboard): count "yesterday" and "N days ago" in calendar days
Notification times rounded the elapsed hours to whole days, so at 23:30
something from 00:45 the same day read "yesterday", and on Wednesday
morning something from Monday night (34 hours earlier) read "yesterday"
too. Days are now counted from midnight to midnight in the reader's time
zone; anything from today stays in hours.
2026-10-24 13:01:04 -07:00
Nariman Jelveh 83fd00d16d fix(dashboard): show notification times in Brazilian Portuguese, Ukrainian and Traditional Chinese
The notification panel and toasts handed Puter's language code straight to
Intl, and a few of those codes aren't BCP 47 tags. Brazilian Portuguese
(`br`) is Breton to Intl, so times read "5 munut zo"; Ukrainian (`ua`)
fell back to the browser's default language; and `zhtw` threw, so Taiwan
users got English. Those codes are now mapped to pt-BR, uk and zh-TW
before formatting.
2026-10-24 13:01:00 -07:00
39 changed files with 1583 additions and 389 deletions

No files matched your search

@@ -38,6 +38,12 @@ function isTouchPrimaryDevice () {
/** Widest the touch menu gets, so phones (90% of up to ~440px) are unaffected. */
const MAX_TOUCH_MENU_WIDTH = 400;
/**
* The menu on screen. A long-press fires both contextmenu and taphold, and
* the second must not stack another sheet over the first.
*/
let openMenu = null;
/** Labels of the destructive file actions, in the current language. */
const dangerLabels = () => new Set([i18n('delete'), i18n('delete_permanently')]);
@@ -67,6 +73,8 @@ export default class ContextMenuModal {
*/
show (menuItems, targetRect, options = {}) {
if ( this.backdrop ) return; // Already showing
if ( openMenu && document.contains(openMenu.backdrop) ) return;
openMenu = this;
this.menuItems = menuItems;
// Stack of parent menus for submenu drill-in, and the anchor rect so we
@@ -348,6 +356,7 @@ export default class ContextMenuModal {
*/
close () {
if ( ! this.backdrop ) return;
if ( openMenu === this ) openMenu = null;
// Remove event listeners
if ( this.backdropClickHandler ) {
@@ -125,3 +125,24 @@ describe('ContextMenuModal accessibility', () => {
expect(label()).toBe('New');
});
});
describe('ContextMenuModal stacking', () => {
it("doesn't open a second sheet over one already up", () => {
setTouchViewport(390);
openMenu();
openMenu();
expect(document.querySelectorAll('.context-menu-modal-backdrop')).toHaveLength(1);
});
it('opens again once the first is closed', () => {
vi.useFakeTimers();
setTouchViewport(390);
const first = new ContextMenuModal();
first.show([{ label: 'Open', action: () => {} }], { left: 0, top: 0, width: 10, height: 10 });
first.close();
openMenu();
vi.runAllTimers();
vi.useRealTimers();
expect(document.querySelectorAll('.context-menu-modal-backdrop')).toHaveLength(1);
});
});
+30 -16
View File
@@ -89,15 +89,20 @@ const TabAccount = {
h += `<span class="username">${html_encode(window.user.username)}</span>`;
h += '</div>';
h += '</div>';
// A seat's name is the team's; the console and audit log key on it.
if ( isOrgSeat(window.user) ) {
// A seat's name and address are the team's; the console and audit
// log key on the name, and admin-issued credentials go to the address.
const setByTeamNote = () => {
const team = orgSeatTeamName(window.user);
h += `<span class="dashboard-settings-card-note">${
return `<span class="dashboard-settings-card-note">${
team
? i18n('username_set_by_team', { team })
: i18n('username_set_by_team_generic')
}</span>`;
} else {
};
if ( isOrgSeat(window.user) ) {
h += setByTeamNote();
} else if ( ! window.user.is_temp ) {
// A temporary account can't change it until it is saved.
h += `<button class="button change-username">${i18n('change_username')}</button>`;
}
h += '</div>';
@@ -130,7 +135,9 @@ const TabAccount = {
h += `<span class="user-email">${html_encode(window.user.email)}</span>`;
h += '</div>';
h += '</div>';
h += `<button class="button change-email">${i18n('change_email')}</button>`;
h += isOrgSeat(window.user)
? setByTeamNote()
: `<button class="button change-email">${i18n('change_email')}</button>`;
h += '</div>';
}
@@ -140,21 +147,25 @@ const TabAccount = {
h += '<div class="dashboard-settings-card-icon">';
h += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>';
h += '</div>';
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('api_token')}</strong>`;
h += `<span>${i18n('api_token_description')}</span>`;
h += '</div>';
h += '</div>';
if ( window.user?.email_confirmed ) {
h += `<button class="button copy-auth-token">${i18n('create_token')}</button>`;
} else {
// "Verify your email" is a dead end for an account with none.
const tokenHint = window.user?.email
// "Verify your email" is a dead end for an account with none.
const tokenHint = window.user?.email_confirmed
? null
: window.user?.email
? i18n('verify_email_to_create_token')
: i18n('email_needed_to_create_token');
h += '<div class="dashboard-settings-card-info">';
h += `<strong>${i18n('api_token')}</strong>`;
// Why the button is off, where keyboard, touch and screen reader
// users can read it too, not only in a hover tooltip.
h += `<span>${tokenHint ?? i18n('api_token_description')}</span>`;
h += '</div>';
h += '</div>';
if ( ! tokenHint ) {
h += `<button class="button copy-auth-token">${i18n('create_token')}</button>`;
} else {
// Disabled buttons have `pointer-events: none`, so the tooltip
// lives on a wrapping span that still receives hover.
h += `<span title="${html_encode(tokenHint)}" style="cursor: not-allowed;">`;
h += `<span title="${tokenHint}" style="cursor: not-allowed;">`;
h += `<button class="button copy-auth-token" disabled>${i18n('create_token')}</button>`;
h += '</span>';
}
@@ -281,8 +292,11 @@ const TabAccount = {
const $btn = $(this);
const $hint = $el_window.find('.dashboard-section-account .dashboard-profile-hint');
$btn.prop('disabled', true);
// A picture saved meanwhile is newer than this removal.
const seq = ++avatar_save_seq;
// update_profile resolves with no profile when the request failed.
const profile = await update_profile(window.user.username, { picture: null });
if ( seq !== avatar_save_seq ) return;
$btn.prop('disabled', false);
if ( ! profile ) {
$hint.text(i18n('profile_picture_remove_failed', [], false));
+68 -6
View File
@@ -759,6 +759,12 @@ function showAddAppModal ({ $el_window }) {
})();
};
// Enter held on the "+" tile opens this with the first option focused;
// its repeats must not pick that option.
$overlay.on('keydown', '.myapps-add-option', function (e) {
if ( e.key === 'Enter' && e.originalEvent?.repeat ) e.preventDefault();
});
$overlay.on('click', '.myapps-add-option', function (e) {
// The second click of a double-click on the "+" tile lands on
// whichever option opened under the pointer; that isn't a choice.
@@ -938,11 +944,15 @@ const TabApps = {
h += '<div class="myapps-search-wrap">';
h += '<div class="myapps-search-inner">';
h += '<svg class="myapps-search-icon myapps-icon-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>';
h += '<svg class="myapps-search-icon myapps-icon-clear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:none"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
// type="search" (plus autocomplete/autofill opt-outs) keeps Chrome from
// offering email/contact autofill suggestions on focus.
h += `<input type="search" name="myapps-search" class="myapps-search" placeholder="${i18n('search_apps')}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" data-form-type="other" data-lpignore="true" data-1p-ignore>`;
// After the field, so Tab reaches it from there.
h += `<button type="button" class="myapps-search-icon myapps-icon-clear" aria-label="${i18n('clear_search')}" title="${i18n('clear_search')}" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
h += '</div>';
// Says when a search finds nothing; the grid itself is re-rendered
// silently.
h += '<div class="myapps-search-status" role="status" aria-live="polite"></div>';
// Touch devices' way into drag-to-reorder (CSS shows it only there);
// toggles the reorder mode — see _setReorderMode.
h += `<button type="button" class="myapps-reorder-btn" aria-pressed="false" aria-label="${i18n('edit_apps')}">${REORDER_BTN_ICON}</button>`;
@@ -1018,6 +1028,15 @@ const TabApps = {
// Tiles double as the app switcher for headless in-page apps: a
// dot marks tiles whose app has an open (or minimized) window.
// UIWindow fires this event on every window open/close.
// Another browser tab may have rearranged the grid or its folders
// meanwhile; this tab's copy would otherwise be what its next change
// writes back over them.
document.addEventListener('visibilitychange', () => {
if ( document.visibilityState !== 'visible' ) return;
if ( ! $el_window.find('.dashboard-section-apps').hasClass('active') ) return;
self.loadApps($el_window);
});
document.addEventListener('dashboard-app-windows-changed', () => {
self.updateRunningDots($el_window);
});
@@ -1315,6 +1334,9 @@ 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) {
// Keys typed into an IME (choosing a CJK candidate) are its own.
// jQuery's event doesn't carry isComposing; the browser's does.
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
// 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.
@@ -1337,8 +1359,8 @@ const TabApps = {
// An open context menu owns arrows and Enter (keyboard.js).
if ( $('.context-menu').length ) return;
// So does a dialog over the grid: the notifications panel, an
// account dialog, or the touch action sheet.
if ( $('.dashboard-notifications-panel.open, .dashboard-dialog-overlay, .context-menu-modal-backdrop').length ) return;
// account dialog, the touch action sheet, or the phone drawer.
if ( $('.dashboard-notifications-panel.open, .dashboard-dialog-overlay, .context-menu-modal-backdrop, .dashboard-sidebar.open').length ) return;
const $panelGrid = $el_window.find('.myapps-group-panel-grid');
const pageTiles = ($panelGrid.length
@@ -1358,7 +1380,8 @@ const TabApps = {
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 ) {
if ( e.key === 'Enter' && $(ae).hasClass('myapps-search') && ! emptySearch ) {
if ( e.originalEvent?.repeat ) return;
const first = pageTiles.find(el => ! el.classList.contains('myapps-add-tile'));
if ( first ) {
e.preventDefault();
@@ -1377,7 +1400,9 @@ const TabApps = {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
$(ae).trigger('click');
// A held key acts once: opening a folder moves focus to its
// first app, which a repeat would then launch.
if ( ! e.originalEvent?.repeat ) $(ae).trigger('click');
return;
}
@@ -1422,7 +1447,11 @@ const TabApps = {
self.goToPage($el_window, self._page, false);
return;
}
self.renderApps($el_window, { preservePage: true });
// A grid already on screen re-flows at once rather than
// blinking back through the load fade (see _spliceDeepLinkApp).
const revealed = $el_window.find('.myapps-pager').length > 0
&& $el_window.find('.myapps-pager-loading').length === 0;
self.renderApps($el_window, { preservePage: true, instant: revealed });
}, 100);
});
self._resizeObserver.observe($el_window.find('.myapps-container')[0]);
@@ -1509,6 +1538,9 @@ const TabApps = {
const emptyNotice = items.length === 0
? (query ? buildNoMatchesHtml() : buildNoAppsHtml())
: '';
const status = query && items.length === 0 ? i18n('no_apps_match', [], false) : '';
const $status = $el_window.find('.myapps-search-status');
if ( $status.text() !== status ) $status.text(status);
// The add-an-app tile rides at the tail of the grid: after every app,
// on the last page, and nowhere else — a drag can't place an app past
@@ -1535,7 +1567,17 @@ const TabApps = {
this._pageCount = Math.ceil(items.length / layout.perPage);
this._page = Math.min(Math.floor(anchorIndex / layout.perPage), this._pageCount - 1);
// Every tile is replaced; a keyboard user on one stays on it.
const focused = document.activeElement;
const focusedTile = focused?.classList.contains('myapps-tile') && $container[0].contains(focused)
? tileIdentity(focused)
: null;
$container.html(emptyNotice + buildPagerHtml(items, layout, instant));
if ( focusedTile ) {
$container.find('.myapps-tile').toArray()
.find(el => tileIdentity(el) === focusedTile)
?.focus({ preventScroll: true });
}
// A tile mid-arrival (a deep-link landing installing its app — see
// _spliceDeepLinkApp) stays parked invisible across re-renders; the
// intro's arrival beat, not the render, is what reveals it.
@@ -1936,6 +1978,9 @@ const TabApps = {
// Enter commits (and gets out of the way); blur commits too, so a
// click straight onto an app in the folder keeps the new name.
$name.on('keydown', function (e) {
// Enter and Escape that confirm or cancel an IME conversion
// aren't the name's.
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
if ( e.key === 'Enter' ) {
e.preventDefault();
// The name box owns this Enter: the grid's document-level key
@@ -2451,6 +2496,9 @@ const TabApps = {
// the drag saves only the final position.
_ejectToGrid (d) {
const appName = d.tileEl.dataset.appName;
// The eject is saved now, mid-drag; a drag cancelled after it puts
// this back (see _endDrag).
const preEject = { groups: JSON.parse(JSON.stringify(this._groups)), apps: this._apps.slice() };
if ( ! this._ejectApp(d.$el_window, d.groupId, appName) ) return;
this._closeGroup(d.$el_window);
this.renderApps(d.$el_window, { preservePage: true, instant: true, duringDrag: true });
@@ -2463,6 +2511,7 @@ const TabApps = {
this._endDrag(false);
return;
}
d.preEject = preEject;
// A folder-page flip armed just before the icon left the card must
// not fire into the grid drag this has become.
@@ -2912,6 +2961,16 @@ const TabApps = {
// drop spot) — it is the tail of the drag, not an exit-the-mode tap.
this._suppressEmptyTapBriefly();
// Cancelled (Escape, the window losing focus) after the app had been
// carried out of its folder: the folder, a two-app one included, and
// the order go back to what they were.
if ( ! commit && d.preEject ) {
this._groups = d.preEject.groups;
this._apps = reconcileAppOrder(this._apps, d.preEject.apps.map(a => a.name));
this.saveGroups();
this.saveOrder();
}
let changed = false;
if ( mergeEl ) {
changed = this._commitMerge(d, mergeEl);
@@ -3709,6 +3768,9 @@ const TabApps = {
const tiles = $el_window.find('.dashboard-section-apps.active .myapps-tile').toArray();
const candidate = tiles.find(el => el.dataset.appName === appName)
|| tiles.find(el => el.dataset.groupId && parseTileGroupApps(el).includes(appName));
// A search that leaves the app out keeps its tile off the
// grid however long this waits.
if ( ! candidate && String($el_window.find('.myapps-search').val() || '').trim() !== '' ) return null;
if ( candidate ) {
const revealed = ! $el_window.find('.myapps-pager').hasClass('myapps-pager-loading');
const img = candidate.querySelector('.myapps-tile-icon img');
+97
View File
@@ -0,0 +1,97 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
// The tab's window and dialog plumbing isn't what these tests exercise.
vi.mock('../UIWindow.js', () => ({ begin_dashboard_tile_launch: vi.fn(), settle_dashboard_tile_launch: vi.fn() }));
vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
vi.mock('../../helpers/launchApp.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.html_encode = value => jQuery('<div>').text(value).html();
globalThis.i18n = globalThis.i18n ?? (key => key);
window.icons = {};
const { default: TabApps } = await import('./TabApps.js');
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
describe('TabApps.beginDeepLinkLaunch', () => {
it("doesn't wait for a tile the search has left off the grid", async () => {
vi.useFakeTimers();
document.body.innerHTML = `
<div class="window">
<input class="myapps-search" value="zzz">
<div class="dashboard-section-apps active"><div class="myapps-container"></div></div>
</div>`;
window.animate_window_opening = true;
window.puter = { kv: { get: vi.fn(async () => null) } };
window.matchMedia = () => ({ matches: false });
TabApps._apps = [{ name: 'app-center', title: 'App Center' }];
TabApps._loadPromise = null;
let settled = false;
const intro = TabApps.beginDeepLinkLaunch('app-center', $('.window')).then((tile) => {
settled = true;
return tile;
});
await vi.advanceTimersByTimeAsync(500);
expect(settled).toBe(true);
expect(await intro).toBe(null);
TabApps._launchingApps.delete('app-center');
});
});
describe('TabApps cancelling a drag out of a folder', () => {
it('puts the folder and the order back as they were', () => {
document.body.innerHTML = '<div class="window"><div class="dashboard-section-apps active"><div class="myapps-container"><div class="myapps-page"><div class="myapps-tile" data-app-name="a"></div></div></div></div></div>';
window.puter = { kv: { set: vi.fn(async () => {}), get: vi.fn(async () => null) } };
TabApps._groupsLoaded = true;
TabApps._orderLoaded = true;
TabApps._groups = [{ id: 'g1', name: 'Work', apps: ['a', 'b'] }];
TabApps._apps = [{ name: 'a' }, { name: 'b' }, { name: 'c' }];
const tile = document.createElement('div');
tile.dataset.appName = 'a';
const $el_window = $('.window');
vi.spyOn(TabApps, '_gridOrderNames').mockReturnValue(['g1', 'c']);
vi.spyOn(TabApps, '_closeGroup').mockImplementation(() => {});
vi.spyOn(TabApps, 'renderApps').mockImplementation(() => {});
vi.spyOn(TabApps, '_updatePlaceholder').mockImplementation(() => {});
const d = { started: true, tileEl: tile, $el_window, groupId: 'g1', ejecting: true };
TabApps._drag = d;
TabApps._ejectToGrid(d);
expect(TabApps._groups).toEqual([]);
TabApps._endDrag(false);
expect(TabApps._groups).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]);
expect(JSON.parse(window.puter.kv.set.mock.calls.at(-2)[1])).toEqual([{ id: 'g1', name: 'Work', apps: ['a', 'b'] }]);
vi.restoreAllMocks();
});
});
+227 -102
View File
@@ -28,7 +28,8 @@ import UIAlert from '../UIAlert.js';
import generate_file_context_menu from '../../helpers/generateFileContextMenu.js';
import update_title_based_on_uploads from '../../helpers/updateTitleBasedOnUploads.js';
import item_icon from '../../helpers/itemIcon.js';
import { user_facing_windows } from '../../helpers/windowVisibility.js';
import { formatRelativeTime } from '../../i18n/formatTime.js';
import { is_window_on_screen, user_facing_windows } from '../../helpers/windowVisibility.js';
import new_context_menu_item from '../../helpers/newContextMenuItem.js';
import publish_as_website from '../../helpers/publishAsWebsite.js';
import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
@@ -40,9 +41,10 @@ import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js'
import { icons } from '../../helpers/actionIcons.js';
import list_all_shared from '../../helpers/listAllShared.js';
import { can_restructure, can_share, remember_shared_root, remember_shared_roots } from '../../helpers/sharedAccess.js';
import { can_restructure, can_share, remember_shared_root, remember_shared_roots, shared_root_for } from '../../helpers/sharedAccess.js';
import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from_paths } from '../../helpers/sharePaths.js';
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
import { is_owned_by_me } from '../../helpers/pathOwner.js';
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
const { html_encode, SelectionArea } = window;
@@ -82,6 +84,29 @@ export function failedUploadsMessage (err) {
return [i18n('upload_some_failed', [], false), ...lines].join('<br>');
}
/** On a Mac, Ctrl+click is a right-click; Cmd is what adds to a selection. */
const CTRL_CLICK_IS_SECONDARY = /mac/i.test(navigator.userAgentData?.platform ?? navigator.platform ?? '');
/**
* The row type-to-select lands on, or -1 for none. A repeated letter ("r",
* "rr") steps through the names starting with it, past the selected one;
* a longer prefix stays on the selected row while it still matches.
*
* @param {string[]} names - The rows' names, lower-cased, in listing order
* @param {number} selected - Index of the selected row, or -1
* @param {string} term - What has been typed, lower-cased
* @returns {number}
*/
export function typeSelectTarget (names, selected, term) {
const cycling = [...term].every(ch => ch === term[0]);
const prefix = cycling ? term[0] : term;
const matches = names.flatMap((name, i) => (name.startsWith(prefix) ? [i] : []));
if ( matches.length === 0 ) return -1;
const at = matches.indexOf(selected);
if ( at === -1 ) return matches[0];
return cycling ? matches[(at + 1) % matches.length] : selected;
}
/**
* TabFiles - File browser tab component for the Puter Dashboard.
*
@@ -130,9 +155,9 @@ const TabFiles = {
<div class="header">
<div class="path">
<div class="path-nav-buttons">
<img draggable="false" class="path-btn path-btn-back path-btn-disabled" tabindex="0" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_back')}" src="${html_encode(window.icons['arrow-left-thin.svg'])}" title="${i18n('window_click_to_go_back')}">
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="0" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="0" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
<img draggable="false" class="path-btn path-btn-back path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_back')}" src="${html_encode(window.icons['arrow-left-thin.svg'])}" title="${i18n('window_click_to_go_back')}">
<img draggable="false" class="path-btn path-btn-forward path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_forward')}" src="${html_encode(window.icons['arrow-right-thin.svg'])}" title="${i18n('window_click_to_go_forward')}">
<img draggable="false" class="path-btn path-btn-up path-btn-disabled" tabindex="-1" role="button" aria-disabled="true" aria-label="${i18n('window_click_to_go_up')}" src="${html_encode(window.icons['arrow-up-thin.svg'])}" title="${i18n('window_click_to_go_up')}">
</div>
<div class="path-breadcrumbs"></div>
<div class="path-actions">
@@ -234,6 +259,7 @@ const TabFiles = {
}
}
if ( file.path ) $row.attr('data-path', file.path);
if ( file.name && ! _this.applyHiddenRule($row, file.name) ) return;
if ( typeof file.type !== 'undefined' ) $row.attr('data-type', file.type || '');
// Refresh the visible Size/Modified cells too, not just the
// hidden data attributes, so a remote overwrite is reflected.
@@ -247,7 +273,7 @@ const TabFiles = {
}
if ( file.modified ) {
$row.attr('data-modified', file.modified);
$row.find('.item-modified').text(window.timeago.format(file.modified * 1000));
$row.find('.item-modified').text(formatRelativeTime(file.modified * 1000, Date.now(), window.locale));
}
if (
_this.isGridView() &&
@@ -288,12 +314,18 @@ const TabFiles = {
return;
}
// A weblink's or app's icon is read from the file. Another folder
// may have opened meanwhile, or the row arrived another way.
const icon = await item_icon(file);
if ( _this.renderingDirectory || _this.currentPath !== parentDir ) return;
if ( _this.$el_window.find(`.files-tab .files .item[data-uid='${file.uid}']`).length > 0 ) return;
// 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):not(.tabfiles-selection-area)').remove();
await _this.renderItem(file);
await _this.renderItem(file, icon);
// Get the newly appended row (it's always last after renderItem)
const $newRow = _this.$el_window.find(`.files-tab .files .item[data-uid='${file.uid}']`);
@@ -647,6 +679,12 @@ const TabFiles = {
// minimized — app window. UIWindow fires this on window open/close.
document.addEventListener('dashboard-app-windows-changed', () => {
this.updateOpenFileDots();
// An app opened from Files (Enter, Open, Open With) would come up
// under the preview, which sits above every window.
const appWindows = $('.window[data-app]').not(this.$el_window).filter((_, w) => !! w.getAttribute('data-app'));
if ( this.previewOpen && user_facing_windows(appWindows).some(is_window_on_screen) ) {
this.closeImagePreview();
}
});
// Select mode's Select and Done buttons only show up to 480px (see
@@ -809,7 +847,7 @@ const TabFiles = {
// When a context menu is open, yield control to keyboard.js
if ( $('.context-menu').length > 0 ) {
if ( (e.which >= 37 && e.which <= 40) || e.which === 13 || e.which === 27 ) {
if ( (e.which >= 33 && e.which <= 40) || e.which === 13 || e.which === 27 ) {
return;
}
if ( !e.ctrlKey && !e.metaKey && e.key.length === 1 ) {
@@ -838,6 +876,9 @@ const TabFiles = {
// row's ⋯ button: the keyboard's only way to Share, Download or
// open Properties.
if ( e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10') ) {
// A focused folder, crumb or button gets the browser's
// contextmenu event, and with it its own menu.
if ( $(focused_el).is('a[href], button, select, [tabindex]') ) return;
const row = $selectedRows.toArray().find(r => r === window.latest_selected_item) ?? $selectedRows[0];
if ( row ) {
e.preventDefault();
@@ -934,10 +975,10 @@ const TabFiles = {
if ( trashedItems.length > 0 ) {
// Permanent delete with confirmation
const alert_resp = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
message: i18n('confirm_delete_multiple_items', [], false),
buttons: [
{ label: i18n('delete'), value: 'delete', type: 'primary' },
{ label: i18n('cancel'), value: 'cancel' },
{ label: i18n('delete', [], false), value: 'delete', type: 'primary' },
{ label: i18n('cancel', [], false), value: 'cancel' },
],
});
if ( alert_resp === 'delete' ) {
@@ -1037,16 +1078,16 @@ const TabFiles = {
return false;
}
// Arrow keys - Navigate items
if ( e.which >= 37 && e.which <= 40 ) {
// Arrow keys, Home/End and PageUp/PageDown - Navigate items
if ( e.which >= 33 && e.which <= 40 ) {
e.preventDefault();
e.stopPropagation();
if ( $allRows.length === 0 ) return false;
// If nothing selected, select first item
// If nothing selected, select first item (End: the last)
if ( $selectedRows.length === 0 ) {
const $first = $allRows.first();
const $first = e.which === 35 ? $allRows.last() : $allRows.first();
$first.addClass('selected');
window.active_element = $first.get(0);
window.latest_selected_item = $first.get(0);
@@ -1088,6 +1129,22 @@ const TabFiles = {
case 40: // Down
nextIndex = Math.min($allRows.length - 1, currentIndex + cols);
break;
case 36: // Home
nextIndex = 0;
break;
case 35: // End
nextIndex = $allRows.length - 1;
break;
case 33: // PageUp
case 34: { // PageDown
// A screenful of rows, whole rows of the grid.
const rowHeight = $allRows.eq(0).outerHeight(true) || 1;
const page = cols * Math.max(1, Math.floor($container.get(0).clientHeight / rowHeight));
nextIndex = e.which === 33
? Math.max(0, currentIndex - page)
: Math.min($allRows.length - 1, currentIndex + page);
break;
}
}
if ( nextIndex !== currentIndex ) {
@@ -1176,46 +1233,15 @@ const TabFiles = {
_this.typeSearchTerm += e.key.toLocaleLowerCase();
let matches = [];
const $currentSelected = $selectedRows.first();
// If selected item already matches, keep it
if ( $currentSelected.length === 1 ) {
const selectedName = ($currentSelected.attr('data-name') || '').toLowerCase();
if ( selectedName.startsWith(_this.typeSearchTerm) ) {
return false;
}
}
// Search all rows for matches
for ( let j = 0; j < $allRows.length; j++ ) {
const name = ($allRows.eq(j).attr('data-name') || '').toLowerCase();
if ( name.startsWith(_this.typeSearchTerm) ) {
matches.push($allRows.get(j));
}
}
if ( matches.length > 0 ) {
// If multiple matches and one is selected, cycle past it
if ( $currentSelected.length > 0 && matches.length > 1 ) {
let match_index;
for ( let i = 0; i < matches.length - 1; i++ ) {
if ( $(matches[i]).is($currentSelected) ) {
match_index = i;
break;
}
}
if ( match_index !== undefined ) {
matches.splice(0, match_index + 1);
}
}
// Deselect all, select the match
const names = $allRows.map((_, row) => (row.getAttribute('data-name') || '').toLowerCase()).get();
const target = typeSelectTarget(names, $allRows.index($selectedRows.first()), _this.typeSearchTerm);
if ( target !== -1 ) {
const row = $allRows.get(target);
$allRows.removeClass('selected');
$(matches[0]).addClass('selected');
window.active_element = matches[0];
window.latest_selected_item = matches[0];
matches[0].scrollIntoView({ block: 'nearest' });
$(row).addClass('selected');
window.active_element = row;
window.latest_selected_item = row;
row.scrollIntoView({ block: 'nearest' });
_this.updateFooterStats();
}
@@ -1617,7 +1643,10 @@ const TabFiles = {
// Download button
$actions.find('.download-btn').on('click', function () {
const selectedRows = document.querySelectorAll('.files-tab .row.selected');
if ( selectedRows.length > 0 ) {
// One file downloads as itself, as from its menu, not as a zip.
if ( selectedRows.length === 1 && $(selectedRows[0]).attr('data-is_dir') !== '1' ) {
window.trigger_download([$(selectedRows[0]).attr('data-path')]);
} else if ( selectedRows.length > 0 ) {
window.zipItems(Array.from(selectedRows), _this.currentPath, true);
}
});
@@ -1663,13 +1692,14 @@ const TabFiles = {
if ( anyTrashed ) {
const confirmed = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
message: i18n('confirm_delete_multiple_items', [], false),
buttons: [
{ label: i18n('delete'), value: 'delete', type: 'primary' },
{ label: i18n('cancel'), value: 'cancel' },
{ label: i18n('delete', [], false), value: 'delete', type: 'primary' },
{ label: i18n('cancel', [], false), value: 'cancel' },
],
});
if ( confirmed === 'delete' ) {
$actions.removeClass('visible');
for ( const row of selectedRows ) {
await window.delete_item(row);
}
@@ -1678,10 +1708,10 @@ const TabFiles = {
} else {
const rows = await _this.restructurableRows(selectedRows);
if ( rows.length > 0 ) {
$actions.removeClass('visible');
window.move_items(rows, window.trash_path);
}
}
$actions.removeClass('visible');
});
// Done button (exits select mode on mobile)
@@ -1802,16 +1832,20 @@ const TabFiles = {
/**
* Moves dragged rows into a folder, leaving out the ones already in it (a
* drop back where they came from does nothing, as on the breadcrumbs)
* and the ones the user can't move (see restructurableRows). move_items
* would stop on an alert apiece for either.
* drop back where they came from does nothing, as on the breadcrumbs),
* the folder itself and its ancestors, and the ones the user can't move
* (see restructurableRows). move_items would stop on an alert apiece.
*
* @param {HTMLElement[]} rows
* @param {string} targetPath
* @returns {Promise<void>}
*/
async moveDraggedRows (rows, targetPath) {
const elsewhere = Array.from(rows).filter((row) => path.dirname($(row).attr('data-path')) !== targetPath);
const elsewhere = Array.from(rows).filter((row) => {
const rowPath = $(row).attr('data-path');
return path.dirname(rowPath) !== targetPath
&& targetPath !== rowPath && ! targetPath.startsWith(`${rowPath}/`);
});
const movable = await this.restructurableRows(elsewhere);
if ( movable.length > 0 ) await window.move_items(movable, targetPath);
},
@@ -2131,7 +2165,8 @@ const TabFiles = {
* Whether items can be created, uploaded or pasted into a folder. Not the
* Trash or a folder inside it (Restore needs metadata only Delete writes),
* not the Shared view (a query, not a directory), not `/`, and not a
* folder Files just failed to list.
* folder Files just failed to list, and not a folder shared with the
* user to view only (where the server refuses each of them).
*
* @param {string} folderPath
* @returns {boolean}
@@ -2142,7 +2177,42 @@ const TabFiles = {
&& folderPath !== '/'
&& folderPath !== window.trash_path
&& ! folderPath.startsWith(`${window.trash_path}/`)
&& folderPath !== window.shared_path;
&& folderPath !== window.shared_path
&& ! this.isReadOnlyShare(folderPath);
},
/**
* Applies the hidden-files rule to a row whose name changed, as listing
* the folder again would: gone while dot-files are hidden, dimmed while
* they show.
*
* @param {jQuery} $row
* @param {string} name - The entry's own name (a trashed one's is its uid)
* @returns {boolean} Whether the row stays
*/
applyHiddenRule ($row, name) {
if ( ! isEntryVisible(name, showHiddenFiles()) ) {
$row.remove();
this.renderEmptyPlaceholderIfNeeded();
this.updateFooterStats();
return false;
}
$row.toggleClass('item-revealed', isHiddenName(name));
return true;
},
/**
* Whether a folder is someone else's, shared with the user without write
* access, by the shares already loaded (the Shared listing records them).
* Unknown counts as writable: the server still has the last word.
*
* @param {string} folderPath
* @returns {boolean}
*/
isReadOnlyShare (folderPath) {
if ( is_owned_by_me(folderPath) ) return false;
const mode = shared_root_for(folderPath)?.mode;
return !! mode && ! ['write', 'manage'].includes(mode);
},
/**
@@ -2430,7 +2500,13 @@ const TabFiles = {
// on — a row for `targetPath/New Folder` sitting in another folder's
// listing, with the rename editor open on it. Drop the click instead,
// exactly as renderDirectory drops navigation clicks while it renders.
if ( this.renderingDirectory ) return;
// A refresh of this folder keeps its rows up, so the click would look
// ignored: take it over, and go on once its listing is in.
if ( this.renderingDirectory ) {
if ( ! this.refreshInFlight || targetPath !== this.currentPath ) return;
await this.renderDirectory(targetPath);
if ( this.renderingDirectory || this.currentPath !== targetPath ) return;
}
// Invoked from a sidebar or breadcrumb menu for a folder that isn't
// the one on screen — go there first, so the row (and the rename that
@@ -2499,6 +2575,9 @@ const TabFiles = {
// hand it to the incremental adder once that guard lifts.
if ( ! document.body.contains($row[0]) ) {
detached = result;
// Undo finds the folder by these, whatever the row's fate.
$row.attr({ 'data-uid': result.uid, 'data-path': result.path });
window.actions_history.push({ operation: 'create_folder', data: $row });
return;
}
@@ -2510,6 +2589,7 @@ const TabFiles = {
$row.attr('data-path', result.path);
$row.attr('data-modified', result.modified);
$row.removeAttr('data-pending');
window.actions_history.push({ operation: 'create_folder', data: $row });
// Our prediction lost a race with another client. Correct the
// row, but never over text the user has started typing.
@@ -2529,7 +2609,7 @@ const TabFiles = {
_this.renderEmptyPlaceholderIfNeeded();
_this.updateFooterStats();
if ( err?.code === 'directory_depth_limit_exceeded' ) {
UIAlert({ message: i18n('directory_depth_limit_exceeded') });
UIAlert({ message: i18n('directory_depth_limit_exceeded', [], false) });
} else if ( err?.message ) {
UIAlert(err.message);
}
@@ -2597,14 +2677,17 @@ const TabFiles = {
* @param {Object} [options] - Optional settings
* @param {boolean} [options.skipUrlUpdate] - If true, don't update browser URL
* @param {boolean} [options.skipNavHistory] - If true, don't add to navigation history
* @param {boolean} [options.force] - Supersede a navigation that is still loading
* @returns {Promise<void>}
*/
async renderDirectory (target, options = {}) {
if ( this.renderBlocked(target) ) return;
if ( ! options.force && this.renderBlocked() ) 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;
// currentPath only moves once the listing arrives.
this.renderTarget = target;
const _this = this;
// The preview shows a row of the listing being replaced.
if ( this.previewOpen && target !== this.currentPath ) this.closeImagePreview();
@@ -3124,7 +3207,7 @@ const TabFiles = {
<div class="item-metadata">
${file.is_dir ? '<div class="item-size"></div>' : `<div class="item-size">${this.formatFileSize(file.size)}</div>`}
<div class="col-spacer"></div>
<div class="item-modified">${window.timeago.format(file.modified * 1000)}</div>
<div class="item-modified">${html_encode(formatRelativeTime(file.modified * 1000, Date.now(), window.locale))}</div>
</div>
<div class="col-spacer"></div>
<div class="item-more">${icons.more}</div>
@@ -3294,7 +3377,7 @@ const TabFiles = {
// gets the grey context-menu state instead (added by the
// contextmenu handler), and an existing selection is preserved
// so the multi-select menu still applies to it.
if ( e.button === 2 ) {
if ( e.button === 2 || (CTRL_CLICK_IS_SECONDARY && e.ctrlKey && e.button === 0) ) {
window.active_element = el_item;
window.active_item_container = el_item.closest('.files');
return;
@@ -3470,6 +3553,7 @@ const TabFiles = {
// inside it) under its new name.
if ( fsentry?.is_dir ) _this.rebaseNavHistory(old_path, path.join(path.dirname(old_path), fsentry.name ?? new_name));
if ( ! fsentry || ! document.body.contains(el_item) ) return;
if ( ! _this.applyHiddenRule($(el_item), fsentry.name ?? new_name) ) return;
$(el_item).attr('data-type', fsentry.type ?? '');
_this.insertAtSortedPosition($(el_item), fsentry);
item_icon(fsentry).then(({ image }) => {
@@ -3706,13 +3790,18 @@ const TabFiles = {
event.clientY >= rect.top && event.clientY <= rect.bottom;
if ( inFiles ) {
// Dropped in file list but not on a folder → move to current dir
// Dropped in file list but not on a folder → move to
// the folder shown, or still opening (whose listing
// is what the drop landed on)
const itemsToMove = [el_item];
$('.item-selected-clone').find('.row').each(function () {
itemsToMove.push(this);
});
const destPath = _this.renderingDirectory && String(_this.renderTarget).startsWith('/')
? _this.renderTarget
: _this.currentPath;
void _this.dropRowsInto(itemsToMove, _this.currentPath, event);
void _this.dropRowsInto(itemsToMove, destPath, event);
} else {
// Dropped outside file list → cancel, navigate back
_this.navigateBackFromSpringLoad();
@@ -3734,7 +3823,9 @@ const TabFiles = {
if ( file.is_dir ) {
$(el_item).droppable({
accept: '.row',
// Not a target for a drag it is part of: that would move it
// into itself, and spring-load it open mid-drag.
accept: (draggable) => draggable.hasClass('row') && ! el_item.classList.contains('dragging-source'),
tolerance: 'pointer',
drop: async function (event, ui) {
@@ -3847,9 +3938,10 @@ const TabFiles = {
const targetPath = $(el_item).attr('data-path');
// Don't allow drop on trash folder
// Don't allow drop on trash folder, or one shared view-only
if ( targetPath === window.trash_path ||
targetPath?.startsWith(`${window.trash_path}/`) ) {
targetPath?.startsWith(`${window.trash_path}/`) ||
TabFiles.isReadOnlyShare(targetPath) ) {
return;
}
@@ -3875,9 +3967,10 @@ const TabFiles = {
const targetPath = $(el_item).attr('data-path');
// Block uploads to trash
// Block uploads to trash, and to a folder shared view-only
if ( targetPath === window.trash_path ||
targetPath?.startsWith(`${window.trash_path}/`) ) {
targetPath?.startsWith(`${window.trash_path}/`) ||
TabFiles.isReadOnlyShare(targetPath) ) {
return;
}
@@ -3988,10 +4081,10 @@ const TabFiles = {
return UIAlert({
message: i18n('item_already_exists', [name], false),
buttons: [
{ label: i18n('replace'), type: 'primary', value: 'replace' },
... multiple ? [{ label: i18n('replace_all'), value: 'replace_all' }] : [],
{ label: i18n('keep_both'), value: 'keep_both' },
... multiple ? [{ label: i18n('skip'), value: 'skip' }] : [{ label: i18n('cancel'), value: 'cancel' }],
{ label: i18n('replace', [], false), type: 'primary', value: 'replace' },
... multiple ? [{ label: i18n('replace_all', [], false), value: 'replace_all' }] : [],
{ label: i18n('keep_both', [], false), value: 'keep_both' },
... multiple ? [{ label: i18n('skip', [], false), value: 'skip' }] : [{ label: i18n('cancel', [], false), value: 'cancel' }],
],
});
},
@@ -4036,6 +4129,8 @@ const TabFiles = {
const multiple_items = window.clipboard.length > 1;
const moved_item_paths = [];
// In the shape the desktop's move_items records, for undo_move.
const moved_items = [];
// Set once the user picks "Replace all" on a conflict; later items
// then overwrite without asking again.
let overwrite_all = false;
@@ -4061,6 +4156,13 @@ const TabFiles = {
// with "Keep Both" it differs from the source's name.
if ( resp?.moved?.path ) {
moved_item_paths.push(resp.moved.path);
if ( item.path ) {
const { uid, path: movedPath, name, is_dir } = resp.moved;
moved_items.push({
options: { uid, path: movedPath, name, is_dir, icon: await item_icon(resp.moved) },
original_path: item.path,
});
}
}
} catch ( err ) {
// Same conflict resolution as the desktop's move_items:
@@ -4090,6 +4192,9 @@ const TabFiles = {
}
window.clipboard = [];
if ( moved_items.length > 0 ) {
window.actions_history.push({ operation: 'move', data: moved_items });
}
return moved_item_paths;
},
@@ -4151,12 +4256,12 @@ const TabFiles = {
const countText = count => i18n(count === 1 ? 'items_count_one' : 'items_count_other', { count }, false);
$footer.find('.files-footer-item-count').text(
`${countText(totalCount)} · ${window.byte_format(totalSize)}`,
`${countText(totalCount)} · ${this.formatFileSize(totalSize)}`,
);
if ( selectedCount > 0 ) {
$footer.find('.files-footer-selected-items')
.text(`${i18n('items_selected', { items: countText(selectedCount) }, false)} · ${window.byte_format(selectedSize)}`)
.text(`${i18n('items_selected', { items: countText(selectedCount) }, false)} · ${this.formatFileSize(selectedSize)}`)
.css('display', 'inline');
$footer.find('.files-footer-separator').css('display', 'inline');
} else {
@@ -4164,6 +4269,12 @@ const TabFiles = {
$footer.find('.files-footer-separator').css('display', 'none');
}
// Rows come and go through here (deleted, trashed, moved away, here or
// elsewhere); the preview goes with the row it shows.
if ( this.previewOpen && ! allRows.some(row => row.getAttribute('data-uid') === this.previewCurrentUid) ) {
this.closeImagePreview();
}
// The floating action bar only appears in select mode (narrow
// screens); elsewhere multi-selection relies on the context menu.
if ( this.selectModeActive && selectedCount >= 1 ) {
@@ -4295,8 +4406,9 @@ const TabFiles = {
$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();
}
// Entering with rows already selected (an upload's) shows the bar.
this.updateFooterStats();
},
/**
@@ -4325,11 +4437,14 @@ const TabFiles = {
*/
navigateBackFromSpringLoad () {
if ( ! this.springLoadedOriginalPath ) return;
// The folder the drag opened may still be loading; going back
// overrides it rather than being dropped.
const force = true;
// Walk back through nav history to find the original path
for ( let i = window.dashboard_nav_history_current_position - 1; i >= 0; i-- ) {
if ( window.dashboard_nav_history[i] === this.springLoadedOriginalPath ) {
this.goToHistoryPosition(i);
this.goToHistoryPosition(i, { force });
return;
}
}
@@ -4338,12 +4453,12 @@ const TabFiles = {
// forward too.
for ( let i = window.dashboard_nav_history_current_position + 1; i < window.dashboard_nav_history.length; i++ ) {
if ( window.dashboard_nav_history[i] === this.springLoadedOriginalPath ) {
this.goToHistoryPosition(i);
this.goToHistoryPosition(i, { force });
return;
}
}
// Fallback: render the original path directly
this.renderDirectory(this.springLoadedOriginalPath);
this.renderDirectory(this.springLoadedOriginalPath, { force });
},
/**
@@ -4433,22 +4548,22 @@ const TabFiles = {
* @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.
* Whether a render of `target` would be dropped: a navigation is already
* loading. A refresh of the folder on screen gives way to anything,
* another refresh included, since its listing may predate what the newer
* one was asked for (an upload that just finished).
*
* @param {string} target
* @returns {boolean}
*/
renderBlocked (target) {
return this.renderingDirectory && ! (this.refreshInFlight && target !== this.currentPath);
renderBlocked () {
return this.renderingDirectory && ! this.refreshInFlight;
},
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.renderBlocked(newPath) ) return;
if ( this.renderBlocked() ) return;
// If history is empty, initialize with this path
if ( window.dashboard_nav_history.length === 0 ) {
window.dashboard_nav_history = [newPath];
@@ -4470,11 +4585,11 @@ const TabFiles = {
* @param {number} position - Index into dashboard_nav_history
* @returns {void}
*/
goToHistoryPosition (position) {
if ( this.renderBlocked(window.dashboard_nav_history[position]) ) return;
goToHistoryPosition (position, { force = false } = {}) {
if ( ! force && this.renderBlocked() ) return;
window.dashboard_nav_history_current_position = position;
this.updateNavButtonStates();
this.renderDirectory(window.dashboard_nav_history[position]);
this.renderDirectory(window.dashboard_nav_history[position], { force });
},
/**
@@ -4492,9 +4607,12 @@ const TabFiles = {
const forwardBtn = this.$el_window.find('.path-btn-forward');
const upBtn = this.$el_window.find('.path-btn-up');
// Out of the Tab order while disabled, as a disabled <button> is: at
// its 10% opacity a focus ring on it can't be seen.
const setDisabled = ($btn, disabled) => $btn
.toggleClass('path-btn-disabled', disabled)
.attr('aria-disabled', disabled ? 'true' : 'false');
.attr('aria-disabled', disabled ? 'true' : 'false')
.attr('tabindex', disabled ? '-1' : '0');
setDisabled(backBtn, window.dashboard_nav_history_current_position === 0);
setDisabled(forwardBtn, window.dashboard_nav_history_current_position >= window.dashboard_nav_history.length - 1);
@@ -4732,10 +4850,10 @@ const TabFiles = {
html: i18n('delete_permanently'),
onClick: async function () {
const confirmed = await UIAlert({
message: i18n('confirm_delete_multiple_items'),
message: i18n('confirm_delete_multiple_items', [], false),
buttons: [
{ label: i18n('delete'), value: 'delete', type: 'primary' },
{ label: i18n('cancel'), value: 'cancel' },
{ label: i18n('delete', [], false), value: 'delete', type: 'primary' },
{ label: i18n('cancel', [], false), value: 'cancel' },
],
});
if ( confirmed === 'delete' ) {
@@ -4826,6 +4944,12 @@ const TabFiles = {
if ( uploadPromise ) {
const result = await uploadPromise;
// Undo deletes by path, so a stand-in row serves
// when the new one isn't on screen.
window.actions_history.push({
operation: 'create_file',
data: $('<div>').attr({ 'data-uid': result.uid, 'data-path': result.path }),
});
if ( targetPath === _this.currentPath ) {
// Remove empty-directory placeholder if present
_this.$el_window.find('.files-tab .files > div:not(.item):not(.place-row):not(.tabfiles-selection-area)').remove();
@@ -4953,7 +5077,8 @@ const TabFiles = {
// the Shared view aren't real fsentries, and Trash itself only offers
// Empty Trash — same as the desktop's folder menus.
if ( targetPath !== '/' && ! isTrashFolder && ! isSharedView ) {
const targetName = path.basename(targetPath);
// A trashed folder's path ends in its uid, not its name.
const targetName = this.trashedNames.get(targetPath) ?? path.basename(targetPath);
items.push('-');
+271 -1
View File
@@ -35,7 +35,7 @@ globalThis.html_encode = value => jQuery('<div>').text(value).html();
globalThis.i18n = globalThis.i18n ?? (key => key);
window.icons = {};
const { default: TabFiles, failedUploadsMessage, rebasePath } = await import('./TabFiles.js');
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
const TRASH = '/alice/Trash';
@@ -142,6 +142,16 @@ describe('TabFiles.moveDraggedRows', () => {
expect(window.move_items).toHaveBeenCalledWith([moves], '/alice/Documents');
});
it('leaves out the target folder and any folder holding it', async () => {
const target = rowAt('/alice/Documents/Work');
const ancestor = rowAt('/alice/Documents');
const moves = rowAt('/alice/Pictures/b.png');
await TabFiles.moveDraggedRows([target, ancestor, moves], '/alice/Documents/Work');
expect(window.move_items).toHaveBeenCalledWith([moves], '/alice/Documents/Work');
});
it('moves nothing when every row is already there', async () => {
await TabFiles.moveDraggedRows([rowAt('/alice/Documents/a.txt'), rowAt('/alice/Documents/b.txt')], '/alice/Documents');
@@ -265,3 +275,263 @@ describe('TabFiles opening a folder during a refresh', () => {
expect(TabFiles.renderingDirectory).toBe(false);
});
});
describe('TabFiles New Folder', () => {
beforeEach(() => {
window.user = { username: 'alice', directories: {} };
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
window.global_element_id = 1;
window.activate_item_name_editor = vi.fn();
window.actions_history = [{ operation: 'upload', data: ['/alice/Documents/notes.txt'] }];
window.puter = { fs: { mkdir: vi.fn(async () => ({
uid: 'f1', name: 'New Folder', path: '/alice/Documents/New Folder', is_dir: true,
})) } };
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}"></div>`);
});
vi.spyOn(TabFiles, 'insertAtSortedPosition').mockImplementation(() => {});
vi.spyOn(TabFiles, 'applyColumnWidths').mockImplementation(() => {});
});
it('is what Undo takes back, not the upload before it', async () => {
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles._creatingItem = 0;
await TabFiles.createFolderInstant('/alice/Documents');
const last = window.actions_history.at(-1);
expect(last.operation).toBe('create_folder');
expect($(last.data).attr('data-path')).toBe('/alice/Documents/New Folder');
});
it('is made, not dropped, while the folder is refreshing', async () => {
mountListing([], '/alice/Documents');
TabFiles.trashedNames ??= new Map();
TabFiles._creatingItem = 0;
window.home_path = '/alice';
window.puter.fs.readdir = vi.fn(async () => []);
TabFiles.renderingDirectory = true;
TabFiles.refreshInFlight = true;
await TabFiles.createFolderInstant('/alice/Documents');
expect(window.puter.fs.mkdir).toHaveBeenCalled();
expect(TabFiles.renderingDirectory).toBe(false);
});
});
describe('TabFiles.moveClipboardItems', () => {
beforeEach(() => {
window.user = { username: 'alice' };
window.actions_history = [{ operation: 'upload', data: ['/alice/Documents/notes.txt'] }];
window.clipboard = [{ path: '/alice/Documents/a.txt', uid: 'a1' }];
window.puter = { fs: { move: vi.fn(async () => ({
moved: { uid: 'a1', name: 'a.txt', path: '/alice/Pictures/a.txt', is_dir: false },
})) } };
});
it('is what Undo takes back, not the upload before it', async () => {
await TabFiles.moveClipboardItems('/alice/Pictures');
const last = window.actions_history.at(-1);
expect(last.operation).toBe('move');
expect(last.data).toEqual([expect.objectContaining({
original_path: '/alice/Documents/a.txt',
options: expect.objectContaining({ uid: 'a1', path: '/alice/Pictures/a.txt' }),
})]);
});
});
describe('TabFiles refreshing a folder during a refresh of it', () => {
beforeEach(() => {
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}" data-name="${file.name}"></div>`);
});
});
it("shows the later listing, not the earlier one's", async () => {
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
const entry = name => ({ uid: name, name, path: `/alice/Documents/${name}`, is_dir: false, size: 1, modified: 0 });
let finishFirst;
window.puter = { fs: { readdir: vi.fn()
.mockImplementationOnce(() => new Promise(resolve => {
finishFirst = resolve;
}))
.mockImplementationOnce(async () => [entry('a.txt'), entry('b.txt')]) } };
const first = TabFiles.renderDirectory('/alice/Documents', { consistency: 'strong' });
await TabFiles.renderDirectory('/alice/Documents', { consistency: 'strong' });
finishFirst([entry('a.txt')]);
await first;
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get().sort()).toEqual(['a.txt', 'b.txt']);
expect(TabFiles.renderingDirectory).toBe(false);
});
});
describe('TabFiles.toggleSelectMode', () => {
beforeEach(() => {
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
});
it('shows the action bar for rows selected before it was entered', () => {
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
$('.files .row').first().addClass('selected');
TabFiles.selectModeActive = false;
TabFiles.toggleSelectMode();
expect($('.files-selection-actions').hasClass('visible')).toBe(true);
TabFiles.toggleSelectMode();
expect($('.files-selection-actions').hasClass('visible')).toBe(false);
});
});
describe('TabFiles footer', () => {
it('gives sizes as the rows do', () => {
window.trash_path = TRASH;
mountListing(['a.txt'], '/alice/Documents');
$('.files .row').attr('data-size', '1536').addClass('selected');
TabFiles.updateFooterStats();
expect($('.files-footer-item-count').text()).toContain('1.5 KB');
expect($('.files-footer-selected-items').text()).toContain('1.5 KB');
$('.files .row').attr('data-size', '0');
TabFiles.updateFooterStats();
expect($('.files-footer-item-count').text()).toContain('0 B');
});
});
describe('typeSelectTarget', () => {
const names = ['notes', 'readme', 'report-a', 'report-b', 'zip'];
it('steps through the names a repeated letter starts', () => {
expect(typeSelectTarget(names, -1, 'r')).toBe(1);
expect(typeSelectTarget(names, 1, 'r')).toBe(2);
expect(typeSelectTarget(names, 2, 'rr')).toBe(3);
expect(typeSelectTarget(names, 3, 'rrr')).toBe(1);
expect(typeSelectTarget(names, 0, 'r')).toBe(1);
});
it('keeps a selected row a longer prefix still matches', () => {
expect(typeSelectTarget(names, 3, 're')).toBe(3);
expect(typeSelectTarget(names, 1, 'rep')).toBe(2);
expect(typeSelectTarget(names, 0, 'rep')).toBe(2);
});
it('finds nothing to select for a prefix nothing has', () => {
expect(typeSelectTarget(names, 0, 'q')).toBe(-1);
});
});
describe('TabFiles.updateNavButtonStates', () => {
it('takes a disabled arrow out of the Tab order and puts it back', () => {
document.body.innerHTML = `
<div class="window">
<img class="path-btn path-btn-back" tabindex="0">
<img class="path-btn path-btn-forward" tabindex="0">
<img class="path-btn path-btn-up" tabindex="0">
</div>`;
TabFiles.$el_window = $('.window');
TabFiles.currentPath = '/alice/Documents';
window.dashboard_nav_history = ['/alice', '/alice/Documents'];
window.dashboard_nav_history_current_position = 1;
TabFiles.updateNavButtonStates();
expect($('.path-btn-back').attr('tabindex')).toBe('0');
expect($('.path-btn-forward').attr('tabindex')).toBe('-1');
expect($('.path-btn-forward').attr('aria-disabled')).toBe('true');
});
});
describe('TabFiles cancelling a drag that opened a folder', () => {
beforeEach(() => {
window.user = { username: 'alice', directories: {} };
window.home_path = '/alice';
window.trash_path = TRASH;
window.byte_format = bytes => `${bytes} B`;
});
it('goes back even while that folder is still loading', async () => {
mountListing([], '/alice/Documents');
TabFiles.renderingDirectory = false;
TabFiles.trashedNames ??= new Map();
window.puter = { fs: { readdir: vi.fn(({ path }) => (path === '/alice/Pictures'
? new Promise(() => {})
: Promise.resolve([]))) } };
window.dashboard_nav_history = ['/alice/Documents'];
window.dashboard_nav_history_current_position = 0;
TabFiles.springLoadedOriginalPath = '/alice/Documents';
TabFiles.pushNavHistory('/alice/Pictures');
void TabFiles.renderDirectory('/alice/Pictures');
TabFiles.navigateBackFromSpringLoad();
await vi.waitFor(() => expect(TabFiles.renderingDirectory).toBe(false));
expect(TabFiles.currentPath).toBe('/alice/Documents');
expect(window.dashboard_nav_history_current_position).toBe(0);
});
});
describe('TabFiles image preview', () => {
it('closes once the row it shows is gone', () => {
window.trash_path = TRASH;
mountListing(['a.png'], '/alice/Pictures');
$('.files .row').attr('data-uid', 'u1');
$('body').append('<div class="image-preview-popover"></div>');
TabFiles.previewOpen = true;
TabFiles.previewCurrentUid = 'u1';
TabFiles.updateFooterStats();
expect(TabFiles.previewOpen).toBe(true);
$('.files .row').remove();
TabFiles.updateFooterStats();
expect(TabFiles.previewOpen).toBe(false);
expect($('.image-preview-popover')).toHaveLength(0);
});
});
describe('TabFiles.acceptsNewItems', () => {
it('refuses a folder shared to view only, and takes one shared to edit', async () => {
const { remember_shared_roots } = await import('../../helpers/sharedAccess.js');
window.user = { username: 'alice' };
window.trash_path = TRASH;
remember_shared_roots([
{ path: '/bob/Photos', mode: 'read' },
{ path: '/bob/Drafts', mode: 'write' },
]);
expect(TabFiles.acceptsNewItems('/bob/Photos')).toBe(false);
expect(TabFiles.acceptsNewItems('/bob/Photos/2024')).toBe(false);
expect(TabFiles.acceptsNewItems('/bob/Drafts')).toBe(true);
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
});
});
describe('TabFiles.applyHiddenRule', () => {
it('hides a row renamed to a dot-name, or dims it when hidden files show', () => {
window.trash_path = TRASH;
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
window.user_preferences = { show_hidden_files: false };
expect(TabFiles.applyHiddenRule($('.files .row').first(), '.a.txt')).toBe(false);
expect($('.files .row')).toHaveLength(1);
window.user_preferences = { show_hidden_files: true };
expect(TabFiles.applyHiddenRule($('.files .row').first(), '.b.txt')).toBe(true);
expect($('.files .row').first().hasClass('item-revealed')).toBe(true);
TabFiles.applyHiddenRule($('.files .row').first(), 'b.txt');
expect($('.files .row').first().hasClass('item-revealed')).toBe(false);
});
});
+65 -22
View File
@@ -24,12 +24,20 @@ import { getMonthlyUsage, getStorageSpace } from './usageRequests.js';
import { appIconAttrs } from '../../helpers/appIcon.js';
import { filterLaunchableApps } from '../../helpers/launchAppsList.js';
import { isOrgSeat, orgSeatTeamName } from './orgSeat.js';
import { intlLocale } from '../../i18n/intlLocale.js';
// How long a completed usage load stays fresh enough to skip a repeat. Long
// enough to absorb the init/onActivate/routing burst on a single dashboard
// open, short enough that a user watching the panel still sees movement.
const USAGE_REFRESH_DEBOUNCE_MS = 5_000;
// How long the plan card waits for the subscription read before giving up.
const PLAN_REQUEST_TIMEOUT_MS = 15_000;
// Whether "Recently used" has its list: until the first answer it says
// nothing, rather than "No recent apps yet", and a failed first load says so.
let recentAppsState = 'loading';
function buildRecentAppsHTML() {
let h = '';
@@ -62,6 +70,8 @@ function buildRecentAppsHTML() {
h += '</button>';
}
h += '</div>';
} else if (recentAppsState === 'loading') {
h += '<div class="bento-recent-apps-empty" aria-busy="true"></div>';
} else {
h += '<div class="bento-recent-apps-empty">';
h +=
@@ -71,8 +81,12 @@ function buildRecentAppsHTML() {
h +=
'<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>';
h += '</svg>';
h += `<p>${i18n('no_recent_apps')}</p>`;
h += `<span>${i18n('no_recent_apps_hint')}</span>`;
if (recentAppsState === 'failed') {
h += `<p>${i18n('apps_load_failed')}</p>`;
} else {
h += `<p>${i18n('no_recent_apps')}</p>`;
h += `<span>${i18n('no_recent_apps_hint')}</span>`;
}
h += '</div>';
}
@@ -87,7 +101,7 @@ function buildUsageHTML() {
h +=
'<div class="bento-usage-section bento-usage-card bento-plan-section">';
h +=
'<a href="#" class="bento-usage-card-header bento-plan-header" data-target-tab="usage">';
'<a href="#usage" class="bento-usage-card-header bento-plan-header" data-target-tab="usage">';
h += `<h3>${i18n('your_plan')}</h3>`;
h += '<span class="bento-usage-card-arrow">›</span>';
h += '</a>';
@@ -104,7 +118,7 @@ function buildUsageHTML() {
// Storage section
h += '<div class="bento-usage-section bento-usage-card">';
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
h += '<a href="#usage" class="bento-usage-card-header" data-target-tab="usage">';
h += `<h3>${i18n('your_storage')}</h3>`;
h += '<span class="bento-usage-card-arrow">›</span>';
h += '</a>';
@@ -124,7 +138,7 @@ function buildUsageHTML() {
// Resources section
h += '<div class="bento-usage-section bento-usage-card">';
h += '<a href="#" class="bento-usage-card-header" data-target-tab="usage">';
h += '<a href="#usage" class="bento-usage-card-header" data-target-tab="usage">';
h += `<h3>${i18n('your_resources')}</h3>`;
h += '<span class="bento-usage-card-arrow">›</span>';
h += '</a>';
@@ -163,12 +177,14 @@ function formatTrialEnd(trialEndsAt) {
0,
Math.ceil((date.getTime() - Date.now()) / 86400000),
);
// In the dashboard's language, which the sentences around it are in.
const locale = intlLocale(window.locale);
return {
short: date.toLocaleDateString(undefined, {
short: date.toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
}),
long: date.toLocaleDateString(undefined, {
long: date.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
@@ -234,7 +250,7 @@ const TabHome = {
// Recent apps card (rectangle)
h += '<div class="bento-card bento-recent">';
h +=
'<a href="#" class="bento-card-fancy-header" data-target-tab="apps">';
'<a href="#apps" class="bento-card-fancy-header" data-target-tab="apps">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-apps">';
h +=
'<svg viewBox="0 0 24 24" fill="currentColor"><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="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>';
@@ -256,7 +272,7 @@ const TabHome = {
// Usage card (spans full width on second row)
h += '<div class="bento-card bento-usage">';
h +=
'<a href="#" class="bento-card-fancy-header" data-target-tab="usage">';
'<a href="#usage" class="bento-card-fancy-header" data-target-tab="usage">';
h += '<div class="bento-card-fancy-icon bento-card-fancy-icon-usage">';
h +=
'<svg viewBox="0 0 16 16" fill="currentColor"><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>';
@@ -310,7 +326,11 @@ const TabHome = {
// Deliberate refreshes (subscription changed, returning from the
// billing portal) must bypass the freshness window — they exist to
// replace state we already know is stale.
const refresh = () => this.loadUsageData($el_window, { force: true });
// Recently used too: an app opened in another browser tab moves up it.
const refresh = () => {
this.loadUsageData($el_window, { force: true });
this.loadRecentApps($el_window);
};
const refreshAndBroadcast = async () => {
// Pull fresh whoami, then broadcast. The dispatched event is handled
// by the `refresh` listener below, so we don't call refresh() here —
@@ -365,6 +385,9 @@ const TabHome = {
$el_window.on('click', '.bento-recent-app', function (e) {
e.preventDefault();
e.stopPropagation();
// The first click of a double-click already opened the app; the
// second would open another tab of it, as an Apps tile won't.
if ((e.detail ?? 0) > 1) return;
const appName = $(this).attr('data-app-name');
const targetLink = $(this).attr('data-target-link');
if (targetLink && targetLink !== '') {
@@ -379,6 +402,9 @@ const TabHome = {
'click',
'.bento-view-more, .bento-usage-card-header, .bento-card-fancy-header[data-target-tab]',
function (e) {
// A modified click opens the tab in a new browser tab, natively,
// as on the sidebar.
if (e.metaKey || e.ctrlKey || e.shiftKey) return;
e.preventDefault();
const targetTab = $(this).attr('data-target-tab');
if (targetTab) {
@@ -396,7 +422,10 @@ const TabHome = {
$el_window.on('click', '.bento-save-account-warning', function (e) {
e.preventDefault();
e.stopPropagation();
// As from the user menu: the temporary username comes prefilled.
UIWindowSaveAccount({
send_confirmation_code: false,
default_username: window.user.username,
window_options: {
backdrop: true,
close_on_backdrop_click: true,
@@ -416,8 +445,8 @@ const TabHome = {
async loadRecentApps($el_window) {
// Always refetch: gating on an empty list froze "Recently used" for the
// whole session (apps launched after load never appeared). loadUsageData
// refreshes on the same activate/focus triggers, so this stays in step.
// whole session (apps launched after load never appeared). It refreshes
// on the same activate/focus triggers as loadUsageData.
// Only the newest request may write: an older one answering last
// would put a stale list back.
const seq = (this._recentAppsSeq = (this._recentAppsSeq ?? 0) + 1);
@@ -433,8 +462,11 @@ const TabHome = {
});
if (seq !== this._recentAppsSeq) return;
window.launch_apps = launchApps;
recentAppsState = 'loaded';
} catch (e) {
console.error('Failed to load launch apps:', e);
// A list already shown stays; it is only out of date.
if (recentAppsState === 'loading') recentAppsState = 'failed';
}
if (seq !== this._recentAppsSeq) return;
$el_window
@@ -494,9 +526,14 @@ const TabHome = {
// upgrade dialog either, which only hosted Puter loads).
let noBilling = false;
try {
// fetch has no timeout of its own; one that never answers would
// hold every later refresh of the cards behind it.
const resp = await fetch(
`${window.api_origin}/marketplace/subscriptions/current`,
{ headers: { Authorization: `Bearer ${puter.authToken}` } },
{
headers: { Authorization: `Bearer ${puter.authToken}` },
signal: AbortSignal.timeout?.(PLAN_REQUEST_TIMEOUT_MS),
},
);
noBilling = resp.status === 404;
// A refusal is not a free plan: a seat owing a password change 403s.
@@ -622,21 +659,26 @@ const TabHome = {
},
async _loadUsageDataUncached($el_window) {
await this._renderPlanCard($el_window);
// Side by side: neither meter needs the plan, and a slow plan read
// must not hold them at "--".
await Promise.all([
this._renderPlanCard($el_window),
this._renderStorageCard($el_window),
this._renderResourcesCard($el_window),
]);
},
// Load storage data
async _renderStorageCard($el_window) {
try {
const res = await getStorageSpace();
// On a self-hosted install the disk's use, Puter's files with it;
// the meter measures the same amount the card names.
const general_used = res.host_used || res.used;
// Guard capacity 0 — 0/0 would render literally as "NaN%".
const usage_percentage = res.capacity
? Math.min(100, meterPercent((res.used / res.capacity) * 100))
? Math.min(100, meterPercent((general_used / res.capacity) * 100))
: 0;
let general_used = res.used;
if (res.host_used) {
general_used = res.host_used;
}
$el_window
.find('.bento-storage-used')
.text(i18n('amount_used', { amount: window.byte_format(general_used) }, false));
@@ -653,8 +695,9 @@ const TabHome = {
} catch (e) {
console.error('Failed to load storage data:', e);
}
},
// Load monthly usage data
async _renderResourcesCard($el_window) {
try {
const res = monthlyUsageOrThrow(await getMonthlyUsage());
const budget = usageBudget(res.usage, res.allowanceInfo);
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi } from 'vitest';
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('../UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.html_encode = value => jQuery('<div>').text(value).html();
globalThis.i18n = key => key;
window.icons = {};
window.launch_apps = { recent: [] };
const { default: TabHome } = await import('./TabHome.js');
describe('TabHome recently used', () => {
const mount = () => {
document.body.innerHTML = '<div class="window"><div class="bento-recent-apps-container"></div></div>';
return $('.window');
};
it("doesn't call the list empty before it has loaded, and says when it couldn't", async () => {
const $el_window = mount();
$el_window.find('.bento-recent-apps-container').html(TabHome.html());
expect($el_window.text()).not.toContain('no_recent_apps');
$.ajax = vi.fn(() => Promise.reject(new Error('offline')));
vi.spyOn(console, 'error').mockImplementation(() => {});
await TabHome.loadRecentApps($el_window);
expect($el_window.text()).toContain('apps_load_failed');
$.ajax = vi.fn(async () => ({ recent: [] }));
await TabHome.loadRecentApps($el_window);
expect($el_window.text()).toContain('no_recent_apps');
});
});
+13 -9
View File
@@ -183,8 +183,20 @@ const TabSecurity = {
// After the dialog has gone, which can trail its answer.
setTimeout(() => returnFocus(this), 250);
const showState = (on) => {
$toggle.prop('checked', on);
const $card = $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa');
$el_window.find('.dashboard-section-security .user-otp-state').text(i18n(on ? 'two_factor_enabled' : 'two_factor_disabled'));
$card.toggleClass('dashboard-settings-card-success', on).toggleClass('dashboard-settings-card-warning', ! on);
};
if ( ! succeeded ) {
$toggle.prop('checked', ! enabling);
// 2FA may have changed regardless: turned on in another tab
// (setup then refuses, "already enabled"), or by a code the
// server accepted after the dialog was closed.
await window.refresh_user_data?.(window.auth_token);
if ( typeof window.user?.otp === 'boolean' ) showState(window.user.otp);
return;
}
@@ -192,15 +204,7 @@ const TabSecurity = {
// real 2FA state. The disable dialog already clears window.user.otp;
// the enable path never set it, so the two directions disagreed.
if ( window.user ) window.user.otp = enabling;
const $card = $el_window.find('.dashboard-section-security .dashboard-settings-card-2fa');
if ( enabling ) {
$el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_enabled'));
$card.removeClass('dashboard-settings-card-warning').addClass('dashboard-settings-card-success');
} else {
$el_window.find('.dashboard-section-security .user-otp-state').text(i18n('two_factor_disabled'));
$card.removeClass('dashboard-settings-card-success').addClass('dashboard-settings-card-warning');
}
showState(enabling);
});
},
};
+4 -4
View File
@@ -55,9 +55,9 @@ const TabUsage = {
<div class="driver-usage-header">
<h3 style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('Storage')}</h3>
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
<span id="storage-used-percent"></span>
<span id="storage-used-percent">--%</span>
<span> &middot; </span>
${i18n('used_of', { used: '<span id="storage-used"></span>', available: '<span id="storage-capacity"></span>' }, false)}
${i18n('used_of', { used: '<span id="storage-used">--</span>', available: '<span id="storage-capacity">--</span>' }, false)}
<span id="storage-puter-used-w" style="display:none;">&nbsp;(<span id="storage-puter-used"></span> ${i18n('storage_puter_used')})</span>
</div>
</div>
@@ -69,9 +69,9 @@ const TabUsage = {
<div class="driver-usage-header">
<h3 style="margin:0; font-size: 14px; flex-grow: 1; font-weight: 500;">${i18n('Resources')}</h3>
<div style="font-size: 13px; margin-bottom: 3px; opacity:0.85;">
<span class="usage-progbar-percent"></span>
<span class="usage-progbar-percent">--%</span>
<span> &middot; </span>
${i18n('used_of', { used: '<span id="total-usage"></span>', available: '<span id="total-capacity"></span>' }, false)}
${i18n('used_of', { used: '<span id="total-usage">--</span>', available: '<span id="total-capacity">--</span>' }, false)}
</div>
</div>
<div class="usage-progbar-wrapper">
+27 -5
View File
@@ -54,6 +54,9 @@ const passwordField = (label) => ({
attrs: { autocomplete: 'current-password' },
});
/** Puts a refused password's status on the field, to be typed again. */
const wrongPassword = (result, field) => (result.data?.code === 'password_mismatch' ? { field } : undefined);
/** Text that tells a provider-only account what will happen instead of a password. */
const oidcNotice = () => (isOidcOnly() ? `<p>${i18n('revalidate_flow_notice')}</p>` : '');
@@ -143,6 +146,11 @@ export function openChangeUsernameDialog (opts = {}) {
dialog.setStatus(t('all_fields_required'), { field: 'username' });
return;
}
// The server would call it taken, and count the password try.
if ( new_username.toLowerCase() === String(window.user?.username).toLowerCase() ) {
dialog.setStatus(t('username_unchanged'), { field: 'username' });
return;
}
if ( ! isOidcOnly() && ! password ) {
dialog.setStatus(t('all_fields_required'), { field: 'password' });
return;
@@ -152,7 +160,7 @@ export function openChangeUsernameDialog (opts = {}) {
onRevalidate: revalidating(dialog),
});
if ( ! result.ok ) {
dialog.setStatus(result.message);
dialog.setStatus(result.message, wrongPassword(result, 'password'));
return;
}
update_username_in_gui(new_username);
@@ -206,6 +214,11 @@ export function openChangePasswordDialog (opts = {}) {
dialog.setStatus(t('passwords_do_not_match'), { field: 'confirm' });
return;
}
// It would go through, and sign every other session out for nothing.
if ( current && next === current ) {
dialog.setStatus(t('password_unchanged'), { field: 'next' });
return;
}
if ( ! check_password_strength(next).overallPass ) {
dialog.setStatus(t('password_strength_error'), { field: 'next' });
return;
@@ -215,9 +228,12 @@ export function openChangePasswordDialog (opts = {}) {
onRevalidate: revalidating(dialog),
});
if ( ! result.ok ) {
dialog.setStatus(result.message);
dialog.setStatus(result.message, wrongPassword(result, 'current'));
return;
}
// A provider-only account now has a password, and the other
// dialogs ask for it instead of the provider's sign-in.
if ( window.user ) window.user.oidc_only = false;
dialog.finish({ message: t('password_changed') });
},
});
@@ -257,6 +273,12 @@ export function openChangeEmailDialog (opts = {}) {
dialog.setStatus(t('all_fields_required'), { field: 'email' });
return;
}
// The server would accept it: mail the same inbox, and unlink any
// sign-in provider once confirmed.
if ( new_email.toLowerCase() === String(window.user?.email).toLowerCase() ) {
dialog.setStatus(t('email_unchanged'), { field: 'email' });
return;
}
if ( ! isOidcOnly() && ! password ) {
dialog.setStatus(t('all_fields_required'), { field: 'password' });
return;
@@ -270,7 +292,7 @@ export function openChangeEmailDialog (opts = {}) {
// Accounts with 2FA turned on since the page loaded learn about it here.
const needs_code = result.data.factor === 'otp';
if ( needs_code ) dialog.showField('code', true);
dialog.setStatus(result.message, needs_code ? { field: 'code' } : undefined);
dialog.setStatus(result.message, needs_code ? { field: 'code' } : wrongPassword(result, 'password'));
return;
}
// The address is the account's once the emailed link is followed, not yet.
@@ -327,7 +349,7 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
h += '</div>';
h += `<p class="dashboard-form-hint">${i18n('token_manage_hint')} <button type="button" class="dashboard-token-sessions">${i18n('ui_manage_sessions')}</button>.</p>`;
// The token is shown only once; only Done, ✕ or Escape put it away.
dialog.finish({ html: h, keepOnBackdrop: true });
dialog.finish({ html: h, describe: '.dashboard-token-warning', keepOnBackdrop: true });
},
});
@@ -418,7 +440,7 @@ export function openDeleteAccountDialog (opts = {}) {
onRevalidate: revalidating(dialog),
});
if ( ! result.ok || ! result.data.success ) {
dialog.setStatus(result.message ?? t('error_unknown_cause'));
dialog.setStatus(result.message ?? t('error_unknown_cause'), wrongPassword(result, 'password'));
return;
}
window.user.deleted = true;
@@ -125,6 +125,13 @@ describe('change username', () => {
expect(fetch).not.toHaveBeenCalled();
});
it("doesn't post the name the account already has", async () => {
dialogs.openChangeUsernameDialog();
await submitWith({ username: 'Alice', password: 'pw' });
expect(status()).toBe('username_unchanged');
expect(fetch).not.toHaveBeenCalled();
});
it('renames the user everywhere on success', async () => {
document.body.innerHTML = '<span class="username">alice</span>';
fetch.mockResolvedValueOnce(reply(200));
@@ -167,6 +174,13 @@ describe('change password', () => {
expect(fetch).not.toHaveBeenCalled();
});
it("doesn't post a new password that is the current one", async () => {
open();
await submitWith({ current: 'Strong1!pass', next: 'Strong1!pass', confirm: 'Strong1!pass' });
expect(status()).toBe('password_unchanged');
expect(fetch).not.toHaveBeenCalled();
});
it('posts the current and new password and confirms the change', async () => {
fetch.mockResolvedValueOnce(reply(200));
open();
@@ -177,6 +191,16 @@ describe('change password', () => {
expect($('.dashboard-form-done p').text()).toBe('password_changed');
});
it('lets a provider-only account use its new password from then on', async () => {
window.user.oidc_only = true;
fetch.mockResolvedValueOnce(reply(200));
open();
await submitWith({ next: 'Strong1!pass', confirm: 'Strong1!pass' });
expect(window.user.oidc_only).toBe(false);
});
it('shows the server message and keeps the form when the change is refused', async () => {
fetch.mockResolvedValueOnce(reply(400, { message: 'Wrong password' }));
open();
@@ -203,6 +227,13 @@ describe('change email', () => {
expect(window.user.email).toBe('[email protected]');
});
it("doesn't post the address the account already has", async () => {
dialogs.openChangeEmailDialog();
await submitWith({ email: ' [email protected] ', password: 'pw' });
expect(status()).toBe('email_unchanged');
expect(fetch).not.toHaveBeenCalled();
});
it('leaves the code out for an account without 2FA', async () => {
fetch.mockResolvedValueOnce(reply(200));
dialogs.openChangeEmailDialog();
+45 -10
View File
@@ -213,6 +213,10 @@ async function UIDashboard (options) {
// Set initial file path BEFORE tabs are initialized (so TabFiles.init() can use it)
if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.path ) {
window.dashboard_initial_file_path = window.dashboard_initial_route.path;
// Consumed here like a share link: Files doesn't keep its folder in
// the URL, so a path left there would be stale after the first move,
// and Back or a reload would jump to it.
window.history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}#files`);
} else if ( window.dashboard_initial_route?.tab === 'files' && window.dashboard_initial_route?.shared ) {
// A share link: open Shared, and pick out the items it names once
// the listing is up. Consumed here, so a reload shows Files plainly.
@@ -231,7 +235,10 @@ async function UIDashboard (options) {
// 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 account_state = () => JSON.stringify([window.user?.email, window.user?.email_confirmed, window.user?.is_temp]);
let rendered_account_state = account_state();
const rerender_account_tabs = () => {
rendered_account_state = account_state();
for ( const tab of tabs ) {
if ( tab.id !== 'account' && tab.id !== 'security' ) continue;
$el_window.find(`.dashboard-section-${tab.id}`).html(tab.html());
@@ -245,6 +252,16 @@ async function UIDashboard (options) {
rerender_account_tabs();
});
// Returning to the browser tab re-reads the account and then sends this
// (see TabHome). An email change confirmed from its link in another tab
// sends no socket event of its own, so it shows here; so does a temporary
// account saved in another tab.
window.addEventListener('puter:subscription:changed', () => {
// Saved from another tab: Home's "Save session" has nothing left to save.
if ( ! window.user?.is_temp ) $el_window.find('.bento-save-account-warning').remove();
if ( account_state() !== rendered_account_state ) rerender_account_tabs();
});
// Dispatch 'dashboard-ready' event for extensions
window.dispatchEvent(new CustomEvent('dashboard-ready', { detail: { window: $el_window } }));
@@ -514,6 +531,12 @@ async function UIDashboard (options) {
const tab = route.tab;
const filePath = route.path;
notifications.close({ restoreFocus: false });
// What was open over the tab being left goes with it, as on a
// sidebar click: the phone drawer, a menu, and Files' Share and
// Properties, which act on its rows (their changes are already saved).
setMobileSidebar(false);
$('.context-menu').remove();
$el_window.find('.share-modal-close, .item-props-close').trigger('click');
// Switch to correct tab
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
@@ -542,12 +565,16 @@ async function UIDashboard (options) {
// Scroll content area to top
$el_window.find('.dashboard-content').scrollTop(0);
// If files tab with path, navigate without adding to history
// A files link with a path opens that folder, as a step in Files' own
// Back/Forward, and leaves the URL (see the initial route above).
if ( tab === 'files' && filePath ) {
const filesTab = tabs.find(t => t.id === 'files');
if ( filesTab?.renderDirectory ) {
filesTab.renderDirectory(filePath, { skipUrlUpdate: true, skipNavHistory: true });
filesTab.pushNavHistory?.(filePath);
filesTab.renderDirectory(filePath, { skipUrlUpdate: true });
}
window.history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}#files`);
lastHandledHref = window.location.href;
}
};
@@ -607,10 +634,10 @@ async function UIDashboard (options) {
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) => {
const setMobileSidebar = (open, { keepFocus = false } = {}) => {
// 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 focusInside = ! open && ! keepFocus && $sidebar.hasClass('open') && $sidebar[0].contains(document.activeElement);
const opening = open && ! $sidebar.hasClass('open');
$sidebar.toggleClass('open', open);
$scrim.toggleClass('open', open);
@@ -623,6 +650,13 @@ async function UIDashboard (options) {
}
};
// Widened past the breakpoint (a tablet turned to landscape), the drawer
// is the desktop sidebar again, shown and no longer modal; focus in it
// can stay where it is.
window.matchMedia('(max-width: 768px)').addEventListener('change', (e) => {
if ( ! e.matches && $sidebar.hasClass('open') ) setMobileSidebar(false, { keepFocus: true });
});
// Tab cycles inside the open drawer instead of reaching the content
// behind the scrim.
$sidebar.on('keydown', function (e) {
@@ -727,9 +761,10 @@ async function UIDashboard (options) {
.attr('aria-expanded', collapsed ? 'false' : 'true');
});
// Sidebar collapsed tooltips
// Sidebar collapsed tooltips, for the keyboard as well as the pointer:
// collapsed, an item shows its icon only.
const $tooltip = $('<div class="dashboard-sidebar-tooltip"></div>').appendTo('body');
$el_window.on('mouseenter', '.dashboard-sidebar-item[data-tooltip]', function () {
$el_window.on('mouseenter focusin', '.dashboard-sidebar-item[data-tooltip]', function () {
if ( ! $el_window.find('.dashboard-sidebar').hasClass('collapsed') ) return;
// The drawer ignores the collapse and shows its labels.
if ( window.matchMedia('(max-width: 768px)').matches ) return;
@@ -741,7 +776,7 @@ async function UIDashboard (options) {
});
$tooltip.addClass('visible');
});
$el_window.on('mouseleave', '.dashboard-sidebar-item[data-tooltip]', function () {
$el_window.on('mouseleave focusout', '.dashboard-sidebar-item[data-tooltip]', function () {
$tooltip.removeClass('visible');
});
@@ -878,15 +913,15 @@ async function UIDashboard (options) {
// Check for open windows
if ( $('.window-app').length > 0 ) {
const alert_resp = await UIAlert({
message: `<p>${i18n('confirm_open_apps_log_out')}</p>`,
message: `<p>${i18n('confirm_open_apps_log_out', [], false)}</p>`,
buttons: [
{
label: i18n('close_all_windows_and_log_out'),
label: i18n('close_all_windows_and_log_out', [], false),
value: 'close_and_log_out',
type: 'primary',
},
{
label: i18n('cancel'),
label: i18n('cancel', [], false),
},
],
});
@@ -56,9 +56,10 @@ const FORM_ICONS = {
* @property {(name: string, show: boolean) => void} showField
* @property {(text: string, opts?: { kind?: 'error'|'info', field?: string }) => void} setStatus
* Plain text; an empty string clears it. Naming a field marks it invalid and focuses it.
* @property {(opts: { message?: string, html?: string, keepOnBackdrop?: boolean }) => void} finish
* @property {(opts: { message?: string, html?: string, describe?: string, keepOnBackdrop?: boolean }) => void} finish
* Swaps the form for a done state (a plain-text `message`, or the caller's
* encoded `html`) with a single Done button. `keepOnBackdrop` stops a stray
* encoded `html`) with a single Done button. `describe` selects the part of
* `html` the focused Done button reads out. `keepOnBackdrop` stops a stray
* click outside the card from closing it, for content shown only once.
*/
@@ -193,15 +194,16 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
else $field.get(0)?.focus({ preventScroll: true });
}
},
finish ({ message, html, keepOnBackdrop = false }) {
finish ({ message, html, describe, keepOnBackdrop = false }) {
keep_on_backdrop = keepOnBackdrop;
const content = html ?? `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message ?? '')}</p></div>`;
$dialog.attr('aria-describedby', null);
$dialog.find('.dashboard-dialog-body').html(`<div class="dashboard-dialog-content" role="status">${content}</div>`);
const described = ! html || (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
// A status region that arrives with its text isn't announced;
// the focused Done button reads the message out instead.
$dialog.find('.dashboard-dialog-footer').html(
`<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-form-done-btn"${html ? '' : ` aria-describedby="${id}-done"`}>${i18n('done')}</button>`);
`<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-primary dashboard-form-done-btn"${described ? ` aria-describedby="${id}-done"` : ''}>${i18n('done')}</button>`);
$dialog.find('.dashboard-form-done-btn').get(0)?.focus({ preventScroll: true });
},
};
@@ -184,6 +184,15 @@ describe('UIDashboardFormDialog', () => {
expect($('.dashboard-form-status').text()).toBe('Server says no');
});
it("has Done read out the part of the caller's html it names", async () => {
open({ onSubmit: (dialog) => dialog.finish({ html: '<p class="warn">Shown once</p><input value="tok">', describe: '.warn' }) });
submit();
await tick();
const described_by = $('.dashboard-form-done-btn').attr('aria-describedby');
expect($(`#${described_by}`).text()).toBe('Shown once');
});
it('swaps the form for a done state whose button closes it', async () => {
open({ onSubmit: (dialog) => dialog.finish({ message: 'All <good>' }) });
submit();
@@ -21,12 +21,12 @@ import UINotification from '../UINotification.js';
import { reveal_dashboard } from '../UIWindow.js';
import { listNotifications, markNotificationAcknowledged } from '../../helpers/notificationApi.js';
import { applyToastMark, createNotificationFeed } from '../../helpers/notificationFeed.js';
import { formatAbsoluteTime, formatRelativeTime } from '../../i18n/formatTime.js';
import {
badgeLabel,
formatAbsoluteTime,
formatRelativeTime,
glyphKey,
isUnread,
listedEntries,
mergeEntries,
notificationTarget,
planBurstToasts,
@@ -54,6 +54,12 @@ const ACK_CONCURRENCY = 6;
/** How many of the most recent notifications the panel lists, read or not. */
const HISTORY_LIMIT = 30;
/**
* Rows a listing asks for: past HISTORY_LIMIT for the ones the panel hides
* and for older unread ones, which it lists too. The driver's maximum.
*/
const LISTING_LIMIT = 200;
/** Matches the panel's CSS transition; `hidden` is set once it has run. */
const CLOSE_ANIMATION_MS = 180;
@@ -366,10 +372,10 @@ export default function UIDashboardNotifications ({ $el_window, socket }) {
loadFailed = false;
if ( ! loaded ) render();
listingRequestedAt = Date.now();
loading = listNotifications({ predicate: 'all', limit: HISTORY_LIMIT })
loading = listNotifications({ predicate: 'all', limit: LISTING_LIMIT })
.then((rows) => {
const now = Date.now();
const server = rows.map((row) => toEntry(row, now)).filter(Boolean);
const server = listedEntries(rows, now, HISTORY_LIMIT);
// Acked since this listing was requested: unread in its snapshot.
// Anything acked before it is settled and can be forgotten.
const readSince = new Set();
@@ -20,6 +20,7 @@
import item_icon from '../../helpers/itemIcon.js';
import UIAlert from '../UIAlert.js';
import { isOverlayOnTop } from './dashboardOverlay.js';
import { formatRelativeTime } from '../../i18n/formatTime.js';
const { html_encode } = window;
@@ -224,9 +225,9 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
addRow($list, i18n('size'),
(fsentry.size === null || fsentry.size === undefined) ? '-' : html_encode(window.byte_format(fsentry.size)));
addRow($list, i18n('modified'),
fsentry.modified === 0 ? '-' : html_encode(timeago.format(fsentry.modified * 1000)));
fsentry.modified === 0 ? '-' : html_encode(formatRelativeTime(fsentry.modified * 1000, Date.now(), window.locale)));
addRow($list, i18n('created'),
fsentry.created === 0 ? '-' : html_encode(timeago.format(fsentry.created * 1000)));
fsentry.created === 0 ? '-' : html_encode(formatRelativeTime(fsentry.created * 1000, Date.now(), window.locale)));
// Worker (published .js files)
if ( fsentry.path?.endsWith('.js') && fsentry.workers?.length > 0 ) {
@@ -252,7 +253,7 @@ export default function UIItemPropertiesModal ({ name, path: item_path, uid, $co
fsentry.versions.slice().reverse().forEach(version => {
$versions.append(`
<div class="item-props-version-entry">
<span class="item-props-version-meta">${html_encode(version.user ? version.user.username : '')} &bull; ${html_encode(timeago.format(version.timestamp * 1000))}</span>
<span class="item-props-version-meta">${html_encode(version.user ? version.user.username : '')} &bull; ${html_encode(formatRelativeTime(version.timestamp * 1000, Date.now(), window.locale))}</span>
<span class="item-props-version-id">${html_encode(version.id)}</span>
</div>
`);
+23 -5
View File
@@ -274,6 +274,14 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
const focus_dialog = () => {
$overlay.find('.share-modal').get(0)?.focus({ preventScroll: true });
};
// Focus a round-trip lost (a disabled control drops it on <body>, a
// re-render removes it) goes back; focus the user took elsewhere while
// waiting, into the add-people field say, stays where they put it.
const focus_moved_on = () => {
const el = document.activeElement;
return !! el && el !== document.body && el !== $overlay.find('.share-modal').get(0)
&& ! $list.get(0).contains(el);
};
// Both take HTML-safe text; `error_html` encodes the one raw source.
const show_error = (html) => {
@@ -458,7 +466,8 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
if ( by_path.size === 0 ) {
$list.attr('aria-busy', 'false').empty();
show_error(error_html(failure));
// Nothing was being shared: share_failed would say otherwise.
show_error(failure?.message ? html_encode(failure.message) : i18n('share_load_failed'));
return;
}
render(aggregateShares(target_paths, by_path));
@@ -746,7 +755,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
picker.remember(chosen, created);
invalidate_shared_roots();
await refresh();
$recipient.get(0)?.focus({ preventScroll: true });
if ( ! focus_moved_on() ) $recipient.get(0)?.focus({ preventScroll: true });
} catch (err) {
show_error(error_html(err));
$submit.prop('disabled', false);
@@ -775,7 +784,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
}
invalidate_shared_roots();
await refresh();
focus_list_control('.share-modal-row-mode', key) || focus_dialog();
if ( ! focus_moved_on() ) focus_list_control('.share-modal-row-mode', key) || focus_dialog();
});
// -- Extend a partial grant to the rest of the selection --
@@ -799,7 +808,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
await refresh();
// The link is gone once they hold everything; the mode select is the
// nearest surviving control for the same person.
focus_list_control('.share-modal-row-mode', key) || focus_dialog();
if ( ! focus_moved_on() ) focus_list_control('.share-modal-row-mode', key) || focus_dialog();
});
// -- Revoke, confirmed inline in the row --
@@ -862,6 +871,15 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
$(this).closest('.share-modal-row-confirm').find('button').prop('disabled', true);
try {
await revoke_access(recipient_of(group), revoke_paths);
// Leaving takes away the access this list is read with, and the
// items may leave the listing behind it (the Shared view) too.
if ( ! group.teamUid && ! group.pending && group.name === window.user.username ) {
invalidate_shared_roots();
close();
const files = window.dashboard_object;
files?.renderDirectory?.(files.currentPath, { consistency: 'strong' });
return;
}
if ( group.pending ) {
show_success(i18n('share_invite_cancelled', { recipient: group.name }));
} else {
@@ -873,7 +891,7 @@ export default function UIShareModal ({ items, path: item_path, name, owner, fse
await refresh();
// The focused row is gone; the dialog itself takes focus (the
// input would pop the on-screen keyboard on touch devices).
focus_dialog();
if ( ! focus_moved_on() ) focus_dialog();
} catch (err) {
show_error(error_html(err));
render(last_groups);
+17 -13
View File
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { intlLocale } from '../../i18n/intlLocale.js';
/**
* Usage renders in credits, a display unit the SERVER scales amounts into
* before they leave the API (a deployment-config multiplier the client never
@@ -25,6 +27,15 @@
* raw amounts from a deployment with no multiplier, rendered as dollars.
*/
// In the dashboard's language: "5,000" reads as five in German.
const numberFormat = (options) => {
try {
return new Intl.NumberFormat(intlLocale(globalThis.window?.locale ?? 'en'), options);
} catch {
return new Intl.NumberFormat('en', options);
}
};
/** Whether a `getMonthlyUsage()` response reports credits. */
export const usageIsCredits = (allowanceInfo) =>
allowanceInfo?.unit === 'credits';
@@ -42,14 +53,9 @@ export const formatCredits = (credits) => {
const value = Number.isFinite(credits) ? credits : 0;
if (value < 0) return `-${formatCredits(-value)}`;
if (value <= 0) return '0';
if (value >= 100) {
return Math.round(value).toLocaleString('en-US');
}
if (value >= 1) {
// Trim trailing zeros so "2.50" reads as "2.5" and "9.00" as "9".
return String(Number(value.toFixed(2)));
}
return value < 0.01 ? '<0.01' : String(Number(value.toFixed(2)));
if (value < 0.01) return `<${numberFormat({}).format(0.01)}`;
// Trailing zeros go, so 2.50 reads as "2.5" and 9.00 as "9".
return numberFormat({ maximumFractionDigits: value >= 100 ? 0 : 2 }).format(value);
};
/**
@@ -59,9 +65,7 @@ export const formatCredits = (credits) => {
export const formatDollarsFromMicrocents = (microcents) => {
const mc = Number.isFinite(microcents) ? microcents : 0;
const dollars = mc / 100_000_000;
if ( dollars !== 0 && Math.abs(dollars) < 0.005 ) return `${dollars < 0 ? '-' : ''}<$0.01`;
return window.number_format(dollars, {
decimals: 2,
prefix: '$',
});
const format = numberFormat({ style: 'currency', currency: 'USD' });
if ( dollars !== 0 && Math.abs(dollars) < 0.005 ) return `${dollars < 0 ? '-' : ''}<${format.format(0.01)}`;
return format.format(dollars);
};
+24 -12
View File
@@ -17,7 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import {
formatCredits,
formatDollarsFromMicrocents,
@@ -58,17 +58,6 @@ describe('formatCredits', () => {
});
describe('formatDollarsFromMicrocents', () => {
beforeEach(() => {
globalThis.window = globalThis.window ?? {};
globalThis.window.number_format = vi.fn(
(n, { decimals, prefix }) => `${prefix}${n.toFixed(decimals)}`,
);
});
afterEach(() => {
delete globalThis.window.number_format;
});
it('renders the raw-amount dollar string for unscaled deployments', () => {
expect(formatDollarsFromMicrocents(37_000_000)).toBe('$0.37');
expect(formatDollarsFromMicrocents(undefined)).toBe('$0.00');
@@ -81,3 +70,26 @@ describe('formatDollarsFromMicrocents', () => {
expect(formatDollarsFromMicrocents(600_000)).toBe('$0.01');
});
});
describe('amounts in the dashboard\'s language', () => {
beforeEach(() => {
globalThis.window = globalThis.window ?? {};
globalThis.window.locale = 'de';
});
afterEach(() => {
delete globalThis.window.locale;
});
it('separates thousands and decimals as the language does', () => {
expect(formatCredits(9000)).toBe('9.000');
expect(formatCredits(2.5)).toBe('2,5');
expect(formatCredits(0.001)).toBe('<0,01');
expect(formatDollarsFromMicrocents(123_450_000_000).replace(/\s/g, ' ')).toBe('1.234,50 $');
});
it("reads Puter's codes that aren't BCP 47 tags", () => {
globalThis.window.locale = 'br';
expect(formatCredits(9000.5)).toBe('9.001');
});
});
+16 -74
View File
@@ -136,6 +136,22 @@ export const toEntry = (raw, now) => {
};
};
/**
* The entries a listing contributes: its newest `limit` the center shows,
* and every unread one past them, so the badge and "Mark all as read" cover
* them all. Hidden rows are dropped before counting, so a run of them (a
* developer's deploy results) can't take the places of the rest.
*
* @param {Object[]} rows - Driver rows, newest first
* @param {number} now - Epoch ms
* @param {number} limit
* @returns {NotificationEntry[]}
*/
export const listedEntries = (rows, now, limit) => rows
.map((row) => toEntry(row, now))
.filter(Boolean)
.filter((entry, i) => i < limit || isUnread(entry));
/**
* Newest first; entries without a time sort as newest (they are the ones
* just pushed), ties keep their order.
@@ -287,80 +303,6 @@ export const titleWithBadge = (title, count) => {
return label ? `(${label}) ${base}` : base;
};
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/**
* How long ago `at` was, in the reader's language: "now", "5 minutes ago",
* "yesterday", then a short date once it's more than a week old. Uses the
* platform's formatters so nothing here needs translating; falls back to a
* plain date where they're missing.
*
* @param {number} at - Epoch ms
* @param {number} now - Epoch ms
* @param {string} [locale]
* @returns {string}
*/
export const formatRelativeTime = (at, now, locale = 'en') => {
const diff = Math.max(0, now - at);
const rtf = relativeFormatter(locale);
if ( diff < 45_000 ) return rtf ? rtf.format(0, 'second') : 'now';
if ( diff < 45 * MINUTE ) {
const minutes = Math.max(1, Math.round(diff / MINUTE));
return rtf ? rtf.format(-minutes, 'minute') : `${minutes}m`;
}
if ( diff < 22 * HOUR ) {
const hours = Math.max(1, Math.round(diff / HOUR));
return rtf ? rtf.format(-hours, 'hour') : `${hours}h`;
}
if ( diff < 7 * DAY ) {
const days = Math.max(1, Math.round(diff / DAY));
return rtf ? rtf.format(-days, 'day') : `${days}d`;
}
const then = new Date(at);
const sameYear = then.getFullYear() === new Date(now).getFullYear();
const dtf = dateFormatter(locale, {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' }),
});
return dtf ? dtf.format(then) : then.toLocaleDateString();
};
/**
* The full moment, for a tooltip behind the relative form.
*
* @param {number} at - Epoch ms
* @param {string} [locale]
* @returns {string}
*/
export const formatAbsoluteTime = (at, locale = 'en') => {
const dtf = dateFormatter(locale, { dateStyle: 'medium', timeStyle: 'short' });
return dtf ? dtf.format(new Date(at)) : new Date(at).toLocaleString();
};
const tryMake = (make) => {
try {
return make();
} catch {
return null;
}
};
// An unknown locale tag throws; the English formatter beats none at all.
const relativeFormatter = (locale) => {
if ( typeof Intl === 'undefined' || ! Intl.RelativeTimeFormat ) return null;
return tryMake(() => new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }))
?? tryMake(() => new Intl.RelativeTimeFormat('en', { numeric: 'auto' }));
};
const dateFormatter = (locale, options) => {
if ( typeof Intl === 'undefined' || ! Intl.DateTimeFormat ) return null;
return tryMake(() => new Intl.DateTimeFormat(locale, options))
?? tryMake(() => new Intl.DateTimeFormat('en', options));
};
/**
* How many toasts a burst may raise before the rest are folded into one
* "N new notifications" summary — a reconnect after a busy day must not
@@ -20,11 +20,10 @@
import { describe, it, expect } from 'vitest';
import {
badgeLabel,
formatAbsoluteTime,
formatRelativeTime,
glyphKey,
isShareNotification,
isUnread,
listedEntries,
mergeEntries,
notificationTarget,
parseCreatedAt,
@@ -140,6 +139,24 @@ describe('toEntry', () => {
});
});
describe('listedEntries', () => {
const row = (uid, type) => ({ uid, value: { type, title: uid }, created_at: null });
it("doesn't let hidden rows take the places of shown ones", () => {
const rows = [
...Array.from({ length: 40 }, (_, i) => row(`deploy${i}`, 'app.worker.deployed')),
row('share', 'fs.share.granted'),
row('older', 'fs.share.granted'),
];
expect(listedEntries(rows, NOW, 30).map((e) => e.uid)).toEqual(['share', 'older']);
});
it('keeps the newest `limit`, and every unread one past them', () => {
const rows = Array.from({ length: 6 }, (_, i) => ({ ...row(`n${i}`), acknowledged: i === 4 ? null : NOW / 1000 }));
expect(listedEntries(rows, NOW, 3).map((e) => e.uid)).toEqual(['n0', 'n1', 'n2', 'n4']);
});
});
describe('sortEntries', () => {
it('orders newest first and keeps undated entries on top', () => {
const out = sortEntries([entry('old', NOW - DAY), entry('unknown', null), entry('new', NOW)]);
@@ -396,42 +413,6 @@ describe('titleWithBadge', () => {
});
});
describe('formatRelativeTime', () => {
it('describes the recent past in words', () => {
expect(formatRelativeTime(NOW, NOW)).toBe('now');
expect(formatRelativeTime(NOW - 30_000, NOW)).toBe('now');
expect(formatRelativeTime(NOW - 5 * MIN, NOW)).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 1 * MIN, NOW)).toBe('1 minute ago');
expect(formatRelativeTime(NOW - 3 * HOUR, NOW)).toBe('3 hours ago');
expect(formatRelativeTime(NOW - DAY, NOW)).toBe('yesterday');
expect(formatRelativeTime(NOW - 3 * DAY, NOW)).toBe('3 days ago');
});
it('falls back to a short date past a week', () => {
expect(formatRelativeTime(NOW - 10 * DAY, NOW)).toBe('Aug 17');
expect(formatRelativeTime(NOW - 400 * DAY, NOW)).toBe('Jul 23, 2025');
});
it('never reports the future', () => {
expect(formatRelativeTime(NOW + HOUR, NOW)).toBe('now');
});
it('localises', () => {
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'fr')).toBe('il y a 5 minutes');
});
it('survives an unknown locale', () => {
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'not-a-locale-!!')).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 10 * DAY, NOW, 'not-a-locale-!!')).toBe('Aug 17');
});
});
describe('formatAbsoluteTime', () => {
it('spells out the moment', () => {
expect(formatAbsoluteTime(NOW, 'en')).toMatch(/Aug 27, 2026/);
});
});
describe('planBurstToasts', () => {
const many = (n) => Array.from({ length: n }, (_, i) => entry(`n${i}`, NOW - i));
+18 -5
View File
@@ -122,15 +122,26 @@ async function UIWindowDisable2FA (options) {
hint.text(REVALIDATE_POPUP_TEXT).show();
try {
await openRevalidatePopup(revalidateUrl);
return true;
} catch (e) {
onError(e.message || 'Authentication failed');
return;
return false;
} finally {
hint.hide();
}
};
$(el_window).find('.disable-2fa-btn').on('click', async function (e) {
$(el_window).find('.disable-2fa-btn').on('click', async function () {
try {
await submit();
} catch {
// A request that never got an answer (offline): say so, and give
// the button and the field back for a retry.
onError(i18n('something_went_wrong', [], false));
}
});
async function submit () {
$(el_window).find('.form-success-msg, .form-error-msg').hide();
const password = password_entry.get('value');
@@ -143,7 +154,9 @@ async function UIWindowDisable2FA (options) {
}
if ( oidc_only && !revalidated && !password ) {
await myOpenRevalidatePopup();
// A popup closed or blocked says so; the request would only be
// refused, and its message would replace that.
if ( ! await myOpenRevalidatePopup() ) return;
const res = await doSubmit({ password: undefined });
const data = await res.json().catch(() => ({}));
@@ -168,7 +181,7 @@ async function UIWindowDisable2FA (options) {
return;
}
if ( data.code === 'oidc_revalidation_required' && data.revalidate_url ) {
await myOpenRevalidatePopup(data.revalidate_url);
if ( ! await myOpenRevalidatePopup(data.revalidate_url) ) return;
const r = await doSubmit({ password: undefined });
if ( r.ok ) {
onSuccess();
@@ -180,7 +193,7 @@ async function UIWindowDisable2FA (options) {
return;
}
onError(data.message || 'Request failed', data);
});
}
function doSubmit ({ password } = {}) {
// Do not send Authorization: user-protected endpoints use session cookie (hasHttpOnlyCookie).
+21 -14
View File
@@ -615,15 +615,22 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
const oidc_only = !!(window.user && window.user.oidc_only);
// ── API helpers ──────────────────────────────────────────────────────
// A network failure is a failed request like any other: rejecting here
// would leave the code inputs disabled and the spinner up for good.
const api = async (endpoint, body = {}) => {
const res = await fetch(`${window.api_origin}/auth/configure-2fa/${endpoint}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
let res;
try {
res = await fetch(`${window.api_origin}/auth/configure-2fa/${endpoint}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
} catch {
return { ok: false, data: {} };
}
return { ok: res.ok, data: await read_json(res) };
};
@@ -709,8 +716,8 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
h += '<div class="tfa-codes-card">';
h += ' <div class="tfa-codes-grid"></div>';
h += ' <div class="tfa-codes-actions">';
h += ` <button class="tfa-copy-codes">${SVG_COPY} <span>${html_encode(i18n('copy'))}</span></button>`;
h += ` <button class="tfa-print-codes">${SVG_PRINT} <span>${html_encode(i18n('print'))}</span></button>`;
h += ` <button class="tfa-copy-codes">${SVG_COPY} <span>${i18n('copy')}</span></button>`;
h += ` <button class="tfa-print-codes">${SVG_PRINT} <span>${i18n('print')}</span></button>`;
h += ' </div>';
h += '</div>';
@@ -719,11 +726,11 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
h += '<div class="tfa-confirmations">';
h += ' <div class="tfa-confirm-item">';
h += ' <input type="checkbox" id="tfa-confirm-saved" />';
h += ` <label for="tfa-confirm-saved">${html_encode(i18n('setup2fa_5_confirmation_1'))}</label>`;
h += ` <label for="tfa-confirm-saved">${i18n('setup2fa_5_confirmation_1')}</label>`;
h += ' </div>';
h += ' <div class="tfa-confirm-item">';
h += ' <input type="checkbox" id="tfa-confirm-ready" />';
h += ` <label for="tfa-confirm-ready">${html_encode(i18n('setup2fa_5_confirmation_2'))}</label>`;
h += ` <label for="tfa-confirm-ready">${i18n('setup2fa_5_confirmation_2')}</label>`;
h += ' </div>';
h += '</div>';
@@ -732,7 +739,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
// ── Screen 3: Verify Code ─────────────────────────────────────────────
h += '<div class="tfa-screen" data-screen="3">';
h += `<p class="tfa-instruction">${html_encode(i18n('setup2fa_3_step_heading'))}</p>`;
h += `<p class="tfa-instruction">${i18n('setup2fa_3_step_heading')}</p>`;
h += '<div class="tfa-code-section">';
h += ' <div class="tfa-code-inputs">';
for ( let i = 0; i < 6; i++ ) {
@@ -747,7 +754,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
// ── Screen 4: Success ────────────────────────────────────────────────
h += '<div class="tfa-screen" data-screen="4">';
h += `<div class="tfa-success-icon">${SVG_SHIELD_CHECK}</div>`;
h += `<div class="tfa-success-title">${html_encode(i18n('two_factor_enabled'))}</div>`;
h += `<div class="tfa-success-title">${i18n('two_factor_enabled')}</div>`;
h += '<p class="tfa-success-text">Your account is now protected with two-factor authentication. You\'ll be asked for a verification code each time you sign in.</p>';
h += `<button class="tfa-btn tfa-btn-success tfa-done-btn">Done</button>`;
h += '</div>'; // end screen 4
+30 -5
View File
@@ -94,6 +94,17 @@ const kindBadgeLabel = (kind) => {
}
};
/** A refused request's message: the body is JSON `{ message, code }`. */
const responseMessage = async (resp) => {
const text = await resp.text();
try {
return JSON.parse(text)?.message || text;
} catch {
return text;
}
};
const UIWindowManageSessions = async function UIWindowManageSessions (options) {
options = options ?? {};
@@ -493,19 +504,23 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
}),
},
);
if ( !resp.ok ) throw new Error(await resp.text());
if ( !resp.ok ) throw new Error(await responseMessage(resp));
} catch ( e ) {
// Roll back optimistic update
session.label = original || null;
el_title.textContent = sessionTitle(session);
alertDialog({ message: e?.toString?.() ?? String(e) });
alertDialog({ message: e?.message ?? String(e) });
}
};
const onBlur = () => finish(true);
el_input.addEventListener('keydown', (ev) => {
if ( ev.key === 'Enter' ) finish(true);
else if ( ev.key === 'Escape' ) finish(false);
else if ( ev.key === 'Escape' ) {
// Cancels the rename only, not the whole modal.
ev.stopPropagation();
finish(false);
}
});
el_input.addEventListener('blur', onBlur);
};
@@ -609,7 +624,9 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
reload_sessions();
return;
}
alertDialog({ message: await resp.text() });
// Revoked meanwhile (another tab): the list is what's stale.
if ( resp.status === 404 ) reload_sessions();
alertDialog({ message: await responseMessage(resp) });
} catch ( e ) {
alertDialog({ message: e.toString() });
}
@@ -731,6 +748,14 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
el_search.type = 'search';
el_search.placeholder = i18n('ui_search') || 'Search sessions…';
el_search.classList.add('session-manager-search');
// Escape empties a search first; the next one closes the modal.
el_search.addEventListener('keydown', (ev) => {
if ( ev.key !== 'Escape' || el_search.value === '' ) return;
ev.preventDefault();
ev.stopPropagation();
el_search.value = '';
el_search.dispatchEvent(new Event('input'));
});
el_search.addEventListener('input', () => {
searchQuery = el_search.value.trim();
// Pure client-side filter — re-render from the cached list
@@ -774,7 +799,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
reload_sessions();
return;
}
alertDialog({ message: await resp.text() });
alertDialog({ message: await responseMessage(resp) });
} catch ( e ) {
alertDialog({ message: e.toString() });
}
+118 -23
View File
@@ -36,6 +36,10 @@
--select-tint: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.1);
--select-tint-strong: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.2);
--select-ring: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.4);
/* --select-color is 3.7:1 on white, short of the 4.5:1 text needs: the
same hue, darker, for text in it (5.3:1) and fills under white text. */
--select-text: hsl(var(--select-hue), var(--select-saturation), 45%);
--select-fill: hsl(var(--select-hue), var(--select-saturation), 45%);
--dashboard-text: #444;
--dashboard-border: #e0e0e0;
@@ -164,6 +168,8 @@ body {
--select-tint: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.16);
--select-tint-strong: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.28);
--select-ring: hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.5);
/* Lighter on dark surfaces (5.5:1 on the cards); fills keep theirs. */
--select-text: hsl(var(--select-hue), var(--select-saturation), 65%);
--dashboard-text: #d4d4d4;
--dashboard-border: #3d3d3d;
@@ -575,15 +581,17 @@ body {
}
/* Keyboard focus on the sidebar's controls: swap the UA's default blue
ring for the dashboard's own selection ring, hugging the control's
ring for the dashboard's own selection color, hugging the control's
rounded corners from the inside. :focus-visible only, so a mouse click
(which also focuses these) never paints it. */
(which also focuses these) never paints it. A 2px ring is the only sign
of focus here, so it is the solid color: --select-ring's translucent
one is 1.6:1 against the surfaces, under the 3:1 a focus ring needs. */
.dashboard-sidebar-item:focus-visible,
.dashboard-user-btn:focus-visible,
.dashboard-sidebar-toggle:focus-visible,
.dashboard-sidebar-collapse-toggle:focus-visible,
.dashboard-sidebar-close:focus-visible {
outline: 2px solid var(--select-ring);
outline: 2px solid var(--select-color);
outline-offset: -2px;
}
@@ -695,6 +703,17 @@ body {
background: linear-gradient(to bottom, rgba(30, 30, 30, 0) 0%, rgba(30, 30, 30, 0.85) 40%, rgba(30, 30, 30, 1) 100%);
}
.usage-table-show-less {
color: #9ca3af;
border-color: #4b5563;
}
.usage-table-show-less:hover {
background: #374151;
border-color: #6b7280;
color: #d1d5db;
}
.dashboard-section-billing .billing-card {
background: var(--dashboard-card-background);
}
@@ -860,6 +879,34 @@ body {
.myapps-icon-clear {
pointer-events: auto;
cursor: pointer;
padding: 0;
border: none;
border-radius: 4px;
background: none;
}
.myapps-icon-clear svg {
display: block;
width: 100%;
height: 100%;
}
.myapps-icon-clear:focus-visible {
outline: 2px solid var(--select-color);
outline-offset: 2px;
}
@media (pointer: coarse) {
/* Keeps its 16px look and hit-tests at 44x44. */
.myapps-icon-clear::before {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
}
@media (hover: hover) {
@@ -1096,6 +1143,17 @@ input.myapps-search:disabled {
opacity: 0.85;
}
/* Touch: the same 7px dot, in a 24px target; at 17px apiece, side by side,
a tap easily lands on the neighbor. */
@media (pointer: coarse) {
.myapps-pager-dot,
.myapps-group-dot {
width: 24px;
height: 24px;
padding: 8.5px;
}
}
@media (hover: hover) {
.myapps-pager-dot:not(.active):hover,
.myapps-group-dot:not(.active):hover {
@@ -1229,7 +1287,7 @@ input.myapps-search:disabled {
/* The enlargement alone is easy to miss; a ring says which tile has focus. */
.myapps-tile:focus-visible {
outline: 2px solid var(--select-ring);
outline: 2px solid var(--select-color);
outline-offset: 2px;
border-radius: 14px;
}
@@ -2110,8 +2168,8 @@ p.myapps-add-error {
color: var(--dashboard-error-text);
}
.myapps-add-send {
background: var(--select-color);
border-color: var(--select-color);
background: var(--select-fill);
border-color: var(--select-fill);
color: #fff;
}
@media (hover: hover) {
@@ -2167,7 +2225,9 @@ p.myapps-add-error {
display: flex;
flex-direction: column;
width: min(460px, 100%);
max-height: min(600px, 85vh);
/* 100%: within the overlay, which is the visible height; vh is the
largest one, and with the browser's bars showing overflows it. */
max-height: min(600px, 85vh, 100%);
background: var(--dashboard-card-background);
border: 1px solid var(--dashboard-border);
border-radius: 14px;
@@ -2411,7 +2471,7 @@ p.myapps-add-error {
.item-props-modal {
width: 100%;
max-width: none;
max-height: 88vh;
max-height: min(88vh, 100%);
border-radius: 16px 16px 0 0;
border-bottom: none;
transform: translateY(24px);
@@ -2465,7 +2525,8 @@ p.myapps-add-error {
display: flex;
flex-direction: column;
width: min(440px, 100%);
max-height: min(620px, 85vh);
/* 100%: within the overlay (see .item-props-modal). */
max-height: min(620px, 85vh, 100%);
background: var(--dashboard-card-background);
border: 1px solid var(--dashboard-border);
border-radius: 14px;
@@ -2986,7 +3047,7 @@ select.share-modal-link-access {
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--select-color);
color: var(--select-text);
cursor: pointer;
}
@media (hover: hover) {
@@ -3216,7 +3277,7 @@ select.share-modal-row-mode:focus {
.share-modal {
width: 100%;
max-width: none;
max-height: 88vh;
max-height: min(88vh, 100%);
border-radius: 16px 16px 0 0;
border-bottom: none;
transform: translateY(24px);
@@ -3992,7 +4053,7 @@ body.myapps-reordering .myapps-tile {
}
.dashboard-section-files .directories li.active {
color: var(--select-color);
color: var(--select-text);
font-weight: 500;
}
@@ -4091,6 +4152,10 @@ body.myapps-reordering .myapps-tile {
.path-btn {
filter: invert(1) hue-rotate(180deg);
}
/* A black chevron; uninverted it disappears into the dark header. */
.dashboard-section-files .path-seperator {
filter: invert(1);
}
@media (hover: hover) {
/* The hover chip is light (#EEE) even in dark mode, so the icon must
drop its dark-mode inversion to stay readable on it. */
@@ -4154,7 +4219,7 @@ body.myapps-reordering .myapps-tile {
.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);
outline: 2px solid var(--select-color);
outline-offset: -2px;
}
@@ -4211,9 +4276,9 @@ body.myapps-reordering .myapps-tile {
}
.dashboard-section-files .header .path .dirname.context-menu-active {
color: var(--dashboard-background);
background-color: var(--select-color);
border: 1px solid var(--select-color);
color: #fff;
background-color: var(--select-fill);
border: 1px solid var(--select-fill);
}
.dashboard-section-files .header .columns {
@@ -4514,7 +4579,7 @@ body.myapps-reordering .myapps-tile {
padding: 0 6px;
box-sizing: border-box;
border-radius: 10px;
background: var(--select-color);
background: var(--select-fill);
color: #fff;
font-size: 11px;
font-weight: 600;
@@ -4887,6 +4952,14 @@ body.myapps-reordering .myapps-tile {
filter: none;
}
@media (prefers-color-scheme: dark) {
/* A black glyph, lost on a dark disc; it gets the white one the shortcut
and worker badges carry. */
.dashboard-section-files .files-tab .files.files-grid-view .row img.item-has-website-url-badge {
background: #ffffff;
}
}
.dashboard-section-files .files-tab .files.files-grid-view .row {
display: flex;
flex-direction: column;
@@ -6014,7 +6087,7 @@ body.myapps-reordering .myapps-tile {
/* Inset: the card's rounded overflow clip would cut an outer ring. */
.dashboard .bento-card-fancy-header:focus-visible {
outline: 2px solid var(--select-ring);
outline: 2px solid var(--select-color);
outline-offset: -2px;
}
@@ -6154,7 +6227,7 @@ body.myapps-reordering .myapps-tile {
}
.dashboard .bento-recent-app:focus-visible {
outline: 2px solid var(--select-ring);
outline: 2px solid var(--select-color);
outline-offset: 2px;
}
@@ -6924,6 +6997,12 @@ body.myapps-reordering .myapps-tile {
transition: transform 0.2s ease;
}
/* Off, the track is barely darker than the card it sits on (1.2:1 on the
2FA warning card); its outline is what shows the control is there. */
.dashboard-switch input:not(:checked) + .dashboard-switch-slider {
box-shadow: inset 0 0 0 1px var(--dashboard-text-hint);
}
.dashboard-switch input:checked + .dashboard-switch-slider {
background-color: var(--dashboard-success-border);
}
@@ -7003,6 +7082,20 @@ body.myapps-reordering .myapps-tile {
box-shadow: 0 0 0 3px var(--select-ring);
}
/* style.css's red puts the white label at 4.4:1; this is the dashboard
dialogs' danger red, at 5.3:1. */
.dashboard-danger-card .button-danger {
border-color: #d90707;
background: #d90707;
}
@media (hover: hover) {
.dashboard-danger-card .button-danger:hover {
border-color: #b80606;
background: #b80606;
}
}
/* Responsive styles for Account tab */
@media (max-width: 768px) {
.dashboard-tab-content {
@@ -7765,7 +7858,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--select-color);
background: var(--select-fill);
color: #fff;
font-size: 11px;
font-weight: 600;
@@ -8216,7 +8309,7 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
.dashboard-notifications-mark-all:focus-visible,
.dashboard-notifications-close:focus-visible,
.dashboard-notifications-retry:focus-visible {
outline: 2px solid var(--select-ring);
outline: 2px solid var(--select-color);
outline-offset: -2px;
}
@@ -8329,9 +8422,11 @@ body.dashboard-mode .window-dashboard-headless .window-body-app {
margin-bottom: 10px;
}
/* Screen-reader-only: the live region for arrivals, and a row's unread state */
/* Screen-reader-only: the live region for arrivals, a row's unread state,
and what the Apps search found */
.dashboard-notifications-live,
.dashboard-notification-sr {
.dashboard-notification-sr,
.myapps-search-status {
position: absolute;
width: 1px;
height: 1px;
+1 -11
View File
@@ -5036,16 +5036,6 @@ html.dark-mode .usage-table-fade-overlay {
color: #374151;
}
html.dark-mode .usage-table-show-less {
color: #9ca3af;
border-color: #4b5563;
}
html.dark-mode .usage-table-show-less:hover {
background: #374151;
border-color: #6b7280;
color: #d1d5db;
}
.driver-usage-details-content-table {
@@ -5104,7 +5094,7 @@ html.dark-mode .usage-table-show-less:hover {
}
.driver-usage-details-content-table thead th .usage-sort-btn:focus-visible {
outline: 2px solid var(--select-ring);
outline: 2px solid var(--select-color);
outline-offset: 2px;
border-radius: 2px;
}
+1 -1
View File
@@ -1695,7 +1695,7 @@ window.refresh_trash_state = async function () {
// 'strong' consistency: an 'eventual' stat can be served from the SDK
// cache, which is stale right after a trash mutation and — when seeded
// by readdir — lacks the `is_empty` field entirely.
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'strong' });
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
+10 -1
View File
@@ -29,6 +29,14 @@ import { fetchWithSessionCookieRetry, isSessionAuthError } from '../util/session
* sign-in flow has been started and the user has to try again after it.
*/
// Refusals whose server message is English and, for the password, reads
// like the new and confirmed passwords disagreeing ("Password mismatch").
const CODE_MESSAGES = {
password_mismatch: 'incorrect_password',
code_mismatch: 'confirm_code_generic_incorrect',
too_many_requests: 'confirm_code_generic_too_many_requests',
};
/**
* POSTs an account change to `/user-protected/<route>`, the way every
* account dialog has to. Those routes authenticate with the session cookie
@@ -62,7 +70,8 @@ export default async function postUserProtected (route, body, { password, onReva
return { ok: false, data, reauth: true, message: i18n('reauth_required_message', [], false) };
}
if ( res.ok ) return { ok: true, data };
return { ok: false, data, message: data.message || res.statusText || i18n('error_unknown_cause', [], false) };
const known = CODE_MESSAGES[data.code];
return { ok: false, data, message: known ? i18n(known, [], false) : data.message || res.statusText || i18n('error_unknown_cause', [], false) };
};
const revalidate = async (url) => {
@@ -113,6 +113,14 @@ describe('postUserProtected', () => {
expect(fetch).not.toHaveBeenCalled();
});
it('says the current password is wrong in the reader\'s language', async () => {
fetch.mockResolvedValueOnce(reply(400, { message: 'Password mismatch', code: 'password_mismatch' }));
const result = await postUserProtected('change-password', { new_pass: 'n' }, { password: 'wrong' });
expect(result).toMatchObject({ ok: false, message: 'incorrect_password' });
});
it('passes the server message through on any other failure', async () => {
fetch.mockResolvedValueOnce(reply(400, { message: 'Username taken', code: 'bad_request' }));
@@ -369,6 +369,9 @@ export default function shareRecipientPicker ({
});
$input.on('keydown', (e) => {
// Keys that drive an IME (picking or confirming a candidate) are its
// own; jQuery's event doesn't carry isComposing, the browser's does.
if ( e.originalEvent?.isComposing || e.keyCode === 229 ) return;
if ( e.key === 'ArrowDown' ) {
e.preventDefault();
if ( picked ) return;
@@ -258,6 +258,17 @@ describe('choosing someone', () => {
});
});
it('leaves Enter and the arrows to an IME that is composing', async () => {
const { picker, $input } = await open();
const key = (k, init = {}) => $input[0].dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init }));
key('ArrowDown', { isComposing: true });
key('Enter', { isComposing: true });
expect(picker.recipient()).toBe(null);
key('ArrowDown');
key('Enter');
expect(picker.recipient()).not.toBe(null);
});
it('hands a typed address over untouched', async () => {
const { picker, $input } = mount();
$input.val(' [email protected] ').trigger('input');
+105
View File
@@ -0,0 +1,105 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { intlLocale } from './intlLocale.js';
const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/**
* How long ago `at` was, in the reader's language: "now", "5 minutes ago",
* "yesterday", then a short date once it's more than a week old. Uses the
* platform's formatters so nothing here needs translating; falls back to a
* plain date where they're missing.
*
* @param {number} at - Epoch ms
* @param {number} now - Epoch ms
* @param {string} [locale]
* @returns {string}
*/
export const formatRelativeTime = (at, now, locale = 'en') => {
const diff = Math.max(0, now - at);
const rtf = relativeFormatter(locale);
if ( diff < 45_000 ) return rtf ? rtf.format(0, 'second') : 'now';
if ( diff < 45 * MINUTE ) {
const minutes = Math.max(1, Math.round(diff / MINUTE));
return rtf ? rtf.format(-minutes, 'minute') : `${minutes}m`;
}
const then = new Date(at);
// "Yesterday" names a calendar day, so days are counted midnight to midnight.
const days = Math.round((startOfDay(new Date(now)) - startOfDay(then)) / DAY);
if ( days === 0 || diff < 22 * HOUR ) {
const hours = Math.max(1, Math.round(diff / HOUR));
return rtf ? rtf.format(-hours, 'hour') : `${hours}h`;
}
if ( days < 7 ) {
return rtf ? rtf.format(-days, 'day') : `${days}d`;
}
const sameYear = then.getFullYear() === new Date(now).getFullYear();
const dtf = dateFormatter(locale, {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' }),
});
return dtf ? dtf.format(then) : then.toLocaleDateString();
};
/**
* The full moment, for a tooltip behind the relative form.
*
* @param {number} at - Epoch ms
* @param {string} [locale]
* @returns {string}
*/
export const formatAbsoluteTime = (at, locale = 'en') => {
const dtf = dateFormatter(locale, { dateStyle: 'medium', timeStyle: 'short' });
return dtf ? dtf.format(new Date(at)) : new Date(at).toLocaleString();
};
const startOfDay = (date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const tryMake = (make) => {
try {
return make();
} catch {
return null;
}
};
// A listing formats a date per row; making a formatter costs far more than
// using one. Keyed by language and options, of which there are a handful.
const formatters = new Map();
const cached = (key, make) => {
if ( ! formatters.has(key) ) formatters.set(key, make());
return formatters.get(key);
};
// An unknown locale tag throws; the English formatter beats none at all.
const relativeFormatter = (locale) => {
if ( typeof Intl === 'undefined' || ! Intl.RelativeTimeFormat ) return null;
return cached(`relative:${locale}`, () => tryMake(() => new Intl.RelativeTimeFormat(intlLocale(locale), { numeric: 'auto' }))
?? tryMake(() => new Intl.RelativeTimeFormat('en', { numeric: 'auto' })));
};
const dateFormatter = (locale, options) => {
if ( typeof Intl === 'undefined' || ! Intl.DateTimeFormat ) return null;
return cached(`date:${locale}:${JSON.stringify(options)}`, () => tryMake(() => new Intl.DateTimeFormat(intlLocale(locale), options))
?? tryMake(() => new Intl.DateTimeFormat('en', options)));
};
+85
View File
@@ -0,0 +1,85 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import { formatAbsoluteTime, formatRelativeTime } from './formatTime.js';
const NOW = Date.UTC(2026, 7, 27, 12, 0, 0);
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
describe('formatRelativeTime', () => {
it('describes the recent past in words', () => {
expect(formatRelativeTime(NOW, NOW)).toBe('now');
expect(formatRelativeTime(NOW - 30_000, NOW)).toBe('now');
expect(formatRelativeTime(NOW - 5 * MIN, NOW)).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 1 * MIN, NOW)).toBe('1 minute ago');
expect(formatRelativeTime(NOW - 3 * HOUR, NOW)).toBe('3 hours ago');
expect(formatRelativeTime(NOW - DAY, NOW)).toBe('yesterday');
expect(formatRelativeTime(NOW - 3 * DAY, NOW)).toBe('3 days ago');
});
it('counts days by the calendar, not by 24-hour spans', () => {
const at = (d, h, m = 0) => new Date(2026, 7, d, h, m).getTime();
expect(formatRelativeTime(at(27, 0, 45), at(27, 23, 30))).toBe('23 hours ago');
expect(formatRelativeTime(at(24, 23), at(26, 9))).toBe('2 days ago');
expect(formatRelativeTime(at(26, 20), at(27, 19))).toBe('yesterday');
});
it('falls back to a short date past a week', () => {
expect(formatRelativeTime(NOW - 10 * DAY, NOW)).toBe('Aug 17');
expect(formatRelativeTime(NOW - 400 * DAY, NOW)).toBe('Jul 23, 2025');
});
it('never reports the future', () => {
expect(formatRelativeTime(NOW + HOUR, NOW)).toBe('now');
});
it('localises', () => {
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'fr')).toBe('il y a 5 minutes');
});
it("reads Puter's language codes that aren't BCP 47 tags", () => {
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'br')).toBe('há 5 minutos');
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'ua')).toBe('5 хвилин тому');
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'zhtw')).toBe('5 分鐘前');
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'emoji')).toBe('5 minutes ago');
expect(formatAbsoluteTime(NOW, 'zhtw')).toMatch(/^2026年8月\d+日/);
});
it('survives an unknown locale', () => {
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'not-a-locale-!!')).toBe('5 minutes ago');
expect(formatRelativeTime(NOW - 10 * DAY, NOW, 'not-a-locale-!!')).toBe('Aug 17');
});
});
describe('formatAbsoluteTime', () => {
it('spells out the moment', () => {
expect(formatAbsoluteTime(NOW, 'en')).toMatch(/Aug 27, 2026/);
});
});
describe('formatter reuse', () => {
it('keeps each language apart', () => {
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'de')).toBe('vor 5 Minuten');
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'fr')).toBe('il y a 5 minutes');
expect(formatRelativeTime(NOW - 5 * MIN, NOW, 'de')).toBe('vor 5 Minuten');
});
});
+30
View File
@@ -0,0 +1,30 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Puter's language codes that aren't the BCP 47 tag for that language: Intl
// reads `br` as Breton, and `ua` and `emoji` as nothing at all.
const INTL_LOCALES = { br: 'pt-BR', ua: 'uk', zh: 'zh-CN', zhtw: 'zh-TW', emoji: 'en' };
/**
* The tag to hand Intl (dates, numbers) for one of Puter's language codes.
*
* @param {string} locale - A Puter language code, e.g. `window.locale`
* @returns {string}
*/
export const intlLocale = (locale) => INTL_LOCALES[locale] ?? locale;
+38
View File
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import translations from './translations/translations.js';
import { intlLocale } from './intlLocale.js';
describe('intlLocale', () => {
it('maps the codes Intl would misread', () => {
expect(intlLocale('br')).toBe('pt-BR');
expect(intlLocale('ua')).toBe('uk');
expect(intlLocale('zhtw')).toBe('zh-TW');
expect(intlLocale('emoji')).toBe('en');
expect(intlLocale('de')).toBe('de');
});
it('gives Intl a tag it accepts for every language Puter ships', () => {
for ( const code of Object.keys(translations) ) {
expect(() => new Intl.DateTimeFormat(intlLocale(code)), code).not.toThrow();
}
});
});
+5
View File
@@ -324,6 +324,7 @@ const en = {
previous_page: 'Previous page',
quit: 'Quit',
search_apps: 'Search apps...',
clear_search: 'Clear search',
uninstall: 'Uninstall',
uninstall_app: 'Uninstall {{name}}?',
uninstall_app_body: 'This will revoke all permissions for this app.',
@@ -347,6 +348,9 @@ const en = {
password_change_required_title: 'Choose your own password',
password_change_required_hint: 'This account was created for you, and its password is still the one you were given. Pick your own to continue.',
password_change_required_same: 'Your new password must be different from the one you were given.',
password_unchanged: 'Your new password must be different from your current one.',
username_unchanged: 'That is already your username.',
email_unchanged: 'That is already your email address.',
password_strength_error: 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.',
passwords_do_not_match: '`New Password` and `Confirm New Password` do not match.',
paste: 'Paste',
@@ -544,6 +548,7 @@ const en = {
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.',
share_load_failed: 'Could not load who has access.',
share_load_partial_failed:
'Could not load who has access to every item.',
share_access_list_too_many:
+8 -1
View File
@@ -478,12 +478,17 @@ $(document).bind('keydown', async function (e) {
//-----------------------------------------------------------------------
if ( e.which === 27 ) {
// close open context menus
const closedMenu = $('.context-menu').length > 0;
$('.context-menu').remove();
// close the Launch Popover if it's open
$('.launch-popover').closest('.popover').fadeOut(200, function () {
$('.launch-popover').closest('.popover').remove();
});
// That Escape was the menu's; later handlers must not also act on it
// (clear a Files selection, close its preview).
if ( closedMenu ) e.stopImmediatePropagation();
}
});
@@ -905,7 +910,9 @@ $(document).bind('keyup keydown', async function (e) {
// Undo
// ctrl/command + z, will undo last action
//-----------------------------------------------------------------------------
if ( (e.ctrlKey || e.metaKey) && e.which === 90 ) {
// Not in a text field, whose own undo is what the user means: the last
// file action could be an upload, which Undo deletes outright.
if ( (e.ctrlKey || e.metaKey) && e.which === 90 && ! $(focused_el).is('input, textarea, [contenteditable]') ) {
window.undo_last_action();
return false;
}