Compare commits

..
60 Commits
Author SHA1 Message Date
Linus Rath fa31933922 chore: update version to 1.7.7 2026-07-09 16:25:12 +02:00
Linus Rath d3f77ef9cf feat: add plugin ui.rerenderEmail API and restyle read-receipt banner 2026-07-09 16:04:33 +02:00
Linus Rath 5ccba83129 Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts:
#	lib/plugin-sandbox/host-api.ts
2026-07-09 14:57:36 +02:00
752e71198c feat: add 3 new plugin hooks : onBeforeBlobUpload, onBeforeDraftAutoSave, onBeforeEditDraft (#586)
Co-authored-by: Linus Rath <minipixxelinfo@gmail.com>
2026-07-09 14:55:34 +02:00
Linus Rath 2cb74c0186 i18n: use example.com in email placeholder strings 2026-07-09 14:54:04 +02:00
Linus Rath 9ab7339320 feat: add plugin ui.prompt dialog and first-class settings-section tabs 2026-07-09 14:51:17 +02:00
Linus Rath 782974ecdb fix: load trusted senders address book on settings page so count isn't 0 2026-07-09 14:01:05 +02:00
Linus Rath c47137fb49 feat: enable trusted-senders address book sync by default when contacts are available 2026-07-09 13:59:52 +02:00
Linus Rath 38313639ed fix: use callable .get to detect Headers in pickRequestHost 2026-07-09 13:51:12 +02:00
Linus Rath e933800792 fix: store event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE 2026-07-09 13:45:04 +02:00
Linus Rath 60a1cc670c fix: apply per-domain favicon override in root metadata #585 2026-07-09 13:30:44 +02:00
dealerwebandLinus Rath c0515001f1 i18n: restore key parity after the Jalali calendar
The translation parity test fails on main again after the Jalali
calendar landed: the two newest locales, he and sk, were missed when
the twelve Jalali month names were added - they get the same Latin
transliterations every other non-Persian locale received.

fa in turn carried four keys that do not exist in en and are not
referenced anywhere in the code (the email_composer.text_direction
block and settings.templates.image_too_large) - removed, as the
suite's no-extra-keys check demands.
2026-07-08 22:43:49 +02:00
Hamed FallahandGitHub e10fced28a feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
* feat: add Jalali (Persian/Shamsi) calendar support with Saturday as week start

- Add jalaali-js library for Gregorian ↔ Jalali date conversion
- Create lib/jalali-utils.ts with Jalali calendar utilities
- Create hooks/use-calendar-locale.ts for unified calendar locale handling
- Expand FirstDayOfWeek type to include 6 (Saturday)
- Update all calendar views (month, week, day, mini, toolbar) to support
  Jalali calendar display and Saturday-first week ordering
- Add Jalali month names (Farvardin … Esfand) to all locale files
- Add Persian (fa) locale with full translations
- Update settings UI to include Saturday as first day of week option
- Update useFormatEventDate to show Jalali dates when locale is fa
- Auto-detect Jalali calendar when fa locale is active

The calendar system automatically switches to Jalali when the locale is
set to Persian (fa). All internal date handling remains Gregorian (ISO
8601) for JMAP protocol compatibility; Jalali conversion is purely at
the display layer.

* Add PR template for Jalali calendar feature

* chore: remove accidentally added PR template

* fix: add image_too_large key to fa locale for PR #462 compatibility
2026-07-08 15:42:42 +02:00
dealerwebandLinus Rath 3d36492518 i18n: restore key parity after the Hebrew locale
The translation parity test fails on main since the Hebrew locale
landed. Three gaps, all from catalogs drifting past each other:

The new he locale was based on an older en catalog and was missing 17
keys (pin/unpin, recipient autocomplete, attachment-upload validation,
the date_locale block, the tint_list_rows and show_folder_total_count
settings, language.sk). They are translated to Hebrew here; date_locale
keeps the same English values every other locale currently has.

The rtl_editing setting existed only in en and he - added translated to
the other 20 locales.

language.he was missing everywhere - added as the endonym "עברית" to
all locales, matching how the other language names are written.
2026-07-08 15:42:02 +02:00
dealerwebandLinus Rath 94af4725b6 Fix: send mailto unsubscribe ourselves instead of via the OS handler
The List-Unsubscribe action for mailto: links created a hidden anchor,
clicked it and reported success. That hands the mailto: URL to the OS
default mail handler - for a webmail user that opens the wrong program
or nothing at all, and the unsubscribe message is never sent, while the
banner still claims it was.

The confirm flow now parses the mailto: URL (address, subject, body -
percent-decoded manually since RFC 6068 does not use plus-encoding) and
sends the message through the account's own JMAP client, preferring the
identity that received the newsletter so the list can match the
subscriber. In unified views the send is routed to the email's owning
account. Success is only reported once the server accepted the message.

The mobile confirm dialog reused the success strings as its question
text; it gets proper confirm_message strings in all 22 locales, and
success_mailto now says what actually happened.
2026-07-08 15:41:46 +02:00
Shuki VakninandLinus Rath 22418c17cf feat(i18n): Hebrew locale + full RTL support 2026-07-08 00:07:34 +02:00
Linus Rath 8904d724bb fix: hide Files when account lacks filenode capability #563 2026-07-07 23:59:19 +02:00
Linus Rath c7d551f185 fix: attachment reminder ignores quoted text on reply/forward #570 2026-07-07 23:54:50 +02:00
Linus Rath 6470fa86f0 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-07 23:44:34 +02:00
Linus Rath 8647d709ff fix: only commit recipient on Space when input is a valid email #571 2026-07-07 23:43:54 +02:00
Shuki VakninandLinus Rath b73d1b55d1 fix(email): render emails that put height:100% on a wrapper element
Some HTML emails set height:100% on a full-bleed wrapper table/div rather
than html/body. With the viewer's body{overflow:hidden}, this collapses
documentElement.scrollHeight to the iframe's 150px default, so the
scrollHeight-based auto-resize locks the iframe short and the body renders
blank below the fold. A Box.co.il verification email rendered as a
logo-only 150px strip.

- Neutralise height:100% on any element so the body grows to its content.
- Measure max(documentElement, body).scrollHeight, and re-measure on a
  fixed cadence over a short settle window so a late reflow (height:100%
  wrapper, or images that resize after onload) is caught even when no
  ResizeObserver/image event fires.
2026-07-07 23:40:18 +02:00
honzupandLinus Rath 7db6fd7e24 feat: add setting to disable tag-color row tint in message list
Message-list rows are tinted with the first tag's color, which becomes
overwhelming when a label applies to most messages (for example
per-account labels). Add a `tintListRowsByTag` setting (default true, so
current behavior is unchanged) with a toggle in Settings beside
"Colorful Sidebar Icons". When off, rows are not tinted; tag dots and
chips still show the color. Gated in both list renderers
(email-list-item and thread-list-item).
2026-07-07 23:39:05 +02:00
Linus Rath e066698938 fix: strike through cancelled events and mute their reminders #572 2026-07-07 23:26:36 +02:00
Linus Rath c3a97de62f fix: use calendarAddress/organizerCalendarAddress for scheduling, drop retired sendTo/replyTo #500 2026-07-07 23:18:05 +02:00
Linus Rath 29283282d5 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-07-07 20:36:05 +02:00
Linus Rath db2c642d74 fix: storage quota not shown with Stalwart #577 2026-07-07 20:35:37 +02:00
dealerwebandLinus Rath 9110bc388f Fix: keep the session when the auth server is briefly unreachable
Any transient failure used to end the session: the token route deleted
the refresh cookies on every non-OK answer from the OAuth endpoint,
refreshAccessToken logged out on any non-OK status or network error,
and the startup restore evicted the account and deleted its session
cookie. A server restart, a proxy hiccup, a Wi-Fi switch or a laptop
waking before the network is back all kicked the user out despite
"stay signed in".

Failures are now classified. Only a definitive rejection (400/401/403
from the OAuth endpoint, 401 from the token route) tears the session
down and deletes cookies, exactly as before. Network errors and 5xx
keep the session: the token refresh re-arms itself and retries every
~30 seconds until the server is back, and the startup restore keeps
the account, marked unreachable - the same treatment the rate-limit
carve-out (#104) already applies.

Token validity stays entirely server-enforced: the first definitive
401 after an outage still logs out as before.
2026-07-06 15:52:05 +02:00
dealerwebandLinus Rath a4dc0b7b4e i18n: restore key parity for the Slovak locale
The Slovak translation was based on a slightly older en catalog, so the
translation parity test currently fails on main.

sk was missing ten keys that landed around the same time: the composer
attachment-upload validation strings, the recipient autocomplete
strings (translated to Slovak here) and the settings date_locale block
(kept as the same English values every other locale currently has).

The other twenty locales were missing language.sk in return - added as
the endonym "Slovenčina" (matching how the other entries are written),
and as "Slovacă" in ro, which translates its language names.
2026-07-06 13:36:51 +02:00
dealerwebandLinus Rath d384c3b553 Feature: pin emails to the top of the folder list
Outlook-Web-style pinning: a context-menu Pin/Unpin action stores a
$pinned keyword on the message (plain IMAP-compatible flag, survives
other clients), and pinned mails stay at the top of the folder list
regardless of age, marked with a pin icon.

Ordering is done server-side via the hasKeyword sort comparator
(RFC 8621), applied consistently to the folder fetch, pagination and
the push-refresh so page windows stay stable. The client-side safety
sort in getEmails mirrors it, and sortThreadGroups keeps threads
containing a pinned mail on top so the client-side thread grouping
does not undo the order.

The new-mail notification in refreshCurrentMailbox now checks the
first non-pinned entry: with pinned mails on top, the newest mail is
no longer at index 0 and arrivals would never have notified.

The toggle reuses the color-tag pathway (routed keyword write for
unified views, in-place local patch), then refetches the first page
so the mail floats or sinks immediately. Search and unified views
keep their existing order.

Pin/Unpin strings are added to all 21 locales.
2026-07-06 13:36:25 +02:00
dealerwebandLinus Rath d7a64fd9d6 Fix: hide the spam action in Sent, Drafts and Scheduled
Marking your own outgoing mail as spam makes no sense, but the action
was offered in every non-junk folder: context menu, hover quick-actions,
viewer toolbar and its overflow menu, plus the "!" shortcut.

All surfaces now skip the action when the folder role is sent, drafts or
scheduled, and the shortcut is a no-op there. Scheduled messages were
already covered per-email via isScheduled; the role check additionally
covers the server-side Scheduled folder before that annotation loads.

The hover quick-actions bar gets a spamApplicable prop for this, since
it renders its buttons without knowing the folder.
2026-07-06 11:04:37 +02:00
dealerwebandLinus Rath 06ddda688d Fix: spam actions left folder counters and the open message stale
Marking mail as spam (or not spam) updated the email list but nothing
else, unlike delete/move which patch the folder counters optimistically.
Without a connected JMAP push the sidebar badges simply never moved, and
"not spam" additionally left the reading pane stuck on the message that
had just left the folder.

markAsSpam now mirrors moveToMailbox: source folder counts down, junk
counts up (honoring that trash-and-read delivers the mail to junk
already read). batchMarkAsSpam and batchUndoSpam mirror the batch move
pattern the same way.

undoSpam advances the selection to the next message like markAsSpam
already did, and refreshes the mailbox list instead of patching counts:
in the undo-toast path the email is no longer in the list, so its unread
state is unknown and an optimistic patch is not possible.
2026-07-06 11:04:20 +02:00
Shuki VakninandLinus Rath 6b74615969 fix(email-viewer): hide images that fail to load
Instead of leaving the browser's broken-image placeholder + alt text (which
reads as stray label text — e.g. a 'logo' alt — in an otherwise image-only
email), hide any image that fails to load. Sanitizer-blocked images already use
a 1x1 transparent pixel with display:none, so they're unaffected.
2026-07-05 21:29:19 +02:00
Shuki VakninandLinus Rath 0d73cb5dfb fix(csp): allow external/data fonts so email webfonts render
The page CSP set font-src 'self', which blocked fonts referenced by
rendered email CSS (brand webfonts loaded over https). Allow https:/data:
for font-src. Email bodies render inside the sandboxed iframe, which keeps
its own stricter blocking-mode font-src for privacy.
2026-07-05 21:28:15 +02:00
b1b09d54c8 feat(pwa): add Apple Touch icons for iOS home screen
- Add apple-touch-icon.png (180x180) - default iOS icon
- Add apple-touch-icon-120x120.png - iPhone retina
- Add apple-touch-icon-152x152.png - iPad retina
- Add apple-touch-icon-167x167.png - iPad Pro
- Add apple-touch-icon-180x180.png - iPhone Plus/Pro

iOS automatically detects these icons when users add the PWA to their home screen.
No code changes required - icons are discovered by convention.

Fixes: PWA icon not appearing on iOS home screen
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:51:06 +02:00
Linus Rath 9930d19ba4 fix: keep advanced search filters applied when switching folders #553 2026-07-04 15:21:11 +02:00
Shuki VakninandLinus Rath 902774eae1 feat(list): click sender avatar to select message/thread (Thunderbird-style)
Wrap the message-list avatar in a SelectableAvatar control: clicking the
avatar toggles the row into the current selection instead of opening it,
matching Thunderbird's correspondent-avatar selection affordance. A check
overlay appears on hover (hinting it is clickable) and stays while selected.

- email-list-item + thread single-email: toggle that message's id
- thread header: toggle the whole thread (reuses existing thread-select logic)
- focused-mail and extra-compact layouts render no avatar, so unaffected
2026-07-04 15:00:15 +02:00
1429a6fe1e feat(login): configurable logo size + hideable heading/subtitle
Login header customization for white-label deployments, all defaults
preserve current behaviour:

- LOGIN_LOGO_MAX_HEIGHT / LOGIN_LOGO_MAX_WIDTH (any CSS length): the logo
  box is otherwise a fixed 64x64 (w-16/h-16), which fits a wide wordmark to
  ~13px tall. When either is set, the fixed box is dropped and the logo
  renders at the configured size.
- LOGIN_SHOW_HEADING / LOGIN_SHOW_SUBTITLE (default true): hide the
  {appName} heading and/or the subtitle when the logo already reads as the
  brand (e.g. a wordmark) and they'd be redundant.

Applied to the standard login header; wired through the existing config
registry (CONFIG_ENV_MAP) -> /api/config -> useConfig.

Refs #519.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 14:59:49 +02:00
Kristofer PettijohnandLinus Rath d1da83a5d6 fix: keep email list scrollable when bottom reading-pane is enabled with no conversation selected 2026-07-04 14:59:32 +02:00
Kristofer PettijohnandLinus Rath babacca482 fix(contacts): clear photo on server by sending media: null when removed 2026-07-04 14:59:06 +02:00
dealerwebandLinus Rath e6aa79ed94 Feature: recipient autocomplete from Sent, with on-demand server search
Compose recipient fields only suggested existing contacts and directory
users, so people you had emailed before but never saved as a contact
never came up. This adds an Outlook-Web-style suggestion flow.

On startup the Sent folder is read once (metadata only) to build a cache
of addresses you have written to; those are merged into the autocomplete
after contacts and directory principals, deduped, contacts winning.

When the recipient is not in the cache, the dropdown offers a "search the
server" row that queries the Sent folder on demand. That lookup fetches
only the to/cc fields (no subject, body or attachments) and returns the
matching addresses, deduped.

New strings are added to all 20 locales.
2026-07-04 14:56:49 +02:00
dealerwebandLinus Rath 4d6b4b5b8e Fix: brand push notifications with the configured PWA icon
The service worker hard-coded the notification icon and badge to the bundled /icon-192x192.png, so push notifications always showed the default Bulwark logo even when an admin had configured a custom PWA/favicon icon (which the manifest already honors via /api/pwa-icon).

Point both notifications at /api/pwa-icon/192, and make that endpoint fall back to the bundled default icon instead of returning 404 when no custom icon is set - so it always returns an app icon and the service worker (which can't run the custom-vs-default check itself) has a single stable URL.
2026-07-04 14:56:19 +02:00
DaniilandLinus Rath d259168bd7 fix(shortcuts): also map /, ?, #, ! by physical position
Extend the layout-agnostic handling to the symbol shortcuts. On non-Latin
layouts the characters '/', '?', '#', '!' are often unreachable or on different
keys, so map them from their US-QWERTY physical codes (Slash, shifted Digit1 /
Digit3). Fixes e.g. '?' (open shortcuts help) on a Cyrillic layout.
2026-07-04 14:54:58 +02:00
DaniilandLinus Rath 97d87c44d0 fix(shortcuts): make keyboard shortcuts layout-agnostic
Shortcuts were matched against event.key, which returns a layout-dependent
character. On non-Latin layouts (Cyrillic, Greek, ...) the physical letter keys
produce non-Latin characters, so single-letter shortcuts (c, j, k, r, e, ...)
never fire and users must switch layout to use them.

Derive the letter from event.code (KeyA..KeyZ) instead, which is
layout-independent. Non-letter keys keep event.key (arrows/Enter are already
layout-independent; #, !, ?, / stay symbol-based).
2026-07-04 14:54:58 +02:00
KazNIISA ITandLinus Rath 396f5f7d60 fix(compose): wait for in-flight attachment uploads before sending
Clicking Send while attachments were still uploading silently dropped
them from the outgoing message: every place that builds the outgoing
attachment list filters on att.blobId && !att.uploading, and the Send
button never accounted for uploads still in flight. Attach a few files,
hit Send right away, and the email could go out missing some of them
with no warning.

handleSend now detects pending uploads before validating:

- Send is disabled with an explanatory tooltip
  (validation.attachments_uploading) while it waits.
- Once uploads finish cleanly, the send proceeds automatically - no
  second click needed.
- If an upload FAILS while waiting, the send is aborted with an error
  toast (validation.attachment_upload_failed) instead of silently
  shipping the email without the failed attachment - the user may not
  be looking at the composer to notice the red error chip.
- If the draft is closed or discarded while waiting, the pending send
  is cancelled cleanly.

The wait/decision logic lives in waitForPendingUploads() in
lib/email-composer-utils.ts (returns completed | cancelled | failed)
with unit tests covering all three outcomes. Outgoing-attachment
call sites read the freshest state via attachmentsRef since the
render closure captured at click time won't reflect uploads that
finished during the wait.

Both new i18n keys added to all 20 locales under
email_composer.validation.
2026-07-04 14:54:41 +02:00
dealerwebandLinus Rath 9aef8bc393 i18n: localize the 'Return to list after delete or mark unread' setting
This reading setting shipped with its English label/description as a placeholder in every locale except fa. Translate it into the remaining 18 locales (de, cs, da, es, fr, hu, it, ja, ko, lv, nl, pl, pt, ro, ru, tr, uk, zh), reusing each locale's existing 'mark as unread' wording for consistency.
2026-07-04 14:54:26 +02:00
Peter GondaandLinus Rath e05ab48a45 feat: add Slovak translation 2026-07-04 14:54:07 +02:00
honzupandLinus Rath 14c2807f0a feat: add user-selectable regional date format 2026-07-04 14:53:37 +02:00
Patrick RotterandLinus Rath a099ab442a fix: route keyword writes to the email's own account in unified view
Tags applied to a shared/group-mailbox message did not persist. Custom
keywords (:*),  and  were written via Email/set
against the reaching client's primary account instead of the email's
owning account, so the server returned notUpdated without an error and
the change was lost on the next reload.

toggleStar already threaded an accountId through (#281); the keyword
methods did not. Add an optional accountId to updateEmailKeywords and
setKeyword and resolve it at the call sites from the email's source
account (sourceClientAccountId / sourceAccountId), matching the existing
delete/archive routing. Personal sources resolve to the account itself,
so behavior there is unchanged.
2026-07-04 14:53:15 +02:00
dealerwebandLinus Rath e9ac2de6cb Fix: notification sound preview - base-path prefix + longer default beep
The sound picker's preview always played the default beep, even for the other choices, on a subpath deployment.

playFile() used a raw '/notification/x.mp3' path, which 404s under a deployment base path (e.g. /webmail); audio.play() then rejected and fell back to the beep for every non-default choice. Prefix the file with withBasePath().

The default beep was a 150 ms tone with no envelope - easy to miss on Bluetooth outputs, whose audio path can take 100-200 ms to wake up and route. Lengthen it to ~0.45 s with a fade in/out (also removes click artifacts).
2026-07-04 14:52:26 +02:00
Shuki VakninandLinus Rath c4a7f575c5 feat(accounts): pin default account on top + drag-to-reorder switcher
The account switcher now always renders the default (starred) account first
and lets you drag the remaining accounts into any order. Default stays
pinned; only non-default rows are draggable (shown via a grip handle on
hover). Wraps each row in a draggable container and persists the new order
through the existing reorderAccounts store action.

Ordering logic extracted to pure helpers (sortDefaultFirst,
reorderNonDefaultIds) in account-utils with unit tests.
2026-07-04 14:51:46 +02:00
dealerwebandLinus Rath 51c3a69be7 Fix: don't toggle mailbox subfolders on Arrow keys while typing
The sidebar registers a global window keydown listener that expands/collapses the selected mailbox's subfolders on ArrowLeft/ArrowRight. It didn't check where focus was, so pressing Left/Right while typing in a new email (the contentEditable composer, the subject field, search, etc.) toggled the inbox's subfolders open/closed.

Bail out of the handler when the event target is an editable element (INPUT/TEXTAREA/SELECT/contentEditable). Folder-tree arrow navigation still works when focus isn't in an input.
2026-07-04 14:50:32 +02:00
Harry YoudandLinus Rath 717b1d8397 feat(headers): add parsing for Stalwart spam headers
Example of headers created by Stalwart below

X-Spam-Result: TRUSTED_DOMAIN (-7.00),
	PROB_HAM_LOW (-2.00),
	RBL_SENDERSCORE_REPUT_9 (-1.00),
	DMARC_POLICY_ALLOW (-0.50),
	RCVD_DKIM_ARC_DNSWL_MED (-0.50)
X-Spam-Score: ham, score=-5.60, avg_confidence=0.26
X-Spam-Status: No

Only need to add small tweak by detecting spam|ham as well as Yes|No
The most useful header is X-Spam-Score, so we make sure to parse that
before X-Spam-Status
2026-07-04 14:50:07 +02:00
Shuki VakninandLinus Rath f291480565 feat(email): send quick reply with Ctrl/Cmd+Enter
The message-viewer quick-reply box only sent via the Send button. Add a
keyboard shortcut (Ctrl+Enter / Cmd+Enter) mirroring the composer (#344),
so a reply can be fired without reaching for the mouse. preventDefault stops
the newline; the shortcut and button share one handleSendQuickReply().
2026-07-04 14:49:45 +02:00
Chris RowlandandLinus Rath 95b5a81924 fix(plugins): preserve settings slot and privileged tier 2026-07-04 14:49:26 +02:00
Linus RathandGitHub 9da27df249 Update README.md 2026-06-30 18:41:58 +02:00
Patrick RotterandLinus Rath b716f95a73 feat(compose): preselect identity of the active mailbox for new messages
Starting a new message while viewing a specific mailbox/account now defaults
the From identity to that mailbox instead of the global primary identity, so
composing from info@ sends as info@. Mirrors the existing reply-time identity
match and rides the same autoSelectReplyIdentity setting; reply/replyAll/forward
keep resolving from the original recipients. Matches exact then +tag-stripped.

Extracts findComposeIdentityId into lib/reply-identity.ts with unit tests.
2026-06-30 16:31:34 +02:00
Shuki VakninandLinus Rath 6c49427c7c fix(list): shift-click on the checkbox extends the selection (range)
selectRangeEmails was only wired to shift-clicking the row, but the
checkbox handler called stopPropagation and a plain toggle — so shift-
clicking checkboxes (the obvious affordance in selection mode) selected
single messages instead of the range. Make all three checkbox handlers
(email-list-item, thread single-email, thread header) shift-aware:
shift -> selectRangeEmails, otherwise toggle. Adds a regression test.
2026-06-30 09:35:20 +02:00
Kristofer PettijohnandLinus Rath 2a41e73bf9 fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X 2026-06-30 06:51:24 +02:00
Kristofer PettijohnandLinus Rath 16daf6ea03 fix(pro): show Edit button on draft emails opened in a new tab 2026-06-30 06:51:12 +02:00
Linus Rath 2704d5dc23 fix: clone source when setup.sh runs detached from a checkout #518 2026-06-28 23:31:32 +02:00
208 changed files with 11623 additions and 1105 deletions
+57
View File
@@ -1,5 +1,62 @@
# Changelog
## 1.7.7 (2026-07-09)
### Features
- **Plugins**: `ui.rerenderEmail` API and restyled read-receipt banner
- **Plugins**: New hooks — `onBeforeBlobUpload`, `onBeforeDraftAutoSave`, `onBeforeEditDraft` (#586)
- **Plugins**: `ui.prompt` dialog and first-class settings-section tabs
- **Calendar**: Jalali (Persian/Shamsi) calendar support with Saturday as week start (#490)
- **i18n**: Hebrew locale with full RTL support
- **i18n**: Slovak translation
- **i18n**: User-selectable regional date format
- **Contacts**: Enable trusted-senders address book sync by default when contacts are available
- **Mail**: Pin emails to the top of the folder list
- **Mail**: Setting to disable the tag-color row tint in the message list
- **Mail**: Click the sender avatar to select a message/thread (Thunderbird-style)
- **Accounts**: Pin the default account on top and drag-to-reorder the account switcher
- **Composer**: Recipient autocomplete from Sent, with on-demand server search
- **Composer**: Preselect the identity of the active mailbox for new messages
- **Email**: Send a quick reply with Ctrl/Cmd+Enter
- **Headers**: Parse Stalwart spam headers
- **Login**: Configurable logo size and hideable heading/subtitle
- **PWA**: Apple Touch icons for the iOS home screen
### Fixes
- **Mail**: Hide Files when the account lacks the filenode capability (#563)
- **Mail**: Keep advanced search filters applied when switching folders (#553)
- **Mail**: Keep the email list scrollable when the bottom reading pane is enabled with no conversation selected
- **Mail**: Route keyword writes to the email's own account in unified view
- **Mail**: Render emails that set `height:100%` on a wrapper element
- **Mail**: Hide images that fail to load
- **Mail**: Storage quota not shown with Stalwart (#577)
- **Spam**: Hide the spam action in Sent, Drafts and Scheduled
- **Spam**: Fix stale folder counters and open message after spam actions
- **Composer**: Wait for in-flight attachment uploads before sending
- **Composer**: Only commit a recipient on Space when the input is a valid email (#571)
- **Composer**: Attachment reminder now ignores quoted text on reply/forward (#570)
- **Calendar**: Store the event organizer as owner-only to prevent duplicate ORGANIZER/ATTENDEE
- **Calendar**: Strike through cancelled events and mute their reminders (#572)
- **Calendar**: Use `calendarAddress`/`organizerCalendarAddress` for scheduling, drop retired `sendTo`/`replyTo` (#500)
- **Auth**: Keep the session when the auth server is briefly unreachable
- **Shortcuts**: Make keyboard shortcuts layout-agnostic and map by physical position
- **Shortcuts**: Don't toggle mailbox subfolders on Arrow keys while typing
- **Contacts**: Clear the photo on the server by sending `media: null` when removed
- **Plugins**: Preserve the settings slot and privileged tier
- **Pro**: Prompt to save or discard a draft when closing a compose tab via the tab-bar X
- **Pro**: Show the Edit button on draft emails opened in a new tab
- **List**: Shift-click on the checkbox extends the selection (range)
- **CSP**: Allow external/data fonts so email webfonts render
- **Notifications**: Brand push notifications with the configured PWA icon
- **Notifications**: Notification sound preview — base-path prefix and longer default beep
- **Unsubscribe**: Send `mailto:` unsubscribe ourselves instead of via the OS handler
- **Branding**: Apply per-domain favicon override in root metadata (#585)
- **Settings**: Load the trusted-senders address book on the settings page so the count isn't 0
- **Setup**: Clone source when `setup.sh` runs detached from a checkout (#518)
- **Server**: Use a callable `.get` to detect `Headers` in `pickRequestHost`
## 1.7.6 (2026-06-28)
### Breaking Changes
+1 -3
View File
@@ -12,10 +12,8 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
[![License: AGPL v3](https://img.shields.io/badge/license-AGPL%20v3-blue.svg?logo=gnu&logoColor=white)](LICENSE)
[![Discord](https://img.shields.io/discord/1482128142939455674?color=7289da&label=discord&logo=discord&logoColor=white)](https://discord.gg/tYCujymGrT)
[![Version](https://img.shields.io/badge/version-1.7.6-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.7-green.svg?logo=git&logoColor=white)](CHANGELOG.md)
[![Docker](https://img.shields.io/badge/docker-ghcr.io%2Fbulwarkmail%2Fwebmail-blue?logo=docker&logoColor=white)](https://ghcr.io/bulwarkmail/webmail)
[![Grafana](https://img.shields.io/badge/grafana-dashboard-orange?logo=grafana&logoColor=white)](https://grafana.external.bulwarkmail.org/)
</div>
---
+1 -1
View File
@@ -1 +1 @@
1.7.6
1.7.7
+5 -5
View File
@@ -1149,13 +1149,13 @@ export default function CalendarPage() {
</div>
<div className="px-6 py-4 space-y-3">
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleImportWebcal}>
<span className="text-left">
<span className="text-start">
<span className="block font-medium">{tWebcalAction("import_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("import_description")}</span>
</span>
</Button>
<Button variant="outline" className="w-full justify-start h-auto py-3" onClick={handleSubscribeWebcal}>
<span className="text-left">
<span className="text-start">
<span className="block font-medium">{tWebcalAction("subscribe_title")}</span>
<span className="block text-xs text-muted-foreground mt-0.5">{tWebcalAction("subscribe_description")}</span>
</span>
@@ -1336,7 +1336,7 @@ export default function CalendarPage() {
<>
<div
className={cn(
"border-r border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
"border-e border-border bg-secondary overflow-y-auto flex-shrink-0 p-3",
!isResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -1475,7 +1475,7 @@ export default function CalendarPage() {
{/* Desktop event panel */}
{!isMobile && showEventModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<EventModal
key={editEvent?.id ?? 'new'}
event={editEvent}
@@ -1498,7 +1498,7 @@ export default function CalendarPage() {
{/* Desktop task panel */}
{!isMobile && showTaskModal && (
<div className="w-[400px] border-l border-border flex-shrink-0 overflow-hidden">
<div className="w-[400px] border-s border-border flex-shrink-0 overflow-hidden">
<TaskModal
key={editTask?.id ?? 'new-task'}
task={editTask}
+4 -4
View File
@@ -751,7 +751,7 @@ export default function ContactsPage() {
<button
key={group.id}
onClick={() => handleBulkAddToGroupConfirm(group.id)}
className="w-full flex items-center gap-3 px-6 py-3 text-left hover:bg-muted transition-colors"
className="w-full flex items-center gap-3 px-6 py-3 text-start hover:bg-muted transition-colors"
>
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
@@ -869,7 +869,7 @@ export default function ContactsPage() {
<>
<div
className={cn(
"border-r border-border flex flex-col flex-shrink-0 bg-background",
"border-e border-border flex flex-col flex-shrink-0 bg-background",
!isSidebarResizing && "transition-[width] duration-300",
isNarrow && cn(
"absolute inset-y-0 left-0 z-50 w-72 pt-[env(safe-area-inset-top)]",
@@ -937,7 +937,7 @@ export default function ContactsPage() {
<div
data-tour="contacts-list"
className={cn(
"border-r border-border bg-background flex flex-col flex-shrink-0",
"border-e border-border bg-background flex flex-col flex-shrink-0",
isMobile ? "w-full" : "",
!isListResizing && !isMobile && "transition-[width] duration-300"
)}
@@ -991,7 +991,7 @@ export default function ContactsPage() {
onClick={mobileBackToList}
className="touch-manipulation"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
</div>
+2 -2
View File
@@ -38,11 +38,11 @@ export default function LocaleError({
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => router.push('/')}>
<Home className="w-4 h-4 mr-2" />
<Home className="w-4 h-4 me-2" />
{t("go_home")}
</Button>
<Button onClick={reset}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
+2 -2
View File
@@ -477,7 +477,7 @@ export default function FilesPage() {
onClick={() => router.push("/")}
className="justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{t("title")}
</Button>
</div>
@@ -611,7 +611,7 @@ export default function FilesPage() {
: '0%' }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-right">
<p className="mt-2 text-xs text-muted-foreground tabular-nums text-end">
{migrationProgress.current} / {migrationProgress.total}
</p>
</div>
+26 -14
View File
@@ -133,9 +133,16 @@ export default function LoginPage() {
const isMobileHandoff = Boolean(mobileRedirectUri);
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
// Login logo sizing: when a max height/width is configured, drop the fixed
// 64×64 box so the logo (e.g. a wide wordmark) can render at its true size.
const hasLogoSize = Boolean(loginLogoMaxHeight || loginLogoMaxWidth);
const loginLogoStyle = hasLogoSize
? { maxHeight: loginLogoMaxHeight || undefined, maxWidth: loginLogoMaxWidth || undefined }
: undefined;
const [formData, setFormData] = useState({
username: "",
password: "",
@@ -718,7 +725,7 @@ export default function LoginPage() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
<span className="flex-1 text-start">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -867,7 +874,7 @@ export default function LoginPage() {
)}
>
<Icon className="w-4 h-4" />
<span className="flex-1 text-left">{option.label}</span>
<span className="flex-1 text-start">{option.label}</span>
{isActive && <Check className="w-3.5 h-3.5 text-primary" />}
</button>
);
@@ -881,19 +888,24 @@ export default function LoginPage() {
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
{/* Header section with logo */}
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<div className={cn("inline-flex items-center justify-center mb-5", !hasLogoSize && "w-16 h-16")}>
<img
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName}
className="max-w-16 max-h-16 object-contain"
className={cn("object-contain", !hasLogoSize && "max-w-16 max-h-16")}
style={loginLogoStyle}
/>
</div>
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{isAddAccountMode ? t("add_account_title") : appName}
</h1>
<p className="text-sm text-muted-foreground mt-1.5">
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
</p>
{loginShowHeading && (
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
{isAddAccountMode ? t("add_account_title") : appName}
</h1>
)}
{loginShowSubtitle && (
<p className="text-sm text-muted-foreground mt-1.5">
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
</p>
)}
</div>
{/* Form section */}
@@ -1121,7 +1133,7 @@ export default function LoginPage() {
type={showPassword ? "text" : "password"}
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="h-11 px-3.5 pr-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
className="h-11 px-3.5 pe-11 bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200"
placeholder={t("password_placeholder")}
required
autoComplete="current-password"
@@ -1241,9 +1253,9 @@ export default function LoginPage() {
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
<LogIn className="w-4 h-4 me-2" />
)}
{t("sign_in_sso")}
</Button>
+137 -33
View File
@@ -75,7 +75,7 @@ import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from
import { emailToReadView } from "@/lib/plugin-projection";
import { buildQuoteHeader } from "@/lib/quote-header";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { useLocaleStore } from "@/stores/locale-store";
import { getEffectiveLocale } from '@/i18n/detect-locale';
import type { QuoteHeader } from "@/lib/plugin-types";
const SCHEDULED_MAILBOX_ID = '__scheduled__';
@@ -121,7 +121,7 @@ export default function Home() {
useIdentitySync();
const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook);
const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds);
const { loadTrustedSendersBook, trustedSendersLoaded } = useContactStore();
const { loadTrustedSendersBook, trustedSendersLoaded, loadRecentRecipients } = useContactStore();
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('email_viewer.reschedule_prompt'));
@@ -342,6 +342,15 @@ export default function Home() {
refreshCurrentMailbox,
} = useEmailStore();
// Load recent recipients (from the Sent folder) for compose autocomplete.
// Runs once when the Sent mailbox is known; the store guards against reloads.
useEffect(() => {
const sent = mailboxes.find((m) => m.role === 'sent');
if (client && sent) {
loadRecentRecipients(client, sent.originalId || sent.id);
}
}, [client, mailboxes, loadRecentRecipients]);
// Pro shell: populate per-account mailbox cache so the sidebar can render
// every connected account Thunderbird-style.
useProMultiAccountMailboxes();
@@ -612,6 +621,10 @@ export default function Home() {
onToggleSpam: async () => {
if (isScheduledView) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
// Marking your own outgoing mail as spam makes no sense - the toolbar
// and menus hide the action in Sent/Drafts/Scheduled, so the shortcut
// is a no-op there too.
if (['sent', 'drafts', 'scheduled'].includes(currentMailbox?.role || '')) return;
const isInJunk = currentMailbox?.role === 'junk';
if (selectedEmailIds.size > 0 && client) {
const ids = Array.from(selectedEmailIds);
@@ -1172,18 +1185,22 @@ export default function Home() {
return;
}
// Mark the original email with $answered or $forwarded keyword
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll')) {
// Mark the original email with $answered or $forwarded keyword. Route the
// write to the email's own account so the flag lands on shared/group-mailbox
// messages instead of being dropped against the reaching account. (#281)
if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
const s = useEmailStore.getState();
const orig = s.emails.find(e => e.id === originalEmailId);
const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined;
const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined;
const kwClient = kwClientId
? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client)
: client;
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
try {
await client.setKeyword(originalEmailId, '$answered');
await kwClient.setKeyword(originalEmailId, keyword, kwAccountId);
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
} else if (originalEmailId && effectiveMode === 'forward') {
try {
await client.setKeyword(originalEmailId, '$forwarded');
} catch (e) {
debug.error('Failed to set $forwarded keyword:', e);
debug.error(`Failed to set ${keyword} keyword:`, e);
}
}
@@ -1263,7 +1280,7 @@ export default function Home() {
},
newTo,
newCc,
locale: useLocaleStore.getState().locale,
locale: getEffectiveLocale(),
timeFormat: useSettingsStore.getState().timeFormat,
unknownLabel: tCommon('unknown'),
labels: {
@@ -1327,6 +1344,8 @@ export default function Home() {
draft = fullDraft;
}
draft = await emailHooks.onBeforeEditDraft.transform(draft);
const bodyText = draft.bodyValues
? Object.values(draft.bodyValues).map(v => v.value).join('\n')
: '';
@@ -1635,6 +1654,45 @@ export default function Home() {
}
};
const handleTogglePinned = async (emailToPin: Email) => {
if (!client) return;
try {
const email = emails.find(e => e.id === emailToPin.id) ?? emailToPin;
const isPinned = email.keywords?.['$pinned'] === true;
// JMAP keywords are a set of present keys - drop the key to unpin
// rather than writing a false value.
const keywords = { ...email.keywords };
if (isPinned) {
delete keywords['$pinned'];
} else {
keywords['$pinned'] = true;
}
// Same unified-view routing as color tags: write to the email's own
// account via the login it is reachable through. (#281)
const pinClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const pinAccountId = isUnifiedView ? email.sourceAccountId : undefined;
const pinClient = pinClientId
? (useAuthStore.getState().getClientForAccount(pinClientId) ?? client)
: client;
await pinClient.updateEmailKeywords(email.id, keywords, pinAccountId);
// Patch in place so the icon flips immediately, then refetch the first
// page so the mail floats/sinks per the server's pinned-first sort.
// Skip the refetch where that sort does not apply (unified views) or
// where it would replace a tag-filtered list (refreshCurrentMailbox
// fetches by folder only).
setEmailKeywordsLocal(email.id, keywords);
if (!isUnifiedView && !useEmailStore.getState().selectedKeyword) {
void refreshCurrentMailbox(client);
}
} catch (error) {
console.error("Failed to toggle pin:", error);
}
};
const handleSetColorTag = async (emailId: string, color: string | null) => {
if (!client) return;
@@ -1663,8 +1721,20 @@ export default function Home() {
}
}
// In unified view route the write to the email's own account, reached
// through the login it is reachable via (`sourceClientAccountId`) and
// applied to its owning JMAP account (`sourceAccountId`). For personal
// sources these resolve to the account itself, so behavior is unchanged.
// Without this, tags on shared/group-mailbox messages are written to the
// reaching account and silently dropped by the server. (#281)
const tagClientId = isUnifiedView ? email.sourceClientAccountId : undefined;
const tagAccountId = isUnifiedView ? email.sourceAccountId : undefined;
const tagClient = tagClientId
? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client)
: client;
// Update email keywords via JMAP
await client.updateEmailKeywords(emailId, keywords);
await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId);
// Patch the email in place so the list keeps its scroll/pagination state
// instead of being reset to the first page by a full refetch.
@@ -1702,7 +1772,15 @@ export default function Home() {
setTabletListVisible(true);
}
if (viewingClient) {
await fetchEmails(viewingClient, mailboxId);
// Keep an active search applied when switching folders (#553); the
// store actions resolve the viewing account's client internally.
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(viewingClient);
} else if (searchQuery) {
await searchEmails(viewingClient, searchQuery);
} else {
await fetchEmails(viewingClient, mailboxId);
}
}
};
@@ -1792,8 +1870,13 @@ export default function Home() {
}
if (client) {
// If there's an active search, re-run it in the new mailbox
if (searchQuery) {
// If there's an active search, re-run it in the new mailbox. Advanced
// filters must go through advancedSearch (which also includes the text
// query) — falling back to fetchEmails would silently drop them while
// the UI still shows them as active (#553).
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else if (searchQuery) {
await searchEmails(client, searchQuery);
} else {
await fetchEmails(client, mailboxId);
@@ -2271,11 +2354,22 @@ export default function Home() {
return;
}
// Mark the original email as answered
try {
await client.setKeyword(originalEmailId, '$answered');
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
// Mark the original email as answered. Route the write to the email's own
// account so the flag lands on shared/group-mailbox messages instead of
// being dropped against the reaching account. (#281)
{
const s = useEmailStore.getState();
const orig = s.emails.find(e => e.id === originalEmailId);
const kwClientId = s.isUnifiedView ? orig?.sourceClientAccountId : undefined;
const kwAccountId = s.isUnifiedView ? orig?.sourceAccountId : undefined;
const kwClient = kwClientId
? (useAuthStore.getState().getClientForAccount(kwClientId) ?? client)
: client;
try {
await kwClient.setKeyword(originalEmailId, '$answered', kwAccountId);
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
}
// Refresh emails to show the sent reply
@@ -2667,17 +2761,19 @@ export default function Home() {
<div
className={cn(
"relative flex flex-col bg-background",
isHorizontalMailLayout ? "md:w-full md:h-auto" : "h-full border-r border-border",
isHorizontalMailLayout
? (shouldHideHorizontalViewerPane ? "md:w-full md:min-h-0" : "md:w-full md:h-auto")
: "h-full border-e border-border",
// Mobile: full width, hidden when viewing email
"max-md:flex-1 max-md:border-r-0 max-md:border-b-0",
"max-md:flex-1 max-md:border-e-0 max-md:border-b-0",
isMobile && activeView !== "list" && "max-md:hidden",
// Tablet/Desktop: fixed width with collapse animation
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-r-0" : "md:flex-shrink-0"),
!isHorizontalMailLayout && (shouldHideViewerPane ? "md:flex-1 md:border-e-0" : "md:flex-shrink-0"),
isHorizontalMailLayout && (shouldHideHorizontalViewerPane ? "md:flex-1" : "md:flex-shrink-0"),
isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]",
!isHorizontalMailLayout && "md:shadow-sm",
!isResizing && "transition-all duration-200 ease-out",
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-r-0"
shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0"
)}
style={
isMobile
@@ -2730,13 +2826,13 @@ export default function Home() {
)}
</button>
<form onSubmit={(e) => { e.preventDefault(); if (searchQuery.trim()) handleSearch(searchQuery); }} className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Search className="absolute start-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
type="text"
placeholder={t("sidebar.search_placeholder_hint")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
className={cn("ps-9 h-9", searchQuery && "pe-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
@@ -2746,7 +2842,7 @@ export default function Home() {
<button
type="button"
onClick={handleClearSearch}
className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
className="absolute end-2 top-1/2 transform -translate-y-1/2 p-1 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
aria-label={t("sidebar.clear_search")}
>
<X className="w-4 h-4" />
@@ -2803,7 +2899,7 @@ export default function Home() {
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => { clearSearchFilters(); setShowAdvancedFields(false); if (client) advancedSearch(client); }} className="h-7 px-2 text-xs text-muted-foreground">
<RotateCcw className="w-3 h-3 mr-1" />
<RotateCcw className="w-3 h-3 me-1" />
{t("advanced_search.clear")}
</Button>
</div>
@@ -2987,6 +3083,9 @@ export default function Home() {
await toggleStar(client, email.id);
}
}}
onTogglePinned={async (email) => {
await handleTogglePinned(email);
}}
onDelete={async (email) => {
await handleDelete(email);
}}
@@ -3025,7 +3124,7 @@ export default function Home() {
}}
className={cn(
"absolute z-40 rounded-full shadow-lg",
isMobile ? "bottom-4 right-4 h-14 w-14" : "bottom-4 right-4 h-12 w-12"
isMobile ? "bottom-4 end-4 h-14 w-14" : "bottom-4 end-4 h-12 w-12"
)}
aria-label={t('sidebar.compose')}
title={t('sidebar.compose_hint')}
@@ -3084,6 +3183,11 @@ export default function Home() {
<EmailComposer
key={composerSessionId}
mode={pendingDraft?.mode ?? composerMode}
composeFromAccountEmail={
useAccountStore
.getState()
.getAccountById(viewingAccountId ?? activeAccountId ?? '')?.email
}
replyTo={pendingDraft !== null ? pendingDraft.replyTo : (selectedEmail ? {
from: selectedEmail.from,
replyToAddresses: selectedEmail.replyTo,
@@ -3148,13 +3252,13 @@ export default function Home() {
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-left"
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
>
<PenLine className="w-4 h-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
{pendingDraft.subject && (
<span className="text-xs text-muted-foreground ml-2 truncate">{pendingDraft.subject}</span>
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
)}
</div>
<X
+2 -2
View File
@@ -139,7 +139,7 @@ export default function ProHome() {
const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab);
const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
@@ -354,7 +354,7 @@ export default function ProHome() {
activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab}
onClose={closeTab}
onClose={requestCloseTab}
onDragStateChange={setIsTabDragging}
/>
+54 -23
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
import { useRouter } from '@/i18n/navigation';
import { useTranslations, useMessages } from 'next-intl';
import {
@@ -66,6 +66,8 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings
import { NotificationSettings } from '@/components/settings/notification-settings';
import { ThemesSettings } from '@/components/settings/themes-settings';
import { PluginsSettings } from '@/components/settings/plugins-settings';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
@@ -113,8 +115,14 @@ type Tab =
type TabGroup = 'general' | 'appearance' | 'mail' | 'privacy' | 'apps' | 'advanced';
// A plugin that exposes a `settings-section` slot gets its own first-class
// Settings entry, keyed `plugin:<id>`, so its UI (e.g. S/MIME key import) is
// discoverable as a menu point rather than buried inside another panel.
type PluginTabId = `plugin:${string}`;
type SettingsTabId = Tab | PluginTabId;
interface TabDef {
id: Tab;
id: SettingsTabId;
label: string;
icon: LucideIcon;
group: TabGroup;
@@ -330,7 +338,7 @@ const LEGACY_TAB_MAP: Record<string, Tab> = {
advanced: 'about_data',
};
function readPersistedTab(): Tab {
function readPersistedTab(): SettingsTabId {
try {
// One-shot deep link from the sidebar section gears (Folders / Tags).
// Used only as the initial tab and intentionally NOT written to
@@ -338,7 +346,7 @@ function readPersistedTab(): Tab {
// default that the regular Settings button lands on. Cleared on mount.
const deepLink = sessionStorage.getItem('settings-deep-link-tab');
if (deepLink) {
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as Tab;
return (deepLink in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[deepLink] : deepLink) as SettingsTabId;
}
const saved = localStorage.getItem('settings-active-tab');
if (!saved) return 'appearance';
@@ -347,7 +355,7 @@ function readPersistedTab(): Tab {
try { localStorage.setItem('settings-active-tab', migrated); } catch { /* ignore */ }
return migrated;
}
return saved as Tab;
return saved as SettingsTabId;
} catch {
return 'appearance';
}
@@ -364,7 +372,15 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<Tab>(readPersistedTab);
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
// Active plugins that expose a `settings-section` slot — each becomes its own
// Settings menu entry. Referentially stable per registry mutation, so it is
// safe to feed useSyncExternalStore directly.
const pluginSettingsOffers = useSyncExternalStore(
pluginRegistrySubscribe,
() => pluginOffersForSlot('settings-section'),
() => pluginOffersForSlot('settings-section'),
);
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
@@ -372,7 +388,7 @@ export default function SettingsPage() {
}, []);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: Tab; label: string; pluginId?: string } | null>(null);
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
const isDesktop = useIsDesktop();
const messages = useMessages() as Record<string, unknown>;
@@ -624,6 +640,14 @@ export default function SettingsPage() {
...(isFeatureEnabled('contactsEnabled') ? [{ id: 'contacts' as Tab, label: t('tabs.contacts'), icon: tabIcons.contacts, group: 'apps' as TabGroup }] : []),
...(supportsFiles && isFeatureEnabled('filesEnabled') ? [{ id: 'files' as Tab, label: t('tabs.files'), icon: tabIcons.files, group: 'apps' as TabGroup }] : []),
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Plugin-contributed settings pages: one entry per active plugin that
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
...pluginSettingsOffers.map((offer): TabDef => ({
id: `plugin:${offer.pluginId}` as PluginTabId,
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
icon: Puzzle,
group: 'apps',
})),
// Advanced
{ id: 'about_data', label: t('tabs.about_data'), icon: tabIcons.about_data, group: 'advanced' },
@@ -644,7 +668,7 @@ export default function SettingsPage() {
].filter(Boolean) as Tab[])
: [];
const visibleTabs = managedAccountId
? tabs.filter((tab) => scopedTabIds.includes(tab.id))
? tabs.filter((tab) => scopedTabIds.includes(tab.id as Tab))
: tabs;
// Group tabs by category
@@ -660,12 +684,12 @@ export default function SettingsPage() {
const matchesQuery = (tab: TabDef) => {
if (!trimmedQuery) return true;
if (tab.label.toLowerCase().includes(trimmedQuery)) return true;
return tabSearchHaystacks[tab.id]?.includes(trimmedQuery) ?? false;
return tabSearchHaystacks[tab.id as Tab]?.includes(trimmedQuery) ?? false;
};
const subResultsForTab = (tabId: Tab): SubResult[] => {
const subResultsForTab = (tabId: SettingsTabId): SubResult[] => {
if (!trimmedQuery) return [];
const list = tabSubResults[tabId] ?? [];
const list = tabSubResults[tabId as Tab] ?? [];
return list
.filter((r) =>
r.label.toLowerCase().includes(trimmedQuery) ||
@@ -684,11 +708,11 @@ export default function SettingsPage() {
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
const effectiveActiveTab: Tab = isActiveVisible
const effectiveActiveTab: SettingsTabId = isActiveVisible
? activeTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: Tab) => {
const handleTabSelect = (tabId: SettingsTabId) => {
setActiveTab(tabId);
try { localStorage.setItem('settings-active-tab', tabId); } catch { /* ignore */ }
if (!isDesktop) {
@@ -696,7 +720,7 @@ export default function SettingsPage() {
}
};
const handleSubResultSelect = (tabId: Tab, sub: SubResult) => {
const handleSubResultSelect = (tabId: SettingsTabId, sub: SubResult) => {
handleTabSelect(tabId);
setPendingHighlight({ tab: tabId, label: sub.label, pluginId: sub.pluginId });
};
@@ -712,11 +736,11 @@ export default function SettingsPage() {
clearManagedAccount();
handleTabSelect('account');
}}
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-left transition-colors"
className="flex items-center gap-2 w-full mb-4 px-3 py-2 rounded-md border border-border bg-muted/40 hover:bg-muted text-start transition-colors"
>
<ArrowLeft className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm text-muted-foreground">{t('scoped.back')}</span>
<span className="ml-auto text-sm font-medium truncate">
<span className="ms-auto text-sm font-medium truncate">
{t('scoped.managing', { name: managedAccount.name })}
</span>
</button>
@@ -754,6 +778,13 @@ export default function SettingsPage() {
{effectiveActiveTab === 'themes' && <ThemesSettings />}
{effectiveActiveTab === 'plugins' && <PluginsSettings />}
{effectiveActiveTab === 'debug' && <DebugSettings />}
{effectiveActiveTab.startsWith('plugin:') && (
<PluginIframeSlot
key={effectiveActiveTab}
pluginId={effectiveActiveTab.slice('plugin:'.length)}
slot="settings-section"
/>
)}
</>
);
@@ -820,7 +851,7 @@ export default function SettingsPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-9 pr-9 h-10"
className="ps-9 pe-9 h-10"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
@@ -868,7 +899,7 @@ export default function SettingsPage() {
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full flex items-center pl-12 pr-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-left"
className="w-full flex items-center ps-12 pe-5 py-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150 text-start"
>
<span className="truncate">{sub.label}</span>
</button>
@@ -932,7 +963,7 @@ export default function SettingsPage() {
<>
<div
className={cn(
"border-r border-border bg-secondary flex flex-col",
"border-e border-border bg-secondary flex flex-col",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
@@ -945,7 +976,7 @@ export default function SettingsPage() {
onClick={() => router.push('/')}
className="w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
<ArrowLeft className="w-4 h-4 me-2" />
{t('back_to_mail')}
</Button>
</div>
@@ -960,7 +991,7 @@ export default function SettingsPage() {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('search_placeholder')}
className="pl-8 pr-8 h-9 text-sm"
className="ps-8 pe-8 h-9 text-sm"
aria-label={t('search_placeholder')}
/>
{searchQuery && (
@@ -997,7 +1028,7 @@ export default function SettingsPage() {
<button
onClick={() => handleTabSelect(tab.id)}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
effectiveActiveTab === tab.id
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -1013,7 +1044,7 @@ export default function SettingsPage() {
<button
key={`${tab.id}:${sub.label}`}
onClick={() => handleSubResultSelect(tab.id, sub)}
className="w-full text-left pl-9 pr-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
className="w-full text-start ps-9 pe-3 py-1.5 rounded-md text-xs text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-150"
>
<span className="truncate block">{sub.label}</span>
</button>
@@ -220,7 +220,7 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
Per-server OAuth (optional, overrides global)
</button>
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
<input
+4 -4
View File
@@ -96,10 +96,10 @@ export function LogsTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Time</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">Action</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground">Details</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground whitespace-nowrap">IP</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
+2 -2
View File
@@ -171,7 +171,7 @@ export function MarketplaceTab() {
placeholder="Search extensions..."
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-ring"
/>
</div>
<div className="flex items-center gap-1 rounded-md border border-input bg-background p-0.5 self-start sm:self-auto">
@@ -210,7 +210,7 @@ export function MarketplaceTab() {
{loading && !error && (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">Searching extensions...</span>
<span className="ms-2 text-sm text-muted-foreground">Searching extensions...</span>
</div>
)}
@@ -155,7 +155,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
@@ -217,7 +217,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
<div key={key}>
<label className="text-sm font-medium text-foreground block mb-1">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
{field.required && <span className="text-destructive ms-0.5">*</span>}
</label>
{field.description && (
<p className="text-xs text-muted-foreground mb-1.5">{field.description}</p>
@@ -250,7 +250,7 @@ export function PluginConfigPanel({ pluginId, onBack }: Props) {
value={formValues[key] ?? ''}
onChange={(e) => setFormValues(prev => ({ ...prev, [key]: e.target.value }))}
placeholder={config[key] ? '•••••••• (unchanged)' : (field.placeholder || '')}
className="w-full h-9 px-3 pr-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
className="w-full h-9 px-3 pe-10 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring font-mono"
/>
<button
type="button"
+2 -2
View File
@@ -117,7 +117,7 @@ export function SettingsTab() {
<TextSetting label="JMAP Server URL" configKey="jmapServerUrl" value={currentValue('jmapServerUrl') as string} source={config.jmapServerUrl?.source} onChange={handleChange} onRevert={handleRevert} placeholder="https://mail.example.com" />
<ToggleSetting label="Allow Custom JMAP Endpoint" description="Show a JMAP server URL field on the login form, allowing users to connect to any JMAP server" configKey="allowCustomJmapEndpoint" value={currentValue('allowCustomJmapEndpoint') as boolean} source={config.allowCustomJmapEndpoint?.source} onChange={handleChange} onRevert={handleRevert} />
{!!currentValue('allowCustomJmapEndpoint') && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> External JMAP servers must include this domain in their CORS <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or requests from the browser will be blocked.
</p>
@@ -145,7 +145,7 @@ export function SettingsTab() {
onRevert={() => handleRevert('jmapServers')}
/>
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-s-2 border-amber-400 dark:border-amber-600">
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
</p>
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ChangePasswordPage() {
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
className="w-full h-9 pl-9 pr-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="w-full h-9 ps-9 pe-3 rounded-md border border-input bg-background text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
autoComplete="current-password"
/>
</div>
+12 -12
View File
@@ -212,7 +212,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
type="button"
onClick={handleClick}
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
active
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -248,7 +248,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<Link
href="/admin/change-password"
className={cn(
'w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
'w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5',
pathname === '/admin/change-password'
? 'bg-accent text-accent-foreground font-medium'
: 'hover:bg-muted text-foreground'
@@ -263,7 +263,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
)}
<button
onClick={handleLogout}
className="w-full text-left px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
className="w-full text-start px-3 py-2 rounded-md text-sm transition-colors duration-150 flex items-center gap-2.5 hover:bg-muted text-foreground"
>
<LogOut className="w-4 h-4 shrink-0 text-muted-foreground" />
Sign out
@@ -275,7 +275,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return (
<div className="min-h-screen flex bg-background">
{/* Slim webmail nav rail (desktop only) */}
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-r border-border sticky top-0 h-screen shrink-0">
<nav className="hidden md:flex w-14 bg-secondary flex-col items-center py-3 gap-2 border-e border-border sticky top-0 h-screen shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-7 h-7 object-contain mb-2" />
) : (
@@ -326,12 +326,12 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</nav>
{/* Admin Sidebar (desktop only) */}
<aside className="hidden md:flex w-60 border-r border-border bg-secondary flex-col sticky top-0 h-screen">
<aside className="hidden md:flex w-60 border-e border-border bg-secondary flex-col sticky top-0 h-screen">
<div className="h-14 flex items-center px-4 border-b border-border shrink-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground">Admin Panel</span>
</div>
@@ -350,7 +350,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{/* Mobile drawer */}
<aside
className={cn(
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-r border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
'md:hidden fixed inset-y-0 left-0 z-50 w-72 max-w-[85vw] border-e border-border bg-secondary flex flex-col transition-transform duration-200 ease-out',
mobileNavOpen ? 'translate-x-0' : '-translate-x-full'
)}
aria-label="Admin navigation"
@@ -359,9 +359,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="h-14 flex items-center justify-between px-3 border-b border-border shrink-0">
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
@@ -391,9 +391,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</button>
<div className="flex items-center min-w-0">
{logoUrl ? (
<img src={logoUrl} alt="" className="w-5 h-5 object-contain mr-2" />
<img src={logoUrl} alt="" className="w-5 h-5 object-contain me-2" />
) : (
<Shield className="w-5 h-5 text-primary mr-2" />
<Shield className="w-5 h-5 text-primary me-2" />
)}
<span className="font-semibold text-sm text-foreground truncate">Admin Panel</span>
</div>
+3 -3
View File
@@ -186,7 +186,7 @@ export default function MarketplacePreviewPage() {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
Loading...
</div>
);
@@ -512,7 +512,7 @@ export default function MarketplacePreviewPage() {
<section className="border border-border rounded-lg">
<button
onClick={() => setShowManifest(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
@@ -532,7 +532,7 @@ export default function MarketplacePreviewPage() {
<section className="border border-border rounded-lg">
<button
onClick={() => setShowSource(v => !v)}
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-left hover:bg-muted/30 transition-colors"
className="w-full flex items-center justify-between gap-2 px-4 py-3 text-start hover:bg-muted/30 transition-colors"
>
<div className="flex items-center gap-2">
<FileCode className="w-4 h-4 text-muted-foreground" />
+1 -1
View File
@@ -44,7 +44,7 @@ export default function GlobalError({
onClick={reset}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
Try again
</button>
</div>
+20 -2
View File
@@ -1,9 +1,15 @@
import type { Metadata, Viewport } from "next";
import { getLocaleDirection } from "@/i18n/direction";
import { Geist, Geist_Mono } from "next/font/google";
import { headers } from "next/headers";
import { getLocale, getTranslations } from "next-intl/server";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
import {
matchDomainBranding,
parseDomainBranding,
pickRequestHost,
} from "@/lib/admin/domain-branding";
import { withBasePath } from "@/lib/browser-navigation";
import { locales } from "@/i18n/routing";
import "../globals.css";
@@ -38,7 +44,19 @@ export const viewport: Viewport = {
export async function generateMetadata(): Promise<Metadata> {
await configManager.ensureLoaded();
const faviconUrl = configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// The <head> favicon must honor per-domain branding, exactly like
// /api/config, app/manifest.ts, and /api/pwa-icon already do. Resolve the
// request host and prefer its override; fall back to the global
// admin/env/default value when the host has no favicon override (#585).
const host = pickRequestHost(await headers());
const domainOverride = matchDomainBranding(
host,
parseDomainBranding(configManager.get<unknown>("domainBranding", [])),
).faviconUrl;
const faviconUrl =
domainOverride && domainOverride.length > 0
? domainOverride
: configManager.get<string>("faviconUrl", "/branding/Bulwark_Favicon.svg");
// Localize the <head> description to match the UI language; a hardcoded
// English description is another signal that makes Chrome offer to
// "translate this page". Resolve the locale from the request path, since this
@@ -76,7 +94,7 @@ export default async function RootLayout({
const parentOrigin = process.env.NEXT_PUBLIC_PARENT_ORIGIN || "";
return (
<html lang={locale} suppressHydrationWarning>
<html lang={locale} dir={getLocaleDirection(locale)} suppressHydrationWarning>
<head>
<meta name="theme-color" content="#ffffff" />
<meta name="mobile-web-app-capable" content="yes" />
+5 -5
View File
@@ -780,7 +780,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
</p>
</div>
</div>
<label className="mt-3 ml-[3.25rem] flex items-center gap-2 cursor-pointer">
<label className="mt-3 ms-[3.25rem] flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmedNonJmap}
@@ -870,7 +870,7 @@ function ServerStep({ config, setConfig, onNext }: Pick<StepProps, 'config' | 's
)}
{hasRowErrors && (
<ul className="text-xs text-destructive list-disc pl-5 space-y-0.5">
<ul className="text-xs text-destructive list-disc ps-5 space-y-0.5">
{rowErrors.map((err, i) => (
<li key={i}>{err}</li>
))}
@@ -1384,7 +1384,7 @@ function BrandingAsset({
</div>
{showUrlField && (
<div className="mt-3 pl-[4.75rem]">
<div className="mt-3 ps-[4.75rem]">
<Input
value={value}
onChange={onChange}
@@ -1394,7 +1394,7 @@ function BrandingAsset({
)}
{uploadError && (
<p className="mt-2 pl-[4.75rem] text-xs text-destructive">{uploadError}</p>
<p className="mt-2 ps-[4.75rem] text-xs text-destructive">{uploadError}</p>
)}
</div>
);
@@ -1601,7 +1601,7 @@ function SummaryRow({ label, value, mono }: { label: string; value: string; mono
return (
<div className="flex justify-between items-baseline gap-3 text-sm">
<span className="text-muted-foreground shrink-0">{label}</span>
<span className={'text-foreground text-right truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
<span className={'text-foreground text-end truncate min-w-0 ' + (mono ? 'font-mono text-xs' : '')}>
{value || <span className="text-muted-foreground italic">-</span>}
</span>
</div>
+1
View File
@@ -341,6 +341,7 @@ export async function POST(request: NextRequest) {
author: (manifest.author as string) || 'Unknown',
description: (manifest.description as string) || '',
type: (manifest.type as string) || 'hook',
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
permissions,
entrypoint,
enabled: existingPlugin?.enabled ?? true,
+10 -3
View File
@@ -82,9 +82,16 @@ export async function PUT(request: NextRequest) {
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
// Drop the refresh token only when the server definitively rejected it
// (invalid/expired/revoked grant). A 5xx or 429 is an outage - keeping
// the cookie lets the session resume once the server is back.
const status = tokenResponse.status;
if (status === 400 || status === 401 || status === 403) {
cookieStore.delete(cookieName);
cookieStore.delete(refreshTokenServerCookieName(slot));
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 });
}
const tokens = await tokenResponse.json();
+4
View File
@@ -74,6 +74,10 @@ export async function GET(request: NextRequest) {
loginImprintUrl: branded<string>('loginImprintUrl', ''),
loginPrivacyPolicyUrl: branded<string>('loginPrivacyPolicyUrl', ''),
loginWebsiteUrl: branded<string>('loginWebsiteUrl', ''),
loginLogoMaxHeight: configManager.get<string>('loginLogoMaxHeight', ''),
loginLogoMaxWidth: configManager.get<string>('loginLogoMaxWidth', ''),
loginShowHeading: configManager.get<boolean>('loginShowHeading', true),
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
demoMode: configManager.get<boolean>('demoMode', false),
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
+2 -1
View File
@@ -1679,7 +1679,8 @@ function handleEmailSubmissionGet(args: MethodArgs, callId: string): MethodResul
}
function handleQuotaGet(_args: MethodArgs, callId: string): MethodResult {
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ resourceType: 'mail', scope: 'mail', used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
// mirroring Stalwart: resourceType "octets", scope "account"
return ['Quota/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'quota-1', resourceType: 'octets', scope: 'account', types: ['Email', 'SieveScript'], used: 52428800, hardLimit: 1073741824 }], notFound: [] }, callId];
}
function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodResult {
+6 -4
View File
@@ -59,10 +59,12 @@ export async function GET(
domainOverrides.pwaIconUrl ||
domainOverrides.faviconUrl ||
(sources.pwaIconUrl?.source !== 'default' ? (sources.pwaIconUrl?.value as string) : '') ||
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '');
if (!iconUrl) {
return new NextResponse('No PWA icon configured', { status: 404 });
}
(sources.faviconUrl?.source !== 'default' ? (sources.faviconUrl?.value as string) : '') ||
// Fall back to the built-in default so this endpoint ALWAYS returns an app
// icon (custom if configured, else the bundled default). This lets callers
// that can't run the custom-vs-default check themselves - notably the
// service worker's notifications - use a single stable URL.
`/icon-${size}x${size}.png`;
const pngHeaders = {
'Content-Type': 'image/png',
+22 -5
View File
@@ -660,13 +660,13 @@ body {
.tiptap ul {
list-style-type: disc;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
margin: 0.25rem 0;
}
.tiptap ol {
list-style-type: decimal;
padding-left: 1.5rem;
padding-inline-start: 1.5rem;
margin: 0.25rem 0;
}
@@ -675,8 +675,8 @@ body {
}
.tiptap blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1rem;
border-inline-start: 3px solid var(--color-border);
padding-inline-start: 1rem;
margin: 0.5rem 0;
color: var(--color-muted-foreground);
}
@@ -719,7 +719,7 @@ body {
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
float: inline-start;
color: var(--color-muted-foreground);
pointer-events: none;
height: 0;
@@ -808,3 +808,20 @@ body {
border-radius: 8px;
animation: settings-search-pulse 1.6s ease-in-out forwards;
}
/* RTL: mirror directional icons (chevrons/arrows) so prev/next, back/forward,
and panel-collapse affordances point the correct way in right-to-left layouts.
lucide-react emits a `lucide-<name>` class per icon, so we target the
directional ones only vertical chevrons (up/down) are intentionally left. */
[dir="rtl"] .lucide-chevron-left,
[dir="rtl"] .lucide-chevron-right,
[dir="rtl"] .lucide-chevrons-left,
[dir="rtl"] .lucide-chevrons-right,
[dir="rtl"] .lucide-arrow-left,
[dir="rtl"] .lucide-arrow-right,
[dir="rtl"] .lucide-arrow-big-left,
[dir="rtl"] .lucide-arrow-big-right,
[dir="rtl"] .lucide-panel-left,
[dir="rtl"] .lucide-panel-right {
transform: scaleX(-1);
}
+9 -3
View File
@@ -131,7 +131,7 @@ export function CalendarAgendaView({
)}>
{formatDateHeader(group.date)}
</span>
<span className="text-xs text-muted-foreground ml-2">
<span className="text-xs text-muted-foreground ms-2">
{intlFormatter.dateTime(group.date, { month: "short", day: "numeric", year: "numeric" })}
</span>
</div>
@@ -148,6 +148,9 @@ export function CalendarAgendaView({
const color = getEventColor(ev, calendar);
const start = getEventStartDate(ev);
const end = getEventEndDate(ev);
// iTIP CANCEL marks the attendee's copy with status "cancelled"
// instead of deleting it (#572).
const isCancelled = ev.status === "cancelled";
const locationName = ev.locations
? Object.values(ev.locations)[0]?.name
: null;
@@ -159,7 +162,10 @@ export function CalendarAgendaView({
onMouseEnter={(e) => onHoverEvent?.(ev, e.currentTarget.getBoundingClientRect())}
onMouseLeave={() => onHoverLeave?.()}
onContextMenu={onContextMenuEvent ? (e) => onContextMenuEvent(e, ev) : undefined}
className="w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-left"
className={cn(
"w-full flex items-start px-4 hover:bg-muted/50 transition-colors text-start",
isCancelled && "opacity-60"
)}
style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)' }}
>
<div className="flex flex-col items-center pt-0.5 min-w-[60px]">
@@ -181,7 +187,7 @@ export function CalendarAgendaView({
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
<div className={cn("text-sm font-medium truncate", isCancelled && "line-through")}>
{ev.title || t("events.no_title")}
</div>
{locationName && (
+4 -4
View File
@@ -218,7 +218,7 @@ export function CalendarDayView({
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
@@ -231,7 +231,7 @@ export function CalendarDayView({
</div>
<div
className="flex-1 relative border-l border-border"
className="flex-1 relative border-s border-border"
role="row"
aria-label={t("views.day")}
onPointerDown={(e) => handleGridPointerDown(e, dayKey, selectedDate)}
@@ -312,7 +312,7 @@ export function CalendarDayView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ml-1" />
<div className="w-2.5 h-2.5 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
@@ -346,7 +346,7 @@ export function CalendarDayView({
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2.5 h-2.5 rounded-full bg-primary -ml-1" />
<div className="w-2.5 h-2.5 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
+28 -29
View File
@@ -1,11 +1,8 @@
"use client";
import { useMemo, useState, useCallback, type DragEvent } from "react";
import { useTranslations, useFormatter } from "next-intl";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameDay, isSameMonth, isToday, format, parseISO,
} from "date-fns";
import { useTranslations } from "next-intl";
import { format, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import { EventCard } from "./event-card";
import { buildWeekSegments, getEventDayBounds, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -14,6 +11,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore } from "@/stores/calendar-store";
import type { PendingEventPreview } from "./event-modal";
import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarMonthViewProps {
selectedDate: Date;
@@ -47,16 +45,21 @@ export function CalendarMonthView({
pendingPreview,
}: CalendarMonthViewProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatFullDate,
} = useCalendarLocale();
const days = useMemo(() => {
const monthStart = startOfMonth(selectedDate);
const monthEnd = endOfMonth(selectedDate);
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [selectedDate, weekStart]);
const days = useMemo(
() => getMonthGridDays(selectedDate),
[selectedDate, getMonthGridDays],
);
const calendarMap = useMemo(() => {
const map = new Map<string, Calendar>();
@@ -83,10 +86,6 @@ export function CalendarMonthView({
return map;
}, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const weeks = useMemo(() => {
const result: Date[][] = [];
for (let i = 0; i < days.length; i += 7) {
@@ -141,11 +140,11 @@ export function CalendarMonthView({
}, [t]);
return (
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={intlFormatter.dateTime(selectedDate, { month: "long", year: "numeric" })}>
<div className="flex flex-col flex-1 overflow-hidden" role="grid" aria-label={formatFullDate(selectedDate)}>
<div className="grid grid-cols-7 border-b border-border" role="row">
{dayHeaders.map((d) => (
{dayHeaderKeys.map((d) => (
<div key={d} role="columnheader" className={cn(
"text-center text-xs font-medium text-muted-foreground py-2 border-r border-border last:border-r-0",
"text-center text-xs font-medium text-muted-foreground py-2 border-e border-border last:border-e-0",
isMobile && "py-1.5 text-[11px]"
)}>
{isMobile ? t(`days.${d}`).slice(0, 2) : t(`days.${d}`)}
@@ -161,12 +160,12 @@ export function CalendarMonthView({
)} role="row" style={isMobile ? undefined : { minHeight: Math.max(100, 34 + rowCount * 22 + 8) }}>
<div className="grid grid-cols-7 h-full">
{week.map((day) => {
const inMonth = isSameMonth(day, selectedDate);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const inMonth = checkIsSameMonth(day, selectedDate);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const key = format(day, "yyyy-MM-dd");
const dayEvents = eventsByDate.get(key) || [];
const fullDateLabel = intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
const fullDateLabel = formatFullDate(day);
return (
<div
@@ -181,7 +180,7 @@ export function CalendarMonthView({
onDragLeave={handleCellDragLeave}
onDrop={(e) => handleCellDrop(e, day)}
className={cn(
"border-r border-border last:border-r-0 p-1 cursor-pointer transition-colors touch-manipulation",
"border-e border-border last:border-e-0 p-1 cursor-pointer transition-colors touch-manipulation",
!inMonth && "bg-muted/30",
"hover:bg-muted/50",
selected && isMobile && "bg-primary/10",
@@ -199,7 +198,7 @@ export function CalendarMonthView({
inMonth && !selected && !today && "font-medium"
)}
>
{format(day, "d")}
{formatDayNumber(day)}
</span>
</div>
{isMobile ? (
@@ -219,7 +218,7 @@ export function CalendarMonthView({
{dayEvents.length > 3 && (
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/40" />
)}
{pendingPreview && isSameDay(pendingPreview.start, day) && (
{pendingPreview && checkIsSameDay(pendingPreview.start, day) && (
<span
className="w-1.5 h-1.5 rounded-full border border-dashed"
style={{ borderColor: calendarMap.get(pendingPreview.calendarId)?.color || "#3b82f6" }}
@@ -233,7 +232,7 @@ export function CalendarMonthView({
</div>
{!isMobile && pendingPreview && (() => {
const previewDayIdx = week.findIndex(d => isSameDay(d, pendingPreview.start));
const previewDayIdx = week.findIndex(d => checkIsSameDay(d, pendingPreview.start));
if (previewDayIdx === -1) return null;
const previewRow = rowCount;
const cal = calendarMap.get(pendingPreview.calendarId);
@@ -397,7 +397,7 @@ export function CalendarSidebarPanel({
<ListTodo className="w-4 h-4 text-muted-foreground" />
<span>{t('tasks.label')}</span>
{pendingTaskCount > 0 && (
<span className="ml-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
<span className="ms-auto text-xs text-muted-foreground">{pendingTaskCount}</span>
)}
{overdueTaskCount > 0 && (
<span className="text-xs text-destructive font-medium">{overdueTaskCount} {t('tasks.filter_overdue').toLowerCase()}</span>
@@ -437,7 +437,7 @@ export function CalendarSidebarPanel({
onCreateCalendar();
}
}}
className="ml-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className="ms-auto p-0.5 rounded text-muted-foreground/70 opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={tMgmt('add_calendar')}
>
<Plus className="w-3 h-3" />
@@ -445,7 +445,7 @@ export function CalendarSidebarPanel({
)}
</button>
{expanded && (
<div className="mt-1 pl-3">
<div className="mt-1 ps-3">
{owned.length > 0 && (
<div>
<div className="px-1 mb-1 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
+30 -28
View File
@@ -1,13 +1,14 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu } from "lucide-react";
import { addDays, startOfWeek } from "date-fns";
import { startOfWeek } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarViewMode } from "@/stores/calendar-store";
import type { Calendar } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
interface CalendarToolbarProps {
selectedDate: Date;
@@ -50,7 +51,14 @@ export function CalendarToolbar({
onMenuClick,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const formatter = useFormatter();
const {
weekStartsOn,
formatMonthYear,
formatMonthYearShort,
formatWeekRange,
formatWeekRangeShort,
formatFullDate,
} = useCalendarLocale();
const views: CalendarViewMode[] = enableCalendarTasks
? ["month", "week", "day", "agenda", "tasks"]
: ["month", "week", "day", "agenda"];
@@ -72,28 +80,22 @@ export function CalendarToolbar({
switch (viewMode) {
case "month":
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
? formatMonthYearShort(selectedDate)
: formatMonthYear(selectedDate);
case "week": {
const ws = startOfWeek(selectedDate, { weekStartsOn: firstDayOfWeek as 0 | 1 });
const we = addDays(ws, 6);
if (isMobile) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}`;
}
const sameMonth = ws.getMonth() === we.getMonth();
if (sameMonth) {
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { day: "numeric" })}, ${we.getFullYear()}`;
}
return `${formatter.dateTime(ws, { month: "short", day: "numeric" })} ${formatter.dateTime(we, { month: "short", day: "numeric" })}, ${we.getFullYear()}`;
const ws = startOfWeek(selectedDate, { weekStartsOn });
return isMobile
? formatWeekRangeShort(ws)
: formatWeekRange(ws);
}
case "day":
return isMobile
? formatter.dateTime(selectedDate, { weekday: "short", month: "short", day: "numeric" })
: formatter.dateTime(selectedDate, { weekday: "long", month: "long", day: "numeric", year: "numeric" });
? formatFullDate(selectedDate)
: formatFullDate(selectedDate);
case "agenda":
return isMobile
? formatter.dateTime(selectedDate, { month: "short", year: "numeric" })
: formatter.dateTime(selectedDate, { month: "long", year: "numeric" });
? formatMonthYearShort(selectedDate)
: formatMonthYear(selectedDate);
case "tasks":
return t("views.tasks");
}
@@ -124,7 +126,7 @@ export function CalendarToolbar({
variant="ghost"
size="icon"
onClick={onMenuClick}
className="h-8 w-8 -ml-1 mr-1"
className="h-8 w-8 -ms-1 me-1"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
@@ -138,7 +140,7 @@ export function CalendarToolbar({
{onMenuClick && (
<button
onClick={onMenuClick}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("nav_open_menu")}
>
<Menu className="w-4 h-4" />
@@ -147,7 +149,7 @@ export function CalendarToolbar({
{onNavigateBack && (
<button
onClick={onNavigateBack}
className="p-1.5 -ml-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
className="p-1.5 -ms-1 rounded-md hover:bg-muted transition-colors touch-manipulation"
aria-label={t("back_to_month")}
>
<ArrowLeft className="w-4 h-4" />
@@ -162,7 +164,7 @@ export function CalendarToolbar({
<button onClick={onNext} className="p-1.5 rounded-md hover:bg-muted transition-colors touch-manipulation" aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</button>
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ml-0.5">
<Button variant="ghost" size="sm" onClick={onToday} className="touch-manipulation text-xs h-7 px-2 ms-0.5">
{t("views.today")}
</Button>
</div>
@@ -284,7 +286,7 @@ export function CalendarToolbar({
{/* ── DESKTOP TOOLBAR ── */}
{!isMobile && (
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 mr-1">
<Button variant="outline" size="sm" onClick={onToday} className="h-8 me-1">
{t("views.today")}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onPrev} aria-label={t("nav_prev")}>
@@ -293,7 +295,7 @@ export function CalendarToolbar({
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onNext} aria-label={t("nav_next")}>
<ChevronRight className="w-4 h-4" />
</Button>
<span className="text-base font-semibold ml-2 select-none">
<span className="text-base font-semibold ms-2 select-none">
{getDateLabel()}
</span>
</div>
@@ -328,9 +330,9 @@ export function CalendarToolbar({
{(onImport || onSubscribe) && !isMobile && (
<div className="relative" ref={importDropdownRef}>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowImportDropdown((v) => !v)}>
<Upload className="w-4 h-4 mr-1" />
<Upload className="w-4 h-4 me-1" />
{t("import.title")}
<ChevronDown className="w-3 h-3 ml-1" />
<ChevronDown className="w-3 h-3 ms-1" />
</Button>
{showImportDropdown && (
<div className="absolute top-full right-0 mt-1 z-50 bg-background border border-border rounded-lg shadow-lg p-1 min-w-[180px]">
@@ -359,7 +361,7 @@ export function CalendarToolbar({
{!isMobile && (
<Button size="sm" className="h-8" onClick={onCreateEvent} data-tour="create-event-button">
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("events.create")}
</Button>
)}
+9 -9
View File
@@ -58,7 +58,7 @@ export function CalendarWeekView({
const intlFormatter = useFormatter();
const scrollRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const weekStart = (firstDayOfWeek === 0 ? 0 : firstDayOfWeek === 6 ? 6 : 1) as 0 | 1 | 6;
const weekDays = useMemo(() => {
const start = startOfWeek(selectedDate, { weekStartsOn: weekStart });
@@ -211,7 +211,7 @@ export function CalendarWeekView({
<div className={cn("flex min-h-0 flex-col flex-1", isMobile && "min-w-[880px]")}> {hasAllDay && (
<div className="flex border-b border-border">
<div
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-right", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
className={cn("flex-shrink-0 text-[10px] text-muted-foreground p-1 text-end", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")}
style={{ minHeight: Math.max(28, (allDayRowCount + taskRowCount) * 24 + 4) }}
>
{t("events.all_day")}
@@ -306,7 +306,7 @@ export function CalendarWeekView({
<div className="flex border-b border-border" role="row">
<div className={cn("flex-shrink-0", isMobile ? "w-10 sticky left-0 z-10 bg-background" : "w-14")} />
<div className="flex-1 border-l border-border grid grid-cols-7">
<div className="flex-1 border-s border-border grid grid-cols-7">
{weekDays.map((day) => {
const todayCol = isToday(day);
const selected = isSameDay(day, selectedDate);
@@ -318,7 +318,7 @@ export function CalendarWeekView({
role="columnheader"
aria-label={fullLabel}
className={cn(
"text-center py-2 text-sm border-r border-border last:border-r-0 transition-colors touch-manipulation",
"text-center py-2 text-sm border-e border-border last:border-e-0 transition-colors touch-manipulation",
"hover:bg-muted/50",
todayCol && "font-bold",
)}
@@ -345,7 +345,7 @@ export function CalendarWeekView({
{HOURS.map((h) => (
<div
key={h}
className="relative text-muted-foreground text-right pr-2"
className="relative text-muted-foreground text-end pe-2"
style={{ height: HOUR_HEIGHT }}
>
{h > 0 && (
@@ -357,7 +357,7 @@ export function CalendarWeekView({
))}
</div>
<div className="flex-1 border-l border-border relative grid grid-cols-7">
<div className="flex-1 border-s border-border relative grid grid-cols-7">
{weekDays.map((day) => {
const key = format(day, "yyyy-MM-dd");
const dayEvents = timedEvents.get(key) || [];
@@ -367,7 +367,7 @@ export function CalendarWeekView({
return (
<div
key={key}
className="relative border-r border-border last:border-r-0"
className="relative border-e border-border last:border-e-0"
role="row"
aria-label={intlFormatter.dateTime(day, { weekday: "long", month: "long", day: "numeric" })}
onPointerDown={(e) => handleGridPointerDown(e, key, day)}
@@ -448,7 +448,7 @@ export function CalendarWeekView({
style={{ top: (nowMinutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-destructive -ml-1" />
<div className="w-2 h-2 rounded-full bg-destructive -ms-1" />
<div className="flex-1 h-px bg-destructive" />
</div>
</div>
@@ -482,7 +482,7 @@ export function CalendarWeekView({
style={{ top: (dropTarget.minutes / 60) * HOUR_HEIGHT }}
>
<div className="flex items-center">
<div className="w-2 h-2 rounded-full bg-primary -ml-1" />
<div className="w-2 h-2 rounded-full bg-primary -ms-1" />
<div className="flex-1 h-0.5 bg-primary rounded-full" />
</div>
<div className="absolute -top-4 left-2 text-[10px] font-medium text-primary bg-background/90 px-1 rounded shadow-sm">
@@ -137,7 +137,7 @@ export function CreateCalendarModal({ client, onClose }: CreateCalendarModalProp
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{tCommon("loading")}
</>
) : (
+14 -8
View File
@@ -88,7 +88,10 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
try { return format(d, fmt); } catch { return "--:--"; }
};
const timeString = `${safeFormat(startDate, timeFmt)} ${safeFormat(endTime, timeFmt)}`;
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}`;
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead of
// deleting it (#572) - render it struck through and dimmed.
const isCancelled = event.status === "cancelled";
const ariaLabel = `${event.title || t("events.no_title")}, ${timeString}${calendarName ? `, ${calendarName}` : ""}${isCancelled ? `, ${t("detail.cancelled")}` : ""}`;
const handleDragStart = useCallback((e: DragEvent) => {
e.stopPropagation();
@@ -131,11 +134,12 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"flex items-center gap-1 w-full text-left text-xs px-1 py-0.5 rounded truncate",
"flex items-center gap-1 w-full text-start text-xs px-1 py-0.5 rounded truncate",
"min-h-[44px] sm:min-h-0",
"hover:opacity-80 transition-opacity",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}20`, color, ...style }}
@@ -144,7 +148,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<span className="truncate">{event.title || t("events.no_title")}</span>
<span className={cn("truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
</button>
);
}
@@ -159,12 +163,13 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
aria-label={ariaLabel}
{...dragProps}
className={cn(
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
continuesAfter && "pr-2",
continuesAfter && "pe-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}24`, borderLeft: `3px solid ${color}`, color, ...style }}
@@ -173,7 +178,7 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{showTimeInMonthView && !event.showWithoutTime && (
<span className="flex-shrink-0 opacity-80">{format(startDate, timeFmt)}</span>
)}
<span className="truncate font-medium">{event.title || t("events.no_title")}</span>
<span className={cn("truncate font-medium", isCancelled && "line-through")}>{event.title || t("events.no_title")}</span>
</div>
</button>
);
@@ -189,15 +194,16 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
{...dragProps}
data-calendar-event
className={cn(
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"w-full h-full text-start rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
isCancelled && !isBeingDragged && "opacity-60",
className
)}
style={{ backgroundColor: `${color}30`, borderLeft: `3px solid ${color}`, color, ...style }}
>
<div className="font-medium truncate">{event.title || t("events.no_title")}</div>
<div className={cn("font-medium truncate", isCancelled && "line-through")}>{event.title || t("events.no_title")}</div>
{!event.showWithoutTime && (
<div className="opacity-80 text-[10px]">
{timeString}
+18 -15
View File
@@ -290,20 +290,23 @@ export function EventDetailPopover({
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
<h3 className="text-base font-semibold truncate text-foreground">
<h3 className={cn(
"text-base font-semibold truncate text-foreground",
event.status === "cancelled" && "line-through text-muted-foreground"
)}>
{event.title || t("events.no_title")}
</h3>
</div>
{calendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">
{calendar.name}
{event.status === "tentative" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-warning/15 text-warning">
{t("detail.tentative")}
</span>
)}
{event.status === "cancelled" && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
<span className="ms-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400 line-through">
{t("detail.cancelled")}
</span>
)}
@@ -343,13 +346,13 @@ export function EventDetailPopover({
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{formatTime(startDate)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{formatTime(endDate)}
</span>
</div>
@@ -364,11 +367,11 @@ export function EventDetailPopover({
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
</>
@@ -434,7 +437,7 @@ export function EventDetailPopover({
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">
<span className="text-muted-foreground ms-1">
({t("participants.organizer").toLowerCase()})
</span>
)}
@@ -512,7 +515,7 @@ export function EventDetailPopover({
disabled={!noteText.trim() || isSavingNote}
className="h-7 text-xs"
>
<Send className="w-3 h-3 mr-1" />
<Send className="w-3 h-3 me-1" />
{t("detail.save_note")}
</Button>
</div>
@@ -546,7 +549,7 @@ export function EventDetailPopover({
: "text-success border-success/30 hover:bg-success/10"
}
>
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "accepted" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.accepted")}
</Button>
<Button
@@ -559,7 +562,7 @@ export function EventDetailPopover({
: "border border-warning/30 text-warning hover:bg-warning/10"
}
>
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "tentative" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.tentative")}
</Button>
<Button
@@ -572,7 +575,7 @@ export function EventDetailPopover({
: "text-destructive hover:bg-destructive/10"
}
>
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 mr-1" />}
{userCurrentStatus === "declined" && <Check className="w-3.5 h-3.5 me-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -606,7 +609,7 @@ export function EventDetailPopover({
) : (
<>
<Button variant="default" size="sm" onClick={onEdit} className="h-7 text-xs">
<Pencil className="w-3.5 h-3.5 mr-1" />
<Pencil className="w-3.5 h-3.5 me-1" />
{t("events.edit")}
</Button>
<Button
@@ -616,7 +619,7 @@ export function EventDetailPopover({
className="h-7 text-xs"
title={t("events.duplicate")}
>
<Copy className="w-3.5 h-3.5 mr-1" />
<Copy className="w-3.5 h-3.5 me-1" />
{t("events.duplicate")}
</Button>
<div className="flex-1" />
+20 -19
View File
@@ -522,13 +522,14 @@ export function EventModal({
{ name: organizerName, email: organizerEmail },
effectiveAttendees
) as Record<string, CalendarParticipant>;
data.replyTo = { imip: `mailto:${organizerEmail}` };
// Stalwart (calcard) derives the iCalendar ORGANIZER property solely from
// organizerCalendarAddress; without it no ORGANIZER is emitted and iTIP
// scheduling is silently skipped (NoSchedulingInfo), so no invites are sent.
// The RFC 8984 replyTo property is retired in jscalendarbis and ignored.
data.organizerCalendarAddress = `mailto:${organizerEmail}`;
} else if (effectiveAttendees.length === 0 && event?.participants) {
data.participants = null;
// Also clear the retired replyTo that older releases (<= 1.7.6) wrote.
data.replyTo = null;
data.organizerCalendarAddress = null;
}
@@ -665,11 +666,11 @@ export function EventModal({
<div className="text-sm">
<div>
<span className="font-medium">{formatEventDate(startD)}</span>
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
<span className="text-muted-foreground ms-2">{format(startD, timeDisplayFmt)}</span>
</div>
<div>
<span className="font-medium">{formatEventDate(endD)}</span>
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
<span className="text-muted-foreground ms-2">{format(endD, timeDisplayFmt)}</span>
</div>
</div>
);
@@ -678,7 +679,7 @@ export function EventModal({
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
<span className="text-muted-foreground ms-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
@@ -700,7 +701,7 @@ export function EventModal({
<Users className="w-4 h-4" />
{t("participants.title")}
</div>
<div className="space-y-1 pl-5">
<div className="space-y-1 ps-5">
{participants.map(p => (
<div key={p.id} className="flex items-center justify-between text-sm">
<span className="truncate">{p.name || p.email}</span>
@@ -725,7 +726,7 @@ export function EventModal({
? "bg-success hover:bg-success/80 text-success-foreground"
: "text-success border-success/30 hover:bg-success/10"}
>
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "accepted" && <Check className="w-4 h-4 me-1" />}
{t("participants.accepted")}
</Button>
<Button
@@ -736,7 +737,7 @@ export function EventModal({
? "bg-warning hover:bg-warning/80 text-warning-foreground"
: "border border-warning/30 text-warning hover:bg-warning/10"}
>
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "tentative" && <Check className="w-4 h-4 me-1" />}
{t("participants.tentative")}
</Button>
<Button
@@ -747,7 +748,7 @@ export function EventModal({
? "bg-destructive hover:bg-destructive/80 text-destructive-foreground"
: "text-destructive hover:bg-destructive/10"}
>
{userCurrentStatus === "declined" && <Check className="w-4 h-4 mr-1" />}
{userCurrentStatus === "declined" && <Check className="w-4 h-4 me-1" />}
{t("participants.declined")}
</Button>
</div>
@@ -783,7 +784,7 @@ export function EventModal({
<h2 className="text-lg font-semibold truncate">{event.title || t("events.no_title")}</h2>
</div>
{eventCalendar && (
<p className="text-xs text-muted-foreground mt-0.5 pl-[18px]">{eventCalendar.name}</p>
<p className="text-xs text-muted-foreground mt-0.5 ps-[18px]">{eventCalendar.name}</p>
)}
</div>
<button onClick={onClose} className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 flex-shrink-0 mt-0.5 text-muted-foreground hover:text-foreground" aria-label={t("form.cancel")}>
@@ -815,13 +816,13 @@ export function EventModal({
<>
<div className="font-medium text-foreground">
{formatEventDate(startD)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{format(startD, timeDisplayFmt)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endD)}
<span className="ml-1.5 font-normal text-muted-foreground">
<span className="ms-1.5 font-normal text-muted-foreground">
{format(endD, timeDisplayFmt)}
</span>
</div>
@@ -837,11 +838,11 @@ export function EventModal({
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
<span className="text-muted-foreground ms-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
<span className="ms-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
</>
@@ -888,7 +889,7 @@ export function EventModal({
<span className="truncate text-foreground">
{p.name || p.email}
{p.isOrganizer && (
<span className="text-muted-foreground ml-1">({t("participants.organizer").toLowerCase()})</span>
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
)}
</span>
<StatusBadge status={p.status} isOrganizer={p.isOrganizer} t={t} />
@@ -941,21 +942,21 @@ export function EventModal({
</div>
) : (
<Button variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} className="text-destructive">
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
)}
{onDuplicate && !showDeleteConfirm && (
<Button variant="ghost" size="sm" onClick={handleDuplicate} aria-label={t("events.duplicate")}>
<Copy className="w-4 h-4 mr-1" />
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
</div>
{!showDeleteConfirm && (
<Button onClick={() => setMode("edit")}>
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("events.edit")}
</Button>
)}
@@ -1319,7 +1320,7 @@ export function EventModal({
onClick={() => setShowDeleteConfirm(true)}
className="text-red-600 dark:text-red-400"
>
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("events.delete")}
</Button>
)
@@ -1331,7 +1332,7 @@ export function EventModal({
onClick={handleDuplicate}
aria-label={t("events.duplicate")}
>
<Copy className="w-4 h-4 mr-1" />
<Copy className="w-4 h-4 me-1" />
{t("events.duplicate")}
</Button>
)}
+1 -1
View File
@@ -444,7 +444,7 @@ export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICal
onClick={handleImport}
disabled={selectedIndices.size === 0}
>
<Check className="w-4 h-4 mr-1" />
<Check className="w-4 h-4 me-1" />
{t("import_button")} ({selectedIndices.size})
</Button>
)}
@@ -208,7 +208,7 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription, initi
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{isEdit ? t("saving") : t("subscribing")}
</>
) : (
+30 -32
View File
@@ -1,25 +1,19 @@
"use client";
import { useState, useMemo, Fragment } from "react";
import { useTranslations, useFormatter } from "next-intl";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, ChevronDown } from "lucide-react";
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
addMonths, subMonths, addYears, subYears, setMonth, setYear,
eachDayOfInterval, getMonth, getYear, getISOWeek, getWeek,
isSameDay, isSameMonth, isToday, format,
getISOWeek, getWeek, format,
} from "date-fns";
import { cn } from "@/lib/utils";
import { getEventDayBounds } from "@/lib/calendar-utils";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
type PickerView = "days" | "months" | "years";
const MONTH_LABELS = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
interface MiniCalendarProps {
selectedDate: Date;
displayMonth: Date;
@@ -40,17 +34,25 @@ export function MiniCalendar({
showWeekNumbers = false,
}: MiniCalendarProps) {
const t = useTranslations("calendar");
const intlFormatter = useFormatter();
const weekStart = (firstDayOfWeek === 0 ? 0 : 1) as 0 | 1;
const {
weekStartsOn,
dayHeaderKeys,
getMonthGridDays,
checkIsToday,
checkIsSameMonth,
checkIsSameDay,
formatDayNumber,
formatMonthYear,
getMonth,
getYear,
monthLabelKeys,
} = useCalendarLocale();
const [pickerView, setPickerView] = useState<PickerView>("days");
const days = useMemo(() => {
const monthStart = startOfMonth(displayMonth);
const monthEnd = endOfMonth(displayMonth);
const gridStart = startOfWeek(monthStart, { weekStartsOn: weekStart });
const gridEnd = endOfWeek(monthEnd, { weekStartsOn: weekStart });
return eachDayOfInterval({ start: gridStart, end: gridEnd });
}, [displayMonth, weekStart]);
const days = useMemo(
() => getMonthGridDays(displayMonth),
[displayMonth, getMonthGridDays],
);
const eventDates = useMemo(() => {
const set = new Set<string>();
@@ -67,20 +69,16 @@ export function MiniCalendar({
return set;
}, [events]);
const dayHeaders = firstDayOfWeek === 0
? ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const
: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
// Compute week numbers for each row (one per 7-day chunk)
const weekNumbers = useMemo(() => {
if (!showWeekNumbers) return [];
const nums: number[] = [];
for (let i = 0; i < days.length; i += 7) {
// Use the first day of each row to determine the week number
nums.push(weekStart === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
nums.push(weekStartsOn === 1 ? getISOWeek(days[i]) : getWeek(days[i], { weekStartsOn: 0 }));
}
return nums;
}, [days, showWeekNumbers, weekStart]);
}, [days, showWeekNumbers, weekStartsOn]);
const currentYear = getYear(displayMonth);
const currentMonth = getMonth(displayMonth);
@@ -116,7 +114,7 @@ export function MiniCalendar({
const headerLabel =
pickerView === "days"
? intlFormatter.dateTime(displayMonth, { month: "long", year: "numeric" })
? formatMonthYear(displayMonth)
: pickerView === "months"
? String(currentYear)
: `${decadeStart}\u2013${decadeStart + 9}`;
@@ -160,15 +158,15 @@ export function MiniCalendar({
{showWeekNumbers && (
<div className="text-center text-[10px] font-medium text-muted-foreground py-1 w-5" />
)}
{dayHeaders.map((d) => (
{dayHeaderKeys.map((d) => (
<div key={d} className="text-center text-[10px] font-medium text-muted-foreground py-1">
{t(`days.${d}`)}
</div>
))}
{days.map((day, index) => {
const inMonth = isSameMonth(day, displayMonth);
const selected = isSameDay(day, selectedDate);
const today = isToday(day);
const inMonth = checkIsSameMonth(day, displayMonth);
const selected = checkIsSameDay(day, selectedDate);
const today = checkIsToday(day);
const hasEvent = eventDates.has(format(day, "yyyy-MM-dd"));
const isFirstDayOfRow = index % 7 === 0;
@@ -193,7 +191,7 @@ export function MiniCalendar({
selected && "bg-primary text-primary-foreground"
)}
>
{format(day, "d")}
{formatDayNumber(day)}
{hasEvent && !selected && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
@@ -206,7 +204,7 @@ export function MiniCalendar({
{pickerView === "months" && (
<div className="grid grid-cols-3 gap-1 py-1">
{MONTH_LABELS.map((label, i) => {
{monthLabelKeys.map((labelKey, i) => {
const isCurrentMonth = i === currentMonth && currentYear === getYear(new Date());
const isSelected = i === getMonth(selectedDate) && currentYear === getYear(selectedDate);
return (
@@ -220,7 +218,7 @@ export function MiniCalendar({
!isSelected && !isCurrentMonth && "hover:bg-muted"
)}
>
{label}
{t(`months.${labelKey}`)}
</button>
);
})}
+1 -1
View File
@@ -302,7 +302,7 @@ export function TaskModal({
className="text-destructive hover:text-destructive"
onClick={() => onDelete(task.id)}
>
<Trash2 className="h-4 w-4 mr-1" />
<Trash2 className="h-4 w-4 me-1" />
{t("tasks.delete")}
</Button>
)}
+2 -2
View File
@@ -44,7 +44,7 @@ export function TaskToolbar({
))}
</div>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ml-2">
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none ms-2">
<input
type="checkbox"
checked={showCompleted}
@@ -57,7 +57,7 @@ export function TaskToolbar({
<div className="flex-1" />
<Button size="sm" onClick={onCreateTask}>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("tasks.create")}
</Button>
</div>
@@ -62,6 +62,27 @@ describe('ContactForm', () => {
expect(phoneAfter.length).toBe(phoneBefore.length + 1);
});
it('sends media: null when an existing photo is removed', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const contactWithPhoto: ContactCard = {
...existingContact,
media: {
photo: { kind: 'photo', uri: 'data:image/png;base64,AAAA', mediaType: 'image/png' },
},
};
render(<ContactForm contact={contactWithPhoto} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('remove_photo'));
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.media).toBeNull();
});
it('submits form data correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
+2 -2
View File
@@ -229,7 +229,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
key={email.id}
type="button"
onClick={() => handleOpenEmail(email)}
className="w-full text-left flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-start flex items-start gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<Avatar name={sender.name} email={sender.address} size="sm" />
<div className="flex-1 min-w-0">
@@ -277,7 +277,7 @@ export function ContactActivity({ contact }: ContactActivityProps) {
key={event.id}
type="button"
onClick={() => handleOpenEvent(event)}
className="w-full text-left flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
className="w-full text-start flex items-baseline gap-3 px-2 py-2 rounded-md hover:bg-muted/60 transition-colors touch-manipulation"
>
<span className="text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0">
{formatEventTime(event)}
+4 -4
View File
@@ -201,7 +201,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onClick={onCompose}
className="touch-manipulation"
>
<Send className="w-4 h-4 mr-1" />
<Send className="w-4 h-4 me-1" />
{t("detail.compose_email")}
</Button>
)}
@@ -210,12 +210,12 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
href={`tel:${phone}`}
className="inline-flex items-center justify-center rounded-md font-medium h-9 px-3 text-sm border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors touch-manipulation"
>
<Phone className="w-4 h-4 mr-1" />
<Phone className="w-4 h-4 me-1" />
{t("context_menu.call")}
</a>
)}
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
@@ -587,7 +587,7 @@ function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string })
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start hover:bg-muted focus:bg-muted focus:outline-none transition-colors",
item.destructive && "text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 focus:bg-red-50 dark:focus:bg-red-950",
)}
>
+16 -11
View File
@@ -72,7 +72,7 @@ function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = t
<button
type="button"
className={cn(
"flex items-center gap-2 w-full text-left",
"flex items-center gap-2 w-full text-start",
collapsible ? "cursor-pointer" : "cursor-default"
)}
onClick={() => collapsible && setOpen(!open)}
@@ -525,6 +525,11 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
mediaMap[photoKey] = { kind: "photo", uri: photoUri, mediaType: photoMediaType };
}
// Set mediaVluae to null if we are removing media, so the server removes it
const hadMedia = !!contact?.media && Object.keys(contact.media).length > 0;
const mediaValue: Record<string, ContactMedia> | null | undefined =
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
@@ -551,7 +556,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
calendarUri: calendarUri.trim() || undefined,
schedulingUri: schedulingUri.trim() || undefined,
freeBusyUri: freeBusyUri.trim() || undefined,
media: Object.keys(mediaMap).length > 0 ? mediaMap : undefined,
media: mediaValue as Record<string, ContactMedia> | undefined,
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
@@ -731,12 +736,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
)}
</div>
{emailErrors[i] && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ml-1">{emailErrors[i]}</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1 ms-1">{emailErrors[i]}</p>
)}
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setEmails([...emails, { address: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_email")}
</Button>
</div>
@@ -794,7 +799,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPhones([...phones, { number: "", context: "", feature: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_phone")}
</Button>
</div>
@@ -850,7 +855,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_address")}
</Button>
</div>
@@ -879,7 +884,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setOnlineServices([...onlineServices, { uri: "", service: "", label: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_online_service")}
</Button>
</div>
@@ -911,7 +916,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setAnniversaries([...anniversaries, { date: "", kind: "birth" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_anniversary")}
</Button>
</div>
@@ -952,7 +957,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</div>
))}
<Button type="button" variant="ghost" size="sm" onClick={() => setPersonalInfoEntries([...personalInfoEntries, { value: "", kind: "hobby", level: "" }])} className="text-xs">
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t("add_personal_info")}
</Button>
</div>
@@ -1140,7 +1145,7 @@ function CategoryComboBox({
<button
key={kw}
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { addKeyword(kw); inputRef.current?.focus(); }}
>
<Tag className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
@@ -1150,7 +1155,7 @@ function CategoryComboBox({
{canAddNew && (
<button
type="button"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left text-primary"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start text-primary"
onClick={() => { addKeyword(inputValue); inputRef.current?.focus(); }}
>
<Plus className="w-3.5 h-3.5 flex-shrink-0" />
+2 -2
View File
@@ -53,7 +53,7 @@ export function ContactGroupDetail({
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
<Pencil className="w-4 h-4 mr-1" />
<Pencil className="w-4 h-4 me-1" />
{t("form.edit_title")}
</Button>
<Button
@@ -106,7 +106,7 @@ export function ContactGroupDetail({
className="flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-muted group transition-colors"
>
<button
className="flex items-center gap-3 flex-1 min-w-0 text-left"
className="flex items-center gap-3 flex-1 min-w-0 text-start"
onClick={() => onSelectMember(member.id)}
>
<Avatar name={mName} email={mEmail} size="sm" />
+2 -2
View File
@@ -114,7 +114,7 @@ export function ContactGroupForm({
placeholder={t("groups.search_members")}
value={memberSearch}
onChange={(e) => setMemberSearch(e.target.value)}
className="pl-9"
className="ps-9"
/>
</div>
@@ -134,7 +134,7 @@ export function ContactGroupForm({
type="button"
onClick={() => toggleMember(contact.id)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors",
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors",
"hover:bg-muted",
isSelected && "bg-primary/5"
)}
+2 -2
View File
@@ -45,7 +45,7 @@ export function ContactGroupList({
<div className={cn("flex flex-col", className)}>
<div className="px-4 py-2 border-b border-border">
<Button size="sm" variant="outline" onClick={onCreateGroup} className="w-full">
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
{t("groups.create")}
</Button>
</div>
@@ -69,7 +69,7 @@ export function ContactGroupList({
key={group.id}
onClick={() => onSelectGroup(group.id)}
className={cn(
"w-full flex items-center px-4 text-left transition-colors",
"w-full flex items-center px-4 text-start transition-colors",
"hover:bg-muted",
group.id === selectedGroupId && "bg-accent text-accent-foreground"
)}
@@ -187,7 +187,7 @@ export function ContactImportDialog({
type="button"
onClick={() => toggleSelect(idx)}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-muted",
"w-full flex items-center gap-3 px-3 py-2.5 text-start transition-colors hover:bg-muted",
isSelected && "bg-primary/5"
)}
>
+7 -7
View File
@@ -310,7 +310,7 @@ export function ContactList({
placeholder={t("search_placeholder")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className={cn("pl-9 h-9", searchQuery && "pr-8")}
className={cn("ps-9 h-9", searchQuery && "pe-8")}
/>
{searchQuery && (
<button
@@ -359,7 +359,7 @@ export function ContactList({
onClick={() => setFilters(EMPTY_FILTERS)}
className="h-7 px-2 text-xs"
>
<RotateCcw className="w-3 h-3 mr-1" />
<RotateCcw className="w-3 h-3 me-1" />
{t("filters.clear")}
</Button>
<Button
@@ -479,11 +479,11 @@ export function ContactList({
</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={onBulkAddToGroup} className="h-7 text-xs">
<Users className="w-3.5 h-3.5 mr-1" />
<Users className="w-3.5 h-3.5 me-1" />
{t("bulk.add_to_group")}
</Button>
<Button variant="ghost" size="sm" onClick={onBulkExport} className="h-7 text-xs">
<Download className="w-3.5 h-3.5 mr-1" />
<Download className="w-3.5 h-3.5 me-1" />
{t("bulk.export")}
</Button>
<Button
@@ -492,7 +492,7 @@ export function ContactList({
onClick={onBulkDelete}
className="h-7 text-xs text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
>
<Trash2 className="w-3.5 h-3.5 mr-1" />
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
</Button>
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
@@ -526,7 +526,7 @@ export function ContactList({
)}
{activeFilters > 0 && (
<Button variant="outline" size="sm" onClick={() => setFilters(EMPTY_FILTERS)}>
<RotateCcw className="w-3.5 h-3.5 mr-1" />
<RotateCcw className="w-3.5 h-3.5 me-1" />
{t("filters.clear")}
</Button>
)}
@@ -538,7 +538,7 @@ export function ContactList({
<p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
<p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
<Button size="sm" className="mt-3" onClick={onCreateNew}>
<UserPlus className="w-4 h-4 mr-1.5" />
<UserPlus className="w-4 h-4 me-1.5" />
{t("create_new")}
</Button>
</>
+20 -20
View File
@@ -287,14 +287,14 @@ export function ContactsSidebar({
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateContact(); }}
>
<UserPlus className="w-4 h-4" />
{t("create_new")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateGroup(); }}
>
<UsersRound className="w-4 h-4" />
@@ -302,7 +302,7 @@ export function ContactsSidebar({
</button>
{onCreateAddressBook && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onCreateAddressBook(); }}
>
<BookPlus className="w-4 h-4" />
@@ -311,7 +311,7 @@ export function ContactsSidebar({
)}
{onImport && (
<button
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-start"
onClick={() => { setShowMenu(false); onImport(); }}
>
<Upload className="w-4 h-4" />
@@ -338,7 +338,7 @@ export function ContactsSidebar({
>
<BookUser className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{t("tabs.all")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{individuals.length}
</span>
</button>
@@ -355,7 +355,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{expanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground" />
@@ -369,7 +369,7 @@ export function ContactsSidebar({
</button>
</div>
{expanded && (
<div className="pl-2">
<div className="ps-2">
{owned.length > 0 && (
<div className="mt-1">
<div className="px-3 py-0.5 text-[10px] font-medium text-muted-foreground/80 uppercase tracking-wider">
@@ -418,7 +418,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection("addressBooks")}
className="flex items-center gap-1 flex-1 text-left"
className="flex items-center gap-1 flex-1 text-start"
>
{collapsed.addressBooks ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -461,7 +461,7 @@ export function ContactsSidebar({
<div className="mt-2">
<button
onClick={() => toggleSection("groups")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
>
{collapsed.groups ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -483,7 +483,7 @@ export function ContactsSidebar({
onClick={() => onSelectCategory({ groupId: group.id })}
onContextMenu={(e) => openGroupContextMenu(e, group)}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -492,7 +492,7 @@ export function ContactsSidebar({
>
<Users className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{getContactDisplayName(group)}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{memberCount}
</span>
</button>
@@ -505,7 +505,7 @@ export function ContactsSidebar({
<div className="mt-2">
<button
onClick={() => toggleSection("categories")}
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
className="flex items-center gap-1 px-3 py-1 w-full text-start group"
>
{collapsed.categories ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -523,7 +523,7 @@ export function ContactsSidebar({
<button
onClick={() => onSelectCategory("uncategorized")}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
activeCategory === "uncategorized"
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted"
@@ -532,7 +532,7 @@ export function ContactsSidebar({
>
<Tag className="w-3.5 h-3.5 flex-shrink-0 opacity-50" />
<span className="truncate italic">{t("no_category")}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{uncategorizedCount}
</span>
</button>
@@ -561,7 +561,7 @@ export function ContactsSidebar({
<div className="flex items-center px-3 py-1 group">
<button
onClick={() => toggleSection(`shared-${group.accountId}`)}
className="flex items-center gap-1 flex-1 min-w-0 text-left"
className="flex items-center gap-1 flex-1 min-w-0 text-start"
>
{collapsed[`shared-${group.accountId}`] ? (
<ChevronRight className="w-3 h-3 text-muted-foreground" />
@@ -779,7 +779,7 @@ function CategoryItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -789,7 +789,7 @@ function CategoryItem({
>
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{keyword}</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
<span className="ms-auto text-xs text-muted-foreground tabular-nums">
{count}
</span>
</button>
@@ -847,7 +847,7 @@ function AddressBookItem({
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
"w-full flex items-center gap-2 ps-5 pe-3 text-sm transition-colors",
isActive
? "bg-accent text-accent-foreground font-medium"
: "text-foreground/80 hover:bg-muted",
@@ -858,11 +858,11 @@ function AddressBookItem({
<Book className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{book.name}</span>
{!book.isShared && Object.keys(book.shareWith || {}).length > 0 && (
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
<Users className="w-3 h-3 text-muted-foreground flex-shrink-0 ms-auto" />
)}
<span className={cn(
"text-xs text-muted-foreground tabular-nums",
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ml-auto"
!(!book.isShared && Object.keys(book.shareWith || {}).length > 0) && "ms-auto"
)}>
{contactCount}
</span>
@@ -540,7 +540,9 @@ describe('CalendarInvitationBanner', () => {
mocks.clientMock,
'event-8',
expect.objectContaining({
replyTo: { imip: 'mailto:organizer@example.com' },
// The stored event lacks an ORGANIZER, so the RSVP repair writes
// organizerCalendarAddress (replyTo is retired in jscalendarbis).
organizerCalendarAddress: 'mailto:organizer@example.com',
participants: expect.objectContaining({
attendee: expect.objectContaining({
participationStatus: 'accepted',
@@ -121,3 +121,34 @@ describe('EmailListItem tag badge', () => {
expect(container.querySelector('p')).toBeNull();
});
});
describe('EmailListItem shift-range checkbox', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split' });
});
it('shift-clicking the checkbox extends the selection from the anchor', () => {
const e1 = makeEmail({ id: 'e1', threadId: 't1' });
const e2 = makeEmail({ id: 'e2', threadId: 't2' });
const e3 = makeEmail({ id: 'e3', threadId: 't3' });
// selection mode active (so the checkbox renders), anchor on e1
useEmailStore.setState({
emails: [e1, e2, e3],
selectedEmailIds: new Set(['e1']),
lastSelectedEmailId: 'e1',
selectedMailbox: 'inbox',
});
render(<EmailListItem email={e3} />);
// the checkbox is the first button in the row (shown in selection mode)
const checkbox = screen.getAllByRole('button')[0];
act(() => {
checkbox.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true }));
});
const sel = useEmailStore.getState().selectedEmailIds;
expect(sel.has('e1')).toBe(true);
expect(sel.has('e2')).toBe(true); // the in-between row got filled in
expect(sel.has('e3')).toBe(true);
});
});
@@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SelectableAvatar } from '../selectable-avatar';
// Isolate from the real Avatar (image fetching, libravatar hashing) — we only
// care about the selection wrapper behaviour here.
vi.mock('@/components/ui/avatar', () => ({
Avatar: (props: { name?: string }) => <span data-testid="avatar">{props.name}</span>,
}));
describe('SelectableAvatar', () => {
it('renders the wrapped avatar', () => {
render(<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />);
expect(screen.getByTestId('avatar')).toHaveTextContent('Marta');
});
it('fires onToggle and stops propagation when the avatar is clicked', () => {
const onToggle = vi.fn();
const onRowClick = vi.fn();
render(
<div onClick={onRowClick}>
<SelectableAvatar name="Marta" checked={false} onToggle={onToggle} selectLabel="Select" />
</div>,
);
fireEvent.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledTimes(1);
// Clicking the avatar must not bubble up to open/select the row.
expect(onRowClick).not.toHaveBeenCalled();
});
it('reflects the checked state via aria-checked', () => {
const { rerender } = render(
<SelectableAvatar name="Marta" checked={false} onToggle={() => {}} selectLabel="Select" />,
);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
rerender(<SelectableAvatar name="Marta" checked onToggle={() => {}} selectLabel="Select" />);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
});
});
@@ -582,7 +582,12 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
} else {
await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
replyTo: replyToForRsvp ?? undefined,
// Stalwart routes the iTIP REPLY via the stored ORGANIZER
// (organizerCalendarAddress; the RFC 8984 replyTo is retired).
// Only repair a missing organizer - attendees may not modify it.
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
? { organizerCalendarAddress: replyToForRsvp.imip }
: {}),
}, true);
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
@@ -1048,7 +1053,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setShowCalendarPicker(false);
handleImport(cal.id);
}}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
>
<span
className="w-3 h-3 rounded-full flex-shrink-0"
@@ -1090,7 +1095,7 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
)}
{isProcessing && (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ml-auto" />
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground ms-auto" />
)}
</div>
)}
+246 -36
View File
@@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck } from "lucide-react";
import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, CalendarClock, ChevronDown, MailCheck, Search } from "lucide-react";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
@@ -17,7 +17,7 @@ import { isFilePreviewable } from "@/lib/file-preview";
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
import { buildSignatureBlock } from "@/components/email/signature-block";
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import type { AlmostSavedDraft, OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
@@ -35,7 +35,7 @@ import { TemplatePicker } from "@/components/templates/template-picker";
import { TemplateForm } from "@/components/templates/template-form";
import type { EmailTemplate } from "@/lib/template-types";
import { appendPlainTextSignature, getPlainTextSignature } from "@/lib/signature-utils";
import { resolveReplyFrom } from "@/lib/reply-identity";
import { findComposeIdentityId, resolveReplyFrom } from "@/lib/reply-identity";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import {
rewriteCidImagesForEditor,
@@ -45,11 +45,15 @@ import {
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
waitForPendingUploads,
extractUserAuthoredText,
type Recipient,
} from "@/lib/email-composer-utils";
import { isValidEmail } from "@/lib/validation";
import { RichTextEditor } from "@/components/email/rich-text-editor";
import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
/**
* Derives the text/plain alternative from the composer's HTML body, preserving
@@ -133,12 +137,28 @@ interface EmailComposerProps {
}) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void;
/**
* When provided, the composer assigns its close handler to `current`. The
* handler shows the unsaved-changes dialog when the draft is dirty, so a
* host (e.g. the Pro tab bar's close button) can route an external close
* request through the same guard instead of discarding silently.
*/
requestCloseRef?: React.MutableRefObject<(() => void) | null>;
onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void;
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Email of the mailbox/account the user is viewing when they start a new
* message. When set (and `autoSelectReplyIdentity` is on), a fresh compose
* preselects the identity matching this address instead of the primary
* identity, so "New message" from info@ defaults its From to info@. Mirrors
* the reply-time identity match; ignored for reply/replyAll/forward (those
* resolve from the original recipients).
*/
composeFromAccountEmail?: string;
replyTo?: {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
@@ -229,12 +249,14 @@ export function EmailComposer({
onSend,
onScheduledSendCreated,
onClose,
requestCloseRef,
onDiscardDraft,
onSaveState,
className,
initialDraftText,
initialData,
mode = 'compose',
composeFromAccountEmail,
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
@@ -667,6 +689,19 @@ export function EmailComposer({
useEffect(() => {
if (!autoSelectReplyIdentity) return;
if (selectedIdentityId || initialData?.selectedIdentityId) return;
// New message started from a specific mailbox/account: default the From to
// that mailbox's identity instead of the primary one, so composing while
// viewing info@ sends as info@. Reply/forward fall through to the
// recipient-based resolution below.
if (mode === 'compose') {
const composeIdentityId = findComposeIdentityId(identities, composeFromAccountEmail);
if (composeIdentityId) {
setSelectedIdentityId(composeIdentityId);
}
return;
}
if (mode !== 'reply' && mode !== 'replyAll') return;
const resolved = resolveReplyFrom(identities, {
@@ -700,6 +735,7 @@ export function EmailComposer({
}
}, [
autoSelectReplyIdentity,
composeFromAccountEmail,
fromOverrideEnabled,
identities,
initialData?.selectedIdentityId,
@@ -787,6 +823,10 @@ export function EmailComposer({
? `<div>${getPlainTextSignature(signatureIdentity).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</div>`
: '';
const getAutocomplete = useContactStore((s) => s.getAutocomplete);
const searchRecipients = useContactStore((s) => s.searchRecipients);
// Whether a Sent mailbox is known so the on-demand server search is worth
// offering (falls back to hiding the "search the server" row otherwise).
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
@@ -862,6 +902,10 @@ export function EmailComposer({
const [autocompleteResults, setAutocompleteResults] = useState<Array<{ name: string; email: string }>>([]);
const [activeAutoField, setActiveAutoField] = useState<'to' | 'cc' | 'bcc' | null>(null);
const [autoSelectedIndex, setAutoSelectedIndex] = useState(-1);
// Current trimmed query behind the open dropdown, plus the in-flight flag for
// the on-demand Sent-folder lookup ("search the server" row).
const [autoQuery, setAutoQuery] = useState('');
const [isSearchingServer, setIsSearchingServer] = useState(false);
const autocompleteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const toInputRef = useRef<HTMLInputElement>(null);
const ccInputRef = useRef<HTMLInputElement>(null);
@@ -909,8 +953,10 @@ export function EmailComposer({
setAutocompleteResults([]);
setActiveAutoField(null);
setAutoSelectedIndex(-1);
setAutoQuery('');
return;
}
setAutoQuery(query);
autocompleteTimeoutRef.current = setTimeout(async () => {
const localResults = getAutocomplete(query);
@@ -918,10 +964,39 @@ export function EmailComposer({
const initial: RecipientSuggestion[] = localResults.map(r => ({ name: r.name, email: r.email }));
const merged = await contactHooks.onProvideRecipientSuggestions.transform(initial, { query });
setAutocompleteResults(merged.map(s => ({ name: s.name, email: s.email })));
setActiveAutoField(merged.length > 0 ? field : null);
// Keep the dropdown open even without local hits when a server search is
// available, so the "search the server" row stays reachable (OWA-style).
setActiveAutoField(merged.length > 0 || canSearchServer ? field : null);
setAutoSelectedIndex(-1);
}, 200);
}, [getAutocomplete]);
}, [getAutocomplete, canSearchServer]);
// On-demand: search the Sent folder server-side for recipients matching the
// current query and merge fresh hits into the open dropdown (deduped by email).
const handleServerSearch = useCallback(async () => {
const query = autoQuery.trim();
if (!composerClient || !query || isSearchingServer) return;
setIsSearchingServer(true);
try {
const serverResults = await searchRecipients(composerClient, query);
setAutocompleteResults((prev) => {
const seen = new Set(prev.map((r) => r.email.toLowerCase()));
const merged = [...prev];
for (const r of serverResults) {
const key = r.email.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
merged.push(r);
}
}
return merged;
});
} catch {
// Best-effort: a failed lookup just leaves the local suggestions in place.
} finally {
setIsSearchingServer(false);
}
}, [autoQuery, composerClient, isSearchingServer, searchRecipients]);
const insertAutocomplete = (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => {
const setter = field === 'to' ? setTo : field === 'cc' ? setCc : setBcc;
@@ -1055,7 +1130,16 @@ export function EmailComposer({
const controller = newAttachments[i].abortController;
try {
if (controller?.signal.aborted) continue;
const { blobId } = await client.uploadBlob(file);
const fileId = generateUUID();
await fileStorage.saveFile(fileId, file);
const newFileId = await emailHooks.onBeforeBlobUpload.transform(fileId);
const newFile = await fileStorage.getFile(newFileId) || file;
await fileStorage.deleteFile(newFileId);
const { blobId } = await client.uploadBlob(newFile);
if (controller?.signal.aborted) continue;
setAttachments(prev =>
@@ -1263,21 +1347,36 @@ export function EmailComposer({
try {
const previousDraftId = draftIdRef.current;
let savedDraft : AlmostSavedDraft = {
to: toAddresses,
subject: subject || t('no_subject'),
body: plainTextMode ? body : htmlToPlainText(body),
cc: ccAddresses,
bcc: bccAddresses,
identityId: currentIdentityRawId,
fromEmail,
draftId: previousDraftId || undefined,
attachments: uploadedAttachments,
fromName,
htmlBody: plainTextMode ? undefined : body
}
savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft);
// Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
// identities. See `composerClient` derivation above.
const savedDraftId = await composerClient.createDraft(
toAddresses,
subject || t('no_subject'),
plainTextMode ? body : htmlToPlainText(body),
ccAddresses,
bccAddresses,
currentIdentityRawId,
fromEmail,
previousDraftId || undefined,
uploadedAttachments,
fromName,
plainTextMode ? undefined : body
savedDraft.to,
savedDraft.subject,
savedDraft.body,
savedDraft.cc,
savedDraft.bcc,
savedDraft.identityId,
savedDraft.fromEmail,
savedDraft.draftId,
savedDraft.attachments,
savedDraft.fromName,
savedDraft.htmlBody
);
// Update the ref synchronously so a queued save sees the new id and
@@ -1381,6 +1480,7 @@ export function EmailComposer({
const canSend = toAddresses.length > 0 && !!subject && hasContent;
const getSendTooltip = (): string | undefined => {
if (isWaitingForUploads) return t('validation.attachments_uploading');
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
if (!subject) return t('validation.subject_required');
@@ -1466,8 +1566,43 @@ export function EmailComposer({
const [isSending, setIsSending] = useState(false);
const isSendingRef = useRef(false);
// Attachments still uploading when Send is clicked used to be silently
// dropped from the outgoing message (the filters below exclude anything
// with uploading:true). attachmentsRef gives handleSend a way to read the
// freshest attachment state after waiting on in-flight uploads, since the
// `attachments` closure captured at click time won't reflect uploads that
// finish during that wait.
const attachmentsRef = useRef<ComposerAttachment[]>(attachments);
useEffect(() => {
attachmentsRef.current = attachments;
}, [attachments]);
const [isWaitingForUploads, setIsWaitingForUploads] = useState(false);
const sendCancelledRef = useRef(false);
const handleSend = async (skipAttachmentCheck = false, delayedUntil?: string) => {
if (isSendingRef.current) return;
if (attachmentsRef.current.some(att => att.uploading)) {
isSendingRef.current = true;
setIsSending(true);
setIsWaitingForUploads(true);
const uploadResult = await waitForPendingUploads(
() => attachmentsRef.current,
() => sendCancelledRef.current
);
setIsWaitingForUploads(false);
isSendingRef.current = false;
setIsSending(false);
if (uploadResult === 'cancelled') return;
if (uploadResult === 'failed') {
// An upload broke while we were waiting - the user may not be
// looking at the composer, so auto-sending would silently drop
// the failed attachment. Abort and let them decide.
toast.error(t('validation.attachment_upload_failed'));
return;
}
}
const ccAddresses = withInput(cc, ccInput);
const bccAddresses = withInput(bcc, bccInput);
@@ -1488,9 +1623,15 @@ export function EmailComposer({
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachments.some(att => att.blobId && !att.uploading && !att.error);
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
const bodyText = htmlToPlainText(body);
// Scan only the user-authored text: the quoted original of a
// reply/forward often mentions an attachment itself, which used to fire
// the reminder even when the user typed no keyword and added nothing (#570).
const bodyText = extractUserAuthoredText(body, {
plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
});
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
@@ -1604,7 +1745,7 @@ export function EmailComposer({
textBody: finalBody,
identityId: currentIdentity?.id || '',
fromEmail,
attachments: attachments
attachments: attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
@@ -1631,7 +1772,7 @@ export function EmailComposer({
references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
attachments: [
...attachments
...attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId })),
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
@@ -1648,7 +1789,7 @@ export function EmailComposer({
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachments
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(att => ({ blobId: att.blobId!, name: att.name, type: att.type || 'application/octet-stream', size: att.size }));
uploadedAttachments.push(...inlineAttachments);
@@ -1775,6 +1916,7 @@ export function EmailComposer({
}, []);
const cleanClose = () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
@@ -1784,6 +1926,7 @@ export function EmailComposer({
};
const handleSaveDraftAndClose = async () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
@@ -1795,6 +1938,7 @@ export function EmailComposer({
};
const handleDiscardAndClose = () => {
sendCancelledRef.current = true;
explicitCloseRef.current = true;
setShowCloseDialog(false);
if (saveTimeoutRef.current) {
@@ -1815,6 +1959,17 @@ export function EmailComposer({
}
};
// Expose the dirty-aware close handler so external hosts (e.g. the Pro tab
// bar) can trigger the same "Save or discard draft?" guard. Re-assigned on
// every render to capture the latest closure over the live refs/state.
useEffect(() => {
if (!requestCloseRef) return;
requestCloseRef.current = handleClose;
return () => {
requestCloseRef.current = null;
};
});
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.defaultPrevented) return;
@@ -1860,7 +2015,7 @@ export function EmailComposer({
<div ref={composerRootRef} className={cn("flex h-full bg-background", className)}>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-r border-border"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
/>
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
@@ -1917,7 +2072,7 @@ export function EmailComposer({
size="sm"
className="md:hidden h-9 px-4"
>
<Send className="w-4 h-4 mr-1.5" />
<Send className="w-4 h-4 me-1.5" />
{t('send')}
</Button>
</div>
@@ -2064,6 +2219,10 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={toDropdownRef}
onInsertAutocomplete={insertAutocomplete}
canSearchServer={canSearchServer}
onServerSearch={handleServerSearch}
isSearchingServer={isSearchingServer}
serverSearchQuery={autoQuery}
validationError={validationErrors.to}
validationMessage={t('validation.recipient_required')}
onTab={focusSubject}
@@ -2141,6 +2300,10 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={ccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
canSearchServer={canSearchServer}
onServerSearch={handleServerSearch}
isSearchingServer={isSearchingServer}
serverSearchQuery={autoQuery}
onMoveChip={handleMoveChip}
/>
</div>
@@ -2166,6 +2329,10 @@ export function EmailComposer({
autoSelectedIndex={autoSelectedIndex}
dropdownRef={bccDropdownRef}
onInsertAutocomplete={insertAutocomplete}
canSearchServer={canSearchServer}
onServerSearch={handleServerSearch}
isSearchingServer={isSearchingServer}
serverSearchQuery={autoQuery}
onMoveChip={handleMoveChip}
/>
</div>
@@ -2303,7 +2470,7 @@ export function EmailComposer({
)}
<button
onClick={() => removeAttachment(index)}
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
className="ms-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
title={att.uploading ? t('upload_cancel') : undefined}
>
<X className="w-3 h-3" />
@@ -2398,9 +2565,9 @@ export function EmailComposer({
onClick={() => handleSend()}
disabled={!canSend || isSending}
title={getSendTooltip()}
className="rounded-r-none border-r border-primary-foreground/20"
className="rounded-e-none border-e border-primary-foreground/20"
>
<Send className="w-4 h-4 mr-2" />
<Send className="w-4 h-4 me-2" />
{t('send')}
</Button>
<Button
@@ -2408,7 +2575,7 @@ export function EmailComposer({
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
title={t('schedule_send')}
className="rounded-l-none px-2"
className="rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
>
@@ -2423,7 +2590,7 @@ export function EmailComposer({
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground"
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
@@ -2438,7 +2605,7 @@ export function EmailComposer({
title={getSendTooltip()}
className="hidden md:inline-flex"
>
<Send className="w-4 h-4 mr-2" />
<Send className="w-4 h-4 me-2" />
{t('send')}
</Button>
)}
@@ -2557,7 +2724,7 @@ export function EmailComposer({
{t('discard')}
</Button>
<Button onClick={handleSaveDraftAndClose}>
<Save className="w-4 h-4 mr-2" />
<Save className="w-4 h-4 me-2" />
{t('save_draft')}
</Button>
</div>
@@ -2576,7 +2743,7 @@ export function EmailComposer({
</div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-l border-border"
className="hidden md:flex shrink-0 h-full overflow-hidden border-s border-border"
/>
</div>
);
@@ -2587,7 +2754,10 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
results: Array<{ name: string; email: string }>;
selectedIndex: number;
onSelect: (suggestion: { name: string; email: string }) => void;
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect }, ref) {
onSearchServer?: () => void;
isSearchingServer?: boolean;
}>(function AutocompleteDropdown({ id, results, selectedIndex, onSelect, onSearchServer, isSearchingServer }, ref) {
const t = useTranslations('email_composer');
return (
<div ref={ref} id={id} role="listbox" className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-48 overflow-y-auto">
{results.map((r, i) => (
@@ -2598,7 +2768,7 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
role="option"
aria-selected={i === selectedIndex}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center gap-2",
"w-full px-3 py-2 text-start text-sm flex items-center gap-2",
i === selectedIndex ? "bg-accent text-accent-foreground" : "hover:bg-muted"
)}
onMouseDown={(e) => {
@@ -2613,6 +2783,27 @@ const AutocompleteDropdown = React.forwardRef<HTMLDivElement, {
)}
</button>
))}
{onSearchServer && (
<button
type="button"
disabled={isSearchingServer}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center gap-2 text-muted-foreground hover:bg-muted disabled:opacity-60 disabled:cursor-default",
results.length > 0 && "border-t border-border"
)}
onMouseDown={(e) => {
e.preventDefault();
if (!isSearchingServer) onSearchServer();
}}
>
{isSearchingServer
? <Loader2 className="w-4 h-4 shrink-0 animate-spin" />
: <Search className="w-4 h-4 shrink-0" />}
<span className="truncate">
{isSearchingServer ? t('autocomplete_searching') : t('autocomplete_search_server')}
</span>
</button>
)}
</div>
);
});
@@ -2633,6 +2824,10 @@ function RecipientChipInput({
autoSelectedIndex,
dropdownRef,
onInsertAutocomplete,
canSearchServer,
onServerSearch,
isSearchingServer,
serverSearchQuery,
validationError,
validationMessage,
onTab,
@@ -2653,6 +2848,10 @@ function RecipientChipInput({
autoSelectedIndex: number;
dropdownRef: React.RefObject<HTMLDivElement | null>;
onInsertAutocomplete: (suggestion: { name: string; email: string }, field: 'to' | 'cc' | 'bcc') => void;
canSearchServer: boolean;
onServerSearch: () => void;
isSearchingServer: boolean;
serverSearchQuery: string;
validationError?: boolean;
validationMessage?: string;
onTab?: () => void;
@@ -2749,7 +2948,15 @@ function RecipientChipInput({
}
}
if ((e.key === ' ' || e.key === 'Enter' || e.key === 'Tab') && inputText.trim()) {
// Enter / Tab commit whatever is typed. Space only commits when the input
// is already a complete email address; otherwise Space is a normal
// character so a name search like "John Doe" can continue past the space
// instead of committing "John" as a bogus recipient (#571).
const trimmedInput = inputText.trim();
const commitOnKey =
((e.key === 'Enter' || e.key === 'Tab') && trimmedInput) ||
(e.key === ' ' && isValidEmail(trimmedInput));
if (commitOnKey) {
if (e.key !== 'Tab') e.preventDefault();
commitCurrentInput();
if (e.key === 'Tab' && onTab) {
@@ -2954,13 +3161,16 @@ function RecipientChipInput({
{validationError && validationMessage && (
<p className="text-xs text-red-600 dark:text-red-400 mt-0.5">{validationMessage}</p>
)}
{activeAutoField === field && autocompleteResults.length > 0 && (
{activeAutoField === field &&
(autocompleteResults.length > 0 || (canSearchServer && serverSearchQuery.length > 0)) && (
<AutocompleteDropdown
ref={dropdownRef}
id={`autocomplete-${field}`}
results={autocompleteResults}
selectedIndex={autoSelectedIndex}
onSelect={(suggestion) => onInsertAutocomplete(suggestion, field)}
onSearchServer={canSearchServer && serverSearchQuery.length > 0 ? onServerSearch : undefined}
isSearchingServer={isSearchingServer}
/>
)}
<ContextMenu
+38 -17
View File
@@ -17,6 +17,8 @@ import {
Mail,
MailOpen,
Star,
Pin,
PinOff,
Trash2,
Archive,
FolderInput,
@@ -59,6 +61,7 @@ interface EmailContextMenuProps {
onForward?: () => void;
onMarkAsRead?: (read: boolean) => void;
onToggleStar?: () => void;
onTogglePinned?: () => void;
onDelete?: () => void;
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
@@ -126,6 +129,7 @@ export function EmailContextMenu({
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -149,10 +153,14 @@ export function EmailContextMenu({
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isDraft = email.keywords?.['$draft'] === true;
const currentColors = getCurrentColors(email.keywords);
const showBatchActions = isMultiSelect && selectedCount > 1;
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
const isScheduled = email.isScheduled === true;
const canCancelScheduled = isScheduled && email.scheduledUndoStatus === 'pending';
@@ -326,7 +334,7 @@ export function EmailContextMenu({
</div>
)}
{node.children.length > 0 && (
<div className="pl-4">
<div className="ps-4">
{renderNodes(node.children)}
</div>
)}
@@ -349,6 +357,15 @@ export function EmailContextMenu({
/>
)}
{/* Pin/Unpin - only for single email; pinned mails float to the top of the list */}
{!showBatchActions && onTogglePinned && (
<ContextMenuItem
icon={isPinned ? PinOff : Pin}
label={isPinned ? t("unpin") : t("pin")}
onClick={() => handleAction(onTogglePinned)}
/>
)}
{/* Set tag submenu - only for single email */}
{!showBatchActions && (
<ContextMenuSubMenu icon={Tag} label={t("color_tag")}>
@@ -360,7 +377,7 @@ export function EmailContextMenu({
role="menuitem"
onClick={() => handleAction(() => onSetColorTag?.(option.value))}
className={cn(
"w-full px-3 py-1.5 text-sm text-left flex items-center gap-2 hover:bg-muted cursor-pointer",
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2 hover:bg-muted cursor-pointer",
isActive && "bg-accent font-medium"
)}
>
@@ -385,22 +402,26 @@ export function EmailContextMenu({
</ContextMenuSubMenu>
)}
<ContextMenuSeparator />
{/* Spam - contextual based on folder; pointless on own outgoing mail */}
{spamApplicable && (
<>
<ContextMenuSeparator />
{/* Spam - contextual based on folder */}
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
onClick={() =>
handleAction(
showBatchActions
? (isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)
: (isInJunkFolder ? onUndoSpam! : onMarkAsSpam!)
)
}
disabled={showBatchActions ? (isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) : (isInJunkFolder ? !onUndoSpam : !onMarkAsSpam)}
destructive={!isInJunkFolder}
/>
</>
)}
<ContextMenuSeparator />
+12 -7
View File
@@ -21,6 +21,8 @@ interface EmailHoverActionsProps {
// the spam quick-action flips to "not spam".
isInJunk?: boolean;
onUndoSpam?: () => void;
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
spamApplicable?: boolean;
}
const ACTION_CONFIG: Record<HoverAction, {
@@ -78,6 +80,7 @@ export function EmailHoverActions({
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
spamApplicable = true,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
@@ -121,6 +124,7 @@ export function EmailHoverActions({
const actionButtons = hoverActions.map((actionId) => {
const config = ACTION_CONFIG[actionId];
if (!config) return null;
if (actionId === "spam" && !spamApplicable) return null;
const Icon = config.icon;
// In a junk context the spam action becomes "not spam".
@@ -177,16 +181,17 @@ export function EmailHoverActions({
return (
<div
className="absolute right-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
className="absolute end-0 top-0 bottom-0 z-10 hidden group-hover:flex items-center"
>
<div
className={cn("w-8 h-full", hoverBackgroundClassName)}
style={{
WebkitMaskImage: "linear-gradient(to right, transparent, black)",
maskImage: "linear-gradient(to right, transparent, black)",
}}
className={cn(
"w-8 h-full",
"[mask-image:linear-gradient(to_right,transparent,black)] [-webkit-mask-image:linear-gradient(to_right,transparent,black)]",
"rtl:[mask-image:linear-gradient(to_left,transparent,black)] rtl:[-webkit-mask-image:linear-gradient(to_left,transparent,black)]",
hoverBackgroundClassName,
)}
/>
<div className={cn("flex items-center gap-0.5 h-full pr-3 pl-0.5", hoverBackgroundClassName)}>
<div className={cn("flex items-center gap-0.5 h-full pe-3 ps-0.5", hoverBackgroundClassName)}>
{actionButtons}
</div>
</div>
+24 -6
View File
@@ -5,8 +5,8 @@ import { useCallback } from "react";
import { formatDate, stripInvisibleLeading } from "@/lib/utils";
import { Email } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, CheckSquare, Square, Reply, Forward } from "lucide-react";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useAuthStore } from "@/stores/auth-store";
@@ -34,16 +34,19 @@ interface EmailListItemProps {
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const { identities } = useAuthStore();
const isChecked = selectedEmailIds.has(email.id);
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
@@ -66,7 +69,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
// Use first tag for background coloring
const keywordDef = keywordDefs[0] ?? null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
// Drag and drop functionality
const { dragHandlers, isDragging } = useEmailDrag({
@@ -87,7 +90,14 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
if (e.shiftKey) {
// Shift-click extends the selection from the anchor to here, like
// shift-clicking the row (the checkbox stops propagation, so the
// row's shift handler never runs — replicate it here).
selectRangeEmails(email.id);
} else {
toggleEmailSelection(email.id);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
@@ -166,19 +176,22 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{/* Avatar */}
{density !== 'extra-compact' && (
<Avatar
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={() => toggleEmailSelection(email.id)}
selectLabel={tBatch('select')}
/>
)}
@@ -206,6 +219,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isImportant && <span className="h-2 w-2 rounded-full bg-warning" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -242,6 +256,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -327,6 +344,7 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
</div>
);
+5 -2
View File
@@ -35,6 +35,7 @@ interface EmailListProps {
onForward?: (email: Email) => void;
onMarkAsRead?: (email: Email, read: boolean) => void;
onToggleStar?: (email: Email) => void;
onTogglePinned?: (email: Email) => void;
onDelete?: (email: Email) => void;
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
@@ -64,6 +65,7 @@ export function EmailList({
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
@@ -415,9 +417,9 @@ export function EmailList({
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
>
{isProcessing ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
<Loader2 className="w-3 h-3 animate-spin me-1" />
) : (
<Trash2 className="w-3 h-3 mr-1" />
<Trash2 className="w-3 h-3 me-1" />
)}
{t('empty_folder.button')}
</Button>
@@ -551,6 +553,7 @@ export function EmailList({
onForward={() => onForward?.(contextMenu.data!)}
onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)}
onToggleStar={() => onToggleStar?.(contextMenu.data!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
onDelete={() => onDelete?.(contextMenu.data!)}
onArchive={() => onArchive?.(contextMenu.data!)}
onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)}
+171 -88
View File
@@ -333,7 +333,7 @@ function renderClickableRecipients(
return (
<span key={r.email + index} className="inline-flex items-center">
{index > 0 && <span className="text-muted-foreground mr-1">,</span>}
{index > 0 && <span className="text-muted-foreground me-1">,</span>}
<RecipientPopover
name={r.name}
email={r.email}
@@ -382,7 +382,7 @@ export function ContactSidebarPanel({
};
return (
<div className="w-[320px] shrink-0 border-l border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
<div className="w-[320px] shrink-0 border-s border-border bg-background flex flex-col h-full animate-in slide-in-from-right-5 duration-200">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold text-foreground truncate">{t('contact_sidebar.title')}</h3>
@@ -607,7 +607,7 @@ function SidebarSection({ icon: Icon, title, children }: { icon: React.Component
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{title}</h4>
</div>
<div className="space-y-1 pl-5.5">{children}</div>
<div className="space-y-1 ps-5.5">{children}</div>
</div>
);
}
@@ -693,6 +693,9 @@ export function EmailViewer({
// Detect if current mailbox is Junk folder
const isInJunkFolder = currentMailboxRole === 'junk';
// Marking your own outgoing mail as spam makes no sense - hide the action
// in Sent, Drafts and Scheduled.
const spamApplicable = !['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '');
// Detect if the email is a draft
const isDraft = email?.keywords?.['$draft'] === true;
@@ -711,6 +714,28 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
// most users. Route to the email's own account in unified views and prefer
// the identity that received the newsletter, so the list can match the
// subscriber; sendEmail resolves the identity (with its own fallback to
// the account default) from the address we pass.
const handleSendMailtoUnsubscribe = async (fields: { to: string[]; subject?: string; body?: string }) => {
const sendClient = (email?.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(email.sourceClientAccountId)
: undefined) ?? client;
if (!sendClient) throw new Error('Not connected');
const recipientAddresses = [...(email?.to ?? []), ...(email?.cc ?? [])].map(r => r.email?.toLowerCase());
// In unified views the owning account's identities are not loaded here -
// pass nothing and let its client fall back to its default identity.
const fromIdentity = email?.sourceClientAccountId
? undefined
: identities.find(i => i.email && recipientAddresses.includes(i.email.toLowerCase()));
await sendClient.sendEmail(fields.to, fields.subject ?? '', fields.body ?? '', undefined, undefined, fromIdentity?.id, fromIdentity?.email, undefined, fromIdentity?.name);
};
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
@@ -752,6 +777,19 @@ export function EmailViewer({
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
const handleSendQuickReply = async () => {
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
try {
await onQuickReply(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
} catch (error) {
console.error("Failed to send quick reply:", error);
} finally {
setIsSendingQuickReply(false);
}
};
const [showSourceModal, setShowSourceModal] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [moreMenuSub, setMoreMenuSub] = useState<'move' | 'tag' | null>(null);
@@ -771,6 +809,15 @@ export function EmailViewer({
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>([]);
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
// after the user unlocks a key from the banner) to force the onRenderEmailBody
// hook to run again for the open message so the body re-decrypts.
const [pluginRenderNonce, setPluginRenderNonce] = useState(0);
useEffect(() => {
const bump = () => setPluginRenderNonce((n) => n + 1);
window.addEventListener('plugin:rerender-email', bump);
return () => window.removeEventListener('plugin:rerender-email', bump);
}, []);
// TNEF (winmail.dat) support
const [tnefHtml, setTnefHtml] = useState<string | null>(null);
@@ -1167,7 +1214,7 @@ export function EmailViewer({
})();
return () => { cancelled = true; };
}, [email]);
}, [email, pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing
useEffect(() => {
@@ -2101,6 +2148,12 @@ export function EmailViewer({
fit - the latter wraps header text to one character per line, which reads
as 90deg-rotated vertical headers (issue #409). */
html { overflow: hidden; height: auto !important; }
/* Some emails put height:100% on a full-bleed wrapper table/div (not html/body),
which - with body's overflow:hidden - clips the content to a sliver, and the
scrollHeight-based auto-resize then locks the iframe short (a Box.co.il
verification email rendered as a logo-only 150px strip). Neutralise the
full-height trick on any element so the body grows to its content. */
[style*="height:100%"], [style*="height: 100%"] { height: auto !important; }
body { margin: 0; padding: ${bodyPadding}; overflow-x: auto; overflow-y: hidden; height: auto !important; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
img { max-width: 100% !important; height: auto !important; }
@@ -2148,17 +2201,52 @@ export function EmailViewer({
const doc = iframe.contentDocument;
if (doc?.body) {
// Auto-resize iframe to fit content
const resizeObserver = new ResizeObserver(() => {
const height = doc.documentElement.scrollHeight;
// Measure max(documentElement, body): a height:100% wrapper can leave
// documentElement.scrollHeight short while the real content lives in body.
const applyHeight = () => {
if (iframe.contentDocument !== doc) return; // navigated away; stale
const height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
iframe.style.height = height + 'px';
lastBodyHeightRef.current = height;
});
};
const resizeObserver = new ResizeObserver(applyHeight);
resizeObserver.observe(doc.body);
const initialHeight = doc.documentElement.scrollHeight;
iframe.style.height = initialHeight + 'px';
lastBodyHeightRef.current = initialHeight;
applyHeight();
// The ResizeObserver only fires on body's border box; a content overflow
// that grows scrollHeight without resizing that box (e.g. a height:100%
// wrapper, or images that reflow the layout after onload) is otherwise
// missed and the iframe stays short. Re-measure on a fixed cadence over a
// short settle window, then stop — a self-clearing catch-all that does
// not depend on image load/error events firing (blocked images may fire
// neither). Cheap: ~12 scrollHeight reads, no early-stop heuristic to
// mis-trigger on a brief-stable-then-grow reflow.
const poll = window.setInterval(() => {
if (iframe.contentDocument !== doc) { window.clearInterval(poll); return; }
applyHeight();
}, 200);
window.setTimeout(() => window.clearInterval(poll), 2400);
setIframeReady(true);
// Hide images that fail to load (dead/mixed-content/unreachable external
// URLs) rather than leaving the browser's broken-image placeholder and
// alt text, which read as stray label text in an otherwise image-only
// email (e.g. a blocked "logo" alt). Blocked images already carry a 1x1
// transparent pixel (naturalWidth 1) and display:none, so they're skipped.
const hideIfBroken = (img: HTMLImageElement) => {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) {
img.style.display = 'none';
}
};
doc.querySelectorAll('img').forEach((el) => {
const img = el as HTMLImageElement;
if (img.complete) {
hideIfBroken(img);
} else {
img.addEventListener('error', () => { img.style.display = 'none'; }, { once: true });
img.addEventListener('load', () => hideIfBroken(img), { once: true });
}
});
// Make links open in new tab
doc.querySelectorAll('a').forEach(a => {
a.setAttribute('target', '_blank');
@@ -2580,7 +2668,7 @@ export function EmailViewer({
<h3 className="text-xl font-semibold text-foreground mb-3">{tDemoWelcome('title')}</h3>
<p className="text-sm text-muted-foreground mb-6 leading-relaxed">{tDemoWelcome('description')}</p>
<div className="flex flex-col gap-3 items-center">
<div className="grid grid-cols-2 gap-3 text-left text-sm text-muted-foreground w-full">
<div className="grid grid-cols-2 gap-3 text-start text-sm text-muted-foreground w-full">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-primary shrink-0" />
<span>{tDemoWelcome('feature_email')}</span>
@@ -2622,7 +2710,7 @@ export function EmailViewer({
<p className="text-muted-foreground">{t('no_conversation_description')}</p>
{onCompose && (
<Button onClick={onCompose} className="mt-6" title={t('compose_hint')}>
<PenSquare className="w-4 h-4 mr-2" />
<PenSquare className="w-4 h-4 me-2" />
{t('compose')}
</Button>
)}
@@ -2647,7 +2735,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
onClick={onBack}
className="h-9 w-9 flex-shrink-0 -ml-1"
className="h-9 w-9 flex-shrink-0 -ms-1"
aria-label={t('back_to_list')}
>
<ChevronLeft className="w-5 h-5" />
@@ -2782,7 +2870,7 @@ export function EmailViewer({
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('move')}</span>}
</Button>
{moveMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10">
<div className="absolute end-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10">
{(() => {
const renderNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
@@ -2793,7 +2881,7 @@ export function EmailViewer({
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoveMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
@@ -2853,7 +2941,7 @@ export function EmailViewer({
)}
</button>
{tagMenuOpen && (
<div className="absolute right-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
<div className="absolute end-0 top-full mt-1 py-1 w-40 bg-background rounded-lg shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
@@ -2861,13 +2949,13 @@ export function EmailViewer({
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setTagMenuOpen(false); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
@@ -2876,7 +2964,7 @@ export function EmailViewer({
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setTagMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
@@ -2889,7 +2977,7 @@ export function EmailViewer({
</div>
{/* Spam */}
{(onMarkAsSpam || onUndoSpam) && (
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<Button
variant="ghost"
size="sm"
@@ -2980,11 +3068,11 @@ export function EmailViewer({
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
</Button>
{moreMenuOpen && !isMobile && (
<div className="absolute right-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10 py-1">
<div className="absolute end-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10 py-1">
{/* Star toggle */}
<button
onClick={() => { onToggleStar?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Star className={cn("w-4 h-4", isStarred && "fill-yellow-400 text-yellow-400")} />
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
@@ -2992,7 +3080,7 @@ export function EmailViewer({
{/* Overflow: reply */}
<button
onClick={() => { onReply?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
>
<Reply className="w-4 h-4" />
{t('reply')}
@@ -3000,7 +3088,7 @@ export function EmailViewer({
{/* Overflow: reply all */}
<button
onClick={() => { onReplyAll?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
>
<ReplyAll className="w-4 h-4" />
{t('reply_all')}
@@ -3008,7 +3096,7 @@ export function EmailViewer({
{/* Overflow: forward */}
<button
onClick={() => { onForward?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
>
<Forward className="w-4 h-4" />
{t('forward')}
@@ -3016,7 +3104,7 @@ export function EmailViewer({
{/* Overflow: archive */}
<button
onClick={() => { onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
>
<Archive className="w-4 h-4" />
{t('archive')}
@@ -3029,14 +3117,14 @@ export function EmailViewer({
>
<button
onClick={() => setMoreMenuSub(moreMenuSub === 'move' ? null : 'move')}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<FolderInput className="w-4 h-4" />
<span className="flex-1">{t('move_to')}</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'move' && (
<div className="absolute right-full top-0 mr-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border z-10">
<div className="absolute end-full top-0 me-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-md shadow-lg border border-border z-10">
{(() => {
const renderMobileNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
@@ -3047,7 +3135,7 @@ export function EmailViewer({
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
>
<Icon className="w-4 h-4 flex-shrink-0" />
@@ -3081,14 +3169,14 @@ export function EmailViewer({
>
<button
onClick={() => setMoreMenuSub(moreMenuSub === 'tag' ? null : 'tag')}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Tag className="w-4 h-4" />
<span className="flex-1">{t('tag')}</span>
<ChevronRight className="w-3 h-3 text-muted-foreground" />
</button>
{moreMenuSub === 'tag' && (
<div className="absolute right-full top-0 mr-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
<div className="absolute end-full top-0 me-1 py-1 w-40 bg-background rounded-md shadow-lg border border-border z-10">
{colorOptions.map((option) => {
const isActive = currentColors.includes(option.value);
return (
@@ -3096,13 +3184,13 @@ export function EmailViewer({
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2",
"w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3 h-3 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-3 h-3 ml-auto flex-shrink-0 text-foreground" />}
{isActive && <Check className="w-3 h-3 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
@@ -3111,7 +3199,7 @@ export function EmailViewer({
<div className="h-px bg-border my-1" />
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted flex items-center gap-2 text-muted-foreground"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2 text-muted-foreground"
>
<X className="w-3 h-3 flex-shrink-0" />
<span>{t('remove_color')}</span>
@@ -3123,10 +3211,10 @@ export function EmailViewer({
</div>
)}
{/* Overflow: spam */}
{(onMarkAsSpam || onUndoSpam) && (
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<button
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
>
{isInJunkFolder ? (
<ShieldCheck className="h-4 w-4 text-green-600 dark:text-green-400" />
@@ -3139,7 +3227,7 @@ export function EmailViewer({
{/* Overflow: toggle read */}
<button
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
>
{isUnread ? <MailOpen className="w-4 h-4" /> : <Mail className="w-4 h-4" />}
{isUnread ? t('mark_read') : t('mark_unread')}
@@ -3147,7 +3235,7 @@ export function EmailViewer({
{/* Overflow: print */}
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
>
<Printer className="w-4 h-4" />
{t('print')}
@@ -3155,7 +3243,7 @@ export function EmailViewer({
{/* Overflow: view source */}
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
>
<Code className="w-4 h-4" />
{t('view_source')}
@@ -3164,7 +3252,7 @@ export function EmailViewer({
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
>
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
@@ -3174,7 +3262,7 @@ export function EmailViewer({
{/* Export email */}
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('export_email')}
@@ -3182,7 +3270,7 @@ export function EmailViewer({
{/* Import email */}
<button
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Upload className="w-4 h-4" />
{t('import_email')}
@@ -3190,7 +3278,7 @@ export function EmailViewer({
{onShowShortcuts && (
<button
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-left hover:bg-muted text-foreground flex items-center gap-2"
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<Keyboard className="w-4 h-4" />
{t('keyboard_shortcuts')}
@@ -3218,7 +3306,7 @@ export function EmailViewer({
)}
{!isScheduled && isMobile && (
<div className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-l border-border z-[70] sm:hidden",
"fixed inset-y-0 right-0 w-72 bg-background border-s border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out",
"flex flex-col",
moreMenuOpen ? "translate-x-0" : "translate-x-full"
@@ -3227,7 +3315,7 @@ export function EmailViewer({
{moreMenuSub ? (
<button
onClick={() => setMoreMenuSub(null)}
className="flex items-center gap-1 -ml-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
className="flex items-center gap-1 -ms-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
<ChevronLeft className="w-5 h-5" />
{moreMenuSub === 'move' ? t('move_to') : t('tag')}
@@ -3245,7 +3333,7 @@ export function EmailViewer({
{/* Star toggle */}
<button
onClick={() => { onToggleStar?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Star className={cn("w-5 h-5", isStarred && "fill-yellow-400 text-yellow-400")} />
{isStarred ? t('tooltips.unstar') : t('tooltips.star')}
@@ -3254,12 +3342,12 @@ export function EmailViewer({
{colorOptions.length > 0 && (
<button
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Tag className="w-5 h-5" />
<span className="flex-1">{t('tag')}</span>
{currentColors.length > 0 && (
<div className="flex -space-x-1 mr-1">
<div className="flex -space-x-1 me-1">
{currentColors.slice(0, 3).map((c) => {
const opt = colorOptions.find((o) => o.value === c);
return opt ? <span key={c} className={cn("w-3 h-3 rounded-full border border-background", opt.color)} /> : null;
@@ -3271,14 +3359,14 @@ export function EmailViewer({
)}
<button
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Printer className="w-5 h-5" />
{t('print')}
</button>
<button
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Code className="w-5 h-5" />
{t('view_source')}
@@ -3286,7 +3374,7 @@ export function EmailViewer({
{effectiveEmailContent.isHtml && (
<button
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
{isDark ? 'View in light mode' : 'View in dark mode'}
@@ -3295,14 +3383,14 @@ export function EmailViewer({
<div className="h-px bg-border my-1" />
<button
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Download className="w-5 h-5" />
{t('export_email')}
</button>
<button
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Upload className="w-5 h-5" />
{t('import_email')}
@@ -3310,7 +3398,7 @@ export function EmailViewer({
{onShowShortcuts && (
<button
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-left hover:bg-muted text-foreground flex items-center gap-3"
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
<Keyboard className="w-5 h-5" />
{t('keyboard_shortcuts')}
@@ -3328,7 +3416,7 @@ export function EmailViewer({
{isTarget ? (
<button
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3"
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
>
<Icon className="w-5 h-5 flex-shrink-0" />
@@ -3359,20 +3447,20 @@ export function EmailViewer({
key={option.value}
onClick={() => { if (email) onSetColorTag?.(email.id, option.value); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn(
"w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3",
"w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
isActive && "bg-accent font-medium"
)}
>
<span className={cn("w-3.5 h-3.5 rounded-full flex-shrink-0", option.color)} />
<span className="truncate">{option.name}</span>
{isActive && <Check className="w-4 h-4 ml-auto flex-shrink-0 text-foreground" />}
{isActive && <Check className="w-4 h-4 ms-auto flex-shrink-0 text-foreground" />}
</button>
);
})}
{currentColors.length > 0 && (
<button
onClick={() => { if (email) onSetColorTag?.(email.id, null); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-left hover:bg-muted flex items-center gap-3 text-muted-foreground"
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3 text-muted-foreground"
>
<X className="w-4 h-4 flex-shrink-0" />
<span>{t('remove_color')}</span>
@@ -3412,7 +3500,7 @@ export function EmailViewer({
variant="ghost"
size="icon"
onClick={onBack}
className="h-11 w-11 lg:h-10 lg:w-10 flex-shrink-0 -ml-2"
className="h-11 w-11 lg:h-10 lg:w-10 flex-shrink-0 -ms-2"
aria-label={t('back_to_list')}
>
<ChevronLeft className="w-5 h-5" />
@@ -3456,7 +3544,7 @@ export function EmailViewer({
</div>
</div>
{/* Date/time on the right of subject row - hidden on mobile, shown next to sender */}
<div className="hidden sm:block flex-shrink-0 text-right">
<div className="hidden sm:block flex-shrink-0 text-end">
<span className="text-xs lg:text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
@@ -3512,7 +3600,7 @@ export function EmailViewer({
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="font-semibold text-left"
className="font-semibold text-start"
/>
) : (
<span className="font-semibold text-foreground">{t('unknown_sender')}</span>
@@ -3522,6 +3610,7 @@ export function EmailViewer({
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onSendMailtoUnsubscribe={handleSendMailtoUnsubscribe}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
@@ -3576,7 +3665,7 @@ export function EmailViewer({
)}
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
>
{showFullHeaders ? (
<>
@@ -3699,7 +3788,7 @@ export function EmailViewer({
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
@@ -3757,7 +3846,7 @@ export function EmailViewer({
name={sender?.name}
email={sender.email}
onViewContact={handleViewContactSidebar}
className="text-sm font-semibold text-left"
className="text-sm font-semibold text-start"
/>
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
@@ -3767,6 +3856,7 @@ export function EmailViewer({
<UnsubscribeBanner
listUnsubscribe={listHeaders.listUnsubscribe}
senderEmail={email?.from?.[0]?.email || ''}
onSendMailtoUnsubscribe={handleSendMailtoUnsubscribe}
onDismiss={() => {
const messageId = email?.messageId || '';
const newSet = new Set(dismissedUnsubBanners).add(messageId);
@@ -3800,7 +3890,7 @@ export function EmailViewer({
)}
<button
onClick={() => setShowFullHeaders(!showFullHeaders)}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ml-1"
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-0.5 transition-colors ms-1"
>
{showFullHeaders ? (
<>
@@ -3817,7 +3907,7 @@ export function EmailViewer({
</div>
</div>
{/* Date/time + size on the right (mobile) */}
<div className="sm:hidden flex-shrink-0 text-right ml-2">
<div className="sm:hidden flex-shrink-0 text-end ms-2">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDateTime(email.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</span>
@@ -3949,7 +4039,7 @@ export function EmailViewer({
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-left"
className="text-sm text-start"
/>
</div>
</Row>
@@ -4477,7 +4567,7 @@ export function EmailViewer({
<span className="text-sm text-foreground truncate max-w-[220px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-xs text-muted-foreground ml-auto flex-shrink-0">
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
@@ -4615,7 +4705,7 @@ export function EmailViewer({
<span className="text-xs text-foreground truncate max-w-[180px]">
{getAttachmentDisplayName(attachment.name, attachment.type)}
</span>
<span className="text-[10px] text-muted-foreground ml-auto flex-shrink-0">
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 right-0 rounded-r-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
@@ -4713,6 +4803,12 @@ export function EmailViewer({
value={quickReplyText}
onChange={(e) => setQuickReplyText(e.target.value)}
onFocus={() => setIsQuickReplyFocused(true)}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void handleSendQuickReply();
}
}}
placeholder={t('quick_reply_placeholder')}
className={cn(
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
@@ -4752,35 +4848,22 @@ export function EmailViewer({
disabled={isSendingQuickReply}
className="text-muted-foreground"
>
<MoreVertical className="w-4 h-4 mr-1" />
<MoreVertical className="w-4 h-4 me-1" />
{t('more_options')}
</Button>
<Button
size="sm"
onClick={async () => {
if (!quickReplyText.trim() || !onQuickReply) return;
setIsSendingQuickReply(true);
try {
await onQuickReply(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
} catch (error) {
console.error("Failed to send quick reply:", error);
} finally {
setIsSendingQuickReply(false);
}
}}
onClick={handleSendQuickReply}
disabled={!quickReplyText.trim() || isSendingQuickReply}
>
{isSendingQuickReply ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
<Loader2 className="w-4 h-4 me-1 animate-spin" />
{t('sending')}
</>
) : (
<>
<Reply className="w-4 h-4 mr-1" />
<Reply className="w-4 h-4 me-1" />
{t('send')}
</>
)}
@@ -4916,7 +4999,7 @@ export function EmailViewer({
<>
{/* Collapse toggle when sidebar is collapsed */}
{detailSidebarCollapsed && (
<div className="flex flex-col items-center border-l border-border bg-background">
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
@@ -4940,7 +5023,7 @@ export function EmailViewer({
onDoubleClick={() => setDetailSidebarWidth(280)}
/>
<div
className="flex flex-col h-full border-l border-border bg-background overflow-hidden"
className="flex flex-col h-full border-s border-border bg-background overflow-hidden"
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
+49 -31
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ReadReceiptBannerProps {
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
const t = useTranslations('email_viewer.read_receipt');
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
// Matches the host's "External Content" banner row: a round tinted icon chip,
// an uppercase eyebrow, a foreground message, and neutral bordered actions.
if (state === 'sent') {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{t('sent')}</span>
<div className="flex items-center gap-3 py-1">
<div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
<CheckCircle className="w-5 h-5" />
</div>
<span className="text-sm text-muted-foreground">{t('sent')}</span>
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30">
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-foreground">{t('prompt')}</span>
<span className="break-all text-muted-foreground">{requestedBy}</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={async () => {
setState('sending');
try {
await onSend();
setState('sent');
} catch {
setState('idle');
}
}}
disabled={state === 'sending'}
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
{t('send')}
</button>
<button
onClick={onIgnore}
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
>
{t('ignore')}
</button>
<div className="flex items-start gap-3 py-1">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<MailCheck className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Read receipt
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('prompt')}
</div>
<div className="text-xs text-muted-foreground break-all">
Requested by <span className="text-foreground/80">{requestedBy}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<button
onClick={async () => {
setState('sending');
try {
await onSend();
setState('sent');
} catch {
setState('idle');
}
}}
disabled={state === 'sending'}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50 disabled:cursor-not-allowed"
>
{state === 'sending' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MailCheck className="w-3.5 h-3.5" />}
{t('send')}
</button>
<button
onClick={onIgnore}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<X className="w-3.5 h-3.5" />
{t('ignore')}
</button>
</div>
</div>
</div>
);
+1 -1
View File
@@ -221,7 +221,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
{onViewContact && (
<button
onClick={handleViewContact}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ml-auto"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors ms-auto"
title={contact ? "View contact" : "View details"}
>
{contact ? <ExternalLink className="w-3.5 h-3.5" /> : <UserPlus className="w-3.5 h-3.5" />}
+29 -8
View File
@@ -8,6 +8,7 @@ import Heading from "@tiptap/extension-heading";
import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link";
import TextAlign from "@tiptap/extension-text-align";
import { TextDirection } from "@/components/email/text-direction";
import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import { ResizableImage } from "@/components/email/resizable-image";
@@ -19,6 +20,7 @@ import { TableCell } from "@tiptap/extension-table-cell";
import { QuotedHtml, serializeEditorContent } from "@/components/email/quoted-html";
import { SignatureBlock } from "@/components/email/signature-block";
import { cn } from "@/lib/utils";
import { useSettingsStore } from "@/stores/settings-store";
import {
Bold,
Italic,
@@ -29,6 +31,7 @@ import {
AlignLeft,
AlignCenter,
AlignRight,
ArrowLeftRight,
Link as LinkIcon,
Undo,
Redo,
@@ -183,6 +186,7 @@ export function RichTextEditor({
hasError,
onEditorReady,
}: RichTextEditorProps) {
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
const onEditorReadyRef = React.useRef(onEditorReady);
@@ -240,6 +244,7 @@ export function RichTextEditor({
// rich/branded signatures keep their inline styling in the editor and
// in the sent mail (see signature-block.ts).
SignatureBlock,
TextDirection,
],
content,
editorProps: {
@@ -454,6 +459,22 @@ export function RichTextEditor({
<AlignRight className="w-4 h-4" />
</ToolbarButton>
{rtlEditingSupport && (
<ToolbarButton
active={
(editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir) === "rtl"
}
onClick={() => {
const cur =
editor.getAttributes("paragraph").dir || editor.getAttributes("heading").dir;
editor.chain().focus().setTextDirection(cur === "rtl" ? "ltr" : "rtl").run();
}}
title="Text direction (RTL/LTR)"
>
<ArrowLeftRight className="w-4 h-4" />
</ToolbarButton>
)}
<ToolbarSeparator />
<ToolbarButton
@@ -478,28 +499,28 @@ export function RichTextEditor({
<div className="flex flex-col gap-0.5">
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addRowBefore().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row above
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addRowAfter().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Add row below
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addColumnBefore().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column before
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().addColumnAfter().run(); setTableMenuOpen(false); }}
>
<Columns3 className="w-4 h-4" /> Add column after
@@ -507,21 +528,21 @@ export function RichTextEditor({
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().deleteRow().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete row
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().deleteColumn().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete column
</button>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start"
onClick={() => { editor.chain().focus().toggleHeaderRow().run(); setTableMenuOpen(false); }}
>
<Rows3 className="w-4 h-4" /> Toggle header row
@@ -529,7 +550,7 @@ export function RichTextEditor({
<div className="h-px bg-border my-1" />
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-left text-red-600 dark:text-red-400"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-accent text-start text-red-600 dark:text-red-400"
onClick={() => { editor.chain().focus().deleteTable().run(); setTableMenuOpen(false); }}
>
<Trash2 className="w-4 h-4" /> Delete table
+59
View File
@@ -0,0 +1,59 @@
"use client";
import type { ComponentProps } from "react";
import { Check } from "lucide-react";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
type SelectableAvatarProps = ComponentProps<typeof Avatar> & {
/** Whether the underlying message/thread is currently selected. */
checked: boolean;
/** Toggle selection. The wrapper stops propagation so the row is not opened. */
onToggle: () => void;
/** Accessible label for the selection control. */
selectLabel?: string;
};
/**
* Avatar that doubles as a selection control, Thunderbird-style: clicking the
* avatar toggles the message/thread into the current selection instead of
* opening it. A check overlay appears on hover (hinting it is clickable) and
* stays visible while the row is selected.
*/
export function SelectableAvatar({
checked,
onToggle,
selectLabel,
className,
...avatarProps
}: SelectableAvatarProps) {
return (
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={selectLabel}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className={cn(
"group/select relative shrink-0 rounded-full outline-none",
"focus-visible:ring-2 focus-visible:ring-primary/60",
className,
)}
>
<Avatar {...avatarProps} />
<span
aria-hidden
className={cn(
"absolute inset-0 flex items-center justify-center rounded-full",
"bg-primary text-primary-foreground transition-opacity duration-150",
checked ? "opacity-100" : "opacity-0 group-hover/select:opacity-100",
)}
>
<Check className="h-4 w-4" />
</span>
</button>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { Extension } from "@tiptap/core";
export type TextDir = "ltr" | "rtl";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
textDirection: {
setTextDirection: (dir: TextDir) => ReturnType;
unsetTextDirection: () => ReturnType;
};
}
}
/**
* Adds a `dir` attribute to block nodes so the composer can mark individual
* paragraphs/headings as LTR or RTL (Gmail-style right-to-left editing). The
* attribute round-trips to HTML so the direction is preserved in the sent mail.
*/
export const TextDirection = Extension.create({
name: "textDirection",
addOptions() {
return { types: ["paragraph", "heading", "blockquote", "listItem"] };
},
addGlobalAttributes() {
return [
{
types: this.options.types,
attributes: {
dir: {
default: null,
parseHTML: (element) => element.getAttribute("dir") || null,
renderHTML: (attributes) =>
attributes.dir ? { dir: attributes.dir } : {},
},
},
},
];
},
addCommands() {
return {
setTextDirection:
(dir) =>
({ commands }) =>
this.options.types.every((type: string) =>
commands.updateAttributes(type, { dir }),
),
unsetTextDirection:
() =>
({ commands }) =>
this.options.types.every((type: string) =>
commands.resetAttributes(type, "dir"),
),
};
},
});
@@ -150,7 +150,7 @@ export function ThreadConversationView({
<div className="flex items-center px-4 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 sticky top-0 z-10" style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-header-py)' }}>
<button
onClick={onBack}
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
className="p-2 -ms-2 rounded-full hover:bg-muted transition-colors"
>
<ArrowLeft className="w-5 h-5" />
</button>
@@ -488,13 +488,13 @@ function EmailCard({
<div className={cn(
"rounded-lg border border-border overflow-hidden transition-all duration-200",
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-l-2 border-l-primary"
isUnread && !isExpanded && "border-s-2 border-l-primary"
)}>
{/* Card Header - Always visible */}
<button
onClick={onToggleExpanded}
className={cn(
"w-full flex items-start text-left transition-colors",
"w-full flex items-start text-start transition-colors",
!isExpanded && "hover:bg-muted/50"
)}
style={{ gap: 'var(--density-item-gap)', padding: 'var(--density-card-p)' }}
@@ -657,7 +657,7 @@ function EmailCard({
}}
className="flex-1"
>
<Reply className="w-4 h-4 mr-2" />
<Reply className="w-4 h-4 me-2" />
{t("email_viewer.reply")}
</Button>
)}
@@ -671,7 +671,7 @@ function EmailCard({
}}
className="flex-1"
>
<ReplyAll className="w-4 h-4 mr-2" />
<ReplyAll className="w-4 h-4 me-2" />
{t("email_viewer.reply_all")}
</Button>
)}
@@ -685,7 +685,7 @@ function EmailCard({
}}
className="flex-1"
>
<Forward className="w-4 h-4 mr-2" />
<Forward className="w-4 h-4 me-2" />
{t("email_viewer.forward")}
</Button>
)}
+3 -3
View File
@@ -86,8 +86,8 @@ export function ThreadEmailItem({
{...longPressHandlers}
className={cn(
"relative cursor-pointer select-none transition-all duration-150",
"pl-12 pr-4",
"border-l-2 border-l-transparent",
"ps-12 pe-4",
"border-s-2 border-l-transparent",
selected
? "bg-selection border-l-primary"
: "hover:bg-muted/50",
@@ -130,7 +130,7 @@ export function ThreadEmailItem({
{/* Unread indicator */}
{isUnread && (
<div className="absolute left-7 top-1/2 -translate-y-1/2">
<div className="absolute start-7 top-1/2 -translate-y-1/2">
<Circle className="w-1.5 h-1.5 fill-unread text-unread" />
</div>
)}
+47 -14
View File
@@ -4,8 +4,8 @@ import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -75,8 +75,10 @@ interface SingleEmailItemProps {
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
@@ -88,6 +90,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
@@ -111,7 +114,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const tagIds = getEmailColorTags(email.keywords);
const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' });
const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null;
const resolvedColorTag = (() => {
const resolvedColorTag = !tintListRowsByTag ? null : (() => {
if (colorTag) return colorTag;
return resolvedKeywordDef ? KEYWORD_PALETTE[resolvedKeywordDef.color]?.bg ?? null : null;
})();
@@ -134,7 +137,11 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
toggleEmailSelection(email.id);
if (e.shiftKey) {
selectRangeEmails(email.id);
} else {
toggleEmailSelection(email.id);
}
};
const handleContextMenu = (e: React.MouseEvent) => {
@@ -211,18 +218,21 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
)}
{isUnread && (
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{density !== 'extra-compact' && (
<Avatar
<SelectableAvatar
name={sender?.name}
email={sender?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="flex-shrink-0 shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={() => toggleEmailSelection(email.id)}
selectLabel={tBatch('select')}
/>
)}
@@ -256,6 +266,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{isPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{isStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{isAnswered && !isForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{isForwarded && !isAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -308,6 +319,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{sender?.name || sender?.email || "Unknown"}
</span>
<div className="flex items-center gap-1.5">
{isPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{isStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -397,6 +411,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
)}
</div>
@@ -427,13 +442,14 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
}, ref) {
const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
@@ -479,8 +495,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const threadColor = getThreadColorTag(thread.emails);
const emailKeywordDefs = useSettingsStore((state) => state.emailKeywords);
const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag);
const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null;
const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const colorTag = (tintListRowsByTag && keywordDef) ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null;
const isSelected = selectedEmailId === latestEmail.id ||
thread.emails.some(e => e.id === selectedEmailId);
@@ -511,9 +528,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
const emailsToShow = expandedEmails || thread.emails;
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
// Toggle selection for all emails in this thread
// Toggle selection for all emails in this thread.
const toggleThreadSelection = () => {
const allSelected = thread.emails.every(em => selectedEmailIds.has(em.id));
const newSelection = new Set(selectedEmailIds);
thread.emails.forEach(em => {
@@ -526,6 +542,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
useEmailStore.setState({ selectedEmailIds: newSelection, lastSelectedEmailId: latestEmail.id });
};
const handleThreadCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (e.shiftKey) {
selectRangeEmails(latestEmail.id);
return;
}
toggleThreadSelection();
};
const handleHeaderClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
@@ -618,19 +643,22 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
)}
{hasUnread && (
<div className="absolute left-0.5 top-1/2 -translate-y-1/2">
<div className="absolute start-0.5 top-1/2 -translate-y-1/2">
<Circle className="w-2 h-2 fill-unread text-unread" />
</div>
)}
{density !== 'extra-compact' && (
<div className="relative flex-shrink-0">
<Avatar
<SelectableAvatar
name={avatarPerson?.name}
email={avatarPerson?.email}
size={isFocusedMailLayout ? "sm" : "md"}
className="shadow-sm"
disableImages={hideJunkAvatarImages}
checked={isChecked}
onToggle={toggleThreadSelection}
selectLabel={tBatch('select')}
/>
{!isMobile && !isFocusedMailLayout && (
<button
@@ -702,6 +730,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
{hasPinned && <Pin className="w-3.5 h-3.5 text-primary" />}
{hasStarred && <Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />}
{hasAnswered && !hasForwarded && <Reply className="w-3.5 h-3.5 text-muted-foreground" />}
{hasForwarded && !hasAnswered && <Forward className="w-3.5 h-3.5 text-muted-foreground" />}
@@ -766,6 +795,9 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{emailCount}
</span>
<div className="flex items-center gap-1.5">
{hasPinned && (
<Pin className="w-3.5 h-3.5 text-primary" />
)}
{hasStarred && (
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
)}
@@ -855,6 +887,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
/>
)}
</div>
@@ -863,7 +896,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
<div className="bg-muted/20 animate-in slide-in-from-top-2 duration-200">
{isLoading ? (
<div className="py-4 flex items-center justify-center text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin mr-2" />
<Loader2 className="w-4 h-4 animate-spin me-2" />
{t('loading')}
</div>
) : (
+22 -11
View File
@@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from 'react';
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl } from '@/lib/validation';
import { isValidUnsubscribeUrl, parseMailtoUrl } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
@@ -14,12 +14,17 @@ interface UnsubscribeBannerProps {
preferred?: 'http' | 'mailto';
};
senderEmail: string;
// Sends the unsubscribe message through the app's own account. This is a
// webmail client - handing a mailto: URL to the OS mail handler goes
// nowhere for most users.
onSendMailtoUnsubscribe: (fields: { to: string[]; subject?: string; body?: string }) => Promise<void>;
onDismiss: () => void;
}
export function UnsubscribeBanner({
listUnsubscribe,
senderEmail: _senderEmail,
onSendMailtoUnsubscribe,
onDismiss
}: UnsubscribeBannerProps) {
const t = useTranslations();
@@ -74,12 +79,18 @@ export function UnsubscribeBanner({
setShowConfirm(false);
setTimeout(onDismiss, 3000);
} else {
const link = document.createElement('a');
link.href = unsubUrl;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Send the unsubscribe message ourselves and only report success
// once the server accepted it. The previous hidden-link click handed
// the mailto: to the OS mail handler and claimed success even though
// nothing was ever sent.
const fields = parseMailtoUrl(unsubUrl);
if (!fields) {
setError(true);
setProcessing(false);
setShowConfirm(false);
return;
}
await onSendMailtoUnsubscribe(fields);
setSuccess(true);
setProcessing(false);
@@ -96,7 +107,7 @@ export function UnsubscribeBanner({
if (success) {
return (
<span className="inline-flex items-center gap-1 ml-1">
<span className="inline-flex items-center gap-1 ms-1">
<CheckCircle className="w-3 h-3 text-green-600 dark:text-green-400" />
<span className="text-xs text-green-600 dark:text-green-400">
{t(unsubMethod === 'http'
@@ -110,7 +121,7 @@ export function UnsubscribeBanner({
if (error) {
return (
<span className="inline-flex items-center gap-1 ml-1">
<span className="inline-flex items-center gap-1 ms-1">
<AlertCircle className="w-3 h-3 text-red-500 dark:text-red-400" />
<button
onClick={onDismiss}
@@ -171,8 +182,8 @@ export function UnsubscribeBanner({
}}
title={t('email_viewer.unsubscribe_banner.confirm_title')}
message={t(unsubMethod === 'http'
? 'email_viewer.unsubscribe_banner.success_http'
: 'email_viewer.unsubscribe_banner.success_mailto'
? 'email_viewer.unsubscribe_banner.confirm_message_http'
: 'email_viewer.unsubscribe_banner.confirm_message_mailto'
)}
confirmText={t('email_viewer.unsubscribe_banner.confirm_button')}
cancelText={t('email_viewer.unsubscribe_banner.cancel')}
+6 -6
View File
@@ -21,7 +21,7 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
{t("page_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
@@ -34,13 +34,13 @@ export function PageErrorFallback({ error: _error, resetError, t }: FallbackProp
*/
export function SidebarErrorFallback({ resetError, t }: FallbackProps) {
return (
<div className="w-64 h-full border-r border-border bg-secondary flex flex-col items-center justify-center p-4">
<div className="w-64 h-full border-e border-border bg-secondary flex flex-col items-center justify-center p-4">
<FolderOpen className="w-10 h-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground text-center mb-4">
{t("sidebar_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-3 h-3 mr-1" />
<RefreshCw className="w-3 h-3 me-1" />
{t("reload")}
</Button>
</div>
@@ -58,7 +58,7 @@ export function EmailListErrorFallback({ resetError, t }: FallbackProps) {
{t("email_list_error")}
</p>
<Button variant="outline" size="sm" onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("reload_emails")}
</Button>
</div>
@@ -81,7 +81,7 @@ export function EmailViewerErrorFallback({ resetError, t }: FallbackProps) {
{t("viewer_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("try_again")}
</Button>
</div>
@@ -119,7 +119,7 @@ export function SettingsErrorFallback({ resetError, t }: FallbackProps) {
{t("settings_error_description")}
</p>
<Button onClick={resetError}>
<RefreshCw className="w-4 h-4 mr-2" />
<RefreshCw className="w-4 h-4 me-2" />
{t("reload_settings")}
</Button>
</div>
+37 -37
View File
@@ -821,8 +821,8 @@ export function FileBrowser({
const SortIndicator = ({ column }: { column: SortKey }) => {
if (sortKey !== column) return null;
return sortDir === "asc"
? <ArrowUp className="w-3 h-3 inline ml-1" />
: <ArrowDown className="w-3 h-3 inline ml-1" />;
? <ArrowUp className="w-3 h-3 inline ms-1" />
: <ArrowDown className="w-3 h-3 inline ms-1" />;
};
// Keyboard shortcuts
@@ -942,7 +942,7 @@ export function FileBrowser({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 -ml-2"
className="h-8 w-8 -ms-2"
onClick={() => setNarrowSidebarOpen((v) => !v)}
aria-label={t("open_folder_tree")}
>
@@ -982,7 +982,7 @@ export function FileBrowser({
className="h-8"
onClick={() => onBatchDownload([...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory))}
>
<Download className="w-4 h-4 mr-1" />
<Download className="w-4 h-4 me-1" />
{t("download")} ({[...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory).length})
</Button>
<Button
@@ -991,7 +991,7 @@ export function FileBrowser({
className="h-8 text-destructive hover:text-destructive"
onClick={() => onBatchDelete([...selectedResources])}
>
<Trash2 className="w-4 h-4 mr-1" />
<Trash2 className="w-4 h-4 me-1" />
{t("delete")} ({selectedResources.size})
</Button>
</>
@@ -1003,7 +1003,7 @@ export function FileBrowser({
className="h-8"
onClick={onPaste}
>
<Clipboard className="w-4 h-4 mr-1" />
<Clipboard className="w-4 h-4 me-1" />
{t("paste")} ({clipboard.names.length})
</Button>
)}
@@ -1157,7 +1157,7 @@ export function FileBrowser({
className="h-7 text-destructive hover:text-destructive shrink-0"
onClick={onRefresh}
>
<RefreshCw className="w-3.5 h-3.5 mr-1" />
<RefreshCw className="w-3.5 h-3.5 me-1" />
{t("retry")}
</Button>
</div>
@@ -1181,12 +1181,12 @@ export function FileBrowser({
<span className="truncate">
{t("uploading")} {uploadProgress.name}
{uploadProgress.totalFiles > 1 && (
<span className="text-muted-foreground ml-1">
<span className="text-muted-foreground ms-1">
({uploadProgress.current}/{uploadProgress.totalFiles})
</span>
)}
</span>
<span className="ml-auto tabular-nums shrink-0 flex items-center gap-2">
<span className="ms-auto tabular-nums shrink-0 flex items-center gap-2">
{uploadProgress.total > 0
? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%`
: "…"}
@@ -1267,7 +1267,7 @@ export function FileBrowser({
)}
{/* Favorites & Recent sidebar (when layout is inline) */}
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
<div className="w-48 border-r border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
<div className="w-48 border-e border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
{favorites.length > 0 && (
<div className="p-3">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
@@ -1280,7 +1280,7 @@ export function FileBrowser({
key={fav}
onClick={() => onNavigate(fav)}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left",
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start",
currentPath === fav && "bg-muted font-medium"
)}
>
@@ -1301,7 +1301,7 @@ export function FileBrowser({
{recentFiles.slice(0, 10).map((recent) => (
<button
key={recent.id}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start"
title={recent.name}
>
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
@@ -1322,14 +1322,14 @@ export function FileBrowser({
<table className="w-full text-sm">
<thead className="bg-muted/50 sticky top-0 z-10">
<tr className="border-b border-border">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
<div className="flex items-center gap-3">
<div className="w-4 h-4" />
{t("name")}
</div>
</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
<th className="w-10 px-2 py-2" />
</tr>
</thead>
@@ -1354,7 +1354,7 @@ export function FileBrowser({
key={`__account__:${acc.accountId}`}
onClick={() => onSelectAccount(acc.accountId)}
title={acc.email}
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-left min-w-0"
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-start min-w-0"
>
<Avatar
name={acc.label}
@@ -1514,7 +1514,7 @@ export function FileBrowser({
>
<thead className="bg-muted/50 sticky top-0 z-10">
<tr className="border-b border-border">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
<div className="flex items-center gap-3">
<input
type="checkbox"
@@ -1530,13 +1530,13 @@ export function FileBrowser({
</button>
</div>
</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
<button onClick={() => handleSortClick("size")} className="hover:text-foreground transition-colors">
{t("size")}
<SortIndicator column="size" />
</button>
</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
<button onClick={() => handleSortClick("modified")} className="hover:text-foreground transition-colors">
{t("modified")}
<SortIndicator column="modified" />
@@ -1681,7 +1681,7 @@ export function FileBrowser({
>
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
if (isImageFile(contextMenu.name)) {
onPreviewImage(contextMenu.name);
@@ -1697,7 +1697,7 @@ export function FileBrowser({
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onDownload(contextMenu.name);
setContextMenu(null);
@@ -1708,7 +1708,7 @@ export function FileBrowser({
</button>
)}
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onCut([contextMenu.name]);
setContextMenu(null);
@@ -1718,7 +1718,7 @@ export function FileBrowser({
{t("cut")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onCopy([contextMenu.name]);
setContextMenu(null);
@@ -1729,7 +1729,7 @@ export function FileBrowser({
</button>
{clipboard && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onPaste();
setContextMenu(null);
@@ -1741,7 +1741,7 @@ export function FileBrowser({
)}
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onDuplicate(contextMenu.name);
setContextMenu(null);
@@ -1753,7 +1753,7 @@ export function FileBrowser({
)}
{canShare(resources.find(r => r.name === contextMenu.name)) && (
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
const r = resources.find(res => res.name === contextMenu.name);
if (r) setShareTargetId(r.id);
@@ -1766,7 +1766,7 @@ export function FileBrowser({
)}
<div className="h-px bg-border my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onShowDetails(contextMenu.name);
setContextMenu(null);
@@ -1776,7 +1776,7 @@ export function FileBrowser({
{t("details")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
setRenameTarget(contextMenu.name);
setContextMenu(null);
@@ -1786,7 +1786,7 @@ export function FileBrowser({
{t("rename")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-start"
onClick={() => {
onDelete(contextMenu.name);
setContextMenu(null);
@@ -1808,7 +1808,7 @@ export function FileBrowser({
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
setShowNewFolder(true);
setEmptyContextMenu(null);
@@ -1818,7 +1818,7 @@ export function FileBrowser({
{t("new_folder")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
setShowNewTextFile(true);
setEmptyContextMenu(null);
@@ -1828,7 +1828,7 @@ export function FileBrowser({
{t("new_text_file")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
fileInputRef.current?.click();
setEmptyContextMenu(null);
@@ -1838,7 +1838,7 @@ export function FileBrowser({
{t("upload")}
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
folderInputRef.current?.click();
setEmptyContextMenu(null);
@@ -1851,7 +1851,7 @@ export function FileBrowser({
<>
<div className="h-px bg-border my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onPaste();
setEmptyContextMenu(null);
@@ -1864,7 +1864,7 @@ export function FileBrowser({
)}
<div className="h-px bg-border my-1" />
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onRefresh();
setEmptyContextMenu(null);
@@ -1895,7 +1895,7 @@ export function FileBrowser({
return (
<button
key={folder.id}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
onClick={() => {
onNavigate(folderPath, folder.id);
setBreadcrumbDropdown(null);
@@ -1926,7 +1926,7 @@ export function FileBrowser({
{/* Details sidebar */}
{showDetails && detailResource && (
<div role="complementary" aria-label={t("details")} className="w-64 border-l border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
<div role="complementary" aria-label={t("details")} className="w-64 border-s border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">{t("details")}</h3>
<button onClick={onToggleDetails} className="text-muted-foreground hover:text-foreground">
+3 -3
View File
@@ -68,11 +68,11 @@ function SimpleMarkdown({ content }: { content: string }) {
} else if (line.startsWith("---") || line.startsWith("***")) {
elements.push(<hr key={i} className="my-4 border-border" />);
} else if (line.startsWith("- ") || line.startsWith("* ")) {
elements.push(<li key={i} className="ml-4 list-disc">{processInline(line.slice(2))}</li>);
elements.push(<li key={i} className="ms-4 list-disc">{processInline(line.slice(2))}</li>);
} else if (/^\d+\. /.test(line)) {
elements.push(<li key={i} className="ml-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
elements.push(<li key={i} className="ms-4 list-decimal">{processInline(line.replace(/^\d+\. /, ""))}</li>);
} else if (line.startsWith("> ")) {
elements.push(<blockquote key={i} className="border-l-4 border-border pl-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
elements.push(<blockquote key={i} className="border-s-4 border-border ps-4 italic text-muted-foreground my-2">{processInline(line.slice(2))}</blockquote>);
} else if (line.startsWith("```")) {
// Code block - collect until closing ```
const codeLines: string[] = [];
+2 -2
View File
@@ -68,7 +68,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
size="sm"
onClick={onCreateFolder}
>
<FolderPlus className="w-4 h-4 mr-2" />
<FolderPlus className="w-4 h-4 me-2" />
{t("new_folder")}
</Button>
{onCreateTextFile && (
@@ -77,7 +77,7 @@ export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCre
size="sm"
onClick={onCreateTextFile}
>
<FilePlus className="w-4 h-4 mr-2" />
<FilePlus className="w-4 h-4 me-2" />
{t("new_text_file")}
</Button>
)}
+8 -8
View File
@@ -135,7 +135,7 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
return (
<div
className={cn(
"border-r border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
"border-e border-border bg-secondary overflow-hidden shrink-0 flex flex-col h-full",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${width}px` }}
@@ -154,10 +154,10 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
>
<button
onClick={() => handleFolderClick("/", null)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
>
<Home className={cn("w-4 h-4 flex-shrink-0 mr-2 transition-colors")} />
<Home className={cn("w-4 h-4 flex-shrink-0 me-2 transition-colors")} />
<span className="truncate">{t("breadcrumb_root")}</span>
</button>
</div>
@@ -207,11 +207,11 @@ export function FolderTreeSidebar({ currentPath, onNavigate, listByParentId, wid
>
<button
onClick={() => handleFolderClick(path, r.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left min-w-0"
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start min-w-0"
style={{ paddingBlock: "var(--density-sidebar-py)", paddingLeft: "24px" }}
title={r.ownerName ? t("shared_by", { name: r.ownerName }) : r.name}
>
<Folder className="w-4 h-4 flex-shrink-0 mr-2 text-primary" />
<Folder className="w-4 h-4 flex-shrink-0 me-2 text-primary" />
<span className="truncate">{r.name}</span>
</button>
</div>
@@ -281,7 +281,7 @@ function FolderTreeItem({
onToggleExpand(node.id, node.path);
}}
className={cn(
"p-0.5 rounded mr-1 transition-all duration-200",
"p-0.5 rounded me-1 transition-all duration-200",
"hover:bg-muted active:bg-accent"
)}
style={{ marginLeft: `${indentPx}px` }}
@@ -297,14 +297,14 @@ function FolderTreeItem({
{/* Folder name */}
<button
onClick={() => onFolderClick(node.path, node.id)}
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-left"
className="flex items-center px-1 rounded transition-colors duration-150 flex-1 text-start"
style={{
paddingBlock: "var(--density-sidebar-py)",
paddingLeft: hasChildren ? "4px" : `${indentPx + 24}px`,
}}
>
<Icon className={cn(
"w-4 h-4 flex-shrink-0 mr-2 transition-colors",
"w-4 h-4 flex-shrink-0 me-2 transition-colors",
isExpanded && hasChildren && "text-primary",
!hasChildren && depth > 0 && "text-muted-foreground"
)} />
+1 -1
View File
@@ -212,7 +212,7 @@ export function PdfMobileViewer({ url }: { url: string }) {
size="sm"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4 mr-2" />
<ExternalLink className="w-4 h-4 me-2" />
{t("open_in_new_tab")}
</Button>
</div>
+2 -2
View File
@@ -100,7 +100,7 @@ export function SieveEditorModal({
<div className="flex-1 min-h-0 flex border border-border rounded-md overflow-hidden">
<div
className="w-10 flex-shrink-0 bg-muted border-r border-border py-2 text-right pr-2 select-none overflow-hidden"
className="w-10 flex-shrink-0 bg-muted border-e border-border py-2 text-end pe-2 select-none overflow-hidden"
aria-hidden="true"
>
{Array.from({ length: lineCount }, (_, i) => (
@@ -172,7 +172,7 @@ export function SieveEditorModal({
>
{isValidating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
<Loader2 className="w-4 h-4 me-2 animate-spin" />
{t("validating")}
</>
) : (
+1 -1
View File
@@ -352,7 +352,7 @@ function SignatureByteCounter({ id, value }: { id: string; value: string }) {
aria-live="polite"
>
{t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })}
{atLimit && <span className="ml-1">{t('signature_byte_limit_reached')}</span>}
{atLimit && <span className="ms-1">{t('signature_byte_limit_reached')}</span>}
</p>
);
}
@@ -275,7 +275,7 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 mr-2" />
<Plus className="w-4 h-4 me-2" />
{t('create_new')}
</Button>
)}
+1 -1
View File
@@ -128,7 +128,7 @@ export function SubAddressHelper({
title={t('button_tooltip')}
className="h-8 px-2"
>
<Plus className="w-4 h-4 mr-1" />
<Plus className="w-4 h-4 me-1" />
<Tag className="w-4 h-4" />
</Button>
+1 -1
View File
@@ -174,7 +174,7 @@ function ShortcutRow({
return (
<div className="flex items-center justify-between py-1.5">
<span className="text-sm text-muted-foreground">{description}</span>
<div className="flex items-center gap-1.5 ml-4">
<div className="flex items-center gap-1.5 ms-4">
{keys.map((key, index) => (
<span key={index}>
{index > 0 && <span className="text-muted-foreground/50 mx-1 text-xs">or</span>}
+58 -8
View File
@@ -1,12 +1,12 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { createPortal } from "react-dom";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical } from "lucide-react";
import { useTranslations } from "next-intl";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { getMaxAccounts } from "@/lib/account-utils";
import { getMaxAccounts, sortDefaultFirst, reorderNonDefaultIds } from "@/lib/account-utils";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
@@ -40,6 +40,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
// Read activeAccountId from authStore so the selector matches the actually-loaded
// session (primaryIdentity, JMAP client). accountStore.activeAccountId is a separate
// persisted copy that can drift out of sync across hydration / partial persist writes.
@@ -113,6 +114,32 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
setDefaultAccount(accountId);
};
// Display order: default account pinned to the top, the rest reorderable.
const displayAccounts = useMemo(() => sortDefaultFirst(accounts), [accounts]);
// Drag-to-rearrange (non-default accounts only; the default stays pinned).
const [dragId, setDragId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
const resetDrag = () => { setDragId(null); setDragOverId(null); };
const handleDragStart = (e: React.DragEvent, id: string) => {
setDragId(id);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent, overId: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (overId !== dragOverId) setDragOverId(overId);
};
const handleDrop = (e: React.DragEvent, overId: string) => {
e.preventDefault();
if (dragId) {
const next = reorderNonDefaultIds(accounts, dragId, overId);
if (next) reorderAccounts(next);
}
resetDrag();
};
// Show the account's own identity, not the preferred sending identity -
// primaryIdentity can be an alias (e.g. info@korazo.net) that differs from
// the actually logged-in account (info@linusrath.de).
@@ -128,7 +155,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
"flex items-center gap-2 rounded-md transition-colors",
variant === "rail"
? "justify-center w-10 h-10 hover:bg-muted"
: "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
: "w-full px-2 py-1.5 hover:bg-muted text-start min-w-0",
className
)}
title={variant === "rail" ? (displayName || displayEmail) : undefined}
@@ -167,15 +194,29 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
>
{/* Account List */}
<div className="py-1 max-h-64 overflow-y-auto">
{accounts.map((account) => {
{displayAccounts.map((account) => {
const isActive = account.id === activeAccountId;
const isDraggable = !account.isDefault && accounts.length > 2;
return (
<button
<div
key={account.id}
draggable={isDraggable}
onDragStart={isDraggable ? (e) => handleDragStart(e, account.id) : undefined}
onDragOver={isDraggable ? (e) => handleDragOver(e, account.id) : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, account.id) : undefined}
onDragEnd={isDraggable ? resetDrag : undefined}
className={cn(
"group/acct relative",
dragId === account.id && "opacity-50",
dragOverId === account.id && dragId !== account.id && "border-t-2 border-primary"
)}
>
<button
onClick={() => handleSwitch(account.id)}
className={cn(
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted"
"w-full flex items-start gap-3 px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isDraggable && "pe-7"
)}
role="menuitem"
disabled={isActive}
@@ -215,6 +256,15 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
</div>
</div>
</button>
{isDraggable && (
<span
aria-hidden
className="pointer-events-none absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 opacity-0 transition-opacity group-hover/acct:opacity-100"
>
<GripVertical className="w-4 h-4" />
</span>
)}
</div>
);
})}
</div>
+1 -1
View File
@@ -95,7 +95,7 @@ export function IconPicker({ value, onChange, className }: IconPickerProps) {
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('search_icons')}
className="pl-8 h-8 text-xs"
className="ps-8 h-8 text-xs"
/>
{search && (
<button
+2 -1
View File
@@ -91,7 +91,8 @@ function StorageQuotaCircle({ quota, usagePercent }: { quota: { used: number; to
return () => document.removeEventListener("mousedown", handleClick);
}, [open, updatePosition]);
const free = quota.total - quota.used;
// Usage can legitimately exceed the quota (e.g. limit lowered after the fact)
const free = Math.max(0, quota.total - quota.used);
const strokeColor = usagePercent > 90
? "stroke-destructive"
: usagePercent > 70
+2 -2
View File
@@ -154,7 +154,7 @@ function SidebarAppForm({
<label className="block text-sm font-medium mb-2">
{t('icon_label')} <span className="text-destructive">*</span>
{SelectedIcon && (
<span className="inline-flex items-center gap-1.5 ml-2 text-muted-foreground font-normal">
<span className="inline-flex items-center gap-1.5 ms-2 text-muted-foreground font-normal">
- <SelectedIcon className="w-4 h-4" /> {formData.icon}
</span>
)}
@@ -275,7 +275,7 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
onClick={() => setIsCreating(true)}
className="mb-6 w-full sm:w-auto"
>
<Plus className="w-4 h-4 mr-2" />
<Plus className="w-4 h-4 me-2" />
{t('add_new')}
</Button>
)}
+25 -10
View File
@@ -220,7 +220,8 @@ function SidebarRowCounts({
) : null;
return (
<span className="ml-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
<span className="ms-2 flex-shrink-0 flex items-baseline gap-1" title={totalCount > 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`}>
{unreadNode}
{unreadCount > 0 && totalCount > 0 && (
<span className="text-xs text-muted-foreground/60">/</span>
@@ -279,12 +280,12 @@ function SidebarRow({
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
isCollapsed ? "justify-center px-1" : "pr-2",
isCollapsed ? "justify-center px-1" : "pe-2",
isVirtual
? "text-muted-foreground"
: isSelected
? "bg-accent text-accent-foreground font-semibold border-l-2 border-primary"
: "hover:bg-muted/50 text-foreground border-l-2 border-transparent",
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
)}
@@ -321,7 +322,7 @@ function SidebarRow({
disabled={isVirtual}
className={cn(
"flex items-center gap-2 min-w-0 transition-colors",
isCollapsed ? "justify-center" : "flex-1 text-left",
isCollapsed ? "justify-center" : "flex-1 text-start",
isVirtual && "cursor-default select-none"
)}
title={isCollapsed ? label : undefined}
@@ -390,8 +391,8 @@ function SidebarSectionHeader({
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
)}
{icon && <span className="ml-1.5 flex-shrink-0">{icon}</span>}
<span className={cn(textClass, icon ? "ml-1.5" : "ml-1.5")}>
{icon && <span className="ms-1.5 flex-shrink-0">{icon}</span>}
<span className={cn(textClass, icon ? "ms-1.5" : "ms-1.5")}>
{label}
</span>
{onSettings && (
@@ -409,7 +410,7 @@ function SidebarSectionHeader({
onSettings();
}
}}
className="ml-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
className="ms-auto p-1 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors cursor-pointer"
title={settingsTitle}
>
<Settings className="w-3.5 h-3.5" />
@@ -665,7 +666,7 @@ function VacationBanner() {
>
<Palmtree className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate font-medium">{t("vacation_active")}</span>
<Settings className="w-3 h-3 ml-auto flex-shrink-0 opacity-60" />
<Settings className="w-3 h-3 ms-auto flex-shrink-0 opacity-60" />
</button>
);
}
@@ -853,6 +854,20 @@ export function Sidebar({
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't hijack Arrow keys while the user is typing. This is a global
// window listener, so without this guard typing in a new email (the
// contentEditable composer, the subject field, search, etc.) toggled the
// selected mailbox's subfolders open/closed on ArrowLeft/ArrowRight.
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' ||
target.isContentEditable)
) {
return;
}
if (!selectedMailbox || isCollapsed) return;
const findNode = (nodes: MailboxNode[]): MailboxNode | null => {
@@ -956,7 +971,7 @@ export function Sidebar({
return (
<div
className={cn(
"relative flex flex-col h-full border-r transition-all duration-300 overflow-hidden",
"relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden",
"bg-secondary border-border",
"max-lg:w-full",
isCollapsed ? "lg:w-12" : "lg:w-full",
+95 -43
View File
@@ -1,10 +1,12 @@
'use client';
// Host-rendered modal for plugin-requested confirm/alert dialogs.
// Host-rendered modal for plugin-requested confirm/alert/prompt dialogs.
// Subscribes to the host-dialog queue and renders the head request, one at
// a time. Closing the modal advances the queue.
// a time. Closing the modal advances the queue. Prompts collect one or more
// (optionally masked) fields so plugins never fall back to `window.prompt`,
// which the sandbox blocks.
import React, { useEffect, useSyncExternalStore } from 'react';
import React, { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
// Lightweight **bold** support in plugin dialog messages. Everything else is
@@ -23,26 +25,50 @@ function renderMessage(message?: string): React.ReactNode {
export function PluginDialogHost(): React.JSX.Element | null {
const current = useSyncExternalStore(subscribe, head, () => null);
const isPrompt = current?.kind === 'prompt';
const fields = useMemo(() => (isPrompt ? current?.fields ?? [] : []), [isPrompt, current]);
// Field values for a prompt, re-initialised whenever a new dialog surfaces.
const [values, setValues] = useState<Record<string, string>>({});
useEffect(() => {
if (!current) return;
const init: Record<string, string> = {};
for (const f of current.fields ?? []) init[f.name] = '';
setValues(init);
}, [current]);
const canSubmit = fields.every((f) => !f.required || (values[f.name] ?? '').length > 0);
const cancel = () => resolveHead(current?.kind === 'prompt' ? null : false);
const submitPrompt = () => { if (canSubmit) resolveHead(values); };
useEffect(() => {
if (!current) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
resolveHead(false);
} else if (e.key === 'Enter') {
cancel();
} else if (e.key === 'Enter' && !isPrompt) {
// For prompts, Enter is handled by the form (so it respects required
// validation and works from within an input); non-prompt dialogs accept.
e.preventDefault();
resolveHead(true);
}
}
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [current]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [current, isPrompt, canSubmit, values]);
if (!current) return null;
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : 'Confirm');
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? 'OK' : current.kind === 'prompt' ? 'Submit' : 'Confirm');
const cancelLabel = current.cancelLabel ?? 'Cancel';
const btnBase: React.CSSProperties = { padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500, cursor: 'pointer' };
const secondaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid var(--color-border, #e2e8f0)', background: 'transparent', color: 'inherit' };
const primaryBtn: React.CSSProperties = { ...btnBase, border: '1px solid transparent', background: 'var(--color-primary, #3b82f6)', color: 'var(--color-primary-foreground, #fff)' };
return (
<div
role="dialog"
@@ -58,7 +84,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
zIndex: 100000,
}}
onMouseDown={(e) => {
if (e.target === e.currentTarget) resolveHead(false);
if (e.target === e.currentTarget) cancel();
}}
>
<div
@@ -76,47 +102,73 @@ export function PluginDialogHost(): React.JSX.Element | null {
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
{current.title}
</h2>
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{renderMessage(current.message)}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{current.kind === 'confirm' && (
{current.message && (
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{renderMessage(current.message)}
</p>
)}
{isPrompt ? (
<form onSubmit={(e) => { e.preventDefault(); submitPrompt(); }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
{fields.map((f, i) => (
<label key={f.name} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<span style={{ fontSize: 12, fontWeight: 500 }}>
{f.label}{f.required ? ' *' : ''}
</span>
<input
type={f.type === 'password' ? 'password' : 'text'}
value={values[f.name] ?? ''}
placeholder={f.placeholder}
autoFocus={i === 0}
autoComplete={f.type === 'password' ? 'off' : undefined}
onChange={(e) => setValues((v) => ({ ...v, [f.name]: e.target.value }))}
style={{
fontSize: 13,
padding: '8px 10px',
borderRadius: 8,
border: '1px solid var(--color-input, var(--color-border, #cbd5e1))',
background: 'var(--color-background, #fff)',
color: 'inherit',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
}}
/>
</label>
))}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" onClick={cancel} style={secondaryBtn}>{cancelLabel}</button>
<button
type="submit"
disabled={!canSubmit}
style={{ ...primaryBtn, opacity: canSubmit ? 1 : 0.5, cursor: canSubmit ? 'pointer' : 'not-allowed' }}
>
{confirmLabel}
</button>
</div>
</form>
) : (
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{current.kind === 'confirm' && (
<button type="button" autoFocus={!!current.danger} onClick={cancel} style={secondaryBtn}>
{cancelLabel}
</button>
)}
<button
type="button"
autoFocus={!!current.danger}
onClick={() => resolveHead(false)}
autoFocus={current.kind === 'alert' || !current.danger}
onClick={() => resolveHead(true)}
style={{
padding: '8px 16px',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent',
color: 'inherit',
...primaryBtn,
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
}}
>
{cancelLabel}
{confirmLabel}
</button>
)}
<button
type="button"
autoFocus={current.kind === 'alert' || !current.danger}
onClick={() => resolveHead(true)}
style={{
padding: '8px 16px',
borderRadius: 8,
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: '1px solid transparent',
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
}}
>
{confirmLabel}
</button>
</div>
</div>
)}
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
From plugin: {current.pluginId}
</div>
+20 -1
View File
@@ -7,7 +7,7 @@ import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store";
import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug";
interface ProComposeTabBodyProps {
@@ -38,6 +38,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const tabIdRef = useRef(tabId);
tabIdRef.current = tabId;
// Set by the composer to its dirty-aware close handler. Lets the Pro tab
// bar's "X" route through the same "Save or discard draft?" guard.
const requestCloseRef = useRef<(() => void) | null>(null);
const handleScheduledSendCreated = useCallback(async () => {
if (client) {
await refreshScheduledMetadata(client);
@@ -120,6 +124,20 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
closeTab(tabIdRef.current);
}, [closeTab]);
// Register a close interceptor so closing the tab from the tab bar (the
// "X" button or middle-click) goes through the composer's unsaved-changes
// guard, mirroring the non-Pro inline composer.
useEffect(() => {
const id = tabIdRef.current;
return registerProTabCloseInterceptor(id, () => {
if (requestCloseRef.current) {
requestCloseRef.current();
} else {
closeTab(id);
}
});
}, [closeTab]);
const handleDiscardDraft = useCallback(async (draftId: string) => {
if (!client) return;
try {
@@ -160,6 +178,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose}
requestCloseRef={requestCloseRef}
onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState}
className="flex-1"
+41
View File
@@ -6,6 +6,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProEmailTabData, type ProReplyContext } from "@/stores/pro-tab-store";
@@ -56,6 +57,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
const setEmailKeywordsLocal = useEmailStore((s) => s.setEmailKeywordsLocal);
const mailboxes = useEmailStore((s) => s.mailboxes);
const settingsKeywords = useSettingsStore((s) => s.emailKeywords);
const identities = useIdentityStore((s) => s.identities);
const closeTab = useProTabStore((s) => s.closeTab);
const openComposeTab = useProTabStore((s) => s.openComposeTab);
@@ -227,6 +229,44 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
handleReply(body);
}, [handleReply]);
const handleEditDraft = useCallback(() => {
if (!email) return;
const bodyText = email.bodyValues
? Object.values(email.bodyValues).map((v) => v.value).join('\n')
: '';
const htmlBody = email.htmlBody?.[0]?.partId && email.bodyValues?.[email.htmlBody[0].partId]
? email.bodyValues[email.htmlBody[0].partId].value
: undefined;
// Preserve the identity that matches the draft's From address.
const draftFromEmail = email.from?.[0]?.email;
const matchedIdentity = draftFromEmail
? identities.find((id) => id.email === draftFromEmail)
: null;
composerSessionIdRef.current += 1;
openComposeTab({
sessionId: composerSessionIdRef.current,
mode: 'compose',
title: email.subject || t('email_composer.new_message'),
initialData: {
to: email.to?.map((a) => a.email).filter(Boolean).join(', ') || '',
cc: email.cc?.map((a) => a.email).filter(Boolean).join(', ') || '',
bcc: email.bcc?.map((a) => a.email).filter(Boolean).join(', ') || '',
subject: email.subject || '',
body: htmlBody || bodyText,
showCc: (email.cc?.length || 0) > 0,
showBcc: (email.bcc?.length || 0) > 0,
selectedIdentityId: matchedIdentity?.id ?? null,
subAddressTag: '',
mode: 'compose',
draftId: email.id,
},
});
closeTab(tabId);
}, [email, identities, openComposeTab, closeTab, tabId, t]);
return (
<div className="flex h-full w-full flex-col bg-background">
<ErrorBoundary fallback={EmailViewerErrorFallback}>
@@ -243,6 +283,7 @@ export function ProEmailTabBody({ tabId, data }: ProEmailTabBodyProps) {
onSetColorTag={handleSetColorTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
onEditDraft={handleEditDraft}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split('@')[0]}
+2 -2
View File
@@ -171,7 +171,7 @@ export function ProTabBar({
className={cn(
"group relative flex items-center gap-1.5 px-3 h-9 text-sm cursor-pointer select-none transition-colors",
"min-w-0 flex-1 basis-0 max-w-[200px] [min-width:80px]",
"border-r border-border first:border-l",
"border-e border-border first:border-s",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
@@ -193,7 +193,7 @@ export function ProTabBar({
onClose(tab.id);
}}
className={cn(
"ml-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
"ms-1 flex items-center justify-center w-4 h-4 rounded-sm transition-colors flex-shrink-0",
"text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground",
!isActive && "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
)}
@@ -106,7 +106,7 @@ export function ProtocolAccountPicker({
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-start transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
+33 -16
View File
@@ -1,13 +1,17 @@
"use client";
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { NextIntlClientProvider } from 'next-intl';
import { useLocaleStore } from '@/stores/locale-store';
import csMessages from '@/locales/cs/common.json';
import daMessages from '@/locales/da/common.json';
import deMessages from '@/locales/de/common.json';
import { getLocaleDirection } from '@/i18n/direction';
import { mergeMessages } from '@/i18n/merge-messages';
import { detectBrowserLocale } from '@/i18n/detect-locale';
import enMessages from '@/locales/en/common.json';
import esMessages from '@/locales/es/common.json';
import heMessages from '@/locales/he/common.json';
import faMessages from '@/locales/fa/common.json';
import frMessages from '@/locales/fr/common.json';
import huMessages from '@/locales/hu/common.json';
@@ -20,6 +24,7 @@ import plMessages from '@/locales/pl/common.json';
import ptMessages from '@/locales/pt/common.json';
import roMessages from '@/locales/ro/common.json';
import ruMessages from '@/locales/ru/common.json';
import skMessages from '@/locales/sk/common.json';
import trMessages from '@/locales/tr/common.json';
import ukMessages from '@/locales/uk/common.json';
import zhMessages from '@/locales/zh/common.json';
@@ -31,6 +36,7 @@ const ALL_MESSAGES = {
de: deMessages,
en: enMessages,
es: esMessages,
he: heMessages,
fa: faMessages,
fr: frMessages,
hu: huMessages,
@@ -43,6 +49,7 @@ const ALL_MESSAGES = {
pt: ptMessages,
ro: roMessages,
ru: ruMessages,
sk: skMessages,
tr: trMessages,
uk: ukMessages,
zh: zhMessages,
@@ -56,7 +63,6 @@ interface IntlProviderProps {
export function IntlProvider({ locale: initialLocale, children }: IntlProviderProps) {
const currentLocale = useLocaleStore((state) => state.locale);
const setLocale = useLocaleStore((state) => state.setLocale);
const [activeLocale, setActiveLocale] = useState(initialLocale);
const [timeZone, setTimeZone] = useState<string>('UTC');
@@ -72,27 +78,38 @@ export function IntlProvider({ locale: initialLocale, children }: IntlProviderPr
}
}, []);
// First mount: seed the store from the server-resolved locale if nothing is persisted.
// Resolve the active locale from the user's stored choice. Empty or 'auto'
// means "follow the browser" (English default); a specific code forces it and
// is never overridden by detection.
useEffect(() => {
if (!currentLocale) {
setLocale(initialLocale);
} else {
setActiveLocale(currentLocale);
}
setActiveLocale(
!currentLocale || currentLocale === 'auto'
? detectBrowserLocale(initialLocale)
: currentLocale
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Switch locale immediately when store changes
useEffect(() => {
if (currentLocale) {
setActiveLocale(currentLocale);
}
}, [currentLocale]);
// Keep <html> lang/dir in sync with the active locale (RTL for he/fa).
useEffect(() => {
document.documentElement.lang = activeLocale;
document.documentElement.dir = getLocaleDirection(activeLocale);
}, [activeLocale]);
// Fall back to English for any key the active locale has not translated, so
// untranslated strings show English text instead of a raw message key.
const messages = useMemo(
() => mergeMessages(
ALL_MESSAGES.en as Record<string, unknown>,
(ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES] ?? {}) as Record<string, unknown>
),
[activeLocale]
);
return (
<NextIntlClientProvider
locale={activeLocale}
messages={ALL_MESSAGES[activeLocale as keyof typeof ALL_MESSAGES] ?? ALL_MESSAGES.en}
messages={messages}
timeZone={timeZone}
>
{children}
+1 -1
View File
@@ -84,7 +84,7 @@ export function AdvancedSearchPanel({
<span className="text-sm font-medium text-foreground">{t("title")}</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={handleClear} className="h-7 px-2 text-xs">
<RotateCcw className="w-3 h-3 mr-1" />
<RotateCcw className="w-3 h-3 me-1" />
{t("clear")}
</Button>
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7">
+1 -1
View File
@@ -81,7 +81,7 @@ export function SearchChips({
<button
type="button"
onClick={() => onRemoveFilter(chip.key)}
className="ml-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
className="ms-0.5 p-0.5 rounded-full hover:bg-primary/20 transition-colors"
>
<X className="w-3 h-3" />
</button>
+2 -2
View File
@@ -36,7 +36,7 @@ function VersionUpdateTag() {
return (
<span
className={cn(
"ml-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
"ms-2 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium align-middle",
important
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
@@ -121,7 +121,7 @@ export function AboutDataSettings() {
{showGame && <SpamSiegeGame onClose={() => setShowGame(false)} />}
<div className="rounded-lg border border-border bg-card p-5 mb-6">
<div className="flex items-center gap-4">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-left focus:outline-none group/about cursor-pointer" aria-label="About">
<button onClick={handleLogoClick} className="flex items-center gap-4 flex-1 text-start focus:outline-none group/about cursor-pointer" aria-label="About">
<div className="shrink-0">
<img
src={`${getPathPrefix()}/branding/Bulwark_Logo_Color.svg`}
@@ -69,7 +69,7 @@ function PasswordChangeSection() {
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="pr-10"
className="pe-10"
/>
<button
type="button"
@@ -90,7 +90,7 @@ function PasswordChangeSection() {
required
minLength={8}
autoComplete="new-password"
className="pr-10"
className="pe-10"
/>
<button
type="button"
@@ -120,7 +120,7 @@ function PasswordChangeSection() {
size="sm"
disabled={isSaving || !currentPassword || !newPassword || !confirmPassword}
>
{isSaving ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-2 animate-spin" /> : null}
{t('password.submit')}
</Button>
</form>
@@ -294,7 +294,7 @@ function TotpSection() {
</SettingItem>
{setupUrl && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-3">
<div className="ms-4 p-3 bg-muted rounded-md space-y-3">
<p className="text-xs text-muted-foreground">{t('totp.setup_instructions')}</p>
{qrDataUrl && (
<div className="flex justify-center">
@@ -315,7 +315,7 @@ function TotpSection() {
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={confirmSetup} disabled={isSaving || !password || !otpCode}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('totp.confirm')}
</Button>
<Button size="sm" variant="ghost" onClick={cancelSetup}>{t('app_passwords.cancel')}</Button>
@@ -324,7 +324,7 @@ function TotpSection() {
)}
{disableOpen && (
<div className="ml-4 p-3 bg-muted rounded-md space-y-2">
<div className="ms-4 p-3 bg-muted rounded-md space-y-2">
<p className="text-xs text-muted-foreground">{t('totp.disable_confirm_prompt')}</p>
<Input
type="password"
@@ -336,7 +336,7 @@ function TotpSection() {
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
<div className="flex gap-2">
<Button size="sm" variant="destructive" onClick={handleDisable} disabled={isSaving || !password}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('totp.disable')}
</Button>
<Button size="sm" variant="ghost" onClick={() => { setDisableOpen(false); setPassword(''); setSetupError(null); }}>
@@ -470,7 +470,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
<h4 className="text-sm font-medium text-foreground">{tk('title')}</h4>
</div>
<Button variant="outline" size="sm" onClick={() => setShowAdd(!showAdd)}>
<Plus className="w-3 h-3 mr-1" />
<Plus className="w-3 h-3 me-1" />
{t('app_passwords.add')}
</Button>
</div>
@@ -521,7 +521,7 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
</div>
<div className="flex gap-2">
<Button type="submit" size="sm" disabled={isSaving || !newDescription.trim()}>
{isSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}
{isSaving ? <Loader2 className="w-4 h-4 me-1 animate-spin" /> : null}
{t('app_passwords.create')}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowAdd(false)}>
@@ -768,9 +768,9 @@ function LinkDeviceSection() {
<Button variant="outline" size="sm" onClick={() => void generate()} disabled={loading}>
{loading ? (
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
<Loader2 className="w-3 h-3 me-1 animate-spin" />
) : (
<QrCode className="w-3 h-3 mr-1" />
<QrCode className="w-3 h-3 me-1" />
)}
{hasGenerated ? t('link_device.regenerate') : t('link_device.generate')}
</Button>
+4 -4
View File
@@ -52,7 +52,7 @@ export function AccountSettings() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const draggedIndexRef = useRef<number | null>(null);
const quotaPercentage = quota ? Math.round((quota.used / quota.total) * 100) : 0;
const quotaPercentage = quota && quota.total > 0 ? Math.min(Math.round((quota.used / quota.total) * 100), 100) : 0;
const displayName = primaryIdentity?.name || account?.displayName || (isDemoMode ? 'Demo User' : undefined);
const email = primaryIdentity?.email || account?.email || username;
const max = getMaxAccounts();
@@ -220,7 +220,7 @@ export function AccountSettings() {
onClick={handleAddAccount}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
<Plus className="w-4 h-4 me-2" />
{t('accounts.add')}
</Button>
)}
@@ -242,7 +242,7 @@ export function AccountSettings() {
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-start transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
@@ -356,7 +356,7 @@ function AccountRow({
onClick={onSwitch}
disabled={isActive}
className={cn(
'min-w-0 flex-1 text-left',
'min-w-0 flex-1 text-start',
!isActive && 'cursor-pointer'
)}
title={isActive ? labels.active : labels.switchTo}
+1 -1
View File
@@ -141,7 +141,7 @@ export function AppearanceSettings() {
onClick={() => { resetTourCompletion(); startTour(); }}
className="text-xs h-7"
>
<PlayCircle className="w-3.5 h-3.5 mr-1" />
<PlayCircle className="w-3.5 h-3.5 me-1" />
{tTour('restart_button')}
</Button>
</SettingItem>

Some files were not shown because too many files have changed in this diff Show More